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 web_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    #[must_use]
583    pub fn config(&self) -> &BudgetControllerConfig {
584        &self.config
585    }
586}
587
588/// Snapshot of budget controller telemetry for diagnostics and debug overlay.
589///
590/// All fields are `Copy` — no allocations. Intended to be cheaply captured
591/// once per frame and forwarded to a tracing subscriber or debug overlay widget.
592#[derive(Debug, Clone, Copy, PartialEq)]
593pub struct BudgetTelemetry {
594    /// Current degradation level.
595    pub level: DegradationLevel,
596    /// Last PID control signal (positive = over budget).
597    pub pid_output: f64,
598    /// Last PID proportional term.
599    pub pid_p: f64,
600    /// Last PID integral term.
601    pub pid_i: f64,
602    /// Last PID derivative term.
603    pub pid_d: f64,
604    /// Current e-process value E_t.
605    pub e_value: f64,
606    /// Frames observed by the e-process.
607    pub frames_observed: u32,
608    /// Frames since last level change.
609    pub frames_since_change: u32,
610    /// Last decision made by the controller.
611    pub last_decision: BudgetDecision,
612    /// Whether the controller is in warmup (e-process not yet active).
613    pub in_warmup: bool,
614}
615
616/// Progressive degradation levels for render quality.
617///
618/// Higher levels mean less visual fidelity but faster rendering.
619/// The ordering is significant: `Full` < `SimpleBorders` < ... < `SkipFrame`.
620#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
621#[repr(u8)]
622pub enum DegradationLevel {
623    /// All visual features enabled.
624    #[default]
625    Full = 0,
626    /// Unicode box-drawing replaced with ASCII (+--+).
627    SimpleBorders = 1,
628    /// Colors disabled, monochrome output.
629    NoStyling = 2,
630    /// Skip decorative widgets, essential content only.
631    EssentialOnly = 3,
632    /// Just layout boxes, no content.
633    Skeleton = 4,
634    /// Emergency: skip frame entirely.
635    SkipFrame = 5,
636}
637
638impl DegradationLevel {
639    /// Move to the next degradation level.
640    ///
641    /// Returns `SkipFrame` if already at maximum degradation.
642    #[inline]
643    #[must_use]
644    pub fn next(self) -> Self {
645        match self {
646            Self::Full => Self::SimpleBorders,
647            Self::SimpleBorders => Self::NoStyling,
648            Self::NoStyling => Self::EssentialOnly,
649            Self::EssentialOnly => Self::Skeleton,
650            Self::Skeleton | Self::SkipFrame => Self::SkipFrame,
651        }
652    }
653
654    /// Move to the previous (better quality) degradation level.
655    ///
656    /// Returns `Full` if already at minimum degradation.
657    #[inline]
658    #[must_use]
659    pub fn prev(self) -> Self {
660        match self {
661            Self::SkipFrame => Self::Skeleton,
662            Self::Skeleton => Self::EssentialOnly,
663            Self::EssentialOnly => Self::NoStyling,
664            Self::NoStyling => Self::SimpleBorders,
665            Self::SimpleBorders | Self::Full => Self::Full,
666        }
667    }
668
669    /// Check if this is the maximum degradation level.
670    #[inline]
671    pub fn is_max(self) -> bool {
672        self == Self::SkipFrame
673    }
674
675    /// Check if this is full quality (no degradation).
676    #[inline]
677    pub fn is_full(self) -> bool {
678        self == Self::Full
679    }
680
681    /// Get a human-readable name for logging.
682    #[inline]
683    pub fn as_str(self) -> &'static str {
684        match self {
685            Self::Full => "Full",
686            Self::SimpleBorders => "SimpleBorders",
687            Self::NoStyling => "NoStyling",
688            Self::EssentialOnly => "EssentialOnly",
689            Self::Skeleton => "Skeleton",
690            Self::SkipFrame => "SkipFrame",
691        }
692    }
693
694    /// Number of levels from Full (0) to this level.
695    #[inline]
696    pub fn level(self) -> u8 {
697        self as u8
698    }
699
700    // ---- Widget convenience queries ----
701
702    /// Whether to use Unicode box-drawing characters.
703    ///
704    /// Returns `false` at `SimpleBorders` and above (use ASCII instead).
705    #[inline]
706    pub fn use_unicode_borders(self) -> bool {
707        self < Self::SimpleBorders
708    }
709
710    /// Whether to apply colors and style attributes to cells.
711    ///
712    /// Returns `false` at `NoStyling` and above.
713    #[inline]
714    pub fn apply_styling(self) -> bool {
715        self < Self::NoStyling
716    }
717
718    /// Whether to render decorative (non-essential) elements.
719    ///
720    /// Returns `false` at `EssentialOnly` and above.
721    /// Decorative elements include borders, scrollbars, spinners, rules.
722    #[inline]
723    pub fn render_decorative(self) -> bool {
724        self < Self::EssentialOnly
725    }
726
727    /// Whether to render content text.
728    ///
729    /// Returns `false` at `Skeleton` and above.
730    #[inline]
731    pub fn render_content(self) -> bool {
732        self < Self::Skeleton
733    }
734}
735
736/// Per-phase time budgets within a frame.
737#[derive(Debug, Clone, Copy, PartialEq, Eq)]
738pub struct PhaseBudgets {
739    /// Budget for diff computation.
740    pub diff: Duration,
741    /// Budget for ANSI presentation/emission.
742    pub present: Duration,
743    /// Budget for widget rendering.
744    pub render: Duration,
745}
746
747impl Default for PhaseBudgets {
748    fn default() -> Self {
749        Self {
750            diff: Duration::from_millis(2),
751            present: Duration::from_millis(4),
752            render: Duration::from_millis(8),
753        }
754    }
755}
756
757/// Configuration for frame budget behavior.
758#[derive(Debug, Clone, PartialEq)]
759pub struct FrameBudgetConfig {
760    /// Total time budget per frame.
761    pub total: Duration,
762    /// Per-phase budgets.
763    pub phase_budgets: PhaseBudgets,
764    /// Allow skipping frames entirely when severely over budget.
765    pub allow_frame_skip: bool,
766    /// Frames to wait between degradation level changes.
767    pub degradation_cooldown: u32,
768    /// Threshold (as fraction of total) above which we consider upgrading.
769    /// Default: 0.5 (upgrade when >50% budget remains).
770    pub upgrade_threshold: f32,
771}
772
773impl Default for FrameBudgetConfig {
774    fn default() -> Self {
775        Self {
776            total: Duration::from_millis(16), // ~60fps feel
777            phase_budgets: PhaseBudgets::default(),
778            allow_frame_skip: true,
779            degradation_cooldown: 3,
780            upgrade_threshold: 0.5,
781        }
782    }
783}
784
785impl FrameBudgetConfig {
786    /// Create a new config with the specified total budget.
787    pub fn with_total(total: Duration) -> Self {
788        Self {
789            total,
790            ..Default::default()
791        }
792    }
793
794    /// Create a strict config that never skips frames.
795    pub fn strict(total: Duration) -> Self {
796        Self {
797            total,
798            allow_frame_skip: false,
799            ..Default::default()
800        }
801    }
802
803    /// Create a relaxed config for slower refresh rates.
804    pub fn relaxed() -> Self {
805        Self {
806            total: Duration::from_millis(33), // ~30fps
807            degradation_cooldown: 5,
808            ..Default::default()
809        }
810    }
811}
812
813/// Render time budget with graceful degradation.
814///
815/// Tracks elapsed time within a frame and manages degradation level
816/// to maintain responsive rendering under load.
817#[derive(Debug, Clone)]
818pub struct RenderBudget {
819    /// Total time budget for this frame.
820    total: Duration,
821    /// When this frame started.
822    start: Instant,
823    /// Measured render+present time for the last frame (if recorded).
824    last_frame_time: Option<Duration>,
825    /// Current degradation level.
826    degradation: DegradationLevel,
827    /// Per-phase budgets.
828    phase_budgets: PhaseBudgets,
829    /// Allow frame skip at maximum degradation.
830    allow_frame_skip: bool,
831    /// Upgrade threshold fraction.
832    upgrade_threshold: f32,
833    /// Frames since last degradation change (for cooldown).
834    frames_since_change: u32,
835    /// Cooldown frames required between changes.
836    cooldown: u32,
837    /// Optional adaptive budget controller (PID + e-process).
838    /// When present, `next_frame()` delegates degradation decisions to the controller.
839    controller: Option<BudgetController>,
840}
841
842impl RenderBudget {
843    /// Create a new budget with the specified total time.
844    pub fn new(total: Duration) -> Self {
845        Self {
846            total,
847            start: Instant::now(),
848            last_frame_time: None,
849            degradation: DegradationLevel::Full,
850            phase_budgets: PhaseBudgets::default(),
851            allow_frame_skip: true,
852            upgrade_threshold: 0.5,
853            frames_since_change: 0,
854            cooldown: 3,
855            controller: None,
856        }
857    }
858
859    /// Create a budget from configuration.
860    pub fn from_config(config: &FrameBudgetConfig) -> Self {
861        Self {
862            total: config.total,
863            start: Instant::now(),
864            last_frame_time: None,
865            degradation: DegradationLevel::Full,
866            phase_budgets: config.phase_budgets,
867            allow_frame_skip: config.allow_frame_skip,
868            upgrade_threshold: config.upgrade_threshold,
869            frames_since_change: 0,
870            cooldown: config.degradation_cooldown,
871            controller: None,
872        }
873    }
874
875    /// Attach an adaptive budget controller to this render budget.
876    ///
877    /// When a controller is attached, `next_frame()` feeds the measured frame
878    /// duration to the controller and applies its degradation decisions
879    /// instead of the simple threshold-based upgrade logic.
880    ///
881    /// # Example
882    ///
883    /// ```
884    /// use ftui_render::budget::{RenderBudget, BudgetControllerConfig};
885    /// use std::time::Duration;
886    ///
887    /// let budget = RenderBudget::new(Duration::from_millis(16))
888    ///     .with_controller(BudgetControllerConfig::default());
889    /// ```
890    #[must_use]
891    pub fn with_controller(mut self, config: BudgetControllerConfig) -> Self {
892        self.controller = Some(BudgetController::new(config));
893        self
894    }
895
896    /// Get the total budget duration.
897    #[inline]
898    pub fn total(&self) -> Duration {
899        self.total
900    }
901
902    /// Get the elapsed time since budget started.
903    #[inline]
904    pub fn elapsed(&self) -> Duration {
905        self.start.elapsed()
906    }
907
908    /// Get the remaining time in the budget.
909    #[inline]
910    pub fn remaining(&self) -> Duration {
911        self.total.saturating_sub(self.start.elapsed())
912    }
913
914    /// Get the remaining time as a fraction of total (0.0 to 1.0).
915    #[inline]
916    pub fn remaining_fraction(&self) -> f32 {
917        if self.total.is_zero() {
918            return 0.0;
919        }
920        let remaining = self.remaining().as_secs_f32();
921        let total = self.total.as_secs_f32();
922        (remaining / total).clamp(0.0, 1.0)
923    }
924
925    /// Check if we should degrade given an estimated operation cost.
926    ///
927    /// Returns `true` if the estimated cost exceeds remaining budget.
928    #[inline]
929    pub fn should_degrade(&self, estimated_cost: Duration) -> bool {
930        self.remaining() < estimated_cost
931    }
932
933    /// Degrade to the next level.
934    ///
935    /// Logs a warning when degradation occurs.
936    pub fn degrade(&mut self) {
937        let from = self.degradation;
938        self.degradation = self.degradation.next();
939        self.frames_since_change = 0;
940
941        #[cfg(feature = "tracing")]
942        if from != self.degradation {
943            warn!(
944                from = from.as_str(),
945                to = self.degradation.as_str(),
946                remaining_ms = self.remaining().as_millis() as u32,
947                "render budget degradation"
948            );
949        }
950        let _ = from; // Suppress unused warning when tracing is disabled
951    }
952
953    /// Get the current degradation level.
954    #[inline]
955    pub fn degradation(&self) -> DegradationLevel {
956        self.degradation
957    }
958
959    /// Set the degradation level directly.
960    ///
961    /// Use with caution - prefer `degrade()` and `upgrade()` for gradual changes.
962    pub fn set_degradation(&mut self, level: DegradationLevel) {
963        if self.degradation != level {
964            self.degradation = level;
965            self.frames_since_change = 0;
966        }
967    }
968
969    /// Check if the budget is exhausted.
970    ///
971    /// Returns `true` if no time remains OR if at SkipFrame level.
972    #[inline]
973    pub fn exhausted(&self) -> bool {
974        self.remaining().is_zero()
975            || (self.degradation == DegradationLevel::SkipFrame && self.allow_frame_skip)
976    }
977
978    /// Check if we should attempt to upgrade quality.
979    ///
980    /// Returns `true` if more than `upgrade_threshold` of budget remains
981    /// and we're not already at full quality, and cooldown has passed.
982    pub fn should_upgrade(&self) -> bool {
983        !self.degradation.is_full()
984            && self.remaining_fraction() > self.upgrade_threshold
985            && self.frames_since_change >= self.cooldown
986    }
987
988    /// Check if we should upgrade using a measured frame time.
989    fn should_upgrade_with_elapsed(&self, elapsed: Duration) -> bool {
990        if self.degradation.is_full() || self.frames_since_change < self.cooldown {
991            return false;
992        }
993        self.remaining_fraction_for_elapsed(elapsed) > self.upgrade_threshold
994    }
995
996    /// Remaining fraction computed from an elapsed frame time.
997    fn remaining_fraction_for_elapsed(&self, elapsed: Duration) -> f32 {
998        if self.total.is_zero() {
999            return 0.0;
1000        }
1001        let remaining = self.total.saturating_sub(elapsed);
1002        let remaining = remaining.as_secs_f32();
1003        let total = self.total.as_secs_f32();
1004        (remaining / total).clamp(0.0, 1.0)
1005    }
1006
1007    /// Upgrade to the previous (better quality) level.
1008    ///
1009    /// Logs when upgrade occurs.
1010    pub fn upgrade(&mut self) {
1011        let from = self.degradation;
1012        self.degradation = self.degradation.prev();
1013        self.frames_since_change = 0;
1014
1015        #[cfg(feature = "tracing")]
1016        if from != self.degradation {
1017            trace!(
1018                from = from.as_str(),
1019                to = self.degradation.as_str(),
1020                remaining_fraction = self.remaining_fraction(),
1021                "render budget upgrade"
1022            );
1023        }
1024        let _ = from; // Suppress unused warning when tracing is disabled
1025    }
1026
1027    /// Reset the budget for a new frame.
1028    ///
1029    /// Keeps the current degradation level but resets timing.
1030    pub fn reset(&mut self) {
1031        self.start = Instant::now();
1032        self.frames_since_change = self.frames_since_change.saturating_add(1);
1033    }
1034
1035    /// Reset the budget and attempt upgrade if conditions are met.
1036    ///
1037    /// Call this at the start of each frame to enable recovery.
1038    ///
1039    /// When an adaptive controller is attached (via [`with_controller`](Self::with_controller)),
1040    /// the measured frame duration is fed to the controller and its decision
1041    /// (degrade / upgrade / hold) is applied automatically. The simple
1042    /// threshold-based upgrade path is skipped in that case.
1043    pub fn next_frame(&mut self) {
1044        let frame_time = self.last_frame_time.unwrap_or_else(|| self.start.elapsed());
1045
1046        if self.controller.is_some() {
1047            // Measure how long the previous frame took
1048
1049            // SAFETY: we just checked is_some; this avoids a borrow-checker
1050            // conflict with `&mut self` needed for degrade/upgrade below.
1051            let decision = self
1052                .controller
1053                .as_mut()
1054                .expect("controller guaranteed by is_some guard")
1055                .update(frame_time);
1056
1057            match decision {
1058                BudgetDecision::Degrade => self.degrade(),
1059                BudgetDecision::Upgrade => self.upgrade(),
1060                BudgetDecision::Hold => {}
1061            }
1062        } else {
1063            // Legacy path: simple threshold-based upgrade
1064            if self.should_upgrade_with_elapsed(frame_time) {
1065                self.upgrade();
1066            }
1067        }
1068        self.reset();
1069    }
1070
1071    /// Record the measured render+present time for the last frame.
1072    pub fn record_frame_time(&mut self, elapsed: Duration) {
1073        self.last_frame_time = Some(elapsed);
1074    }
1075
1076    /// Get a telemetry snapshot from the adaptive controller, if attached.
1077    ///
1078    /// Returns `None` if no controller is attached.
1079    /// This is allocation-free and safe to call every frame.
1080    #[inline]
1081    pub fn telemetry(&self) -> Option<BudgetTelemetry> {
1082        self.controller.as_ref().map(BudgetController::telemetry)
1083    }
1084
1085    /// Get a reference to the adaptive controller, if attached.
1086    #[inline]
1087    pub fn controller(&self) -> Option<&BudgetController> {
1088        self.controller.as_ref()
1089    }
1090
1091    /// Get the phase budgets.
1092    #[inline]
1093    #[must_use]
1094    pub fn phase_budgets(&self) -> &PhaseBudgets {
1095        &self.phase_budgets
1096    }
1097
1098    /// Check if a specific phase has budget remaining.
1099    pub fn phase_has_budget(&self, phase: Phase) -> bool {
1100        let phase_budget = match phase {
1101            Phase::Diff => self.phase_budgets.diff,
1102            Phase::Present => self.phase_budgets.present,
1103            Phase::Render => self.phase_budgets.render,
1104        };
1105        self.remaining() >= phase_budget
1106    }
1107
1108    /// Create a sub-budget for a specific phase.
1109    ///
1110    /// The sub-budget shares the same start time but has a phase-specific total.
1111    #[must_use]
1112    pub fn phase_budget(&self, phase: Phase) -> Self {
1113        let phase_total = match phase {
1114            Phase::Diff => self.phase_budgets.diff,
1115            Phase::Present => self.phase_budgets.present,
1116            Phase::Render => self.phase_budgets.render,
1117        };
1118        Self {
1119            total: phase_total.min(self.remaining()),
1120            start: self.start,
1121            last_frame_time: self.last_frame_time,
1122            degradation: self.degradation,
1123            phase_budgets: self.phase_budgets,
1124            allow_frame_skip: self.allow_frame_skip,
1125            upgrade_threshold: self.upgrade_threshold,
1126            frames_since_change: self.frames_since_change,
1127            cooldown: self.cooldown,
1128            controller: None, // Phase sub-budgets don't carry the controller
1129        }
1130    }
1131}
1132
1133/// Render phases for budget allocation.
1134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1135pub enum Phase {
1136    /// Buffer diff computation.
1137    Diff,
1138    /// ANSI sequence presentation.
1139    Present,
1140    /// Widget tree rendering.
1141    Render,
1142}
1143
1144impl Phase {
1145    /// Get a human-readable name.
1146    pub fn as_str(self) -> &'static str {
1147        match self {
1148            Self::Diff => "diff",
1149            Self::Present => "present",
1150            Self::Render => "render",
1151        }
1152    }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158    use std::thread;
1159
1160    #[test]
1161    fn degradation_level_ordering() {
1162        assert!(DegradationLevel::Full < DegradationLevel::SimpleBorders);
1163        assert!(DegradationLevel::SimpleBorders < DegradationLevel::NoStyling);
1164        assert!(DegradationLevel::NoStyling < DegradationLevel::EssentialOnly);
1165        assert!(DegradationLevel::EssentialOnly < DegradationLevel::Skeleton);
1166        assert!(DegradationLevel::Skeleton < DegradationLevel::SkipFrame);
1167    }
1168
1169    #[test]
1170    fn degradation_level_next() {
1171        assert_eq!(
1172            DegradationLevel::Full.next(),
1173            DegradationLevel::SimpleBorders
1174        );
1175        assert_eq!(
1176            DegradationLevel::SimpleBorders.next(),
1177            DegradationLevel::NoStyling
1178        );
1179        assert_eq!(
1180            DegradationLevel::NoStyling.next(),
1181            DegradationLevel::EssentialOnly
1182        );
1183        assert_eq!(
1184            DegradationLevel::EssentialOnly.next(),
1185            DegradationLevel::Skeleton
1186        );
1187        assert_eq!(
1188            DegradationLevel::Skeleton.next(),
1189            DegradationLevel::SkipFrame
1190        );
1191        assert_eq!(
1192            DegradationLevel::SkipFrame.next(),
1193            DegradationLevel::SkipFrame
1194        );
1195    }
1196
1197    #[test]
1198    fn degradation_level_prev() {
1199        assert_eq!(
1200            DegradationLevel::SkipFrame.prev(),
1201            DegradationLevel::Skeleton
1202        );
1203        assert_eq!(
1204            DegradationLevel::Skeleton.prev(),
1205            DegradationLevel::EssentialOnly
1206        );
1207        assert_eq!(
1208            DegradationLevel::EssentialOnly.prev(),
1209            DegradationLevel::NoStyling
1210        );
1211        assert_eq!(
1212            DegradationLevel::NoStyling.prev(),
1213            DegradationLevel::SimpleBorders
1214        );
1215        assert_eq!(
1216            DegradationLevel::SimpleBorders.prev(),
1217            DegradationLevel::Full
1218        );
1219        assert_eq!(DegradationLevel::Full.prev(), DegradationLevel::Full);
1220    }
1221
1222    #[test]
1223    fn degradation_level_is_max() {
1224        assert!(!DegradationLevel::Full.is_max());
1225        assert!(!DegradationLevel::Skeleton.is_max());
1226        assert!(DegradationLevel::SkipFrame.is_max());
1227    }
1228
1229    #[test]
1230    fn degradation_level_is_full() {
1231        assert!(DegradationLevel::Full.is_full());
1232        assert!(!DegradationLevel::SimpleBorders.is_full());
1233        assert!(!DegradationLevel::SkipFrame.is_full());
1234    }
1235
1236    #[test]
1237    fn degradation_level_as_str() {
1238        assert_eq!(DegradationLevel::Full.as_str(), "Full");
1239        assert_eq!(DegradationLevel::SimpleBorders.as_str(), "SimpleBorders");
1240        assert_eq!(DegradationLevel::NoStyling.as_str(), "NoStyling");
1241        assert_eq!(DegradationLevel::EssentialOnly.as_str(), "EssentialOnly");
1242        assert_eq!(DegradationLevel::Skeleton.as_str(), "Skeleton");
1243        assert_eq!(DegradationLevel::SkipFrame.as_str(), "SkipFrame");
1244    }
1245
1246    #[test]
1247    fn degradation_level_values() {
1248        assert_eq!(DegradationLevel::Full.level(), 0);
1249        assert_eq!(DegradationLevel::SimpleBorders.level(), 1);
1250        assert_eq!(DegradationLevel::NoStyling.level(), 2);
1251        assert_eq!(DegradationLevel::EssentialOnly.level(), 3);
1252        assert_eq!(DegradationLevel::Skeleton.level(), 4);
1253        assert_eq!(DegradationLevel::SkipFrame.level(), 5);
1254    }
1255
1256    #[test]
1257    fn budget_remaining_decreases() {
1258        let budget = RenderBudget::new(Duration::from_millis(100));
1259        let initial = budget.remaining();
1260
1261        thread::sleep(Duration::from_millis(10));
1262
1263        let later = budget.remaining();
1264        assert!(later < initial);
1265    }
1266
1267    #[test]
1268    fn budget_remaining_fraction() {
1269        let budget = RenderBudget::new(Duration::from_millis(100));
1270
1271        // Initially should be close to 1.0
1272        let initial = budget.remaining_fraction();
1273        assert!(initial > 0.9);
1274
1275        thread::sleep(Duration::from_millis(50));
1276
1277        // Should be around 0.5 now
1278        let later = budget.remaining_fraction();
1279        assert!(later < 0.6);
1280        assert!(later > 0.3);
1281    }
1282
1283    #[test]
1284    fn should_degrade_when_cost_exceeds_remaining() {
1285        // Use wider margins to avoid timing flakiness
1286        let budget = RenderBudget::new(Duration::from_millis(100));
1287
1288        // Wait until ~half budget is consumed (~50ms remaining)
1289        thread::sleep(Duration::from_millis(50));
1290
1291        // Should degrade for expensive operations (80ms > ~50ms remaining)
1292        assert!(budget.should_degrade(Duration::from_millis(80)));
1293        // Should not degrade for cheap operations (10ms < ~50ms remaining)
1294        assert!(!budget.should_degrade(Duration::from_millis(10)));
1295    }
1296
1297    #[test]
1298    fn degrade_advances_level() {
1299        let mut budget = RenderBudget::new(Duration::from_millis(16));
1300
1301        assert_eq!(budget.degradation(), DegradationLevel::Full);
1302
1303        budget.degrade();
1304        assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
1305
1306        budget.degrade();
1307        assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1308    }
1309
1310    #[test]
1311    fn exhausted_when_no_time_left() {
1312        let budget = RenderBudget::new(Duration::from_millis(5));
1313
1314        assert!(!budget.exhausted());
1315
1316        thread::sleep(Duration::from_millis(10));
1317
1318        assert!(budget.exhausted());
1319    }
1320
1321    #[test]
1322    fn exhausted_at_skip_frame() {
1323        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1324
1325        // Set to SkipFrame
1326        budget.set_degradation(DegradationLevel::SkipFrame);
1327
1328        // Should be exhausted even with time remaining
1329        assert!(budget.exhausted());
1330    }
1331
1332    #[test]
1333    fn should_upgrade_with_remaining_budget() {
1334        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1335
1336        // At Full, should not upgrade
1337        assert!(!budget.should_upgrade());
1338
1339        // Degrade and set cooldown frames
1340        budget.degrade();
1341        budget.frames_since_change = 5;
1342
1343        // With lots of budget remaining, should upgrade
1344        assert!(budget.should_upgrade());
1345    }
1346
1347    #[test]
1348    fn upgrade_improves_level() {
1349        let mut budget = RenderBudget::new(Duration::from_millis(16));
1350
1351        budget.set_degradation(DegradationLevel::Skeleton);
1352        assert_eq!(budget.degradation(), DegradationLevel::Skeleton);
1353
1354        budget.upgrade();
1355        assert_eq!(budget.degradation(), DegradationLevel::EssentialOnly);
1356
1357        budget.upgrade();
1358        assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1359    }
1360
1361    #[test]
1362    fn upgrade_downgrade_symmetric() {
1363        let mut budget = RenderBudget::new(Duration::from_millis(16));
1364
1365        // Degrade all the way
1366        while !budget.degradation().is_max() {
1367            budget.degrade();
1368        }
1369        assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
1370
1371        // Upgrade all the way
1372        while !budget.degradation().is_full() {
1373            budget.upgrade();
1374        }
1375        assert_eq!(budget.degradation(), DegradationLevel::Full);
1376    }
1377
1378    #[test]
1379    fn reset_preserves_degradation() {
1380        let mut budget = RenderBudget::new(Duration::from_millis(16));
1381
1382        budget.degrade();
1383        budget.degrade();
1384        let level = budget.degradation();
1385
1386        budget.reset();
1387
1388        assert_eq!(budget.degradation(), level);
1389        // Remaining should be close to full again
1390        assert!(budget.remaining_fraction() > 0.9);
1391    }
1392
1393    #[test]
1394    fn next_frame_upgrades_when_possible() {
1395        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1396
1397        // Degrade and simulate several frames
1398        budget.degrade();
1399        for _ in 0..5 {
1400            budget.reset();
1401        }
1402
1403        let before = budget.degradation();
1404        budget.next_frame();
1405
1406        // Should have upgraded
1407        assert!(budget.degradation() < before);
1408    }
1409
1410    #[test]
1411    fn next_frame_prefers_recorded_frame_time_for_upgrade() {
1412        let mut budget = RenderBudget::new(Duration::from_millis(16));
1413
1414        budget.degrade();
1415        for _ in 0..5 {
1416            budget.reset();
1417        }
1418
1419        // Record a fast frame, then wait long enough that start.elapsed()
1420        // would otherwise exceed the budget.
1421        budget.record_frame_time(Duration::from_millis(1));
1422        std::thread::sleep(Duration::from_millis(25));
1423
1424        let before = budget.degradation();
1425        budget.next_frame();
1426
1427        assert!(budget.degradation() < before);
1428    }
1429
1430    #[test]
1431    fn config_defaults() {
1432        let config = FrameBudgetConfig::default();
1433
1434        assert_eq!(config.total, Duration::from_millis(16));
1435        assert!(config.allow_frame_skip);
1436        assert_eq!(config.degradation_cooldown, 3);
1437        assert!((config.upgrade_threshold - 0.5).abs() < f32::EPSILON);
1438    }
1439
1440    #[test]
1441    fn config_with_total() {
1442        let config = FrameBudgetConfig::with_total(Duration::from_millis(33));
1443
1444        assert_eq!(config.total, Duration::from_millis(33));
1445        // Other defaults preserved
1446        assert!(config.allow_frame_skip);
1447    }
1448
1449    #[test]
1450    fn config_strict() {
1451        let config = FrameBudgetConfig::strict(Duration::from_millis(16));
1452
1453        assert!(!config.allow_frame_skip);
1454    }
1455
1456    #[test]
1457    fn config_relaxed() {
1458        let config = FrameBudgetConfig::relaxed();
1459
1460        assert_eq!(config.total, Duration::from_millis(33));
1461        assert_eq!(config.degradation_cooldown, 5);
1462    }
1463
1464    #[test]
1465    fn from_config() {
1466        let config = FrameBudgetConfig {
1467            total: Duration::from_millis(20),
1468            allow_frame_skip: false,
1469            ..Default::default()
1470        };
1471
1472        let budget = RenderBudget::from_config(&config);
1473
1474        assert_eq!(budget.total(), Duration::from_millis(20));
1475        assert!(!budget.exhausted()); // allow_frame_skip is false
1476
1477        // Set to SkipFrame - should NOT be exhausted since frame skip disabled
1478        let mut budget = RenderBudget::from_config(&config);
1479        budget.set_degradation(DegradationLevel::SkipFrame);
1480        assert!(!budget.exhausted());
1481    }
1482
1483    #[test]
1484    fn phase_budgets_default() {
1485        let budgets = PhaseBudgets::default();
1486
1487        assert_eq!(budgets.diff, Duration::from_millis(2));
1488        assert_eq!(budgets.present, Duration::from_millis(4));
1489        assert_eq!(budgets.render, Duration::from_millis(8));
1490    }
1491
1492    #[test]
1493    fn phase_has_budget() {
1494        let budget = RenderBudget::new(Duration::from_millis(100));
1495
1496        assert!(budget.phase_has_budget(Phase::Diff));
1497        assert!(budget.phase_has_budget(Phase::Present));
1498        assert!(budget.phase_has_budget(Phase::Render));
1499    }
1500
1501    #[test]
1502    fn phase_budget_respects_remaining() {
1503        let budget = RenderBudget::new(Duration::from_millis(100));
1504
1505        let diff_budget = budget.phase_budget(Phase::Diff);
1506        assert_eq!(diff_budget.total(), Duration::from_millis(2));
1507
1508        let present_budget = budget.phase_budget(Phase::Present);
1509        assert_eq!(present_budget.total(), Duration::from_millis(4));
1510    }
1511
1512    #[test]
1513    fn phase_as_str() {
1514        assert_eq!(Phase::Diff.as_str(), "diff");
1515        assert_eq!(Phase::Present.as_str(), "present");
1516        assert_eq!(Phase::Render.as_str(), "render");
1517    }
1518
1519    #[test]
1520    fn zero_budget_is_immediately_exhausted() {
1521        let budget = RenderBudget::new(Duration::ZERO);
1522        assert!(budget.exhausted());
1523        assert_eq!(budget.remaining_fraction(), 0.0);
1524    }
1525
1526    #[test]
1527    fn degradation_level_never_exceeds_skip_frame() {
1528        let mut level = DegradationLevel::Full;
1529
1530        for _ in 0..100 {
1531            level = level.next();
1532        }
1533
1534        assert_eq!(level, DegradationLevel::SkipFrame);
1535    }
1536
1537    #[test]
1538    fn budget_remaining_never_negative() {
1539        let budget = RenderBudget::new(Duration::from_millis(1));
1540
1541        // Wait well past the budget
1542        thread::sleep(Duration::from_millis(10));
1543
1544        // Should be zero, not negative
1545        assert_eq!(budget.remaining(), Duration::ZERO);
1546        assert_eq!(budget.remaining_fraction(), 0.0);
1547    }
1548
1549    #[test]
1550    fn infinite_budget_stays_at_full() {
1551        let mut budget = RenderBudget::new(Duration::from_secs(1000));
1552
1553        // With huge budget, should never need to degrade
1554        assert!(!budget.should_degrade(Duration::from_millis(100)));
1555        assert_eq!(budget.degradation(), DegradationLevel::Full);
1556
1557        // Next frame should not upgrade since already at full
1558        budget.next_frame();
1559        assert_eq!(budget.degradation(), DegradationLevel::Full);
1560    }
1561
1562    #[test]
1563    fn cooldown_prevents_immediate_upgrade() {
1564        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1565        budget.cooldown = 3;
1566
1567        // Degrade
1568        budget.degrade();
1569        assert_eq!(budget.frames_since_change, 0);
1570
1571        // Should not upgrade immediately (cooldown not met)
1572        assert!(!budget.should_upgrade());
1573
1574        // Simulate frames
1575        budget.frames_since_change = 3;
1576
1577        // Now should be able to upgrade
1578        assert!(budget.should_upgrade());
1579    }
1580
1581    #[test]
1582    fn set_degradation_resets_cooldown() {
1583        let mut budget = RenderBudget::new(Duration::from_millis(16));
1584        budget.frames_since_change = 10;
1585
1586        budget.set_degradation(DegradationLevel::NoStyling);
1587
1588        assert_eq!(budget.frames_since_change, 0);
1589    }
1590
1591    #[test]
1592    fn set_degradation_same_level_preserves_cooldown() {
1593        let mut budget = RenderBudget::new(Duration::from_millis(16));
1594        budget.frames_since_change = 10;
1595
1596        // Set to same level
1597        budget.set_degradation(DegradationLevel::Full);
1598
1599        // Cooldown preserved since level didn't change
1600        assert_eq!(budget.frames_since_change, 10);
1601    }
1602
1603    // -----------------------------------------------------------------------
1604    // Budget Controller Tests (bd-4kq0.3.1)
1605    // -----------------------------------------------------------------------
1606
1607    mod controller_tests {
1608        use super::super::*;
1609
1610        fn make_controller() -> BudgetController {
1611            BudgetController::new(BudgetControllerConfig::default())
1612        }
1613
1614        fn make_controller_with_config(
1615            target_ms: u64,
1616            warmup: u32,
1617            cooldown: u32,
1618        ) -> BudgetController {
1619            BudgetController::new(BudgetControllerConfig {
1620                target: Duration::from_millis(target_ms),
1621                eprocess: EProcessConfig {
1622                    warmup_frames: warmup,
1623                    ..Default::default()
1624                },
1625                cooldown_frames: cooldown,
1626                ..Default::default()
1627            })
1628        }
1629
1630        // --- PID response tests ---
1631
1632        #[test]
1633        fn pid_step_input_yields_nonzero_output() {
1634            let mut state = PidState::default();
1635            let gains = PidGains::default();
1636
1637            // Step input: constant error of 1.0
1638            let u = state.update(1.0, &gains);
1639            // Kp*1.0 + Ki*1.0 + Kd*(1.0 - 0.0) = 0.5 + 0.05 + 0.2 = 0.75
1640            assert!(
1641                (u - 0.75).abs() < 1e-10,
1642                "First PID output should be 0.75, got {}",
1643                u
1644            );
1645        }
1646
1647        #[test]
1648        fn pid_zero_error_zero_output() {
1649            let mut state = PidState::default();
1650            let gains = PidGains::default();
1651
1652            let u = state.update(0.0, &gains);
1653            assert!(
1654                u.abs() < 1e-10,
1655                "Zero error should produce zero output, got {}",
1656                u
1657            );
1658        }
1659
1660        #[test]
1661        fn pid_integral_accumulates() {
1662            let mut state = PidState::default();
1663            let gains = PidGains::default();
1664
1665            // Feed constant error
1666            state.update(1.0, &gains);
1667            state.update(1.0, &gains);
1668            state.update(1.0, &gains);
1669
1670            assert!(
1671                state.integral > 2.5,
1672                "Integral should accumulate: {}",
1673                state.integral
1674            );
1675        }
1676
1677        #[test]
1678        fn pid_integral_anti_windup() {
1679            let mut state = PidState::default();
1680            let gains = PidGains {
1681                integral_max: 2.0,
1682                ..Default::default()
1683            };
1684
1685            // Feed many frames of error to saturate integral
1686            for _ in 0..100 {
1687                state.update(10.0, &gains);
1688            }
1689
1690            assert!(
1691                state.integral <= 2.0 + f64::EPSILON,
1692                "Integral should be clamped to max: {}",
1693                state.integral
1694            );
1695            assert!(
1696                state.integral >= -2.0 - f64::EPSILON,
1697                "Integral should be clamped to -max: {}",
1698                state.integral
1699            );
1700        }
1701
1702        #[test]
1703        fn pid_derivative_responds_to_change() {
1704            let mut state = PidState::default();
1705            let gains = PidGains::default();
1706
1707            // First frame: error=0
1708            let u1 = state.update(0.0, &gains);
1709            // Second frame: error=1.0 (step change)
1710            let u2 = state.update(1.0, &gains);
1711
1712            // u2 should include derivative component Kd*(1.0 - 0.0) = 0.2
1713            assert!(
1714                u2 > u1,
1715                "Step change should produce larger output: u1={}, u2={}",
1716                u1,
1717                u2
1718            );
1719        }
1720
1721        #[test]
1722        fn pid_settling_after_step() {
1723            let mut state = PidState::default();
1724            let gains = PidGains::default();
1725
1726            // Apply step error then zero error (simulate settling)
1727            state.update(1.0, &gains);
1728            state.update(1.0, &gains);
1729            state.update(1.0, &gains);
1730
1731            // Now remove the error
1732            let mut outputs = Vec::new();
1733            for _ in 0..20 {
1734                outputs.push(state.update(0.0, &gains));
1735            }
1736
1737            // Output should trend toward zero (settling)
1738            let last = *outputs.last().unwrap();
1739            assert!(
1740                last.abs() < 0.5,
1741                "PID should settle toward zero: last={}",
1742                last
1743            );
1744        }
1745
1746        #[test]
1747        fn pid_reset_clears_state() {
1748            let mut state = PidState::default();
1749            let gains = PidGains::default();
1750
1751            state.update(5.0, &gains);
1752            state.update(5.0, &gains);
1753            assert!(state.integral.abs() > 0.0);
1754
1755            state.reset();
1756            assert_eq!(state.integral, 0.0);
1757            assert_eq!(state.prev_error, 0.0);
1758        }
1759
1760        // --- E-process tests ---
1761
1762        #[test]
1763        fn eprocess_starts_at_one() {
1764            let state = EProcessState::default();
1765            assert!(
1766                (state.e_value - 1.0).abs() < f64::EPSILON,
1767                "E-process should start at 1.0"
1768            );
1769        }
1770
1771        #[test]
1772        fn eprocess_grows_under_overload() {
1773            let mut state = EProcessState::default();
1774            let config = EProcessConfig {
1775                warmup_frames: 0,
1776                ..Default::default()
1777            };
1778
1779            // Feed sustained overload (30ms vs 16ms target)
1780            for _ in 0..20 {
1781                state.update(30.0, 16.0, &config);
1782            }
1783
1784            assert!(
1785                state.e_value > 1.0,
1786                "E-value should grow under overload: {}",
1787                state.e_value
1788            );
1789        }
1790
1791        #[test]
1792        fn eprocess_shrinks_under_underload() {
1793            let mut state = EProcessState::default();
1794            let config = EProcessConfig {
1795                warmup_frames: 0,
1796                ..Default::default()
1797            };
1798
1799            // Feed fast frames (8ms vs 16ms target)
1800            for _ in 0..20 {
1801                state.update(8.0, 16.0, &config);
1802            }
1803
1804            assert!(
1805                state.e_value < 1.0,
1806                "E-value should shrink under underload: {}",
1807                state.e_value
1808            );
1809        }
1810
1811        #[test]
1812        fn eprocess_gate_blocks_during_warmup() {
1813            let mut state = EProcessState::default();
1814            let config = EProcessConfig {
1815                warmup_frames: 10,
1816                ..Default::default()
1817            };
1818
1819            // Feed overload during warmup
1820            for _ in 0..5 {
1821                state.update(50.0, 16.0, &config);
1822            }
1823
1824            assert!(
1825                !state.should_degrade(&config),
1826                "E-process should not permit degradation during warmup"
1827            );
1828        }
1829
1830        #[test]
1831        fn eprocess_gate_allows_after_warmup() {
1832            let mut state = EProcessState::default();
1833            let config = EProcessConfig {
1834                warmup_frames: 5,
1835                alpha: 0.05,
1836                ..Default::default()
1837            };
1838
1839            // Feed severe overload past warmup
1840            for _ in 0..50 {
1841                state.update(80.0, 16.0, &config);
1842            }
1843
1844            assert!(
1845                state.should_degrade(&config),
1846                "E-process should permit degradation after sustained overload: E={}",
1847                state.e_value
1848            );
1849        }
1850
1851        #[test]
1852        fn eprocess_recovery_after_overload() {
1853            let mut state = EProcessState::default();
1854            let config = EProcessConfig {
1855                warmup_frames: 0,
1856                ..Default::default()
1857            };
1858
1859            // Overload phase
1860            for _ in 0..30 {
1861                state.update(40.0, 16.0, &config);
1862            }
1863            let peak = state.e_value;
1864
1865            // Recovery phase (fast frames)
1866            for _ in 0..100 {
1867                state.update(8.0, 16.0, &config);
1868            }
1869
1870            assert!(
1871                state.e_value < peak,
1872                "E-value should decrease after recovery: peak={}, now={}",
1873                peak,
1874                state.e_value
1875            );
1876        }
1877
1878        #[test]
1879        fn eprocess_sigma_floor_prevents_instability() {
1880            let mut state = EProcessState::default();
1881            let config = EProcessConfig {
1882                sigma_floor_ms: 1.0,
1883                warmup_frames: 0,
1884                ..Default::default()
1885            };
1886
1887            // Feed identical frames (zero variance)
1888            for _ in 0..20 {
1889                state.update(16.0, 16.0, &config);
1890            }
1891
1892            // sigma_ema should not be below floor
1893            assert!(
1894                state.sigma_ema >= 0.0,
1895                "Sigma should be non-negative: {}",
1896                state.sigma_ema
1897            );
1898            // E-value should remain finite
1899            assert!(
1900                state.e_value.is_finite(),
1901                "E-value should be finite: {}",
1902                state.e_value
1903            );
1904        }
1905
1906        #[test]
1907        fn eprocess_reset_returns_to_initial() {
1908            let mut state = EProcessState::default();
1909            let config = EProcessConfig::default();
1910
1911            state.update(50.0, 16.0, &config);
1912            state.update(50.0, 16.0, &config);
1913
1914            state.reset();
1915            assert!((state.e_value - 1.0).abs() < f64::EPSILON);
1916            assert_eq!(state.frames_observed, 0);
1917        }
1918
1919        // --- Controller integration tests ---
1920
1921        #[test]
1922        fn controller_holds_under_normal_load() {
1923            let mut ctrl = make_controller_with_config(16, 0, 0);
1924
1925            // Feed on-target frames
1926            for _ in 0..20 {
1927                let decision = ctrl.update(Duration::from_millis(16));
1928                assert_eq!(
1929                    decision,
1930                    BudgetDecision::Hold,
1931                    "On-target frames should hold"
1932                );
1933            }
1934            assert_eq!(ctrl.level(), DegradationLevel::Full);
1935        }
1936
1937        #[test]
1938        fn controller_degrades_under_sustained_overload() {
1939            let mut ctrl = make_controller_with_config(16, 0, 0);
1940
1941            let mut degraded = false;
1942            // Feed severe overload
1943            for _ in 0..50 {
1944                let decision = ctrl.update(Duration::from_millis(40));
1945                if decision == BudgetDecision::Degrade {
1946                    degraded = true;
1947                }
1948            }
1949
1950            assert!(
1951                degraded,
1952                "Controller should degrade under sustained overload"
1953            );
1954            assert!(
1955                ctrl.level() > DegradationLevel::Full,
1956                "Level should be degraded: {:?}",
1957                ctrl.level()
1958            );
1959        }
1960
1961        #[test]
1962        fn controller_upgrades_after_recovery() {
1963            let mut ctrl = make_controller_with_config(16, 0, 0);
1964
1965            // Overload to degrade
1966            for _ in 0..50 {
1967                ctrl.update(Duration::from_millis(40));
1968            }
1969            let degraded_level = ctrl.level();
1970            assert!(degraded_level > DegradationLevel::Full);
1971
1972            // Recovery: fast frames
1973            let mut upgraded = false;
1974            for _ in 0..200 {
1975                let decision = ctrl.update(Duration::from_millis(4));
1976                if decision == BudgetDecision::Upgrade {
1977                    upgraded = true;
1978                }
1979            }
1980
1981            assert!(upgraded, "Controller should upgrade after recovery");
1982            assert!(
1983                ctrl.level() < degraded_level,
1984                "Level should improve: before={:?}, after={:?}",
1985                degraded_level,
1986                ctrl.level()
1987            );
1988        }
1989
1990        #[test]
1991        fn controller_cooldown_prevents_oscillation() {
1992            let mut ctrl = make_controller_with_config(16, 0, 5);
1993
1994            // Trigger degradation
1995            for _ in 0..50 {
1996                ctrl.update(Duration::from_millis(40));
1997            }
1998
1999            // Immediately try fast frames
2000            let mut decisions_during_cooldown = Vec::new();
2001            for _ in 0..4 {
2002                decisions_during_cooldown.push(ctrl.update(Duration::from_millis(4)));
2003            }
2004
2005            // During cooldown (frames 0-4), should all be Hold
2006            assert!(
2007                decisions_during_cooldown
2008                    .iter()
2009                    .all(|d| *d == BudgetDecision::Hold),
2010                "Cooldown should prevent changes: {:?}",
2011                decisions_during_cooldown
2012            );
2013        }
2014
2015        #[test]
2016        fn controller_no_oscillation_under_constant_load() {
2017            let mut ctrl = make_controller_with_config(16, 0, 3);
2018
2019            // Moderate overload (20ms vs 16ms)
2020            let mut transitions = 0u32;
2021            let mut prev_level = ctrl.level();
2022            for _ in 0..100 {
2023                ctrl.update(Duration::from_millis(20));
2024                if ctrl.level() != prev_level {
2025                    transitions += 1;
2026                    prev_level = ctrl.level();
2027                }
2028            }
2029
2030            // Under constant load, transitions should be limited
2031            // (progressive degradation, not oscillation)
2032            assert!(
2033                transitions < 10,
2034                "Too many transitions under constant load: {}",
2035                transitions
2036            );
2037        }
2038
2039        #[test]
2040        fn controller_reset_restores_full_quality() {
2041            let mut ctrl = make_controller();
2042
2043            // Degrade
2044            for _ in 0..50 {
2045                ctrl.update(Duration::from_millis(40));
2046            }
2047
2048            ctrl.reset();
2049
2050            assert_eq!(ctrl.level(), DegradationLevel::Full);
2051            assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
2052            assert_eq!(ctrl.pid_integral(), 0.0);
2053        }
2054
2055        #[test]
2056        fn controller_transient_spike_does_not_degrade() {
2057            let mut ctrl = make_controller_with_config(16, 5, 3);
2058
2059            // Normal frames to build history
2060            for _ in 0..20 {
2061                ctrl.update(Duration::from_millis(16));
2062            }
2063
2064            // Single spike
2065            ctrl.update(Duration::from_millis(100));
2066
2067            // Back to normal
2068            for _ in 0..5 {
2069                ctrl.update(Duration::from_millis(16));
2070            }
2071
2072            // Should still be at full quality (spike was transient)
2073            assert_eq!(
2074                ctrl.level(),
2075                DegradationLevel::Full,
2076                "Single spike should not cause degradation"
2077            );
2078        }
2079
2080        #[test]
2081        fn controller_never_exceeds_skip_frame() {
2082            let mut ctrl = make_controller_with_config(16, 0, 0);
2083
2084            // Extreme overload
2085            for _ in 0..500 {
2086                ctrl.update(Duration::from_millis(200));
2087            }
2088
2089            assert!(
2090                ctrl.level() <= DegradationLevel::SkipFrame,
2091                "Level should not exceed SkipFrame: {:?}",
2092                ctrl.level()
2093            );
2094        }
2095
2096        #[test]
2097        fn controller_never_goes_below_full() {
2098            let mut ctrl = make_controller_with_config(16, 0, 0);
2099
2100            // Extreme underload
2101            for _ in 0..200 {
2102                ctrl.update(Duration::from_millis(1));
2103            }
2104
2105            assert_eq!(
2106                ctrl.level(),
2107                DegradationLevel::Full,
2108                "Level should not go below Full"
2109            );
2110        }
2111
2112        // --- Config tests ---
2113
2114        #[test]
2115        fn pid_gains_default_valid() {
2116            let gains = PidGains::default();
2117            assert!(gains.kp > 0.0);
2118            assert!(gains.ki > 0.0);
2119            assert!(gains.kd > 0.0);
2120            assert!(gains.integral_max > 0.0);
2121        }
2122
2123        #[test]
2124        fn eprocess_config_default_valid() {
2125            let config = EProcessConfig::default();
2126            assert!(config.lambda > 0.0);
2127            assert!(config.alpha > 0.0 && config.alpha < 1.0);
2128            assert!(config.beta > 0.0 && config.beta < 1.0);
2129            assert!(config.sigma_floor_ms > 0.0);
2130        }
2131
2132        #[test]
2133        fn controller_config_default_valid() {
2134            let config = BudgetControllerConfig::default();
2135            assert!(config.degrade_threshold > 0.0);
2136            assert!(config.upgrade_threshold > 0.0);
2137            assert!(config.target > Duration::ZERO);
2138        }
2139
2140        #[test]
2141        fn budget_decision_equality() {
2142            assert_eq!(BudgetDecision::Hold, BudgetDecision::Hold);
2143            assert_ne!(BudgetDecision::Hold, BudgetDecision::Degrade);
2144            assert_ne!(BudgetDecision::Degrade, BudgetDecision::Upgrade);
2145        }
2146    }
2147
2148    // -----------------------------------------------------------------------
2149    // Budget Controller Integration + Telemetry Tests (bd-4kq0.3.2)
2150    // -----------------------------------------------------------------------
2151
2152    mod integration_tests {
2153        use super::super::*;
2154
2155        #[test]
2156        fn render_budget_without_controller_returns_no_telemetry() {
2157            let budget = RenderBudget::new(Duration::from_millis(16));
2158            assert!(budget.telemetry().is_none());
2159            assert!(budget.controller().is_none());
2160        }
2161
2162        #[test]
2163        fn render_budget_with_controller_returns_telemetry() {
2164            let budget = RenderBudget::new(Duration::from_millis(16))
2165                .with_controller(BudgetControllerConfig::default());
2166            assert!(budget.controller().is_some());
2167
2168            let telem = budget.telemetry().unwrap();
2169            assert_eq!(telem.level, DegradationLevel::Full);
2170            assert_eq!(telem.last_decision, BudgetDecision::Hold);
2171            assert_eq!(telem.frames_observed, 0);
2172            assert!(telem.in_warmup);
2173        }
2174
2175        #[test]
2176        fn telemetry_fields_update_after_next_frame() {
2177            let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2178                BudgetControllerConfig {
2179                    eprocess: EProcessConfig {
2180                        warmup_frames: 0,
2181                        ..Default::default()
2182                    },
2183                    cooldown_frames: 0,
2184                    ..Default::default()
2185                },
2186            );
2187
2188            // Simulate a few frames
2189            for _ in 0..5 {
2190                budget.next_frame();
2191            }
2192
2193            let telem = budget.telemetry().unwrap();
2194            assert_eq!(telem.frames_observed, 5);
2195            assert!(!telem.in_warmup);
2196            // PID output should be non-positive (frames are fast, under budget)
2197            // but the exact value depends on timing, so just check it's finite
2198            assert!(telem.pid_output.is_finite());
2199            assert!(telem.e_value.is_finite());
2200        }
2201
2202        #[test]
2203        fn controller_next_frame_degrades_under_simulated_overload() {
2204            // We can't easily simulate slow frames in unit tests (thread::sleep
2205            // would be flaky), so we test the controller integration by verifying
2206            // the decision path works: attach controller, manually check that
2207            // the controller's level is reflected in the budget's degradation.
2208            let config = BudgetControllerConfig {
2209                target: Duration::from_millis(16),
2210                eprocess: EProcessConfig {
2211                    warmup_frames: 0,
2212                    ..Default::default()
2213                },
2214                cooldown_frames: 0,
2215                ..Default::default()
2216            };
2217            let mut ctrl = BudgetController::new(config);
2218
2219            // Feed severe overload to the controller directly
2220            for _ in 0..50 {
2221                ctrl.update(Duration::from_millis(40));
2222            }
2223
2224            // Controller should have degraded
2225            assert!(
2226                ctrl.level() > DegradationLevel::Full,
2227                "Controller should degrade: {:?}",
2228                ctrl.level()
2229            );
2230
2231            // Telemetry should reflect the degradation
2232            let telem = ctrl.telemetry();
2233            assert!(telem.level > DegradationLevel::Full);
2234            assert!(
2235                telem.pid_output > 0.0,
2236                "PID output should be positive under overload"
2237            );
2238            assert!(telem.e_value > 1.0, "E-value should grow under overload");
2239        }
2240
2241        #[test]
2242        fn next_frame_delegates_to_controller_when_attached() {
2243            // With a controller, next_frame should not use the simple
2244            // threshold-based upgrade path
2245            let mut budget = RenderBudget::new(Duration::from_millis(1000))
2246                .with_controller(BudgetControllerConfig::default());
2247
2248            // Degrade manually
2249            budget.degrade();
2250            assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
2251
2252            // In legacy mode, next_frame would upgrade immediately (lots of budget).
2253            // With controller, it should hold because the controller hasn't seen
2254            // enough underload evidence yet.
2255            budget.next_frame();
2256
2257            // The controller may or may not upgrade depending on the single frame
2258            // measurement, but the key assertion is that the code path works.
2259            // With a fresh controller, the fast frame should eventually allow upgrade.
2260            // Just verify it doesn't panic and telemetry is populated.
2261            let telem = budget.telemetry().unwrap();
2262            assert_eq!(telem.frames_observed, 1);
2263        }
2264
2265        #[test]
2266        fn telemetry_is_copy_and_no_alloc() {
2267            let budget = RenderBudget::new(Duration::from_millis(16))
2268                .with_controller(BudgetControllerConfig::default());
2269
2270            let telem = budget.telemetry().unwrap();
2271            // BudgetTelemetry is Copy — verify by copying
2272            let telem2 = telem;
2273            assert_eq!(telem.level, telem2.level);
2274            assert_eq!(telem.e_value, telem2.e_value);
2275        }
2276
2277        #[test]
2278        fn telemetry_warmup_flag_transitions() {
2279            let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2280                BudgetControllerConfig {
2281                    eprocess: EProcessConfig {
2282                        warmup_frames: 3,
2283                        ..Default::default()
2284                    },
2285                    ..Default::default()
2286                },
2287            );
2288
2289            // During warmup
2290            budget.next_frame();
2291            budget.next_frame();
2292            let telem = budget.telemetry().unwrap();
2293            assert!(telem.in_warmup, "Should be in warmup at frame 2");
2294
2295            // After warmup
2296            budget.next_frame();
2297            let telem = budget.telemetry().unwrap();
2298            assert!(!telem.in_warmup, "Should exit warmup at frame 3");
2299        }
2300
2301        #[test]
2302        fn phase_sub_budget_does_not_carry_controller() {
2303            let budget = RenderBudget::new(Duration::from_millis(100))
2304                .with_controller(BudgetControllerConfig::default());
2305
2306            let phase = budget.phase_budget(Phase::Render);
2307            assert!(
2308                phase.controller().is_none(),
2309                "Phase sub-budgets should not carry the controller"
2310            );
2311        }
2312
2313        #[test]
2314        fn controller_telemetry_tracks_frames_since_change() {
2315            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2316                eprocess: EProcessConfig {
2317                    warmup_frames: 0,
2318                    ..Default::default()
2319                },
2320                cooldown_frames: 0,
2321                ..Default::default()
2322            });
2323
2324            // On-target frames: frames_since_change should increase
2325            for i in 1..=5 {
2326                ctrl.update(Duration::from_millis(16));
2327                let telem = ctrl.telemetry();
2328                assert_eq!(
2329                    telem.frames_since_change, i,
2330                    "frames_since_change should be {} after {} frames",
2331                    i, i
2332                );
2333            }
2334        }
2335
2336        #[test]
2337        fn telemetry_last_decision_reflects_controller_decision() {
2338            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2339                eprocess: EProcessConfig {
2340                    warmup_frames: 0,
2341                    ..Default::default()
2342                },
2343                cooldown_frames: 0,
2344                ..Default::default()
2345            });
2346
2347            // On-target: should hold
2348            ctrl.update(Duration::from_millis(16));
2349            assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Hold);
2350
2351            // Feed enough overload to trigger degrade
2352            let mut saw_degrade = false;
2353            for _ in 0..50 {
2354                let d = ctrl.update(Duration::from_millis(50));
2355                if d == BudgetDecision::Degrade {
2356                    saw_degrade = true;
2357                    assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Degrade);
2358                    break;
2359                }
2360            }
2361            assert!(saw_degrade, "Should have seen a Degrade decision");
2362        }
2363
2364        #[test]
2365        fn perf_overhead_controller_update_is_fast() {
2366            // Verify the controller update is a lightweight arithmetic operation.
2367            // We run 10_000 iterations and check they complete quickly.
2368            // This is a smoke test, not a precise benchmark (that's bd-4kq0.3.3).
2369            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2370
2371            let start = Instant::now();
2372            for _ in 0..10_000 {
2373                ctrl.update(Duration::from_millis(16));
2374            }
2375            let elapsed = start.elapsed();
2376
2377            // 10k iterations should complete in well under 10ms on any modern CPU.
2378            // At 16ms target, 2% overhead = 0.32ms per frame, so 10k frames
2379            // budget = 3.2 seconds worth of overhead budget. We check <50ms total.
2380            assert!(
2381                elapsed < Duration::from_millis(50),
2382                "10k controller updates took {:?}, expected <50ms",
2383                elapsed
2384            );
2385        }
2386
2387        #[test]
2388        fn perf_overhead_telemetry_snapshot_is_fast() {
2389            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2390            ctrl.update(Duration::from_millis(16));
2391
2392            let start = Instant::now();
2393            for _ in 0..10_000 {
2394                let _telem = ctrl.telemetry();
2395            }
2396            let elapsed = start.elapsed();
2397
2398            assert!(
2399                elapsed < Duration::from_millis(10),
2400                "10k telemetry snapshots took {:?}, expected <10ms",
2401                elapsed
2402            );
2403        }
2404    }
2405
2406    // -----------------------------------------------------------------------
2407    // Budget Stability + E2E Replay Tests (bd-4kq0.3.3)
2408    // -----------------------------------------------------------------------
2409
2410    mod stability_tests {
2411        use super::super::*;
2412
2413        /// Helper: create a controller with minimal warmup/cooldown for testing.
2414        fn fast_controller(target_ms: u64) -> BudgetController {
2415            BudgetController::new(BudgetControllerConfig {
2416                target: Duration::from_millis(target_ms),
2417                eprocess: EProcessConfig {
2418                    warmup_frames: 0,
2419                    ..Default::default()
2420                },
2421                cooldown_frames: 0,
2422                ..Default::default()
2423            })
2424        }
2425
2426        /// Helper: run a frame time trace through the controller and collect
2427        /// JSONL-style telemetry records (as structured tuples).
2428        /// Returns `(frame_index, frame_time_us, telemetry)` for each frame.
2429        fn run_trace(
2430            ctrl: &mut BudgetController,
2431            trace: &[Duration],
2432        ) -> Vec<(u64, u64, BudgetTelemetry)> {
2433            trace
2434                .iter()
2435                .enumerate()
2436                .map(|(i, &ft)| {
2437                    ctrl.update(ft);
2438                    let telem = ctrl.telemetry();
2439                    (i as u64, ft.as_micros() as u64, telem)
2440                })
2441                .collect()
2442        }
2443
2444        /// Count level transitions in a trace log.
2445        fn count_transitions(log: &[(u64, u64, BudgetTelemetry)]) -> u32 {
2446            let mut transitions = 0u32;
2447            for pair in log.windows(2) {
2448                if pair[0].2.level != pair[1].2.level {
2449                    transitions += 1;
2450                }
2451            }
2452            transitions
2453        }
2454
2455        // --- e2e_burst_logs ---
2456
2457        #[test]
2458        fn e2e_burst_logs_no_oscillation() {
2459            // Simulate bursty output: alternating bursts of slow frames
2460            // and calm periods. Verify no oscillation (bounded transitions).
2461            let mut ctrl = fast_controller(16);
2462
2463            let mut trace = Vec::new();
2464            for _cycle in 0..5 {
2465                // Burst: 10 frames at 40ms
2466                for _ in 0..10 {
2467                    trace.push(Duration::from_millis(40));
2468                }
2469                // Calm: 20 frames at 16ms
2470                for _ in 0..20 {
2471                    trace.push(Duration::from_millis(16));
2472                }
2473            }
2474
2475            let log = run_trace(&mut ctrl, &trace);
2476
2477            // Count level transitions. Under bursty load, transitions should
2478            // be bounded — no rapid oscillation. With 5 cycles of 30 frames
2479            // each (150 total), we expect at most ~15 transitions (degrade
2480            // during each burst, upgrade during each calm).
2481            let transitions = count_transitions(&log);
2482            assert!(
2483                transitions < 20,
2484                "Too many transitions under bursty load: {} (expected <20)",
2485                transitions
2486            );
2487
2488            // Verify all telemetry fields are populated
2489            for (frame, ft_us, telem) in &log {
2490                assert!(
2491                    telem.pid_output.is_finite(),
2492                    "frame {}: NaN pid_output",
2493                    frame
2494                );
2495                assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2496                assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2497                assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2498                assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2499                assert!(*ft_us > 0, "frame {}: zero frame time", frame);
2500            }
2501        }
2502
2503        #[test]
2504        fn e2e_burst_recovers_after_moderate_overload() {
2505            // Moderate bursts (30ms vs 16ms target) followed by calm periods.
2506            // The controller may degrade during bursts, but should recover
2507            // during calm periods — final state should not be SkipFrame.
2508            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2509                target: Duration::from_millis(16),
2510                eprocess: EProcessConfig {
2511                    warmup_frames: 5,
2512                    ..Default::default()
2513                },
2514                cooldown_frames: 3,
2515                ..Default::default()
2516            });
2517
2518            let mut trace = Vec::new();
2519            for _cycle in 0..3 {
2520                // Moderate burst
2521                for _ in 0..15 {
2522                    trace.push(Duration::from_millis(30));
2523                }
2524                // Extended calm to allow recovery
2525                for _ in 0..50 {
2526                    trace.push(Duration::from_millis(10));
2527                }
2528            }
2529
2530            let log = run_trace(&mut ctrl, &trace);
2531
2532            // After each calm period, level should have recovered below Skeleton.
2533            // Check at the end of each calm phase (frames 64, 129, 194).
2534            for cycle in 0..3 {
2535                let calm_end = (cycle + 1) * 65 - 1;
2536                if calm_end < log.len() {
2537                    assert!(
2538                        log[calm_end].2.level < DegradationLevel::SkipFrame,
2539                        "cycle {}: should recover after calm period, got {:?} at frame {}",
2540                        cycle,
2541                        log[calm_end].2.level,
2542                        calm_end
2543                    );
2544                }
2545            }
2546
2547            // Final level should be better than Skeleton
2548            let final_level = log.last().unwrap().2.level;
2549            assert!(
2550                final_level < DegradationLevel::Skeleton,
2551                "Final level should recover below Skeleton: {:?}",
2552                final_level
2553            );
2554        }
2555
2556        // --- e2e_idle_to_burst ---
2557
2558        #[test]
2559        fn e2e_idle_to_burst_recovery() {
2560            // Start idle (well under budget), then sudden burst, then back to idle.
2561            // Verify: fast recovery without over-degrading.
2562            let mut ctrl = fast_controller(16);
2563
2564            let mut trace = Vec::new();
2565            // Phase 1: idle (8ms frames)
2566            for _ in 0..50 {
2567                trace.push(Duration::from_millis(8));
2568            }
2569            // Phase 2: sudden burst (50ms frames)
2570            for _ in 0..20 {
2571                trace.push(Duration::from_millis(50));
2572            }
2573            // Phase 3: recovery (8ms frames)
2574            for _ in 0..100 {
2575                trace.push(Duration::from_millis(8));
2576            }
2577
2578            let log = run_trace(&mut ctrl, &trace);
2579
2580            // After idle phase (frame 49), should still be Full
2581            assert_eq!(
2582                log[49].2.level,
2583                DegradationLevel::Full,
2584                "Should be Full during idle phase"
2585            );
2586
2587            // During burst, should degrade
2588            let max_during_burst = log[50..70].iter().map(|(_, _, t)| t.level).max().unwrap();
2589            assert!(
2590                max_during_burst > DegradationLevel::Full,
2591                "Should degrade during burst"
2592            );
2593
2594            // After recovery (last 20 frames), should have recovered toward Full
2595            let final_level = log.last().unwrap().2.level;
2596            assert!(
2597                final_level < max_during_burst,
2598                "Should recover after burst: final={:?}, max_during_burst={:?}",
2599                final_level,
2600                max_during_burst
2601            );
2602        }
2603
2604        #[test]
2605        fn e2e_idle_to_burst_no_over_degrade() {
2606            // A brief burst (5 frames) should not cause more than 1-2 levels
2607            // of degradation, even with zero warmup.
2608            let mut ctrl = fast_controller(16);
2609
2610            // Idle
2611            for _ in 0..30 {
2612                ctrl.update(Duration::from_millis(8));
2613            }
2614
2615            // Brief burst (only 5 frames)
2616            for _ in 0..5 {
2617                ctrl.update(Duration::from_millis(40));
2618            }
2619
2620            // Check degradation is modest
2621            let level = ctrl.level();
2622            assert!(
2623                level <= DegradationLevel::NoStyling,
2624                "Brief burst should not over-degrade: {:?}",
2625                level
2626            );
2627        }
2628
2629        // --- property_random_load ---
2630
2631        #[test]
2632        fn property_random_load_hysteresis_bounds() {
2633            // Verify: degradation changes are bounded by hysteresis constraints.
2634            // Specifically, level can only change by 1 step per decision.
2635            let mut ctrl = fast_controller(16);
2636
2637            // Generate a deterministic pseudo-random load trace using a simple
2638            // linear congruential generator (no std::rand dependency).
2639            let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_BABE;
2640            let mut trace = Vec::new();
2641            for _ in 0..1000 {
2642                // LCG: next = (a * state + c) mod m
2643                rng_state = rng_state
2644                    .wrapping_mul(6_364_136_223_846_793_005)
2645                    .wrapping_add(1_442_695_040_888_963_407);
2646                // Map to frame time: 4ms..80ms
2647                let frame_ms = 4 + ((rng_state >> 33) % 77);
2648                trace.push(Duration::from_millis(frame_ms));
2649            }
2650
2651            let log = run_trace(&mut ctrl, &trace);
2652
2653            // Property 1: Level only changes by at most 1 step per frame
2654            for pair in log.windows(2) {
2655                let prev = pair[0].2.level.level();
2656                let curr = pair[1].2.level.level();
2657                let delta = (curr as i16 - prev as i16).unsigned_abs();
2658                assert!(
2659                    delta <= 1,
2660                    "Level jumped {} steps at frame {}: {:?} -> {:?}",
2661                    delta,
2662                    pair[1].0,
2663                    pair[0].2.level,
2664                    pair[1].2.level
2665                );
2666            }
2667
2668            // Property 2: Level never exceeds valid range
2669            for (frame, _, telem) in &log {
2670                assert!(
2671                    telem.level <= DegradationLevel::SkipFrame,
2672                    "frame {}: level out of range: {:?}",
2673                    frame,
2674                    telem.level
2675                );
2676            }
2677
2678            // Property 3: All numeric fields are finite
2679            for (frame, _, telem) in &log {
2680                assert!(
2681                    telem.pid_output.is_finite(),
2682                    "frame {}: NaN pid_output",
2683                    frame
2684                );
2685                assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2686                assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2687                assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2688                assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2689                assert!(
2690                    telem.e_value > 0.0,
2691                    "frame {}: e_value not positive: {}",
2692                    frame,
2693                    telem.e_value
2694                );
2695            }
2696        }
2697
2698        #[test]
2699        fn property_random_load_bounded_transitions() {
2700            // Under random load, transitions should be bounded and not exceed
2701            // a reasonable rate (no rapid oscillation).
2702            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2703                target: Duration::from_millis(16),
2704                eprocess: EProcessConfig {
2705                    warmup_frames: 5,
2706                    ..Default::default()
2707                },
2708                cooldown_frames: 3,
2709                ..Default::default()
2710            });
2711
2712            // Deterministic pseudo-random trace
2713            let mut rng_state: u64 = 0x1234_5678_9ABC_DEF0;
2714            let mut trace = Vec::new();
2715            for _ in 0..500 {
2716                rng_state = rng_state
2717                    .wrapping_mul(6_364_136_223_846_793_005)
2718                    .wrapping_add(1_442_695_040_888_963_407);
2719                let frame_ms = 8 + ((rng_state >> 33) % 40);
2720                trace.push(Duration::from_millis(frame_ms));
2721            }
2722
2723            let log = run_trace(&mut ctrl, &trace);
2724            let transitions = count_transitions(&log);
2725
2726            // With cooldown=3 and 500 frames, max theoretical transitions = 500/4 = 125.
2727            // In practice with hysteresis + e-process gating, much less.
2728            assert!(
2729                transitions < 80,
2730                "Too many transitions under random load: {} (expected <80 with cooldown=3)",
2731                transitions
2732            );
2733        }
2734
2735        #[test]
2736        fn property_deterministic_replay() {
2737            // Same trace should produce identical telemetry every time.
2738            let trace: Vec<Duration> = (0..100)
2739                .map(|i| Duration::from_millis(10 + (i * 7 % 30)))
2740                .collect();
2741
2742            let mut ctrl1 = fast_controller(16);
2743            let log1 = run_trace(&mut ctrl1, &trace);
2744
2745            let mut ctrl2 = fast_controller(16);
2746            let log2 = run_trace(&mut ctrl2, &trace);
2747
2748            for (r1, r2) in log1.iter().zip(log2.iter()) {
2749                assert_eq!(r1.0, r2.0, "frame index mismatch");
2750                assert_eq!(r1.1, r2.1, "frame time mismatch");
2751                assert_eq!(r1.2.level, r2.2.level, "level mismatch at frame {}", r1.0);
2752                assert_eq!(
2753                    r1.2.last_decision, r2.2.last_decision,
2754                    "decision mismatch at frame {}",
2755                    r1.0
2756                );
2757                assert!(
2758                    (r1.2.pid_output - r2.2.pid_output).abs() < 1e-10,
2759                    "pid_output mismatch at frame {}: {} vs {}",
2760                    r1.0,
2761                    r1.2.pid_output,
2762                    r2.2.pid_output
2763                );
2764                assert!(
2765                    (r1.2.e_value - r2.2.e_value).abs() < 1e-10,
2766                    "e_value mismatch at frame {}: {} vs {}",
2767                    r1.0,
2768                    r1.2.e_value,
2769                    r2.2.e_value
2770                );
2771            }
2772        }
2773
2774        // --- JSONL schema validation ---
2775
2776        #[test]
2777        fn telemetry_jsonl_fields_complete() {
2778            // Verify all JSONL schema fields are accessible from BudgetTelemetry.
2779            let mut ctrl = fast_controller(16);
2780            ctrl.update(Duration::from_millis(20));
2781
2782            let telem = ctrl.telemetry();
2783
2784            // All schema fields present and accessible:
2785            let _degradation: &str = telem.level.as_str();
2786            let _pid_p: f64 = telem.pid_p;
2787            let _pid_i: f64 = telem.pid_i;
2788            let _pid_d: f64 = telem.pid_d;
2789            let _e_value: f64 = telem.e_value;
2790            let _decision: &str = telem.last_decision.as_str();
2791            let _frames: u32 = telem.frames_observed;
2792
2793            // Verify decision string mapping
2794            assert_eq!(BudgetDecision::Hold.as_str(), "stay");
2795            assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
2796            assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
2797        }
2798
2799        #[test]
2800        fn telemetry_pid_components_sum_to_output() {
2801            // Verify P + I + D == total PID output.
2802            let mut ctrl = fast_controller(16);
2803
2804            for ms in [10u64, 16, 20, 30, 8, 50] {
2805                ctrl.update(Duration::from_millis(ms));
2806                let telem = ctrl.telemetry();
2807                let sum = telem.pid_p + telem.pid_i + telem.pid_d;
2808                assert!(
2809                    (sum - telem.pid_output).abs() < 1e-10,
2810                    "P+I+D != output at {}ms: {} + {} + {} = {} != {}",
2811                    ms,
2812                    telem.pid_p,
2813                    telem.pid_i,
2814                    telem.pid_d,
2815                    sum,
2816                    telem.pid_output
2817                );
2818            }
2819        }
2820    }
2821
2822    // -----------------------------------------------------------------------
2823    // Edge-case tests (bd-1x69n)
2824    // -----------------------------------------------------------------------
2825
2826    mod edge_case_tests {
2827        use super::super::*;
2828
2829        // --- PID edge cases ---
2830
2831        #[test]
2832        fn pid_negative_integral_windup() {
2833            // Sustained negative error should clamp integral at -integral_max
2834            let mut state = PidState::default();
2835            let gains = PidGains {
2836                integral_max: 3.0,
2837                ..Default::default()
2838            };
2839
2840            for _ in 0..200 {
2841                state.update(-10.0, &gains);
2842            }
2843
2844            assert!(
2845                state.integral >= -3.0 - f64::EPSILON,
2846                "Negative integral should be clamped to -max: {}",
2847                state.integral
2848            );
2849            assert!(
2850                state.integral <= -3.0 + f64::EPSILON,
2851                "Negative integral should saturate at -max: {}",
2852                state.integral
2853            );
2854        }
2855
2856        #[test]
2857        fn pid_zero_gains_zero_output() {
2858            let mut state = PidState::default();
2859            let gains = PidGains {
2860                kp: 0.0,
2861                ki: 0.0,
2862                kd: 0.0,
2863                integral_max: 5.0,
2864            };
2865
2866            let u = state.update(42.0, &gains);
2867            assert!(
2868                u.abs() < 1e-10,
2869                "Zero gains should yield zero output: {}",
2870                u
2871            );
2872        }
2873
2874        #[test]
2875        fn pid_large_error_stays_finite() {
2876            let mut state = PidState::default();
2877            let gains = PidGains::default();
2878
2879            // Very large error
2880            let u = state.update(1e12, &gains);
2881            assert!(
2882                u.is_finite(),
2883                "PID output should be finite for large error: {}",
2884                u
2885            );
2886
2887            // Integral should be clamped
2888            assert!(
2889                state.integral <= gains.integral_max + f64::EPSILON,
2890                "Integral should be clamped: {}",
2891                state.integral
2892            );
2893        }
2894
2895        #[test]
2896        fn pid_alternating_error_derivative_responds() {
2897            let mut state = PidState::default();
2898            let gains = PidGains::default();
2899
2900            // Alternating +1/-1 error
2901            let u1 = state.update(1.0, &gains);
2902            let u2 = state.update(-1.0, &gains);
2903
2904            // Derivative component for second call: Kd * (-1.0 - 1.0) = 0.2 * -2.0 = -0.4
2905            // So u2 should have negative derivative contribution
2906            assert!(
2907                u2 < u1,
2908                "Alternating error should reduce output: u1={}, u2={}",
2909                u1,
2910                u2
2911            );
2912        }
2913
2914        #[test]
2915        fn pid_telemetry_terms_match_after_update() {
2916            let mut state = PidState::default();
2917            let gains = PidGains::default();
2918
2919            state.update(2.0, &gains);
2920
2921            // P = Kp * error = 0.5 * 2.0 = 1.0
2922            assert!(
2923                (state.last_p - 1.0).abs() < 1e-10,
2924                "P term: {}",
2925                state.last_p
2926            );
2927            // I = Ki * integral = 0.05 * 2.0 = 0.1
2928            assert!(
2929                (state.last_i - 0.1).abs() < 1e-10,
2930                "I term: {}",
2931                state.last_i
2932            );
2933            // D = Kd * (error - prev_error) = 0.2 * (2.0 - 0.0) = 0.4
2934            assert!(
2935                (state.last_d - 0.4).abs() < 1e-10,
2936                "D term: {}",
2937                state.last_d
2938            );
2939        }
2940
2941        #[test]
2942        fn pid_integral_clamping_symmetric() {
2943            let mut state = PidState::default();
2944            let gains = PidGains {
2945                integral_max: 1.0,
2946                ..Default::default()
2947            };
2948
2949            // Positive saturation
2950            for _ in 0..50 {
2951                state.update(100.0, &gains);
2952            }
2953            let pos_integral = state.integral;
2954
2955            state.reset();
2956
2957            // Negative saturation
2958            for _ in 0..50 {
2959                state.update(-100.0, &gains);
2960            }
2961            let neg_integral = state.integral;
2962
2963            assert!(
2964                (pos_integral + neg_integral).abs() < f64::EPSILON,
2965                "Clamping should be symmetric: pos={}, neg={}",
2966                pos_integral,
2967                neg_integral
2968            );
2969        }
2970
2971        // --- E-process edge cases ---
2972
2973        #[test]
2974        fn eprocess_first_frame_initializes_mean() {
2975            let mut state = EProcessState::default();
2976            let config = EProcessConfig::default();
2977
2978            state.update(25.0, 16.0, &config);
2979
2980            assert!(
2981                (state.mean_ema - 25.0).abs() < f64::EPSILON,
2982                "First frame should set mean_ema directly: {}",
2983                state.mean_ema
2984            );
2985            assert!(
2986                (state.sigma_ema - config.sigma_floor_ms).abs() < f64::EPSILON,
2987                "First frame should set sigma_ema to floor: {}",
2988                state.sigma_ema
2989            );
2990            assert_eq!(state.frames_observed, 1);
2991        }
2992
2993        #[test]
2994        fn eprocess_e_value_clamped_at_upper_bound() {
2995            let mut state = EProcessState::default();
2996            let config = EProcessConfig {
2997                lambda: 2.0, // High sensitivity to force rapid growth
2998                warmup_frames: 0,
2999                sigma_floor_ms: 0.001, // Tiny floor to amplify residuals
3000                ..Default::default()
3001            };
3002
3003            // Extreme overload to push e_value toward upper clamp
3004            for _ in 0..1000 {
3005                state.update(1e6, 16.0, &config);
3006            }
3007
3008            assert!(
3009                state.e_value <= 1e10,
3010                "E-value should be clamped at 1e10: {}",
3011                state.e_value
3012            );
3013        }
3014
3015        #[test]
3016        fn eprocess_e_value_clamped_at_lower_bound() {
3017            let mut state = EProcessState::default();
3018            let config = EProcessConfig {
3019                lambda: 2.0,
3020                warmup_frames: 0,
3021                sigma_floor_ms: 0.001,
3022                ..Default::default()
3023            };
3024
3025            // Extreme underload to push e_value toward lower clamp
3026            for _ in 0..1000 {
3027                state.update(0.001, 1e6, &config);
3028            }
3029
3030            assert!(
3031                state.e_value >= 1e-10,
3032                "E-value should be clamped at 1e-10: {}",
3033                state.e_value
3034            );
3035        }
3036
3037        #[test]
3038        fn eprocess_should_upgrade_during_warmup() {
3039            let state = EProcessState::default();
3040            let config = EProcessConfig {
3041                warmup_frames: 10,
3042                ..Default::default()
3043            };
3044
3045            // During warmup, should_upgrade returns true to allow PID-driven upgrades
3046            assert!(
3047                state.should_upgrade(&config),
3048                "should_upgrade should return true during warmup"
3049            );
3050        }
3051
3052        #[test]
3053        fn eprocess_frames_observed_saturates() {
3054            let mut state = EProcessState {
3055                frames_observed: u32::MAX,
3056                ..EProcessState::default()
3057            };
3058            let config = EProcessConfig::default();
3059
3060            // Should not panic or wrap around
3061            state.update(16.0, 16.0, &config);
3062            assert_eq!(
3063                state.frames_observed,
3064                u32::MAX,
3065                "frames_observed should saturate at u32::MAX"
3066            );
3067        }
3068
3069        #[test]
3070        fn eprocess_sigma_ema_decay_boundary_zero() {
3071            let mut state = EProcessState::default();
3072            let config = EProcessConfig {
3073                sigma_ema_decay: 0.0,
3074                warmup_frames: 0,
3075                ..Default::default()
3076            };
3077
3078            // With decay=0, each update fully replaces the EMA
3079            state.update(20.0, 16.0, &config);
3080            state.update(30.0, 16.0, &config);
3081
3082            // mean_ema should be exactly the latest value
3083            assert!(
3084                (state.mean_ema - 30.0).abs() < f64::EPSILON,
3085                "decay=0 should fully replace mean_ema: {}",
3086                state.mean_ema
3087            );
3088        }
3089
3090        #[test]
3091        fn eprocess_sigma_ema_decay_boundary_one() {
3092            let mut state = EProcessState::default();
3093            let config = EProcessConfig {
3094                sigma_ema_decay: 1.0,
3095                warmup_frames: 0,
3096                ..Default::default()
3097            };
3098
3099            // With decay=1, EMA never changes from initial
3100            state.update(20.0, 16.0, &config);
3101            let first_mean = state.mean_ema;
3102            state.update(100.0, 16.0, &config);
3103
3104            assert!(
3105                (state.mean_ema - first_mean).abs() < f64::EPSILON,
3106                "decay=1 should lock mean_ema at first value: got {}, expected {}",
3107                state.mean_ema,
3108                first_mean
3109            );
3110        }
3111
3112        #[test]
3113        fn eprocess_zero_target_no_panic() {
3114            let mut state = EProcessState::default();
3115            let config = EProcessConfig {
3116                warmup_frames: 0,
3117                ..Default::default()
3118            };
3119
3120            // Zero target — residual computation divides by sigma (floored), not target
3121            let e = state.update(16.0, 0.0, &config);
3122            assert!(
3123                e.is_finite(),
3124                "E-value should be finite with zero target: {}",
3125                e
3126            );
3127        }
3128
3129        // --- DegradationLevel edge cases ---
3130
3131        #[test]
3132        fn degradation_level_default_is_full() {
3133            assert_eq!(DegradationLevel::default(), DegradationLevel::Full);
3134        }
3135
3136        #[test]
3137        fn degradation_level_hash_unique() {
3138            use std::collections::HashSet;
3139            let levels = [
3140                DegradationLevel::Full,
3141                DegradationLevel::SimpleBorders,
3142                DegradationLevel::NoStyling,
3143                DegradationLevel::EssentialOnly,
3144                DegradationLevel::Skeleton,
3145                DegradationLevel::SkipFrame,
3146            ];
3147            let set: HashSet<DegradationLevel> = levels.iter().copied().collect();
3148            assert_eq!(set.len(), 6, "All levels should hash uniquely");
3149        }
3150
3151        #[test]
3152        fn degradation_level_widget_queries_full() {
3153            let l = DegradationLevel::Full;
3154            assert!(l.use_unicode_borders());
3155            assert!(l.apply_styling());
3156            assert!(l.render_decorative());
3157            assert!(l.render_content());
3158        }
3159
3160        #[test]
3161        fn degradation_level_widget_queries_simple_borders() {
3162            let l = DegradationLevel::SimpleBorders;
3163            assert!(!l.use_unicode_borders());
3164            assert!(l.apply_styling());
3165            assert!(l.render_decorative());
3166            assert!(l.render_content());
3167        }
3168
3169        #[test]
3170        fn degradation_level_widget_queries_no_styling() {
3171            let l = DegradationLevel::NoStyling;
3172            assert!(!l.use_unicode_borders());
3173            assert!(!l.apply_styling());
3174            assert!(l.render_decorative());
3175            assert!(l.render_content());
3176        }
3177
3178        #[test]
3179        fn degradation_level_widget_queries_essential_only() {
3180            let l = DegradationLevel::EssentialOnly;
3181            assert!(!l.use_unicode_borders());
3182            assert!(!l.apply_styling());
3183            assert!(!l.render_decorative());
3184            assert!(l.render_content());
3185        }
3186
3187        #[test]
3188        fn degradation_level_widget_queries_skeleton() {
3189            let l = DegradationLevel::Skeleton;
3190            assert!(!l.use_unicode_borders());
3191            assert!(!l.apply_styling());
3192            assert!(!l.render_decorative());
3193            assert!(!l.render_content());
3194        }
3195
3196        #[test]
3197        fn degradation_level_widget_queries_skip_frame() {
3198            let l = DegradationLevel::SkipFrame;
3199            assert!(!l.use_unicode_borders());
3200            assert!(!l.apply_styling());
3201            assert!(!l.render_decorative());
3202            assert!(!l.render_content());
3203        }
3204
3205        #[test]
3206        fn degradation_level_partial_ord_consistent() {
3207            // PartialOrd should agree with Ord for all pairs
3208            let levels = [
3209                DegradationLevel::Full,
3210                DegradationLevel::SimpleBorders,
3211                DegradationLevel::NoStyling,
3212                DegradationLevel::EssentialOnly,
3213                DegradationLevel::Skeleton,
3214                DegradationLevel::SkipFrame,
3215            ];
3216            for (i, a) in levels.iter().enumerate() {
3217                for (j, b) in levels.iter().enumerate() {
3218                    let po = a.partial_cmp(b);
3219                    let o = a.cmp(b);
3220                    assert_eq!(po, Some(o), "PartialOrd != Ord for {:?} vs {:?}", a, b);
3221                    if i < j {
3222                        assert!(*a < *b, "{:?} should be < {:?}", a, b);
3223                    }
3224                }
3225            }
3226        }
3227
3228        #[test]
3229        fn degradation_level_clone_eq() {
3230            let a = DegradationLevel::NoStyling;
3231            let b = a;
3232            assert_eq!(a, b);
3233        }
3234
3235        #[test]
3236        fn degradation_level_debug() {
3237            let s = format!("{:?}", DegradationLevel::EssentialOnly);
3238            assert!(s.contains("EssentialOnly"), "Debug output: {}", s);
3239        }
3240
3241        // --- BudgetController accessor edge cases ---
3242
3243        #[test]
3244        fn controller_eprocess_sigma_ms_uses_floor() {
3245            let ctrl = BudgetController::new(BudgetControllerConfig {
3246                eprocess: EProcessConfig {
3247                    sigma_floor_ms: 2.5,
3248                    ..Default::default()
3249                },
3250                ..Default::default()
3251            });
3252
3253            // Before any updates, sigma_ema is 0.0, so should return floor
3254            assert!(
3255                (ctrl.eprocess_sigma_ms() - 2.5).abs() < f64::EPSILON,
3256                "Should return sigma_floor_ms when sigma_ema < floor: {}",
3257                ctrl.eprocess_sigma_ms()
3258            );
3259        }
3260
3261        #[test]
3262        fn controller_config_accessor() {
3263            let config = BudgetControllerConfig {
3264                degrade_threshold: 0.42,
3265                ..Default::default()
3266            };
3267            let ctrl = BudgetController::new(config.clone());
3268
3269            assert_eq!(ctrl.config().degrade_threshold, 0.42);
3270            assert_eq!(ctrl.config().target, Duration::from_millis(16));
3271        }
3272
3273        #[test]
3274        fn controller_frames_observed_accessor() {
3275            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
3276
3277            assert_eq!(ctrl.frames_observed(), 0);
3278
3279            ctrl.update(Duration::from_millis(16));
3280            assert_eq!(ctrl.frames_observed(), 1);
3281
3282            ctrl.update(Duration::from_millis(16));
3283            assert_eq!(ctrl.frames_observed(), 2);
3284        }
3285
3286        // --- RenderBudget edge cases ---
3287
3288        #[test]
3289        fn render_budget_record_frame_time_used_by_next_frame() {
3290            let mut budget = RenderBudget::new(Duration::from_millis(1000));
3291            budget.degrade();
3292
3293            // Simulate many frames to pass cooldown
3294            for _ in 0..10 {
3295                budget.reset();
3296            }
3297
3298            // Record a very fast frame time
3299            budget.record_frame_time(Duration::from_millis(1));
3300            // Sleep past the budget so start.elapsed() would be large
3301            std::thread::sleep(Duration::from_millis(15));
3302
3303            let before = budget.degradation();
3304            budget.next_frame();
3305
3306            // The recorded frame time (1ms) should trigger upgrade
3307            // since remaining_fraction_for_elapsed(1ms) > upgrade_threshold
3308            assert!(
3309                budget.degradation() < before,
3310                "Recorded frame time should enable upgrade: before={:?}, after={:?}",
3311                before,
3312                budget.degradation()
3313            );
3314        }
3315
3316        #[test]
3317        fn render_budget_phase_budget_clamped_by_remaining() {
3318            // Create a budget that has very little remaining
3319            let budget = RenderBudget::new(Duration::from_millis(1));
3320            std::thread::sleep(Duration::from_millis(5));
3321
3322            // Phase budget should be clamped to remaining (0ms)
3323            let phase = budget.phase_budget(Phase::Render);
3324            assert!(
3325                phase.total() <= Duration::from_millis(1),
3326                "Phase budget should be clamped by remaining: {:?}",
3327                phase.total()
3328            );
3329        }
3330
3331        #[test]
3332        fn render_budget_exhausted_skipframe_with_no_frame_skip() {
3333            let mut budget = RenderBudget::new(Duration::from_millis(1000));
3334            budget.allow_frame_skip = false;
3335            budget.set_degradation(DegradationLevel::SkipFrame);
3336
3337            // With allow_frame_skip = false, SkipFrame should NOT cause exhaustion
3338            // (only time-based exhaustion matters)
3339            assert!(
3340                !budget.exhausted(),
3341                "SkipFrame should not exhaust when frame skip disabled"
3342            );
3343        }
3344
3345        #[test]
3346        fn render_budget_remaining_fraction_zero_total() {
3347            let budget = RenderBudget::new(Duration::ZERO);
3348            assert_eq!(budget.remaining_fraction(), 0.0);
3349        }
3350
3351        #[test]
3352        fn render_budget_total_accessor() {
3353            let budget = RenderBudget::new(Duration::from_millis(42));
3354            assert_eq!(budget.total(), Duration::from_millis(42));
3355        }
3356
3357        #[test]
3358        fn render_budget_phase_budgets_accessor() {
3359            let budget = RenderBudget::new(Duration::from_millis(16));
3360            let pb = budget.phase_budgets();
3361            assert_eq!(pb.diff, Duration::from_millis(2));
3362            assert_eq!(pb.present, Duration::from_millis(4));
3363            assert_eq!(pb.render, Duration::from_millis(8));
3364        }
3365
3366        #[test]
3367        fn render_budget_set_degradation_no_op_preserves_cooldown() {
3368            let mut budget = RenderBudget::new(Duration::from_millis(16));
3369            budget.set_degradation(DegradationLevel::NoStyling);
3370            budget.frames_since_change = 7;
3371
3372            // Setting to same level is a no-op
3373            budget.set_degradation(DegradationLevel::NoStyling);
3374            assert_eq!(budget.frames_since_change, 7);
3375
3376            // Setting to different level resets cooldown
3377            budget.set_degradation(DegradationLevel::Skeleton);
3378            assert_eq!(budget.frames_since_change, 0);
3379        }
3380
3381        #[test]
3382        fn render_budget_should_upgrade_false_at_full() {
3383            let budget = RenderBudget::new(Duration::from_millis(1000));
3384            assert!(!budget.should_upgrade(), "Full level should never upgrade");
3385        }
3386
3387        #[test]
3388        fn render_budget_should_upgrade_false_during_cooldown() {
3389            let mut budget = RenderBudget::new(Duration::from_millis(1000));
3390            budget.degrade();
3391            // frames_since_change is 0, cooldown is 3
3392            assert!(
3393                !budget.should_upgrade(),
3394                "Should not upgrade during cooldown"
3395            );
3396        }
3397
3398        #[test]
3399        fn render_budget_degrade_at_max_stays_at_max() {
3400            let mut budget = RenderBudget::new(Duration::from_millis(16));
3401            budget.set_degradation(DegradationLevel::SkipFrame);
3402            budget.degrade();
3403            assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
3404        }
3405
3406        #[test]
3407        fn render_budget_upgrade_at_full_stays_at_full() {
3408            let mut budget = RenderBudget::new(Duration::from_millis(16));
3409            budget.upgrade();
3410            assert_eq!(budget.degradation(), DegradationLevel::Full);
3411        }
3412
3413        // --- Config edge cases ---
3414
3415        #[test]
3416        fn frame_budget_config_partial_eq() {
3417            let a = FrameBudgetConfig::default();
3418            let b = FrameBudgetConfig::default();
3419            assert_eq!(a, b);
3420
3421            let c = FrameBudgetConfig::strict(Duration::from_millis(16));
3422            assert_ne!(a, c, "Different configs should not be equal");
3423        }
3424
3425        #[test]
3426        fn phase_budgets_eq_and_copy() {
3427            let a = PhaseBudgets::default();
3428            let b = a; // Copy
3429            assert_eq!(a, b);
3430
3431            let c = PhaseBudgets {
3432                diff: Duration::from_millis(1),
3433                ..Default::default()
3434            };
3435            assert_ne!(a, c);
3436        }
3437
3438        #[test]
3439        fn budget_controller_config_partial_eq() {
3440            let a = BudgetControllerConfig::default();
3441            let b = BudgetControllerConfig::default();
3442            assert_eq!(a, b);
3443        }
3444
3445        #[test]
3446        fn pid_gains_partial_eq() {
3447            let a = PidGains::default();
3448            let b = PidGains::default();
3449            assert_eq!(a, b);
3450        }
3451
3452        #[test]
3453        fn eprocess_config_partial_eq() {
3454            let a = EProcessConfig::default();
3455            let b = EProcessConfig::default();
3456            assert_eq!(a, b);
3457        }
3458
3459        // --- BudgetDecision edge cases ---
3460
3461        #[test]
3462        fn budget_decision_debug_format() {
3463            assert!(format!("{:?}", BudgetDecision::Hold).contains("Hold"));
3464            assert!(format!("{:?}", BudgetDecision::Degrade).contains("Degrade"));
3465            assert!(format!("{:?}", BudgetDecision::Upgrade).contains("Upgrade"));
3466        }
3467
3468        #[test]
3469        fn budget_decision_clone_copy() {
3470            let d = BudgetDecision::Degrade;
3471            let d2 = d;
3472            assert_eq!(d, d2);
3473        }
3474
3475        #[test]
3476        fn budget_decision_as_str_coverage() {
3477            assert_eq!(BudgetDecision::Hold.as_str(), "stay");
3478            assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
3479            assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
3480        }
3481
3482        // --- Phase edge cases ---
3483
3484        #[test]
3485        fn phase_eq_and_hash() {
3486            use std::collections::HashSet;
3487            let mut set = HashSet::new();
3488            set.insert(Phase::Diff);
3489            set.insert(Phase::Present);
3490            set.insert(Phase::Render);
3491            assert_eq!(set.len(), 3);
3492
3493            // Same phase hashes to same bucket
3494            set.insert(Phase::Diff);
3495            assert_eq!(set.len(), 3);
3496        }
3497
3498        #[test]
3499        fn phase_debug() {
3500            assert!(format!("{:?}", Phase::Diff).contains("Diff"));
3501            assert!(format!("{:?}", Phase::Present).contains("Present"));
3502            assert!(format!("{:?}", Phase::Render).contains("Render"));
3503        }
3504
3505        #[test]
3506        fn phase_clone_copy() {
3507            let p = Phase::Present;
3508            let p2 = p;
3509            assert_eq!(p, p2);
3510        }
3511
3512        // --- BudgetTelemetry edge cases ---
3513
3514        #[test]
3515        fn budget_telemetry_debug() {
3516            let telem = BudgetTelemetry {
3517                level: DegradationLevel::Full,
3518                pid_output: 0.0,
3519                pid_p: 0.0,
3520                pid_i: 0.0,
3521                pid_d: 0.0,
3522                e_value: 1.0,
3523                frames_observed: 0,
3524                frames_since_change: 0,
3525                last_decision: BudgetDecision::Hold,
3526                in_warmup: true,
3527            };
3528            let s = format!("{:?}", telem);
3529            assert!(s.contains("BudgetTelemetry"), "Debug output: {}", s);
3530        }
3531
3532        #[test]
3533        fn budget_telemetry_partial_eq() {
3534            let a = BudgetTelemetry {
3535                level: DegradationLevel::Full,
3536                pid_output: 0.5,
3537                pid_p: 0.3,
3538                pid_i: 0.1,
3539                pid_d: 0.1,
3540                e_value: 1.0,
3541                frames_observed: 5,
3542                frames_since_change: 2,
3543                last_decision: BudgetDecision::Hold,
3544                in_warmup: false,
3545            };
3546            let b = a;
3547            assert_eq!(a, b);
3548
3549            let c = BudgetTelemetry {
3550                level: DegradationLevel::SimpleBorders,
3551                ..a
3552            };
3553            assert_ne!(a, c);
3554        }
3555
3556        // --- Controller + RenderBudget integration edge cases ---
3557
3558        #[test]
3559        fn next_frame_without_recorded_time_uses_elapsed() {
3560            let mut budget = RenderBudget::new(Duration::from_millis(1000));
3561
3562            // Don't record frame time — next_frame falls back to start.elapsed()
3563            budget.next_frame();
3564
3565            // Should not panic, remaining should reset
3566            assert!(budget.remaining_fraction() > 0.9);
3567        }
3568
3569        #[test]
3570        fn controller_at_max_degradation_holds() {
3571            let mut ctrl = BudgetController::new(BudgetControllerConfig {
3572                eprocess: EProcessConfig {
3573                    warmup_frames: 0,
3574                    ..Default::default()
3575                },
3576                cooldown_frames: 0,
3577                ..Default::default()
3578            });
3579
3580            // Drive to SkipFrame
3581            for _ in 0..500 {
3582                ctrl.update(Duration::from_millis(200));
3583            }
3584            assert_eq!(ctrl.level(), DegradationLevel::SkipFrame);
3585
3586            // At max level, further overload should Hold (can't degrade further)
3587            let d = ctrl.update(Duration::from_millis(200));
3588            assert_eq!(d, BudgetDecision::Hold, "At max level, should hold");
3589        }
3590
3591        #[test]
3592        fn controller_at_full_level_no_upgrade() {
3593            let mut ctrl = BudgetController::new(BudgetControllerConfig {
3594                eprocess: EProcessConfig {
3595                    warmup_frames: 0,
3596                    ..Default::default()
3597                },
3598                cooldown_frames: 0,
3599                ..Default::default()
3600            });
3601
3602            // Feed underload — already at Full, so no upgrade possible
3603            for _ in 0..50 {
3604                let d = ctrl.update(Duration::from_millis(1));
3605                assert_ne!(
3606                    d,
3607                    BudgetDecision::Upgrade,
3608                    "Full level should never upgrade"
3609                );
3610            }
3611        }
3612
3613        #[test]
3614        fn render_budget_full_degrade_cycle_with_controller() {
3615            let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
3616                BudgetControllerConfig {
3617                    eprocess: EProcessConfig {
3618                        warmup_frames: 0,
3619                        ..Default::default()
3620                    },
3621                    cooldown_frames: 0,
3622                    ..Default::default()
3623                },
3624            );
3625
3626            // Overload to degrade via controller
3627            for _ in 0..100 {
3628                budget.record_frame_time(Duration::from_millis(40));
3629                budget.next_frame();
3630            }
3631            let degraded = budget.degradation();
3632            assert!(
3633                degraded > DegradationLevel::Full,
3634                "Should degrade: {:?}",
3635                degraded
3636            );
3637
3638            // Recovery via controller
3639            for _ in 0..200 {
3640                budget.record_frame_time(Duration::from_millis(4));
3641                budget.next_frame();
3642            }
3643            let recovered = budget.degradation();
3644            assert!(
3645                recovered < degraded,
3646                "Should recover: {:?} -> {:?}",
3647                degraded,
3648                recovered
3649            );
3650        }
3651
3652        #[test]
3653        fn render_budget_phase_has_budget_exhausted() {
3654            let budget = RenderBudget::new(Duration::from_millis(1));
3655            std::thread::sleep(Duration::from_millis(10));
3656
3657            // All phases should report no budget
3658            assert!(!budget.phase_has_budget(Phase::Diff));
3659            assert!(!budget.phase_has_budget(Phase::Present));
3660            assert!(!budget.phase_has_budget(Phase::Render));
3661        }
3662
3663        #[test]
3664        fn render_budget_elapsed_increases() {
3665            let budget = RenderBudget::new(Duration::from_millis(1000));
3666            let e1 = budget.elapsed();
3667            std::thread::sleep(Duration::from_millis(5));
3668            let e2 = budget.elapsed();
3669            assert!(e2 > e1, "Elapsed should increase: {:?} vs {:?}", e1, e2);
3670        }
3671
3672        #[test]
3673        fn controller_pid_integral_accessor() {
3674            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
3675
3676            assert_eq!(ctrl.pid_integral(), 0.0);
3677
3678            // Feed overload to accumulate integral
3679            ctrl.update(Duration::from_millis(32)); // 2x target
3680            assert!(
3681                ctrl.pid_integral() > 0.0,
3682                "Integral should grow: {}",
3683                ctrl.pid_integral()
3684            );
3685        }
3686
3687        #[test]
3688        fn controller_e_value_accessor() {
3689            let ctrl = BudgetController::new(BudgetControllerConfig::default());
3690            assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
3691        }
3692    }
3693}