Skip to main content

ftui_render/
budget.rs

1#![forbid(unsafe_code)]
2
3//! Render budget enforcement with graceful degradation.
4//!
5//! This module provides time-based budget tracking for frame rendering,
6//! enabling the system to gracefully degrade visual fidelity when
7//! performance budgets are exceeded.
8//!
9//! # Overview
10//!
11//! Agent UIs receive unpredictable content (burst log output, large tool responses).
12//! A frozen UI during burst input makes the agent feel broken. Users tolerate
13//! reduced visual fidelity; they do NOT tolerate hangs.
14//!
15//! # Usage
16//!
17//! ```
18//! use ftui_render::budget::{RenderBudget, DegradationLevel, FrameBudgetConfig};
19//! use std::time::Duration;
20//!
21//! // Create a budget with 16ms total (60fps target)
22//! let mut budget = RenderBudget::new(Duration::from_millis(16));
23//!
24//! // Check remaining time
25//! let remaining = budget.remaining();
26//!
27//! // Check if we should degrade for an expensive operation
28//! if budget.should_degrade(Duration::from_millis(5)) {
29//!     budget.degrade();
30//! }
31//!
32//! // Render at current degradation level
33//! match budget.degradation() {
34//!     DegradationLevel::Full => { /* full rendering */ }
35//!     DegradationLevel::SimpleBorders => { /* ASCII borders */ }
36//!     _ => { /* further degradation */ }
37//! }
38//! ```
39
40use std::time::{Duration, Instant};
41
42#[cfg(feature = "tracing")]
43use tracing::{trace, warn};
44
45// ---------------------------------------------------------------------------
46// Budget Controller: PID + Anytime-Valid E-Process
47// ---------------------------------------------------------------------------
48
49/// PID controller gains for frame time regulation.
50///
51/// # Mathematical Model
52///
53/// Let `e_t = frame_time_t − target` be the error signal at frame `t`.
54///
55/// The PID control output is:
56///
57/// ```text
58/// u_t = Kp * e_t  +  Ki * Σ_{j=0..t} e_j  +  Kd * (e_t − e_{t−1})
59/// ```
60///
61/// The output `u_t` maps to degradation level adjustments:
62/// - `u_t > degrade_threshold` → degrade one level (if e-process permits)
63/// - `u_t < -upgrade_threshold` → upgrade one level
64/// - otherwise → hold current level
65///
66/// # Gain Selection Rationale
67///
68/// For a 16ms target (60fps):
69/// - `Kp = 0.5`: Proportional response. Moderate gain avoids oscillation
70///   while still reacting to single-frame overruns.
71/// - `Ki = 0.05`: Integral term. Low gain eliminates steady-state error
72///   over ~20 frames without integral windup issues.
73/// - `Kd = 0.2`: Derivative term. Provides anticipatory damping to reduce
74///   overshoot when frame times are trending upward.
75///
76/// # Stability Analysis
77///
78/// For a first-order plant model G(s) = 1/(τs + 1) with τ ≈ 1 frame:
79/// - Phase margin > 45° with these gains
80/// - Gain margin > 6dB
81/// - Settling time ≈ 8-12 frames for a step disturbance
82///
83/// Anti-windup: integral term is clamped to `[-integral_max, +integral_max]`
84/// to prevent runaway accumulation during sustained overload.
85#[derive(Debug, Clone, PartialEq)]
86pub struct PidGains {
87    /// Proportional gain. Reacts to current error magnitude.
88    pub kp: f64,
89    /// Integral gain. Eliminates steady-state error over time.
90    pub ki: f64,
91    /// Derivative gain. Dampens oscillations by reacting to error rate.
92    pub kd: f64,
93    /// Maximum absolute value of the integral accumulator (anti-windup).
94    pub integral_max: f64,
95}
96
97impl Default for PidGains {
98    fn default() -> Self {
99        Self {
100            kp: 0.5,
101            ki: 0.05,
102            kd: 0.2,
103            integral_max: 5.0,
104        }
105    }
106}
107
108/// Internal PID controller state.
109///
110/// Tracks the error integral and previous error for derivative computation.
111#[derive(Debug, Clone)]
112struct PidState {
113    /// Accumulated integral of error (clamped by `integral_max`).
114    integral: f64,
115    /// Previous frame's error value (for derivative).
116    prev_error: f64,
117    /// Last proportional term (for telemetry).
118    last_p: f64,
119    /// Last integral term (for telemetry).
120    last_i: f64,
121    /// Last derivative term (for telemetry).
122    last_d: f64,
123}
124
125impl Default for PidState {
126    fn default() -> Self {
127        Self {
128            integral: 0.0,
129            prev_error: 0.0,
130            last_p: 0.0,
131            last_i: 0.0,
132            last_d: 0.0,
133        }
134    }
135}
136
137impl PidState {
138    /// Compute PID output for the current error and update internal state.
139    ///
140    /// Returns the control signal `u_t`.
141    fn update(&mut self, error: f64, gains: &PidGains) -> f64 {
142        // Integral with anti-windup clamping
143        self.integral = (self.integral + error).clamp(-gains.integral_max, gains.integral_max);
144
145        // Derivative (first-frame uses zero derivative)
146        let derivative = error - self.prev_error;
147        self.prev_error = error;
148
149        // Record individual PID terms for telemetry
150        self.last_p = gains.kp * error;
151        self.last_i = gains.ki * self.integral;
152        self.last_d = gains.kd * derivative;
153
154        // PID output
155        self.last_p + self.last_i + self.last_d
156    }
157
158    /// Reset controller state (e.g., after a mode change).
159    fn reset(&mut self) {
160        *self = Self::default();
161    }
162}
163
164/// Anytime-valid e-process for gating degradation decisions.
165///
166/// # Mathematical Model
167///
168/// The e-process is a nonnegative supermartingale under H₀ (system is healthy):
169///
170/// ```text
171/// E_t = Π_{j=1..t} exp(λ * r_j − λ² * σ² / 2)
172/// ```
173///
174/// where:
175/// - `r_j` is the standardized residual at frame j: `(frame_time − target) / σ`
176/// - `σ` is the estimated standard deviation of frame times
177/// - `λ` is a tuning parameter controlling sensitivity (default: 0.5)
178///
179/// # Decision Rule
180///
181/// - **Degrade** only when `E_t > 1/α` (evidence exceeds threshold).
182///   Default α = 0.05, so we need `E_t > 20`.
183/// - **Upgrade** only when `E_t < β` (evidence that overload has passed).
184///   Default β = 0.5.
185///
186/// # Properties
187///
188/// 1. **Anytime-valid**: The test is valid at any stopping time, unlike
189///    fixed-sample tests. We can check after every frame without p-hacking.
190/// 2. **Bounded false positive rate**: P(E_t ever exceeds 1/α | H₀) ≤ α
191///    (Ville's inequality).
192/// 3. **Self-correcting**: After a burst passes, E_t decays back toward 1.0,
193///    naturally enabling recovery.
194///
195/// # Failure Modes
196///
197/// - **Sustained overload**: E_t grows exponentially → rapid degradation.
198/// - **Transient spike**: E_t grows briefly → may not cross threshold →
199///   PID handles short-term. Only persistent overload triggers e-process gate.
200/// - **σ estimation drift**: We use an exponential moving average for σ with
201///   a warmup period of 10 frames to avoid unstable early estimates.
202#[derive(Debug, Clone, PartialEq)]
203pub struct EProcessConfig {
204    /// Sensitivity parameter λ. Higher values detect overload faster
205    /// but increase false positive risk near the boundary.
206    pub lambda: f64,
207    /// Significance level α. Degrade when E_t > 1/α.
208    /// Default: 0.05 (need E_t > 20 to degrade).
209    pub alpha: f64,
210    /// Recovery threshold β. Upgrade allowed when E_t < β.
211    /// Default: 0.5.
212    pub beta: f64,
213    /// EMA decay for σ estimation. Closer to 1.0 = slower adaptation.
214    /// Default: 0.9 (adapts over ~10 frames).
215    pub sigma_ema_decay: f64,
216    /// Minimum σ floor to prevent division by zero.
217    /// Default: 1.0 ms.
218    pub sigma_floor_ms: f64,
219    /// Warmup frames before e-process activates. During warmup, fall back
220    /// to PID-only decisions.
221    pub warmup_frames: u32,
222}
223
224impl Default for EProcessConfig {
225    fn default() -> Self {
226        Self {
227            lambda: 0.5,
228            alpha: 0.05,
229            beta: 0.5,
230            sigma_ema_decay: 0.9,
231            sigma_floor_ms: 1.0,
232            warmup_frames: 10,
233        }
234    }
235}
236
237/// Internal e-process state.
238#[derive(Debug, Clone)]
239struct EProcessState {
240    /// Current e-process value E_t (starts at 1.0).
241    e_value: f64,
242    /// EMA estimate of frame time standard deviation (ms).
243    sigma_ema: f64,
244    /// EMA estimate of mean frame time (ms) for residual computation.
245    mean_ema: f64,
246    /// Frames observed so far.
247    frames_observed: u32,
248}
249
250impl Default for EProcessState {
251    fn default() -> Self {
252        Self {
253            e_value: 1.0,
254            sigma_ema: 0.0,
255            mean_ema: 0.0,
256            frames_observed: 0,
257        }
258    }
259}
260
261impl EProcessState {
262    /// Update the e-process with a new frame time observation.
263    ///
264    /// Returns the updated E_t value.
265    fn update(&mut self, frame_time_ms: f64, target_ms: f64, config: &EProcessConfig) -> f64 {
266        self.frames_observed = self.frames_observed.saturating_add(1);
267
268        // Update mean EMA
269        if self.frames_observed == 1 {
270            self.mean_ema = frame_time_ms;
271            self.sigma_ema = config.sigma_floor_ms;
272        } else {
273            let decay = config.sigma_ema_decay;
274            self.mean_ema = decay * self.mean_ema + (1.0 - decay) * frame_time_ms;
275            // Update sigma EMA using absolute deviation as proxy
276            let deviation = (frame_time_ms - self.mean_ema).abs();
277            self.sigma_ema = decay * self.sigma_ema + (1.0 - decay) * deviation;
278        }
279
280        // Floor sigma to prevent instability
281        let sigma = self.sigma_ema.max(config.sigma_floor_ms);
282
283        // Compute standardized residual
284        let residual = (frame_time_ms - target_ms) / sigma;
285
286        // E-process multiplicative update:
287        // E_{t+1} = E_t * exp(λ * r_t − λ² * σ² / 2)
288        // Since r_t is already standardized, σ in the exponent is 1.0.
289        let lambda = config.lambda;
290        let log_factor = lambda * residual - lambda * lambda / 2.0;
291        self.e_value *= log_factor.exp();
292
293        // Clamp to avoid numerical issues (but preserve the supermartingale property
294        // by allowing it to grow large or shrink small).
295        self.e_value = self.e_value.clamp(1e-10, 1e10);
296
297        self.e_value
298    }
299
300    /// Check if evidence supports degradation.
301    fn should_degrade(&self, config: &EProcessConfig) -> bool {
302        if self.frames_observed < config.warmup_frames {
303            return false; // Fall back to PID during warmup
304        }
305        self.e_value > 1.0 / config.alpha
306    }
307
308    /// Check if evidence supports upgrade (overload has passed).
309    fn should_upgrade(&self, config: &EProcessConfig) -> bool {
310        if self.frames_observed < config.warmup_frames {
311            return true; // Allow PID-driven upgrades during warmup
312        }
313        self.e_value < config.beta
314    }
315
316    /// Reset state.
317    fn reset(&mut self) {
318        *self = Self::default();
319    }
320}
321
322/// Configuration for the adaptive budget controller.
323#[derive(Debug, Clone, PartialEq)]
324pub struct BudgetControllerConfig {
325    /// PID controller gains.
326    pub pid: PidGains,
327    /// E-process configuration.
328    pub eprocess: EProcessConfig,
329    /// Target frame time.
330    pub target: Duration,
331    /// Hysteresis: PID output must exceed this to trigger degradation.
332    ///
333    /// This prevents oscillation at the boundary. The value is in
334    /// normalized units (error / target). Default: 0.3 (30% of target).
335    ///
336    /// # Justification
337    ///
338    /// A threshold of 0.3 means the controller needs ~5ms sustained error
339    /// at 16ms target before degrading. This filters out single-frame jitter
340    /// while remaining responsive to genuine overload (2-3 consecutive
341    /// slow frames will cross the threshold via integral accumulation).
342    pub degrade_threshold: f64,
343    /// Hysteresis: PID output must be below negative of this to trigger upgrade.
344    /// Default: 0.2 (20% of target).
345    pub upgrade_threshold: f64,
346    /// Cooldown frames between level changes.
347    pub cooldown_frames: u32,
348}
349
350impl Default for BudgetControllerConfig {
351    fn default() -> Self {
352        Self {
353            pid: PidGains::default(),
354            eprocess: EProcessConfig::default(),
355            target: Duration::from_millis(16),
356            degrade_threshold: 0.3,
357            upgrade_threshold: 0.2,
358            cooldown_frames: 3,
359        }
360    }
361}
362
363/// Adaptive budget controller combining PID regulation with e-process gating.
364///
365/// # Architecture
366///
367/// ```text
368/// frame_time ─┬─► PID Controller ─► control signal u_t
369///             │                              │
370///             └─► E-Process ──────► gate ────┤
371///                                            ▼
372///                                    Decision Logic
373///                                    ┌───────────────┐
374///                                    │ u_t > thresh   │──► DEGRADE (if e-process permits)
375///                                    │ u_t < -thresh  │──► UPGRADE (if e-process permits)
376///                                    │ otherwise      │──► HOLD
377///                                    └───────────────┘
378/// ```
379///
380/// The PID controller provides smooth, reactive adaptation. The e-process
381/// gates decisions to ensure statistical validity — we only degrade when
382/// there is strong evidence of sustained overload, not just transient spikes.
383///
384/// # Usage
385///
386/// ```rust
387/// use ftui_render::budget::{BudgetController, BudgetControllerConfig, DegradationLevel};
388/// use std::time::Duration;
389///
390/// let mut controller = BudgetController::new(BudgetControllerConfig::default());
391///
392/// // After each frame, feed the observed frame time:
393/// let decision = controller.update(Duration::from_millis(20)); // slow frame
394/// // decision tells you what to do: Hold, Degrade, or Upgrade
395/// ```
396#[derive(Debug, Clone)]
397pub struct BudgetController {
398    config: BudgetControllerConfig,
399    pid: PidState,
400    eprocess: EProcessState,
401    current_level: DegradationLevel,
402    frames_since_change: u32,
403    last_pid_output: f64,
404    last_decision: BudgetDecision,
405}
406
407/// Decision output from the budget controller.
408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
409pub enum BudgetDecision {
410    /// Maintain current degradation level.
411    Hold,
412    /// Degrade one level (reduce visual fidelity).
413    Degrade,
414    /// Upgrade one level (restore visual fidelity).
415    Upgrade,
416}
417
418impl BudgetDecision {
419    /// JSONL-compatible string representation.
420    #[inline]
421    pub fn as_str(self) -> &'static str {
422        match self {
423            Self::Hold => "stay",
424            Self::Degrade => "degrade",
425            Self::Upgrade => "upgrade",
426        }
427    }
428}
429
430impl BudgetController {
431    /// Create a new budget controller with the given configuration.
432    pub fn new(config: BudgetControllerConfig) -> Self {
433        Self {
434            config,
435            pid: PidState::default(),
436            eprocess: EProcessState::default(),
437            current_level: DegradationLevel::Full,
438            frames_since_change: 0,
439            last_pid_output: 0.0,
440            last_decision: BudgetDecision::Hold,
441        }
442    }
443
444    /// Feed a frame time observation and get a decision.
445    ///
446    /// Call this once per frame with the measured frame duration.
447    pub fn update(&mut self, frame_time: Duration) -> BudgetDecision {
448        let target_ms = self.config.target.as_secs_f64() * 1000.0;
449        let frame_ms = frame_time.as_secs_f64() * 1000.0;
450
451        // Compute normalized error (positive = over budget)
452        let error = (frame_ms - target_ms) / target_ms;
453
454        // Update PID
455        let u = self.pid.update(error, &self.config.pid);
456        self.last_pid_output = u;
457
458        // Update e-process
459        self.eprocess
460            .update(frame_ms, target_ms, &self.config.eprocess);
461
462        // Increment cooldown counter
463        self.frames_since_change = self.frames_since_change.saturating_add(1);
464
465        // Decision logic with hysteresis + e-process gating
466        let decision = if self.frames_since_change < self.config.cooldown_frames {
467            // Cooldown active — hold
468            BudgetDecision::Hold
469        } else if u > self.config.degrade_threshold
470            && !self.current_level.is_max()
471            && self.eprocess.should_degrade(&self.config.eprocess)
472        {
473            BudgetDecision::Degrade
474        } else if u < -self.config.upgrade_threshold
475            && !self.current_level.is_full()
476            && self.eprocess.should_upgrade(&self.config.eprocess)
477        {
478            BudgetDecision::Upgrade
479        } else {
480            BudgetDecision::Hold
481        };
482
483        // Record decision for telemetry
484        self.last_decision = decision;
485
486        // Apply decision
487        match decision {
488            BudgetDecision::Degrade => {
489                self.current_level = self.current_level.next();
490                self.frames_since_change = 0;
491
492                #[cfg(feature = "tracing")]
493                warn!(
494                    level = self.current_level.as_str(),
495                    pid_output = u,
496                    e_value = self.eprocess.e_value,
497                    "budget controller: degrade"
498                );
499            }
500            BudgetDecision::Upgrade => {
501                self.current_level = self.current_level.prev();
502                self.frames_since_change = 0;
503
504                #[cfg(feature = "tracing")]
505                trace!(
506                    level = self.current_level.as_str(),
507                    pid_output = u,
508                    e_value = self.eprocess.e_value,
509                    "budget controller: upgrade"
510                );
511            }
512            BudgetDecision::Hold => {}
513        }
514
515        decision
516    }
517
518    /// Get the current degradation level.
519    #[inline]
520    pub fn level(&self) -> DegradationLevel {
521        self.current_level
522    }
523
524    /// Get the current e-process value (for diagnostics/logging).
525    #[inline]
526    pub fn e_value(&self) -> f64 {
527        self.eprocess.e_value
528    }
529
530    /// Get the current e-process sigma estimate (ms).
531    #[inline]
532    pub fn eprocess_sigma_ms(&self) -> f64 {
533        self.eprocess
534            .sigma_ema
535            .max(self.config.eprocess.sigma_floor_ms)
536    }
537
538    /// Get the current PID integral term (for diagnostics/logging).
539    #[inline]
540    pub fn pid_integral(&self) -> f64 {
541        self.pid.integral
542    }
543
544    /// Get the number of frames observed by the e-process.
545    #[inline]
546    pub fn frames_observed(&self) -> u32 {
547        self.eprocess.frames_observed
548    }
549
550    /// Capture a telemetry snapshot of the controller state.
551    ///
552    /// This is allocation-free and suitable for calling every frame.
553    /// Forward the result to a debug overlay or structured logger.
554    #[inline]
555    pub fn telemetry(&self) -> BudgetTelemetry {
556        BudgetTelemetry {
557            level: self.current_level,
558            pid_output: self.last_pid_output,
559            pid_p: self.pid.last_p,
560            pid_i: self.pid.last_i,
561            pid_d: self.pid.last_d,
562            e_value: self.eprocess.e_value,
563            frames_observed: self.eprocess.frames_observed,
564            frames_since_change: self.frames_since_change,
565            last_decision: self.last_decision,
566            in_warmup: self.eprocess.frames_observed < self.config.eprocess.warmup_frames,
567        }
568    }
569
570    /// Reset the controller to initial state.
571    pub fn reset(&mut self) {
572        self.pid.reset();
573        self.eprocess.reset();
574        self.current_level = DegradationLevel::Full;
575        self.frames_since_change = 0;
576        self.last_pid_output = 0.0;
577        self.last_decision = BudgetDecision::Hold;
578    }
579
580    /// Get a reference to the controller configuration.
581    #[inline]
582    pub fn config(&self) -> &BudgetControllerConfig {
583        &self.config
584    }
585}
586
587/// Snapshot of budget controller telemetry for diagnostics and debug overlay.
588///
589/// All fields are `Copy` — no allocations. Intended to be cheaply captured
590/// once per frame and forwarded to a tracing subscriber or debug overlay widget.
591#[derive(Debug, Clone, Copy, PartialEq)]
592pub struct BudgetTelemetry {
593    /// Current degradation level.
594    pub level: DegradationLevel,
595    /// Last PID control signal (positive = over budget).
596    pub pid_output: f64,
597    /// Last PID proportional term.
598    pub pid_p: f64,
599    /// Last PID integral term.
600    pub pid_i: f64,
601    /// Last PID derivative term.
602    pub pid_d: f64,
603    /// Current e-process value E_t.
604    pub e_value: f64,
605    /// Frames observed by the e-process.
606    pub frames_observed: u32,
607    /// Frames since last level change.
608    pub frames_since_change: u32,
609    /// Last decision made by the controller.
610    pub last_decision: BudgetDecision,
611    /// Whether the controller is in warmup (e-process not yet active).
612    pub in_warmup: bool,
613}
614
615/// Progressive degradation levels for render quality.
616///
617/// Higher levels mean less visual fidelity but faster rendering.
618/// The ordering is significant: `Full` < `SimpleBorders` < ... < `SkipFrame`.
619#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
620#[repr(u8)]
621pub enum DegradationLevel {
622    /// All visual features enabled.
623    #[default]
624    Full = 0,
625    /// Unicode box-drawing replaced with ASCII (+--+).
626    SimpleBorders = 1,
627    /// Colors disabled, monochrome output.
628    NoStyling = 2,
629    /// Skip decorative widgets, essential content only.
630    EssentialOnly = 3,
631    /// Just layout boxes, no content.
632    Skeleton = 4,
633    /// Emergency: skip frame entirely.
634    SkipFrame = 5,
635}
636
637impl DegradationLevel {
638    /// Move to the next degradation level.
639    ///
640    /// Returns `SkipFrame` if already at maximum degradation.
641    #[inline]
642    pub fn next(self) -> Self {
643        match self {
644            Self::Full => Self::SimpleBorders,
645            Self::SimpleBorders => Self::NoStyling,
646            Self::NoStyling => Self::EssentialOnly,
647            Self::EssentialOnly => Self::Skeleton,
648            Self::Skeleton | Self::SkipFrame => Self::SkipFrame,
649        }
650    }
651
652    /// Move to the previous (better quality) degradation level.
653    ///
654    /// Returns `Full` if already at minimum degradation.
655    #[inline]
656    pub fn prev(self) -> Self {
657        match self {
658            Self::SkipFrame => Self::Skeleton,
659            Self::Skeleton => Self::EssentialOnly,
660            Self::EssentialOnly => Self::NoStyling,
661            Self::NoStyling => Self::SimpleBorders,
662            Self::SimpleBorders | Self::Full => Self::Full,
663        }
664    }
665
666    /// Check if this is the maximum degradation level.
667    #[inline]
668    pub fn is_max(self) -> bool {
669        self == Self::SkipFrame
670    }
671
672    /// Check if this is full quality (no degradation).
673    #[inline]
674    pub fn is_full(self) -> bool {
675        self == Self::Full
676    }
677
678    /// Get a human-readable name for logging.
679    #[inline]
680    pub fn as_str(self) -> &'static str {
681        match self {
682            Self::Full => "Full",
683            Self::SimpleBorders => "SimpleBorders",
684            Self::NoStyling => "NoStyling",
685            Self::EssentialOnly => "EssentialOnly",
686            Self::Skeleton => "Skeleton",
687            Self::SkipFrame => "SkipFrame",
688        }
689    }
690
691    /// Number of levels from Full (0) to this level.
692    #[inline]
693    pub fn level(self) -> u8 {
694        self as u8
695    }
696
697    // ---- Widget convenience queries ----
698
699    /// Whether to use Unicode box-drawing characters.
700    ///
701    /// Returns `false` at `SimpleBorders` and above (use ASCII instead).
702    #[inline]
703    pub fn use_unicode_borders(self) -> bool {
704        self < Self::SimpleBorders
705    }
706
707    /// Whether to apply colors and style attributes to cells.
708    ///
709    /// Returns `false` at `NoStyling` and above.
710    #[inline]
711    pub fn apply_styling(self) -> bool {
712        self < Self::NoStyling
713    }
714
715    /// Whether to render decorative (non-essential) elements.
716    ///
717    /// Returns `false` at `EssentialOnly` and above.
718    /// Decorative elements include borders, scrollbars, spinners, rules.
719    #[inline]
720    pub fn render_decorative(self) -> bool {
721        self < Self::EssentialOnly
722    }
723
724    /// Whether to render content text.
725    ///
726    /// Returns `false` at `Skeleton` and above.
727    #[inline]
728    pub fn render_content(self) -> bool {
729        self < Self::Skeleton
730    }
731}
732
733/// Per-phase time budgets within a frame.
734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735pub struct PhaseBudgets {
736    /// Budget for diff computation.
737    pub diff: Duration,
738    /// Budget for ANSI presentation/emission.
739    pub present: Duration,
740    /// Budget for widget rendering.
741    pub render: Duration,
742}
743
744impl Default for PhaseBudgets {
745    fn default() -> Self {
746        Self {
747            diff: Duration::from_millis(2),
748            present: Duration::from_millis(4),
749            render: Duration::from_millis(8),
750        }
751    }
752}
753
754/// Configuration for frame budget behavior.
755#[derive(Debug, Clone, PartialEq)]
756pub struct FrameBudgetConfig {
757    /// Total time budget per frame.
758    pub total: Duration,
759    /// Per-phase budgets.
760    pub phase_budgets: PhaseBudgets,
761    /// Allow skipping frames entirely when severely over budget.
762    pub allow_frame_skip: bool,
763    /// Frames to wait between degradation level changes.
764    pub degradation_cooldown: u32,
765    /// Threshold (as fraction of total) above which we consider upgrading.
766    /// Default: 0.5 (upgrade when >50% budget remains).
767    pub upgrade_threshold: f32,
768}
769
770impl Default for FrameBudgetConfig {
771    fn default() -> Self {
772        Self {
773            total: Duration::from_millis(16), // ~60fps feel
774            phase_budgets: PhaseBudgets::default(),
775            allow_frame_skip: true,
776            degradation_cooldown: 3,
777            upgrade_threshold: 0.5,
778        }
779    }
780}
781
782impl FrameBudgetConfig {
783    /// Create a new config with the specified total budget.
784    pub fn with_total(total: Duration) -> Self {
785        Self {
786            total,
787            ..Default::default()
788        }
789    }
790
791    /// Create a strict config that never skips frames.
792    pub fn strict(total: Duration) -> Self {
793        Self {
794            total,
795            allow_frame_skip: false,
796            ..Default::default()
797        }
798    }
799
800    /// Create a relaxed config for slower refresh rates.
801    pub fn relaxed() -> Self {
802        Self {
803            total: Duration::from_millis(33), // ~30fps
804            degradation_cooldown: 5,
805            ..Default::default()
806        }
807    }
808}
809
810/// Render time budget with graceful degradation.
811///
812/// Tracks elapsed time within a frame and manages degradation level
813/// to maintain responsive rendering under load.
814#[derive(Debug, Clone)]
815pub struct RenderBudget {
816    /// Total time budget for this frame.
817    total: Duration,
818    /// When this frame started.
819    start: Instant,
820    /// Measured render+present time for the last frame (if recorded).
821    last_frame_time: Option<Duration>,
822    /// Current degradation level.
823    degradation: DegradationLevel,
824    /// Per-phase budgets.
825    phase_budgets: PhaseBudgets,
826    /// Allow frame skip at maximum degradation.
827    allow_frame_skip: bool,
828    /// Upgrade threshold fraction.
829    upgrade_threshold: f32,
830    /// Frames since last degradation change (for cooldown).
831    frames_since_change: u32,
832    /// Cooldown frames required between changes.
833    cooldown: u32,
834    /// Optional adaptive budget controller (PID + e-process).
835    /// When present, `next_frame()` delegates degradation decisions to the controller.
836    controller: Option<BudgetController>,
837}
838
839impl RenderBudget {
840    /// Create a new budget with the specified total time.
841    pub fn new(total: Duration) -> Self {
842        Self {
843            total,
844            start: Instant::now(),
845            last_frame_time: None,
846            degradation: DegradationLevel::Full,
847            phase_budgets: PhaseBudgets::default(),
848            allow_frame_skip: true,
849            upgrade_threshold: 0.5,
850            frames_since_change: 0,
851            cooldown: 3,
852            controller: None,
853        }
854    }
855
856    /// Create a budget from configuration.
857    pub fn from_config(config: &FrameBudgetConfig) -> Self {
858        Self {
859            total: config.total,
860            start: Instant::now(),
861            last_frame_time: None,
862            degradation: DegradationLevel::Full,
863            phase_budgets: config.phase_budgets,
864            allow_frame_skip: config.allow_frame_skip,
865            upgrade_threshold: config.upgrade_threshold,
866            frames_since_change: 0,
867            cooldown: config.degradation_cooldown,
868            controller: None,
869        }
870    }
871
872    /// Attach an adaptive budget controller to this render budget.
873    ///
874    /// When a controller is attached, `next_frame()` feeds the measured frame
875    /// duration to the controller and applies its degradation decisions
876    /// instead of the simple threshold-based upgrade logic.
877    ///
878    /// # Example
879    ///
880    /// ```
881    /// use ftui_render::budget::{RenderBudget, BudgetControllerConfig};
882    /// use std::time::Duration;
883    ///
884    /// let budget = RenderBudget::new(Duration::from_millis(16))
885    ///     .with_controller(BudgetControllerConfig::default());
886    /// ```
887    pub fn with_controller(mut self, config: BudgetControllerConfig) -> Self {
888        self.controller = Some(BudgetController::new(config));
889        self
890    }
891
892    /// Get the total budget duration.
893    #[inline]
894    pub fn total(&self) -> Duration {
895        self.total
896    }
897
898    /// Get the elapsed time since budget started.
899    #[inline]
900    pub fn elapsed(&self) -> Duration {
901        self.start.elapsed()
902    }
903
904    /// Get the remaining time in the budget.
905    #[inline]
906    pub fn remaining(&self) -> Duration {
907        self.total.saturating_sub(self.start.elapsed())
908    }
909
910    /// Get the remaining time as a fraction of total (0.0 to 1.0).
911    #[inline]
912    pub fn remaining_fraction(&self) -> f32 {
913        if self.total.is_zero() {
914            return 0.0;
915        }
916        let remaining = self.remaining().as_secs_f32();
917        let total = self.total.as_secs_f32();
918        (remaining / total).clamp(0.0, 1.0)
919    }
920
921    /// Check if we should degrade given an estimated operation cost.
922    ///
923    /// Returns `true` if the estimated cost exceeds remaining budget.
924    #[inline]
925    pub fn should_degrade(&self, estimated_cost: Duration) -> bool {
926        self.remaining() < estimated_cost
927    }
928
929    /// Degrade to the next level.
930    ///
931    /// Logs a warning when degradation occurs.
932    pub fn degrade(&mut self) {
933        let from = self.degradation;
934        self.degradation = self.degradation.next();
935        self.frames_since_change = 0;
936
937        #[cfg(feature = "tracing")]
938        if from != self.degradation {
939            warn!(
940                from = from.as_str(),
941                to = self.degradation.as_str(),
942                remaining_ms = self.remaining().as_millis() as u32,
943                "render budget degradation"
944            );
945        }
946        let _ = from; // Suppress unused warning when tracing is disabled
947    }
948
949    /// Get the current degradation level.
950    #[inline]
951    pub fn degradation(&self) -> DegradationLevel {
952        self.degradation
953    }
954
955    /// Set the degradation level directly.
956    ///
957    /// Use with caution - prefer `degrade()` and `upgrade()` for gradual changes.
958    pub fn set_degradation(&mut self, level: DegradationLevel) {
959        if self.degradation != level {
960            self.degradation = level;
961            self.frames_since_change = 0;
962        }
963    }
964
965    /// Check if the budget is exhausted.
966    ///
967    /// Returns `true` if no time remains OR if at SkipFrame level.
968    #[inline]
969    pub fn exhausted(&self) -> bool {
970        self.remaining().is_zero()
971            || (self.degradation == DegradationLevel::SkipFrame && self.allow_frame_skip)
972    }
973
974    /// Check if we should attempt to upgrade quality.
975    ///
976    /// Returns `true` if more than `upgrade_threshold` of budget remains
977    /// and we're not already at full quality, and cooldown has passed.
978    pub fn should_upgrade(&self) -> bool {
979        !self.degradation.is_full()
980            && self.remaining_fraction() > self.upgrade_threshold
981            && self.frames_since_change >= self.cooldown
982    }
983
984    /// Check if we should upgrade using a measured frame time.
985    fn should_upgrade_with_elapsed(&self, elapsed: Duration) -> bool {
986        if self.degradation.is_full() || self.frames_since_change < self.cooldown {
987            return false;
988        }
989        self.remaining_fraction_for_elapsed(elapsed) > self.upgrade_threshold
990    }
991
992    /// Remaining fraction computed from an elapsed frame time.
993    fn remaining_fraction_for_elapsed(&self, elapsed: Duration) -> f32 {
994        if self.total.is_zero() {
995            return 0.0;
996        }
997        let remaining = self.total.saturating_sub(elapsed);
998        let remaining = remaining.as_secs_f32();
999        let total = self.total.as_secs_f32();
1000        (remaining / total).clamp(0.0, 1.0)
1001    }
1002
1003    /// Upgrade to the previous (better quality) level.
1004    ///
1005    /// Logs when upgrade occurs.
1006    pub fn upgrade(&mut self) {
1007        let from = self.degradation;
1008        self.degradation = self.degradation.prev();
1009        self.frames_since_change = 0;
1010
1011        #[cfg(feature = "tracing")]
1012        if from != self.degradation {
1013            trace!(
1014                from = from.as_str(),
1015                to = self.degradation.as_str(),
1016                remaining_fraction = self.remaining_fraction(),
1017                "render budget upgrade"
1018            );
1019        }
1020        let _ = from; // Suppress unused warning when tracing is disabled
1021    }
1022
1023    /// Reset the budget for a new frame.
1024    ///
1025    /// Keeps the current degradation level but resets timing.
1026    pub fn reset(&mut self) {
1027        self.start = Instant::now();
1028        self.frames_since_change = self.frames_since_change.saturating_add(1);
1029    }
1030
1031    /// Reset the budget and attempt upgrade if conditions are met.
1032    ///
1033    /// Call this at the start of each frame to enable recovery.
1034    ///
1035    /// When an adaptive controller is attached (via [`with_controller`](Self::with_controller)),
1036    /// the measured frame duration is fed to the controller and its decision
1037    /// (degrade / upgrade / hold) is applied automatically. The simple
1038    /// threshold-based upgrade path is skipped in that case.
1039    pub fn next_frame(&mut self) {
1040        let frame_time = self.last_frame_time.unwrap_or_else(|| self.start.elapsed());
1041
1042        if self.controller.is_some() {
1043            // Measure how long the previous frame took
1044
1045            // SAFETY: we just checked is_some; this avoids a borrow-checker
1046            // conflict with `&mut self` needed for degrade/upgrade below.
1047            let decision = self.controller.as_mut().unwrap().update(frame_time);
1048
1049            match decision {
1050                BudgetDecision::Degrade => self.degrade(),
1051                BudgetDecision::Upgrade => self.upgrade(),
1052                BudgetDecision::Hold => {}
1053            }
1054        } else {
1055            // Legacy path: simple threshold-based upgrade
1056            if self.should_upgrade_with_elapsed(frame_time) {
1057                self.upgrade();
1058            }
1059        }
1060        self.reset();
1061    }
1062
1063    /// Record the measured render+present time for the last frame.
1064    pub fn record_frame_time(&mut self, elapsed: Duration) {
1065        self.last_frame_time = Some(elapsed);
1066    }
1067
1068    /// Get a telemetry snapshot from the adaptive controller, if attached.
1069    ///
1070    /// Returns `None` if no controller is attached.
1071    /// This is allocation-free and safe to call every frame.
1072    #[inline]
1073    pub fn telemetry(&self) -> Option<BudgetTelemetry> {
1074        self.controller.as_ref().map(BudgetController::telemetry)
1075    }
1076
1077    /// Get a reference to the adaptive controller, if attached.
1078    #[inline]
1079    pub fn controller(&self) -> Option<&BudgetController> {
1080        self.controller.as_ref()
1081    }
1082
1083    /// Get the phase budgets.
1084    #[inline]
1085    pub fn phase_budgets(&self) -> &PhaseBudgets {
1086        &self.phase_budgets
1087    }
1088
1089    /// Check if a specific phase has budget remaining.
1090    pub fn phase_has_budget(&self, phase: Phase) -> bool {
1091        let phase_budget = match phase {
1092            Phase::Diff => self.phase_budgets.diff,
1093            Phase::Present => self.phase_budgets.present,
1094            Phase::Render => self.phase_budgets.render,
1095        };
1096        self.remaining() >= phase_budget
1097    }
1098
1099    /// Create a sub-budget for a specific phase.
1100    ///
1101    /// The sub-budget shares the same start time but has a phase-specific total.
1102    pub fn phase_budget(&self, phase: Phase) -> Self {
1103        let phase_total = match phase {
1104            Phase::Diff => self.phase_budgets.diff,
1105            Phase::Present => self.phase_budgets.present,
1106            Phase::Render => self.phase_budgets.render,
1107        };
1108        Self {
1109            total: phase_total.min(self.remaining()),
1110            start: self.start,
1111            last_frame_time: self.last_frame_time,
1112            degradation: self.degradation,
1113            phase_budgets: self.phase_budgets,
1114            allow_frame_skip: self.allow_frame_skip,
1115            upgrade_threshold: self.upgrade_threshold,
1116            frames_since_change: self.frames_since_change,
1117            cooldown: self.cooldown,
1118            controller: None, // Phase sub-budgets don't carry the controller
1119        }
1120    }
1121}
1122
1123/// Render phases for budget allocation.
1124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1125pub enum Phase {
1126    /// Buffer diff computation.
1127    Diff,
1128    /// ANSI sequence presentation.
1129    Present,
1130    /// Widget tree rendering.
1131    Render,
1132}
1133
1134impl Phase {
1135    /// Get a human-readable name.
1136    pub fn as_str(self) -> &'static str {
1137        match self {
1138            Self::Diff => "diff",
1139            Self::Present => "present",
1140            Self::Render => "render",
1141        }
1142    }
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147    use super::*;
1148    use std::thread;
1149
1150    #[test]
1151    fn degradation_level_ordering() {
1152        assert!(DegradationLevel::Full < DegradationLevel::SimpleBorders);
1153        assert!(DegradationLevel::SimpleBorders < DegradationLevel::NoStyling);
1154        assert!(DegradationLevel::NoStyling < DegradationLevel::EssentialOnly);
1155        assert!(DegradationLevel::EssentialOnly < DegradationLevel::Skeleton);
1156        assert!(DegradationLevel::Skeleton < DegradationLevel::SkipFrame);
1157    }
1158
1159    #[test]
1160    fn degradation_level_next() {
1161        assert_eq!(
1162            DegradationLevel::Full.next(),
1163            DegradationLevel::SimpleBorders
1164        );
1165        assert_eq!(
1166            DegradationLevel::SimpleBorders.next(),
1167            DegradationLevel::NoStyling
1168        );
1169        assert_eq!(
1170            DegradationLevel::NoStyling.next(),
1171            DegradationLevel::EssentialOnly
1172        );
1173        assert_eq!(
1174            DegradationLevel::EssentialOnly.next(),
1175            DegradationLevel::Skeleton
1176        );
1177        assert_eq!(
1178            DegradationLevel::Skeleton.next(),
1179            DegradationLevel::SkipFrame
1180        );
1181        assert_eq!(
1182            DegradationLevel::SkipFrame.next(),
1183            DegradationLevel::SkipFrame
1184        );
1185    }
1186
1187    #[test]
1188    fn degradation_level_prev() {
1189        assert_eq!(
1190            DegradationLevel::SkipFrame.prev(),
1191            DegradationLevel::Skeleton
1192        );
1193        assert_eq!(
1194            DegradationLevel::Skeleton.prev(),
1195            DegradationLevel::EssentialOnly
1196        );
1197        assert_eq!(
1198            DegradationLevel::EssentialOnly.prev(),
1199            DegradationLevel::NoStyling
1200        );
1201        assert_eq!(
1202            DegradationLevel::NoStyling.prev(),
1203            DegradationLevel::SimpleBorders
1204        );
1205        assert_eq!(
1206            DegradationLevel::SimpleBorders.prev(),
1207            DegradationLevel::Full
1208        );
1209        assert_eq!(DegradationLevel::Full.prev(), DegradationLevel::Full);
1210    }
1211
1212    #[test]
1213    fn degradation_level_is_max() {
1214        assert!(!DegradationLevel::Full.is_max());
1215        assert!(!DegradationLevel::Skeleton.is_max());
1216        assert!(DegradationLevel::SkipFrame.is_max());
1217    }
1218
1219    #[test]
1220    fn degradation_level_is_full() {
1221        assert!(DegradationLevel::Full.is_full());
1222        assert!(!DegradationLevel::SimpleBorders.is_full());
1223        assert!(!DegradationLevel::SkipFrame.is_full());
1224    }
1225
1226    #[test]
1227    fn degradation_level_as_str() {
1228        assert_eq!(DegradationLevel::Full.as_str(), "Full");
1229        assert_eq!(DegradationLevel::SimpleBorders.as_str(), "SimpleBorders");
1230        assert_eq!(DegradationLevel::NoStyling.as_str(), "NoStyling");
1231        assert_eq!(DegradationLevel::EssentialOnly.as_str(), "EssentialOnly");
1232        assert_eq!(DegradationLevel::Skeleton.as_str(), "Skeleton");
1233        assert_eq!(DegradationLevel::SkipFrame.as_str(), "SkipFrame");
1234    }
1235
1236    #[test]
1237    fn degradation_level_values() {
1238        assert_eq!(DegradationLevel::Full.level(), 0);
1239        assert_eq!(DegradationLevel::SimpleBorders.level(), 1);
1240        assert_eq!(DegradationLevel::NoStyling.level(), 2);
1241        assert_eq!(DegradationLevel::EssentialOnly.level(), 3);
1242        assert_eq!(DegradationLevel::Skeleton.level(), 4);
1243        assert_eq!(DegradationLevel::SkipFrame.level(), 5);
1244    }
1245
1246    #[test]
1247    fn budget_remaining_decreases() {
1248        let budget = RenderBudget::new(Duration::from_millis(100));
1249        let initial = budget.remaining();
1250
1251        thread::sleep(Duration::from_millis(10));
1252
1253        let later = budget.remaining();
1254        assert!(later < initial);
1255    }
1256
1257    #[test]
1258    fn budget_remaining_fraction() {
1259        let budget = RenderBudget::new(Duration::from_millis(100));
1260
1261        // Initially should be close to 1.0
1262        let initial = budget.remaining_fraction();
1263        assert!(initial > 0.9);
1264
1265        thread::sleep(Duration::from_millis(50));
1266
1267        // Should be around 0.5 now
1268        let later = budget.remaining_fraction();
1269        assert!(later < 0.6);
1270        assert!(later > 0.3);
1271    }
1272
1273    #[test]
1274    fn should_degrade_when_cost_exceeds_remaining() {
1275        // Use wider margins to avoid timing flakiness
1276        let budget = RenderBudget::new(Duration::from_millis(100));
1277
1278        // Wait until ~half budget is consumed (~50ms remaining)
1279        thread::sleep(Duration::from_millis(50));
1280
1281        // Should degrade for expensive operations (80ms > ~50ms remaining)
1282        assert!(budget.should_degrade(Duration::from_millis(80)));
1283        // Should not degrade for cheap operations (10ms < ~50ms remaining)
1284        assert!(!budget.should_degrade(Duration::from_millis(10)));
1285    }
1286
1287    #[test]
1288    fn degrade_advances_level() {
1289        let mut budget = RenderBudget::new(Duration::from_millis(16));
1290
1291        assert_eq!(budget.degradation(), DegradationLevel::Full);
1292
1293        budget.degrade();
1294        assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
1295
1296        budget.degrade();
1297        assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1298    }
1299
1300    #[test]
1301    fn exhausted_when_no_time_left() {
1302        let budget = RenderBudget::new(Duration::from_millis(5));
1303
1304        assert!(!budget.exhausted());
1305
1306        thread::sleep(Duration::from_millis(10));
1307
1308        assert!(budget.exhausted());
1309    }
1310
1311    #[test]
1312    fn exhausted_at_skip_frame() {
1313        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1314
1315        // Set to SkipFrame
1316        budget.set_degradation(DegradationLevel::SkipFrame);
1317
1318        // Should be exhausted even with time remaining
1319        assert!(budget.exhausted());
1320    }
1321
1322    #[test]
1323    fn should_upgrade_with_remaining_budget() {
1324        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1325
1326        // At Full, should not upgrade
1327        assert!(!budget.should_upgrade());
1328
1329        // Degrade and set cooldown frames
1330        budget.degrade();
1331        budget.frames_since_change = 5;
1332
1333        // With lots of budget remaining, should upgrade
1334        assert!(budget.should_upgrade());
1335    }
1336
1337    #[test]
1338    fn upgrade_improves_level() {
1339        let mut budget = RenderBudget::new(Duration::from_millis(16));
1340
1341        budget.set_degradation(DegradationLevel::Skeleton);
1342        assert_eq!(budget.degradation(), DegradationLevel::Skeleton);
1343
1344        budget.upgrade();
1345        assert_eq!(budget.degradation(), DegradationLevel::EssentialOnly);
1346
1347        budget.upgrade();
1348        assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1349    }
1350
1351    #[test]
1352    fn upgrade_downgrade_symmetric() {
1353        let mut budget = RenderBudget::new(Duration::from_millis(16));
1354
1355        // Degrade all the way
1356        while !budget.degradation().is_max() {
1357            budget.degrade();
1358        }
1359        assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
1360
1361        // Upgrade all the way
1362        while !budget.degradation().is_full() {
1363            budget.upgrade();
1364        }
1365        assert_eq!(budget.degradation(), DegradationLevel::Full);
1366    }
1367
1368    #[test]
1369    fn reset_preserves_degradation() {
1370        let mut budget = RenderBudget::new(Duration::from_millis(16));
1371
1372        budget.degrade();
1373        budget.degrade();
1374        let level = budget.degradation();
1375
1376        budget.reset();
1377
1378        assert_eq!(budget.degradation(), level);
1379        // Remaining should be close to full again
1380        assert!(budget.remaining_fraction() > 0.9);
1381    }
1382
1383    #[test]
1384    fn next_frame_upgrades_when_possible() {
1385        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1386
1387        // Degrade and simulate several frames
1388        budget.degrade();
1389        for _ in 0..5 {
1390            budget.reset();
1391        }
1392
1393        let before = budget.degradation();
1394        budget.next_frame();
1395
1396        // Should have upgraded
1397        assert!(budget.degradation() < before);
1398    }
1399
1400    #[test]
1401    fn next_frame_prefers_recorded_frame_time_for_upgrade() {
1402        let mut budget = RenderBudget::new(Duration::from_millis(16));
1403
1404        budget.degrade();
1405        for _ in 0..5 {
1406            budget.reset();
1407        }
1408
1409        // Record a fast frame, then wait long enough that start.elapsed()
1410        // would otherwise exceed the budget.
1411        budget.record_frame_time(Duration::from_millis(1));
1412        std::thread::sleep(Duration::from_millis(25));
1413
1414        let before = budget.degradation();
1415        budget.next_frame();
1416
1417        assert!(budget.degradation() < before);
1418    }
1419
1420    #[test]
1421    fn config_defaults() {
1422        let config = FrameBudgetConfig::default();
1423
1424        assert_eq!(config.total, Duration::from_millis(16));
1425        assert!(config.allow_frame_skip);
1426        assert_eq!(config.degradation_cooldown, 3);
1427        assert!((config.upgrade_threshold - 0.5).abs() < f32::EPSILON);
1428    }
1429
1430    #[test]
1431    fn config_with_total() {
1432        let config = FrameBudgetConfig::with_total(Duration::from_millis(33));
1433
1434        assert_eq!(config.total, Duration::from_millis(33));
1435        // Other defaults preserved
1436        assert!(config.allow_frame_skip);
1437    }
1438
1439    #[test]
1440    fn config_strict() {
1441        let config = FrameBudgetConfig::strict(Duration::from_millis(16));
1442
1443        assert!(!config.allow_frame_skip);
1444    }
1445
1446    #[test]
1447    fn config_relaxed() {
1448        let config = FrameBudgetConfig::relaxed();
1449
1450        assert_eq!(config.total, Duration::from_millis(33));
1451        assert_eq!(config.degradation_cooldown, 5);
1452    }
1453
1454    #[test]
1455    fn from_config() {
1456        let config = FrameBudgetConfig {
1457            total: Duration::from_millis(20),
1458            allow_frame_skip: false,
1459            ..Default::default()
1460        };
1461
1462        let budget = RenderBudget::from_config(&config);
1463
1464        assert_eq!(budget.total(), Duration::from_millis(20));
1465        assert!(!budget.exhausted()); // allow_frame_skip is false
1466
1467        // Set to SkipFrame - should NOT be exhausted since frame skip disabled
1468        let mut budget = RenderBudget::from_config(&config);
1469        budget.set_degradation(DegradationLevel::SkipFrame);
1470        assert!(!budget.exhausted());
1471    }
1472
1473    #[test]
1474    fn phase_budgets_default() {
1475        let budgets = PhaseBudgets::default();
1476
1477        assert_eq!(budgets.diff, Duration::from_millis(2));
1478        assert_eq!(budgets.present, Duration::from_millis(4));
1479        assert_eq!(budgets.render, Duration::from_millis(8));
1480    }
1481
1482    #[test]
1483    fn phase_has_budget() {
1484        let budget = RenderBudget::new(Duration::from_millis(100));
1485
1486        assert!(budget.phase_has_budget(Phase::Diff));
1487        assert!(budget.phase_has_budget(Phase::Present));
1488        assert!(budget.phase_has_budget(Phase::Render));
1489    }
1490
1491    #[test]
1492    fn phase_budget_respects_remaining() {
1493        let budget = RenderBudget::new(Duration::from_millis(100));
1494
1495        let diff_budget = budget.phase_budget(Phase::Diff);
1496        assert_eq!(diff_budget.total(), Duration::from_millis(2));
1497
1498        let present_budget = budget.phase_budget(Phase::Present);
1499        assert_eq!(present_budget.total(), Duration::from_millis(4));
1500    }
1501
1502    #[test]
1503    fn phase_as_str() {
1504        assert_eq!(Phase::Diff.as_str(), "diff");
1505        assert_eq!(Phase::Present.as_str(), "present");
1506        assert_eq!(Phase::Render.as_str(), "render");
1507    }
1508
1509    #[test]
1510    fn zero_budget_is_immediately_exhausted() {
1511        let budget = RenderBudget::new(Duration::ZERO);
1512        assert!(budget.exhausted());
1513        assert_eq!(budget.remaining_fraction(), 0.0);
1514    }
1515
1516    #[test]
1517    fn degradation_level_never_exceeds_skip_frame() {
1518        let mut level = DegradationLevel::Full;
1519
1520        for _ in 0..100 {
1521            level = level.next();
1522        }
1523
1524        assert_eq!(level, DegradationLevel::SkipFrame);
1525    }
1526
1527    #[test]
1528    fn budget_remaining_never_negative() {
1529        let budget = RenderBudget::new(Duration::from_millis(1));
1530
1531        // Wait well past the budget
1532        thread::sleep(Duration::from_millis(10));
1533
1534        // Should be zero, not negative
1535        assert_eq!(budget.remaining(), Duration::ZERO);
1536        assert_eq!(budget.remaining_fraction(), 0.0);
1537    }
1538
1539    #[test]
1540    fn infinite_budget_stays_at_full() {
1541        let mut budget = RenderBudget::new(Duration::from_secs(1000));
1542
1543        // With huge budget, should never need to degrade
1544        assert!(!budget.should_degrade(Duration::from_millis(100)));
1545        assert_eq!(budget.degradation(), DegradationLevel::Full);
1546
1547        // Next frame should not upgrade since already at full
1548        budget.next_frame();
1549        assert_eq!(budget.degradation(), DegradationLevel::Full);
1550    }
1551
1552    #[test]
1553    fn cooldown_prevents_immediate_upgrade() {
1554        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1555        budget.cooldown = 3;
1556
1557        // Degrade
1558        budget.degrade();
1559        assert_eq!(budget.frames_since_change, 0);
1560
1561        // Should not upgrade immediately (cooldown not met)
1562        assert!(!budget.should_upgrade());
1563
1564        // Simulate frames
1565        budget.frames_since_change = 3;
1566
1567        // Now should be able to upgrade
1568        assert!(budget.should_upgrade());
1569    }
1570
1571    #[test]
1572    fn set_degradation_resets_cooldown() {
1573        let mut budget = RenderBudget::new(Duration::from_millis(16));
1574        budget.frames_since_change = 10;
1575
1576        budget.set_degradation(DegradationLevel::NoStyling);
1577
1578        assert_eq!(budget.frames_since_change, 0);
1579    }
1580
1581    #[test]
1582    fn set_degradation_same_level_preserves_cooldown() {
1583        let mut budget = RenderBudget::new(Duration::from_millis(16));
1584        budget.frames_since_change = 10;
1585
1586        // Set to same level
1587        budget.set_degradation(DegradationLevel::Full);
1588
1589        // Cooldown preserved since level didn't change
1590        assert_eq!(budget.frames_since_change, 10);
1591    }
1592
1593    // -----------------------------------------------------------------------
1594    // Budget Controller Tests (bd-4kq0.3.1)
1595    // -----------------------------------------------------------------------
1596
1597    mod controller_tests {
1598        use super::super::*;
1599
1600        fn make_controller() -> BudgetController {
1601            BudgetController::new(BudgetControllerConfig::default())
1602        }
1603
1604        fn make_controller_with_config(
1605            target_ms: u64,
1606            warmup: u32,
1607            cooldown: u32,
1608        ) -> BudgetController {
1609            BudgetController::new(BudgetControllerConfig {
1610                target: Duration::from_millis(target_ms),
1611                eprocess: EProcessConfig {
1612                    warmup_frames: warmup,
1613                    ..Default::default()
1614                },
1615                cooldown_frames: cooldown,
1616                ..Default::default()
1617            })
1618        }
1619
1620        // --- PID response tests ---
1621
1622        #[test]
1623        fn pid_step_input_yields_nonzero_output() {
1624            let mut state = PidState::default();
1625            let gains = PidGains::default();
1626
1627            // Step input: constant error of 1.0
1628            let u = state.update(1.0, &gains);
1629            // Kp*1.0 + Ki*1.0 + Kd*(1.0 - 0.0) = 0.5 + 0.05 + 0.2 = 0.75
1630            assert!(
1631                (u - 0.75).abs() < 1e-10,
1632                "First PID output should be 0.75, got {}",
1633                u
1634            );
1635        }
1636
1637        #[test]
1638        fn pid_zero_error_zero_output() {
1639            let mut state = PidState::default();
1640            let gains = PidGains::default();
1641
1642            let u = state.update(0.0, &gains);
1643            assert!(
1644                u.abs() < 1e-10,
1645                "Zero error should produce zero output, got {}",
1646                u
1647            );
1648        }
1649
1650        #[test]
1651        fn pid_integral_accumulates() {
1652            let mut state = PidState::default();
1653            let gains = PidGains::default();
1654
1655            // Feed constant error
1656            state.update(1.0, &gains);
1657            state.update(1.0, &gains);
1658            state.update(1.0, &gains);
1659
1660            assert!(
1661                state.integral > 2.5,
1662                "Integral should accumulate: {}",
1663                state.integral
1664            );
1665        }
1666
1667        #[test]
1668        fn pid_integral_anti_windup() {
1669            let mut state = PidState::default();
1670            let gains = PidGains {
1671                integral_max: 2.0,
1672                ..Default::default()
1673            };
1674
1675            // Feed many frames of error to saturate integral
1676            for _ in 0..100 {
1677                state.update(10.0, &gains);
1678            }
1679
1680            assert!(
1681                state.integral <= 2.0 + f64::EPSILON,
1682                "Integral should be clamped to max: {}",
1683                state.integral
1684            );
1685            assert!(
1686                state.integral >= -2.0 - f64::EPSILON,
1687                "Integral should be clamped to -max: {}",
1688                state.integral
1689            );
1690        }
1691
1692        #[test]
1693        fn pid_derivative_responds_to_change() {
1694            let mut state = PidState::default();
1695            let gains = PidGains::default();
1696
1697            // First frame: error=0
1698            let u1 = state.update(0.0, &gains);
1699            // Second frame: error=1.0 (step change)
1700            let u2 = state.update(1.0, &gains);
1701
1702            // u2 should include derivative component Kd*(1.0 - 0.0) = 0.2
1703            assert!(
1704                u2 > u1,
1705                "Step change should produce larger output: u1={}, u2={}",
1706                u1,
1707                u2
1708            );
1709        }
1710
1711        #[test]
1712        fn pid_settling_after_step() {
1713            let mut state = PidState::default();
1714            let gains = PidGains::default();
1715
1716            // Apply step error then zero error (simulate settling)
1717            state.update(1.0, &gains);
1718            state.update(1.0, &gains);
1719            state.update(1.0, &gains);
1720
1721            // Now remove the error
1722            let mut outputs = Vec::new();
1723            for _ in 0..20 {
1724                outputs.push(state.update(0.0, &gains));
1725            }
1726
1727            // Output should trend toward zero (settling)
1728            let last = *outputs.last().unwrap();
1729            assert!(
1730                last.abs() < 0.5,
1731                "PID should settle toward zero: last={}",
1732                last
1733            );
1734        }
1735
1736        #[test]
1737        fn pid_reset_clears_state() {
1738            let mut state = PidState::default();
1739            let gains = PidGains::default();
1740
1741            state.update(5.0, &gains);
1742            state.update(5.0, &gains);
1743            assert!(state.integral.abs() > 0.0);
1744
1745            state.reset();
1746            assert_eq!(state.integral, 0.0);
1747            assert_eq!(state.prev_error, 0.0);
1748        }
1749
1750        // --- E-process tests ---
1751
1752        #[test]
1753        fn eprocess_starts_at_one() {
1754            let state = EProcessState::default();
1755            assert!(
1756                (state.e_value - 1.0).abs() < f64::EPSILON,
1757                "E-process should start at 1.0"
1758            );
1759        }
1760
1761        #[test]
1762        fn eprocess_grows_under_overload() {
1763            let mut state = EProcessState::default();
1764            let config = EProcessConfig {
1765                warmup_frames: 0,
1766                ..Default::default()
1767            };
1768
1769            // Feed sustained overload (30ms vs 16ms target)
1770            for _ in 0..20 {
1771                state.update(30.0, 16.0, &config);
1772            }
1773
1774            assert!(
1775                state.e_value > 1.0,
1776                "E-value should grow under overload: {}",
1777                state.e_value
1778            );
1779        }
1780
1781        #[test]
1782        fn eprocess_shrinks_under_underload() {
1783            let mut state = EProcessState::default();
1784            let config = EProcessConfig {
1785                warmup_frames: 0,
1786                ..Default::default()
1787            };
1788
1789            // Feed fast frames (8ms vs 16ms target)
1790            for _ in 0..20 {
1791                state.update(8.0, 16.0, &config);
1792            }
1793
1794            assert!(
1795                state.e_value < 1.0,
1796                "E-value should shrink under underload: {}",
1797                state.e_value
1798            );
1799        }
1800
1801        #[test]
1802        fn eprocess_gate_blocks_during_warmup() {
1803            let mut state = EProcessState::default();
1804            let config = EProcessConfig {
1805                warmup_frames: 10,
1806                ..Default::default()
1807            };
1808
1809            // Feed overload during warmup
1810            for _ in 0..5 {
1811                state.update(50.0, 16.0, &config);
1812            }
1813
1814            assert!(
1815                !state.should_degrade(&config),
1816                "E-process should not permit degradation during warmup"
1817            );
1818        }
1819
1820        #[test]
1821        fn eprocess_gate_allows_after_warmup() {
1822            let mut state = EProcessState::default();
1823            let config = EProcessConfig {
1824                warmup_frames: 5,
1825                alpha: 0.05,
1826                ..Default::default()
1827            };
1828
1829            // Feed severe overload past warmup
1830            for _ in 0..50 {
1831                state.update(80.0, 16.0, &config);
1832            }
1833
1834            assert!(
1835                state.should_degrade(&config),
1836                "E-process should permit degradation after sustained overload: E={}",
1837                state.e_value
1838            );
1839        }
1840
1841        #[test]
1842        fn eprocess_recovery_after_overload() {
1843            let mut state = EProcessState::default();
1844            let config = EProcessConfig {
1845                warmup_frames: 0,
1846                ..Default::default()
1847            };
1848
1849            // Overload phase
1850            for _ in 0..30 {
1851                state.update(40.0, 16.0, &config);
1852            }
1853            let peak = state.e_value;
1854
1855            // Recovery phase (fast frames)
1856            for _ in 0..100 {
1857                state.update(8.0, 16.0, &config);
1858            }
1859
1860            assert!(
1861                state.e_value < peak,
1862                "E-value should decrease after recovery: peak={}, now={}",
1863                peak,
1864                state.e_value
1865            );
1866        }
1867
1868        #[test]
1869        fn eprocess_sigma_floor_prevents_instability() {
1870            let mut state = EProcessState::default();
1871            let config = EProcessConfig {
1872                sigma_floor_ms: 1.0,
1873                warmup_frames: 0,
1874                ..Default::default()
1875            };
1876
1877            // Feed identical frames (zero variance)
1878            for _ in 0..20 {
1879                state.update(16.0, 16.0, &config);
1880            }
1881
1882            // sigma_ema should not be below floor
1883            assert!(
1884                state.sigma_ema >= 0.0,
1885                "Sigma should be non-negative: {}",
1886                state.sigma_ema
1887            );
1888            // E-value should remain finite
1889            assert!(
1890                state.e_value.is_finite(),
1891                "E-value should be finite: {}",
1892                state.e_value
1893            );
1894        }
1895
1896        #[test]
1897        fn eprocess_reset_returns_to_initial() {
1898            let mut state = EProcessState::default();
1899            let config = EProcessConfig::default();
1900
1901            state.update(50.0, 16.0, &config);
1902            state.update(50.0, 16.0, &config);
1903
1904            state.reset();
1905            assert!((state.e_value - 1.0).abs() < f64::EPSILON);
1906            assert_eq!(state.frames_observed, 0);
1907        }
1908
1909        // --- Controller integration tests ---
1910
1911        #[test]
1912        fn controller_holds_under_normal_load() {
1913            let mut ctrl = make_controller_with_config(16, 0, 0);
1914
1915            // Feed on-target frames
1916            for _ in 0..20 {
1917                let decision = ctrl.update(Duration::from_millis(16));
1918                assert_eq!(
1919                    decision,
1920                    BudgetDecision::Hold,
1921                    "On-target frames should hold"
1922                );
1923            }
1924            assert_eq!(ctrl.level(), DegradationLevel::Full);
1925        }
1926
1927        #[test]
1928        fn controller_degrades_under_sustained_overload() {
1929            let mut ctrl = make_controller_with_config(16, 0, 0);
1930
1931            let mut degraded = false;
1932            // Feed severe overload
1933            for _ in 0..50 {
1934                let decision = ctrl.update(Duration::from_millis(40));
1935                if decision == BudgetDecision::Degrade {
1936                    degraded = true;
1937                }
1938            }
1939
1940            assert!(
1941                degraded,
1942                "Controller should degrade under sustained overload"
1943            );
1944            assert!(
1945                ctrl.level() > DegradationLevel::Full,
1946                "Level should be degraded: {:?}",
1947                ctrl.level()
1948            );
1949        }
1950
1951        #[test]
1952        fn controller_upgrades_after_recovery() {
1953            let mut ctrl = make_controller_with_config(16, 0, 0);
1954
1955            // Overload to degrade
1956            for _ in 0..50 {
1957                ctrl.update(Duration::from_millis(40));
1958            }
1959            let degraded_level = ctrl.level();
1960            assert!(degraded_level > DegradationLevel::Full);
1961
1962            // Recovery: fast frames
1963            let mut upgraded = false;
1964            for _ in 0..200 {
1965                let decision = ctrl.update(Duration::from_millis(4));
1966                if decision == BudgetDecision::Upgrade {
1967                    upgraded = true;
1968                }
1969            }
1970
1971            assert!(upgraded, "Controller should upgrade after recovery");
1972            assert!(
1973                ctrl.level() < degraded_level,
1974                "Level should improve: before={:?}, after={:?}",
1975                degraded_level,
1976                ctrl.level()
1977            );
1978        }
1979
1980        #[test]
1981        fn controller_cooldown_prevents_oscillation() {
1982            let mut ctrl = make_controller_with_config(16, 0, 5);
1983
1984            // Trigger degradation
1985            for _ in 0..50 {
1986                ctrl.update(Duration::from_millis(40));
1987            }
1988
1989            // Immediately try fast frames
1990            let mut decisions_during_cooldown = Vec::new();
1991            for _ in 0..4 {
1992                decisions_during_cooldown.push(ctrl.update(Duration::from_millis(4)));
1993            }
1994
1995            // During cooldown (frames 0-4), should all be Hold
1996            assert!(
1997                decisions_during_cooldown
1998                    .iter()
1999                    .all(|d| *d == BudgetDecision::Hold),
2000                "Cooldown should prevent changes: {:?}",
2001                decisions_during_cooldown
2002            );
2003        }
2004
2005        #[test]
2006        fn controller_no_oscillation_under_constant_load() {
2007            let mut ctrl = make_controller_with_config(16, 0, 3);
2008
2009            // Moderate overload (20ms vs 16ms)
2010            let mut transitions = 0u32;
2011            let mut prev_level = ctrl.level();
2012            for _ in 0..100 {
2013                ctrl.update(Duration::from_millis(20));
2014                if ctrl.level() != prev_level {
2015                    transitions += 1;
2016                    prev_level = ctrl.level();
2017                }
2018            }
2019
2020            // Under constant load, transitions should be limited
2021            // (progressive degradation, not oscillation)
2022            assert!(
2023                transitions < 10,
2024                "Too many transitions under constant load: {}",
2025                transitions
2026            );
2027        }
2028
2029        #[test]
2030        fn controller_reset_restores_full_quality() {
2031            let mut ctrl = make_controller();
2032
2033            // Degrade
2034            for _ in 0..50 {
2035                ctrl.update(Duration::from_millis(40));
2036            }
2037
2038            ctrl.reset();
2039
2040            assert_eq!(ctrl.level(), DegradationLevel::Full);
2041            assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
2042            assert_eq!(ctrl.pid_integral(), 0.0);
2043        }
2044
2045        #[test]
2046        fn controller_transient_spike_does_not_degrade() {
2047            let mut ctrl = make_controller_with_config(16, 5, 3);
2048
2049            // Normal frames to build history
2050            for _ in 0..20 {
2051                ctrl.update(Duration::from_millis(16));
2052            }
2053
2054            // Single spike
2055            ctrl.update(Duration::from_millis(100));
2056
2057            // Back to normal
2058            for _ in 0..5 {
2059                ctrl.update(Duration::from_millis(16));
2060            }
2061
2062            // Should still be at full quality (spike was transient)
2063            assert_eq!(
2064                ctrl.level(),
2065                DegradationLevel::Full,
2066                "Single spike should not cause degradation"
2067            );
2068        }
2069
2070        #[test]
2071        fn controller_never_exceeds_skip_frame() {
2072            let mut ctrl = make_controller_with_config(16, 0, 0);
2073
2074            // Extreme overload
2075            for _ in 0..500 {
2076                ctrl.update(Duration::from_millis(200));
2077            }
2078
2079            assert!(
2080                ctrl.level() <= DegradationLevel::SkipFrame,
2081                "Level should not exceed SkipFrame: {:?}",
2082                ctrl.level()
2083            );
2084        }
2085
2086        #[test]
2087        fn controller_never_goes_below_full() {
2088            let mut ctrl = make_controller_with_config(16, 0, 0);
2089
2090            // Extreme underload
2091            for _ in 0..200 {
2092                ctrl.update(Duration::from_millis(1));
2093            }
2094
2095            assert_eq!(
2096                ctrl.level(),
2097                DegradationLevel::Full,
2098                "Level should not go below Full"
2099            );
2100        }
2101
2102        // --- Config tests ---
2103
2104        #[test]
2105        fn pid_gains_default_valid() {
2106            let gains = PidGains::default();
2107            assert!(gains.kp > 0.0);
2108            assert!(gains.ki > 0.0);
2109            assert!(gains.kd > 0.0);
2110            assert!(gains.integral_max > 0.0);
2111        }
2112
2113        #[test]
2114        fn eprocess_config_default_valid() {
2115            let config = EProcessConfig::default();
2116            assert!(config.lambda > 0.0);
2117            assert!(config.alpha > 0.0 && config.alpha < 1.0);
2118            assert!(config.beta > 0.0 && config.beta < 1.0);
2119            assert!(config.sigma_floor_ms > 0.0);
2120        }
2121
2122        #[test]
2123        fn controller_config_default_valid() {
2124            let config = BudgetControllerConfig::default();
2125            assert!(config.degrade_threshold > 0.0);
2126            assert!(config.upgrade_threshold > 0.0);
2127            assert!(config.target > Duration::ZERO);
2128        }
2129
2130        #[test]
2131        fn budget_decision_equality() {
2132            assert_eq!(BudgetDecision::Hold, BudgetDecision::Hold);
2133            assert_ne!(BudgetDecision::Hold, BudgetDecision::Degrade);
2134            assert_ne!(BudgetDecision::Degrade, BudgetDecision::Upgrade);
2135        }
2136    }
2137
2138    // -----------------------------------------------------------------------
2139    // Budget Controller Integration + Telemetry Tests (bd-4kq0.3.2)
2140    // -----------------------------------------------------------------------
2141
2142    mod integration_tests {
2143        use super::super::*;
2144
2145        #[test]
2146        fn render_budget_without_controller_returns_no_telemetry() {
2147            let budget = RenderBudget::new(Duration::from_millis(16));
2148            assert!(budget.telemetry().is_none());
2149            assert!(budget.controller().is_none());
2150        }
2151
2152        #[test]
2153        fn render_budget_with_controller_returns_telemetry() {
2154            let budget = RenderBudget::new(Duration::from_millis(16))
2155                .with_controller(BudgetControllerConfig::default());
2156            assert!(budget.controller().is_some());
2157
2158            let telem = budget.telemetry().unwrap();
2159            assert_eq!(telem.level, DegradationLevel::Full);
2160            assert_eq!(telem.last_decision, BudgetDecision::Hold);
2161            assert_eq!(telem.frames_observed, 0);
2162            assert!(telem.in_warmup);
2163        }
2164
2165        #[test]
2166        fn telemetry_fields_update_after_next_frame() {
2167            let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2168                BudgetControllerConfig {
2169                    eprocess: EProcessConfig {
2170                        warmup_frames: 0,
2171                        ..Default::default()
2172                    },
2173                    cooldown_frames: 0,
2174                    ..Default::default()
2175                },
2176            );
2177
2178            // Simulate a few frames
2179            for _ in 0..5 {
2180                budget.next_frame();
2181            }
2182
2183            let telem = budget.telemetry().unwrap();
2184            assert_eq!(telem.frames_observed, 5);
2185            assert!(!telem.in_warmup);
2186            // PID output should be non-positive (frames are fast, under budget)
2187            // but the exact value depends on timing, so just check it's finite
2188            assert!(telem.pid_output.is_finite());
2189            assert!(telem.e_value.is_finite());
2190        }
2191
2192        #[test]
2193        fn controller_next_frame_degrades_under_simulated_overload() {
2194            // We can't easily simulate slow frames in unit tests (thread::sleep
2195            // would be flaky), so we test the controller integration by verifying
2196            // the decision path works: attach controller, manually check that
2197            // the controller's level is reflected in the budget's degradation.
2198            let config = BudgetControllerConfig {
2199                target: Duration::from_millis(16),
2200                eprocess: EProcessConfig {
2201                    warmup_frames: 0,
2202                    ..Default::default()
2203                },
2204                cooldown_frames: 0,
2205                ..Default::default()
2206            };
2207            let mut ctrl = BudgetController::new(config);
2208
2209            // Feed severe overload to the controller directly
2210            for _ in 0..50 {
2211                ctrl.update(Duration::from_millis(40));
2212            }
2213
2214            // Controller should have degraded
2215            assert!(
2216                ctrl.level() > DegradationLevel::Full,
2217                "Controller should degrade: {:?}",
2218                ctrl.level()
2219            );
2220
2221            // Telemetry should reflect the degradation
2222            let telem = ctrl.telemetry();
2223            assert!(telem.level > DegradationLevel::Full);
2224            assert!(
2225                telem.pid_output > 0.0,
2226                "PID output should be positive under overload"
2227            );
2228            assert!(telem.e_value > 1.0, "E-value should grow under overload");
2229        }
2230
2231        #[test]
2232        fn next_frame_delegates_to_controller_when_attached() {
2233            // With a controller, next_frame should not use the simple
2234            // threshold-based upgrade path
2235            let mut budget = RenderBudget::new(Duration::from_millis(1000))
2236                .with_controller(BudgetControllerConfig::default());
2237
2238            // Degrade manually
2239            budget.degrade();
2240            assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
2241
2242            // In legacy mode, next_frame would upgrade immediately (lots of budget).
2243            // With controller, it should hold because the controller hasn't seen
2244            // enough underload evidence yet.
2245            budget.next_frame();
2246
2247            // The controller may or may not upgrade depending on the single frame
2248            // measurement, but the key assertion is that the code path works.
2249            // With a fresh controller, the fast frame should eventually allow upgrade.
2250            // Just verify it doesn't panic and telemetry is populated.
2251            let telem = budget.telemetry().unwrap();
2252            assert_eq!(telem.frames_observed, 1);
2253        }
2254
2255        #[test]
2256        fn telemetry_is_copy_and_no_alloc() {
2257            let budget = RenderBudget::new(Duration::from_millis(16))
2258                .with_controller(BudgetControllerConfig::default());
2259
2260            let telem = budget.telemetry().unwrap();
2261            // BudgetTelemetry is Copy — verify by copying
2262            let telem2 = telem;
2263            assert_eq!(telem.level, telem2.level);
2264            assert_eq!(telem.e_value, telem2.e_value);
2265        }
2266
2267        #[test]
2268        fn telemetry_warmup_flag_transitions() {
2269            let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2270                BudgetControllerConfig {
2271                    eprocess: EProcessConfig {
2272                        warmup_frames: 3,
2273                        ..Default::default()
2274                    },
2275                    ..Default::default()
2276                },
2277            );
2278
2279            // During warmup
2280            budget.next_frame();
2281            budget.next_frame();
2282            let telem = budget.telemetry().unwrap();
2283            assert!(telem.in_warmup, "Should be in warmup at frame 2");
2284
2285            // After warmup
2286            budget.next_frame();
2287            let telem = budget.telemetry().unwrap();
2288            assert!(!telem.in_warmup, "Should exit warmup at frame 3");
2289        }
2290
2291        #[test]
2292        fn phase_sub_budget_does_not_carry_controller() {
2293            let budget = RenderBudget::new(Duration::from_millis(100))
2294                .with_controller(BudgetControllerConfig::default());
2295
2296            let phase = budget.phase_budget(Phase::Render);
2297            assert!(
2298                phase.controller().is_none(),
2299                "Phase sub-budgets should not carry the controller"
2300            );
2301        }
2302
2303        #[test]
2304        fn controller_telemetry_tracks_frames_since_change() {
2305            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2306                eprocess: EProcessConfig {
2307                    warmup_frames: 0,
2308                    ..Default::default()
2309                },
2310                cooldown_frames: 0,
2311                ..Default::default()
2312            });
2313
2314            // On-target frames: frames_since_change should increase
2315            for i in 1..=5 {
2316                ctrl.update(Duration::from_millis(16));
2317                let telem = ctrl.telemetry();
2318                assert_eq!(
2319                    telem.frames_since_change, i,
2320                    "frames_since_change should be {} after {} frames",
2321                    i, i
2322                );
2323            }
2324        }
2325
2326        #[test]
2327        fn telemetry_last_decision_reflects_controller_decision() {
2328            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2329                eprocess: EProcessConfig {
2330                    warmup_frames: 0,
2331                    ..Default::default()
2332                },
2333                cooldown_frames: 0,
2334                ..Default::default()
2335            });
2336
2337            // On-target: should hold
2338            ctrl.update(Duration::from_millis(16));
2339            assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Hold);
2340
2341            // Feed enough overload to trigger degrade
2342            let mut saw_degrade = false;
2343            for _ in 0..50 {
2344                let d = ctrl.update(Duration::from_millis(50));
2345                if d == BudgetDecision::Degrade {
2346                    saw_degrade = true;
2347                    assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Degrade);
2348                    break;
2349                }
2350            }
2351            assert!(saw_degrade, "Should have seen a Degrade decision");
2352        }
2353
2354        #[test]
2355        fn perf_overhead_controller_update_is_fast() {
2356            // Verify the controller update is a lightweight arithmetic operation.
2357            // We run 10_000 iterations and check they complete quickly.
2358            // This is a smoke test, not a precise benchmark (that's bd-4kq0.3.3).
2359            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2360
2361            let start = Instant::now();
2362            for _ in 0..10_000 {
2363                ctrl.update(Duration::from_millis(16));
2364            }
2365            let elapsed = start.elapsed();
2366
2367            // 10k iterations should complete in well under 10ms on any modern CPU.
2368            // At 16ms target, 2% overhead = 0.32ms per frame, so 10k frames
2369            // budget = 3.2 seconds worth of overhead budget. We check <50ms total.
2370            assert!(
2371                elapsed < Duration::from_millis(50),
2372                "10k controller updates took {:?}, expected <50ms",
2373                elapsed
2374            );
2375        }
2376
2377        #[test]
2378        fn perf_overhead_telemetry_snapshot_is_fast() {
2379            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2380            ctrl.update(Duration::from_millis(16));
2381
2382            let start = Instant::now();
2383            for _ in 0..10_000 {
2384                let _telem = ctrl.telemetry();
2385            }
2386            let elapsed = start.elapsed();
2387
2388            assert!(
2389                elapsed < Duration::from_millis(10),
2390                "10k telemetry snapshots took {:?}, expected <10ms",
2391                elapsed
2392            );
2393        }
2394    }
2395
2396    // -----------------------------------------------------------------------
2397    // Budget Stability + E2E Replay Tests (bd-4kq0.3.3)
2398    // -----------------------------------------------------------------------
2399
2400    mod stability_tests {
2401        use super::super::*;
2402
2403        /// Helper: create a controller with minimal warmup/cooldown for testing.
2404        fn fast_controller(target_ms: u64) -> BudgetController {
2405            BudgetController::new(BudgetControllerConfig {
2406                target: Duration::from_millis(target_ms),
2407                eprocess: EProcessConfig {
2408                    warmup_frames: 0,
2409                    ..Default::default()
2410                },
2411                cooldown_frames: 0,
2412                ..Default::default()
2413            })
2414        }
2415
2416        /// Helper: run a frame time trace through the controller and collect
2417        /// JSONL-style telemetry records (as structured tuples).
2418        /// Returns `(frame_index, frame_time_us, telemetry)` for each frame.
2419        fn run_trace(
2420            ctrl: &mut BudgetController,
2421            trace: &[Duration],
2422        ) -> Vec<(u64, u64, BudgetTelemetry)> {
2423            trace
2424                .iter()
2425                .enumerate()
2426                .map(|(i, &ft)| {
2427                    ctrl.update(ft);
2428                    let telem = ctrl.telemetry();
2429                    (i as u64, ft.as_micros() as u64, telem)
2430                })
2431                .collect()
2432        }
2433
2434        /// Count level transitions in a trace log.
2435        fn count_transitions(log: &[(u64, u64, BudgetTelemetry)]) -> u32 {
2436            let mut transitions = 0u32;
2437            for pair in log.windows(2) {
2438                if pair[0].2.level != pair[1].2.level {
2439                    transitions += 1;
2440                }
2441            }
2442            transitions
2443        }
2444
2445        // --- e2e_burst_logs ---
2446
2447        #[test]
2448        fn e2e_burst_logs_no_oscillation() {
2449            // Simulate bursty output: alternating bursts of slow frames
2450            // and calm periods. Verify no oscillation (bounded transitions).
2451            let mut ctrl = fast_controller(16);
2452
2453            let mut trace = Vec::new();
2454            for _cycle in 0..5 {
2455                // Burst: 10 frames at 40ms
2456                for _ in 0..10 {
2457                    trace.push(Duration::from_millis(40));
2458                }
2459                // Calm: 20 frames at 16ms
2460                for _ in 0..20 {
2461                    trace.push(Duration::from_millis(16));
2462                }
2463            }
2464
2465            let log = run_trace(&mut ctrl, &trace);
2466
2467            // Count level transitions. Under bursty load, transitions should
2468            // be bounded — no rapid oscillation. With 5 cycles of 30 frames
2469            // each (150 total), we expect at most ~15 transitions (degrade
2470            // during each burst, upgrade during each calm).
2471            let transitions = count_transitions(&log);
2472            assert!(
2473                transitions < 20,
2474                "Too many transitions under bursty load: {} (expected <20)",
2475                transitions
2476            );
2477
2478            // Verify all telemetry fields are populated
2479            for (frame, ft_us, telem) in &log {
2480                assert!(
2481                    telem.pid_output.is_finite(),
2482                    "frame {}: NaN pid_output",
2483                    frame
2484                );
2485                assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2486                assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2487                assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2488                assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2489                assert!(*ft_us > 0, "frame {}: zero frame time", frame);
2490            }
2491        }
2492
2493        #[test]
2494        fn e2e_burst_recovers_after_moderate_overload() {
2495            // Moderate bursts (30ms vs 16ms target) followed by calm periods.
2496            // The controller may degrade during bursts, but should recover
2497            // during calm periods — final state should not be SkipFrame.
2498            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2499                target: Duration::from_millis(16),
2500                eprocess: EProcessConfig {
2501                    warmup_frames: 5,
2502                    ..Default::default()
2503                },
2504                cooldown_frames: 3,
2505                ..Default::default()
2506            });
2507
2508            let mut trace = Vec::new();
2509            for _cycle in 0..3 {
2510                // Moderate burst
2511                for _ in 0..15 {
2512                    trace.push(Duration::from_millis(30));
2513                }
2514                // Extended calm to allow recovery
2515                for _ in 0..50 {
2516                    trace.push(Duration::from_millis(10));
2517                }
2518            }
2519
2520            let log = run_trace(&mut ctrl, &trace);
2521
2522            // After each calm period, level should have recovered below Skeleton.
2523            // Check at the end of each calm phase (frames 64, 129, 194).
2524            for cycle in 0..3 {
2525                let calm_end = (cycle + 1) * 65 - 1;
2526                if calm_end < log.len() {
2527                    assert!(
2528                        log[calm_end].2.level < DegradationLevel::SkipFrame,
2529                        "cycle {}: should recover after calm period, got {:?} at frame {}",
2530                        cycle,
2531                        log[calm_end].2.level,
2532                        calm_end
2533                    );
2534                }
2535            }
2536
2537            // Final level should be better than Skeleton
2538            let final_level = log.last().unwrap().2.level;
2539            assert!(
2540                final_level < DegradationLevel::Skeleton,
2541                "Final level should recover below Skeleton: {:?}",
2542                final_level
2543            );
2544        }
2545
2546        // --- e2e_idle_to_burst ---
2547
2548        #[test]
2549        fn e2e_idle_to_burst_recovery() {
2550            // Start idle (well under budget), then sudden burst, then back to idle.
2551            // Verify: fast recovery without over-degrading.
2552            let mut ctrl = fast_controller(16);
2553
2554            let mut trace = Vec::new();
2555            // Phase 1: idle (8ms frames)
2556            for _ in 0..50 {
2557                trace.push(Duration::from_millis(8));
2558            }
2559            // Phase 2: sudden burst (50ms frames)
2560            for _ in 0..20 {
2561                trace.push(Duration::from_millis(50));
2562            }
2563            // Phase 3: recovery (8ms frames)
2564            for _ in 0..100 {
2565                trace.push(Duration::from_millis(8));
2566            }
2567
2568            let log = run_trace(&mut ctrl, &trace);
2569
2570            // After idle phase (frame 49), should still be Full
2571            assert_eq!(
2572                log[49].2.level,
2573                DegradationLevel::Full,
2574                "Should be Full during idle phase"
2575            );
2576
2577            // During burst, should degrade
2578            let max_during_burst = log[50..70].iter().map(|(_, _, t)| t.level).max().unwrap();
2579            assert!(
2580                max_during_burst > DegradationLevel::Full,
2581                "Should degrade during burst"
2582            );
2583
2584            // After recovery (last 20 frames), should have recovered toward Full
2585            let final_level = log.last().unwrap().2.level;
2586            assert!(
2587                final_level < max_during_burst,
2588                "Should recover after burst: final={:?}, max_during_burst={:?}",
2589                final_level,
2590                max_during_burst
2591            );
2592        }
2593
2594        #[test]
2595        fn e2e_idle_to_burst_no_over_degrade() {
2596            // A brief burst (5 frames) should not cause more than 1-2 levels
2597            // of degradation, even with zero warmup.
2598            let mut ctrl = fast_controller(16);
2599
2600            // Idle
2601            for _ in 0..30 {
2602                ctrl.update(Duration::from_millis(8));
2603            }
2604
2605            // Brief burst (only 5 frames)
2606            for _ in 0..5 {
2607                ctrl.update(Duration::from_millis(40));
2608            }
2609
2610            // Check degradation is modest
2611            let level = ctrl.level();
2612            assert!(
2613                level <= DegradationLevel::NoStyling,
2614                "Brief burst should not over-degrade: {:?}",
2615                level
2616            );
2617        }
2618
2619        // --- property_random_load ---
2620
2621        #[test]
2622        fn property_random_load_hysteresis_bounds() {
2623            // Verify: degradation changes are bounded by hysteresis constraints.
2624            // Specifically, level can only change by 1 step per decision.
2625            let mut ctrl = fast_controller(16);
2626
2627            // Generate a deterministic pseudo-random load trace using a simple
2628            // linear congruential generator (no std::rand dependency).
2629            let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_BABE;
2630            let mut trace = Vec::new();
2631            for _ in 0..1000 {
2632                // LCG: next = (a * state + c) mod m
2633                rng_state = rng_state
2634                    .wrapping_mul(6_364_136_223_846_793_005)
2635                    .wrapping_add(1_442_695_040_888_963_407);
2636                // Map to frame time: 4ms..80ms
2637                let frame_ms = 4 + ((rng_state >> 33) % 77);
2638                trace.push(Duration::from_millis(frame_ms));
2639            }
2640
2641            let log = run_trace(&mut ctrl, &trace);
2642
2643            // Property 1: Level only changes by at most 1 step per frame
2644            for pair in log.windows(2) {
2645                let prev = pair[0].2.level.level();
2646                let curr = pair[1].2.level.level();
2647                let delta = (curr as i16 - prev as i16).unsigned_abs();
2648                assert!(
2649                    delta <= 1,
2650                    "Level jumped {} steps at frame {}: {:?} -> {:?}",
2651                    delta,
2652                    pair[1].0,
2653                    pair[0].2.level,
2654                    pair[1].2.level
2655                );
2656            }
2657
2658            // Property 2: Level never exceeds valid range
2659            for (frame, _, telem) in &log {
2660                assert!(
2661                    telem.level <= DegradationLevel::SkipFrame,
2662                    "frame {}: level out of range: {:?}",
2663                    frame,
2664                    telem.level
2665                );
2666            }
2667
2668            // Property 3: All numeric fields are finite
2669            for (frame, _, telem) in &log {
2670                assert!(
2671                    telem.pid_output.is_finite(),
2672                    "frame {}: NaN pid_output",
2673                    frame
2674                );
2675                assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2676                assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2677                assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2678                assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2679                assert!(
2680                    telem.e_value > 0.0,
2681                    "frame {}: e_value not positive: {}",
2682                    frame,
2683                    telem.e_value
2684                );
2685            }
2686        }
2687
2688        #[test]
2689        fn property_random_load_bounded_transitions() {
2690            // Under random load, transitions should be bounded and not exceed
2691            // a reasonable rate (no rapid oscillation).
2692            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2693                target: Duration::from_millis(16),
2694                eprocess: EProcessConfig {
2695                    warmup_frames: 5,
2696                    ..Default::default()
2697                },
2698                cooldown_frames: 3,
2699                ..Default::default()
2700            });
2701
2702            // Deterministic pseudo-random trace
2703            let mut rng_state: u64 = 0x1234_5678_9ABC_DEF0;
2704            let mut trace = Vec::new();
2705            for _ in 0..500 {
2706                rng_state = rng_state
2707                    .wrapping_mul(6_364_136_223_846_793_005)
2708                    .wrapping_add(1_442_695_040_888_963_407);
2709                let frame_ms = 8 + ((rng_state >> 33) % 40);
2710                trace.push(Duration::from_millis(frame_ms));
2711            }
2712
2713            let log = run_trace(&mut ctrl, &trace);
2714            let transitions = count_transitions(&log);
2715
2716            // With cooldown=3 and 500 frames, max theoretical transitions = 500/4 = 125.
2717            // In practice with hysteresis + e-process gating, much less.
2718            assert!(
2719                transitions < 80,
2720                "Too many transitions under random load: {} (expected <80 with cooldown=3)",
2721                transitions
2722            );
2723        }
2724
2725        #[test]
2726        fn property_deterministic_replay() {
2727            // Same trace should produce identical telemetry every time.
2728            let trace: Vec<Duration> = (0..100)
2729                .map(|i| Duration::from_millis(10 + (i * 7 % 30)))
2730                .collect();
2731
2732            let mut ctrl1 = fast_controller(16);
2733            let log1 = run_trace(&mut ctrl1, &trace);
2734
2735            let mut ctrl2 = fast_controller(16);
2736            let log2 = run_trace(&mut ctrl2, &trace);
2737
2738            for (r1, r2) in log1.iter().zip(log2.iter()) {
2739                assert_eq!(r1.0, r2.0, "frame index mismatch");
2740                assert_eq!(r1.1, r2.1, "frame time mismatch");
2741                assert_eq!(r1.2.level, r2.2.level, "level mismatch at frame {}", r1.0);
2742                assert_eq!(
2743                    r1.2.last_decision, r2.2.last_decision,
2744                    "decision mismatch at frame {}",
2745                    r1.0
2746                );
2747                assert!(
2748                    (r1.2.pid_output - r2.2.pid_output).abs() < 1e-10,
2749                    "pid_output mismatch at frame {}: {} vs {}",
2750                    r1.0,
2751                    r1.2.pid_output,
2752                    r2.2.pid_output
2753                );
2754                assert!(
2755                    (r1.2.e_value - r2.2.e_value).abs() < 1e-10,
2756                    "e_value mismatch at frame {}: {} vs {}",
2757                    r1.0,
2758                    r1.2.e_value,
2759                    r2.2.e_value
2760                );
2761            }
2762        }
2763
2764        // --- JSONL schema validation ---
2765
2766        #[test]
2767        fn telemetry_jsonl_fields_complete() {
2768            // Verify all JSONL schema fields are accessible from BudgetTelemetry.
2769            let mut ctrl = fast_controller(16);
2770            ctrl.update(Duration::from_millis(20));
2771
2772            let telem = ctrl.telemetry();
2773
2774            // All schema fields present and accessible:
2775            let _degradation: &str = telem.level.as_str();
2776            let _pid_p: f64 = telem.pid_p;
2777            let _pid_i: f64 = telem.pid_i;
2778            let _pid_d: f64 = telem.pid_d;
2779            let _e_value: f64 = telem.e_value;
2780            let _decision: &str = telem.last_decision.as_str();
2781            let _frames: u32 = telem.frames_observed;
2782
2783            // Verify decision string mapping
2784            assert_eq!(BudgetDecision::Hold.as_str(), "stay");
2785            assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
2786            assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
2787        }
2788
2789        #[test]
2790        fn telemetry_pid_components_sum_to_output() {
2791            // Verify P + I + D == total PID output.
2792            let mut ctrl = fast_controller(16);
2793
2794            for ms in [10u64, 16, 20, 30, 8, 50] {
2795                ctrl.update(Duration::from_millis(ms));
2796                let telem = ctrl.telemetry();
2797                let sum = telem.pid_p + telem.pid_i + telem.pid_d;
2798                assert!(
2799                    (sum - telem.pid_output).abs() < 1e-10,
2800                    "P+I+D != output at {}ms: {} + {} + {} = {} != {}",
2801                    ms,
2802                    telem.pid_p,
2803                    telem.pid_i,
2804                    telem.pid_d,
2805                    sum,
2806                    telem.pid_output
2807                );
2808            }
2809        }
2810    }
2811}