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        if error.is_nan() {
143            return 0.0;
144        }
145        // Integral with anti-windup clamping
146        self.integral = (self.integral + error).clamp(-gains.integral_max, gains.integral_max);
147
148        // Derivative (first-frame uses zero derivative)
149        let derivative = error - self.prev_error;
150        self.prev_error = error;
151
152        // Record individual PID terms for telemetry
153        self.last_p = gains.kp * error;
154        self.last_i = gains.ki * self.integral;
155        self.last_d = gains.kd * derivative;
156
157        // PID output
158        self.last_p + self.last_i + self.last_d
159    }
160
161    /// Reset controller state (e.g., after a mode change).
162    fn reset(&mut self) {
163        *self = Self::default();
164    }
165}
166
167/// Anytime-valid e-process for gating degradation decisions.
168///
169/// # Mathematical Model
170///
171/// The e-process is a nonnegative supermartingale under H₀ (system is healthy):
172///
173/// ```text
174/// E_t = Π_{j=1..t} exp(λ * r_j − λ² * σ² / 2)
175/// ```
176///
177/// where:
178/// - `r_j` is the standardized residual at frame j: `(frame_time − target) / σ`
179/// - `σ` is the estimated standard deviation of frame times
180/// - `λ` is a tuning parameter controlling sensitivity (default: 0.5)
181///
182/// # Decision Rule
183///
184/// - **Degrade** only when `E_t > 1/α` (evidence exceeds threshold).
185///   Default α = 0.05, so we need `E_t > 20`.
186/// - **Upgrade** only when `E_t < β` (evidence that overload has passed).
187///   Default β = 0.5.
188///
189/// # Properties
190///
191/// 1. **Anytime-valid**: The test is valid at any stopping time, unlike
192///    fixed-sample tests. We can check after every frame without p-hacking.
193/// 2. **Bounded false positive rate**: P(E_t ever exceeds 1/α | H₀) ≤ α
194///    (Ville's inequality).
195/// 3. **Self-correcting**: After a burst passes, E_t decays back toward 1.0,
196///    naturally enabling recovery.
197///
198/// # Failure Modes
199///
200/// - **Sustained overload**: E_t grows exponentially → rapid degradation.
201/// - **Transient spike**: E_t grows briefly → may not cross threshold →
202///   PID handles short-term. Only persistent overload triggers e-process gate.
203/// - **σ estimation drift**: We use an exponential moving average for σ with
204///   a warmup period of 10 frames to avoid unstable early estimates.
205#[derive(Debug, Clone, PartialEq)]
206pub struct EProcessConfig {
207    /// Sensitivity parameter λ. Higher values detect overload faster
208    /// but increase false positive risk near the boundary.
209    pub lambda: f64,
210    /// Significance level α. Degrade when E_t > 1/α.
211    /// Default: 0.05 (need E_t > 20 to degrade).
212    pub alpha: f64,
213    /// Recovery threshold β. Upgrade allowed when E_t < β.
214    /// Default: 0.5.
215    pub beta: f64,
216    /// EMA decay for σ estimation. Closer to 1.0 = slower adaptation.
217    /// Default: 0.9 (adapts over ~10 frames).
218    pub sigma_ema_decay: f64,
219    /// Minimum σ floor to prevent division by zero.
220    /// Default: 1.0 ms.
221    pub sigma_floor_ms: f64,
222    /// Warmup frames before e-process activates. During warmup, fall back
223    /// to PID-only decisions.
224    pub warmup_frames: u32,
225}
226
227impl Default for EProcessConfig {
228    fn default() -> Self {
229        Self {
230            lambda: 0.5,
231            alpha: 0.05,
232            beta: 0.5,
233            sigma_ema_decay: 0.9,
234            sigma_floor_ms: 1.0,
235            warmup_frames: 10,
236        }
237    }
238}
239
240/// Internal e-process state.
241#[derive(Debug, Clone)]
242struct EProcessState {
243    /// Current e-process value E_t (starts at 1.0).
244    e_value: f64,
245    /// EMA estimate of frame time standard deviation (ms).
246    sigma_ema: f64,
247    /// EMA estimate of mean frame time (ms) for residual computation.
248    mean_ema: f64,
249    /// Frames observed so far.
250    frames_observed: u32,
251}
252
253impl Default for EProcessState {
254    fn default() -> Self {
255        Self {
256            e_value: 1.0,
257            sigma_ema: 0.0,
258            mean_ema: 0.0,
259            frames_observed: 0,
260        }
261    }
262}
263
264impl EProcessState {
265    /// Update the e-process with a new frame time observation.
266    ///
267    /// Returns the updated E_t value.
268    fn update(&mut self, frame_time_ms: f64, target_ms: f64, config: &EProcessConfig) -> f64 {
269        self.frames_observed = self.frames_observed.saturating_add(1);
270
271        // Update mean EMA
272        if self.frames_observed == 1 {
273            self.mean_ema = frame_time_ms;
274            self.sigma_ema = config.sigma_floor_ms;
275        } else {
276            let decay = config.sigma_ema_decay;
277            self.mean_ema = decay * self.mean_ema + (1.0 - decay) * frame_time_ms;
278            // Update sigma EMA using absolute deviation as proxy
279            let deviation = (frame_time_ms - self.mean_ema).abs();
280            self.sigma_ema = decay * self.sigma_ema + (1.0 - decay) * deviation;
281        }
282
283        // Floor sigma to prevent instability
284        let sigma = self.sigma_ema.max(config.sigma_floor_ms);
285
286        // Compute standardized residual
287        let residual = (frame_time_ms - target_ms) / sigma;
288
289        // E-process multiplicative update:
290        // E_{t+1} = E_t * exp(λ * r_t − λ² * σ² / 2)
291        // Since r_t is already standardized, σ in the exponent is 1.0.
292        let lambda = config.lambda;
293        let log_factor = lambda * residual - lambda * lambda / 2.0;
294        if !log_factor.is_nan() {
295            self.e_value *= log_factor.exp();
296            // Clamp to avoid numerical issues (but preserve the supermartingale property
297            // by allowing it to grow large or shrink small).
298            self.e_value = self.e_value.clamp(1e-10, 1e10);
299        }
300
301        self.e_value
302    }
303
304    /// Check if evidence supports degradation.
305    fn should_degrade(&self, config: &EProcessConfig) -> bool {
306        if self.frames_observed < config.warmup_frames {
307            return false; // Fall back to PID during warmup
308        }
309        self.e_value > 1.0 / config.alpha
310    }
311
312    /// Check if evidence supports upgrade (overload has passed).
313    fn should_upgrade(&self, config: &EProcessConfig) -> bool {
314        if self.frames_observed < config.warmup_frames {
315            return true; // Allow PID-driven upgrades during warmup
316        }
317        self.e_value < config.beta
318    }
319
320    /// Reset state.
321    fn reset(&mut self) {
322        *self = Self::default();
323    }
324}
325
326/// Configuration for the adaptive budget controller.
327#[derive(Debug, Clone, PartialEq)]
328pub struct BudgetControllerConfig {
329    /// PID controller gains.
330    pub pid: PidGains,
331    /// E-process configuration.
332    pub eprocess: EProcessConfig,
333    /// Target frame time.
334    pub target: Duration,
335    /// Hysteresis: PID output must exceed this to trigger degradation.
336    ///
337    /// This prevents oscillation at the boundary. The value is in
338    /// normalized units (error / target). Default: 0.3 (30% of target).
339    ///
340    /// # Justification
341    ///
342    /// A threshold of 0.3 means the controller needs ~5ms sustained error
343    /// at 16ms target before degrading. This filters out single-frame jitter
344    /// while remaining responsive to genuine overload (2-3 consecutive
345    /// slow frames will cross the threshold via integral accumulation).
346    pub degrade_threshold: f64,
347    /// Hysteresis: PID output must be below negative of this to trigger upgrade.
348    /// Default: 0.2 (20% of target).
349    pub upgrade_threshold: f64,
350    /// Cooldown frames between level changes.
351    pub cooldown_frames: u32,
352    /// Minimum quality floor: the controller will never degrade past this level.
353    ///
354    /// Default: `DegradationLevel::SimpleBorders` — preserves readable text
355    /// content while still allowing border simplification.
356    ///
357    /// Setting this to `DegradationLevel::Full` disables all degradation.
358    /// Setting this to `DegradationLevel::SkipFrame` effectively removes the floor.
359    pub degradation_floor: DegradationLevel,
360}
361
362impl Default for BudgetControllerConfig {
363    fn default() -> Self {
364        Self {
365            pid: PidGains::default(),
366            eprocess: EProcessConfig::default(),
367            target: Duration::from_millis(16),
368            degrade_threshold: 0.3,
369            upgrade_threshold: 0.2,
370            cooldown_frames: 3,
371            degradation_floor: DegradationLevel::SimpleBorders,
372        }
373    }
374}
375
376/// Adaptive budget controller combining PID regulation with e-process gating.
377///
378/// # Architecture
379///
380/// ```text
381/// frame_time ─┬─► PID Controller ─► control signal u_t
382///             │                              │
383///             └─► E-Process ──────► gate ────┤
384///                                            ▼
385///                                    Decision Logic
386///                                    ┌───────────────┐
387///                                    │ u_t > thresh   │──► DEGRADE (if e-process permits)
388///                                    │ u_t < -thresh  │──► UPGRADE (if e-process permits)
389///                                    │ otherwise      │──► HOLD
390///                                    └───────────────┘
391/// ```
392///
393/// The PID controller provides smooth, reactive adaptation. The e-process
394/// gates decisions to ensure statistical validity — we only degrade when
395/// there is strong evidence of sustained overload, not just transient spikes.
396///
397/// # Usage
398///
399/// ```rust
400/// use ftui_render::budget::{BudgetController, BudgetControllerConfig, DegradationLevel};
401/// use std::time::Duration;
402///
403/// let mut controller = BudgetController::new(BudgetControllerConfig::default());
404///
405/// // After each frame, feed the observed frame time:
406/// let decision = controller.update(Duration::from_millis(20)); // slow frame
407/// // decision tells you what to do: Hold, Degrade, or Upgrade
408/// ```
409#[derive(Debug, Clone)]
410pub struct BudgetController {
411    config: BudgetControllerConfig,
412    pid: PidState,
413    eprocess: EProcessState,
414    current_level: DegradationLevel,
415    frames_since_change: u32,
416    last_pid_output: f64,
417    last_decision: BudgetDecision,
418    last_decision_reason: BudgetDecisionReason,
419    last_frame_ms: f64,
420    transition_seq: u64,
421    last_transition_correlation_id: u64,
422    last_pid_gate_threshold: f64,
423    last_pid_gate_margin: f64,
424    last_evidence_threshold: f64,
425    last_evidence_margin: f64,
426}
427
428/// Decision output from the budget controller.
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum BudgetDecision {
431    /// Maintain current degradation level.
432    Hold,
433    /// Degrade one level (reduce visual fidelity).
434    Degrade,
435    /// Upgrade one level (restore visual fidelity).
436    Upgrade,
437}
438
439impl BudgetDecision {
440    /// JSONL-compatible string representation.
441    #[inline]
442    pub fn as_str(self) -> &'static str {
443        match self {
444            Self::Hold => "stay",
445            Self::Degrade => "degrade",
446            Self::Upgrade => "upgrade",
447        }
448    }
449}
450
451/// Version tag for budget telemetry schema emitted by [`BudgetTelemetry`].
452pub const BUDGET_TELEMETRY_SCHEMA_VERSION: u16 = 1;
453
454/// Controller rationale for a per-frame decision.
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum BudgetDecisionReason {
457    /// No decision change while cooldown is active.
458    CooldownActive,
459    /// Overload + evidence gate passed, so degrade one level.
460    OverloadEvidencePassed,
461    /// Underload + evidence gate passed, so upgrade one level.
462    UnderloadEvidencePassed,
463    /// Already at maximum degradation; cannot degrade further.
464    AtMaxDegradation,
465    /// Already at the configured degradation floor; policy forbids degrading further.
466    AtDegradationFloor,
467    /// Already at full quality; cannot upgrade further.
468    AtFullQuality,
469    /// Overload signal present but e-process degrade gate not satisfied.
470    OverloadEvidenceInsufficient,
471    /// Underload signal present but e-process upgrade gate not satisfied.
472    UnderloadEvidenceInsufficient,
473    /// PID output remained in the hold band.
474    WithinThresholdBand,
475}
476
477impl BudgetDecisionReason {
478    /// Stable string code for JSONL logs and CI parsing.
479    #[inline]
480    pub fn as_str(self) -> &'static str {
481        match self {
482            Self::CooldownActive => "cooldown_active",
483            Self::OverloadEvidencePassed => "overload_evidence_passed",
484            Self::UnderloadEvidencePassed => "underload_evidence_passed",
485            Self::AtMaxDegradation => "at_max_degradation",
486            Self::AtDegradationFloor => "at_degradation_floor",
487            Self::AtFullQuality => "at_full_quality",
488            Self::OverloadEvidenceInsufficient => "overload_evidence_insufficient",
489            Self::UnderloadEvidenceInsufficient => "underload_evidence_insufficient",
490            Self::WithinThresholdBand => "within_threshold_band",
491        }
492    }
493}
494
495impl BudgetController {
496    /// Create a new budget controller with the given configuration.
497    pub fn new(config: BudgetControllerConfig) -> Self {
498        Self {
499            config,
500            pid: PidState::default(),
501            eprocess: EProcessState::default(),
502            current_level: DegradationLevel::Full,
503            frames_since_change: 0,
504            last_pid_output: 0.0,
505            last_decision: BudgetDecision::Hold,
506            last_decision_reason: BudgetDecisionReason::WithinThresholdBand,
507            last_frame_ms: 0.0,
508            transition_seq: 0,
509            last_transition_correlation_id: 0,
510            last_pid_gate_threshold: 0.0,
511            last_pid_gate_margin: 0.0,
512            last_evidence_threshold: 0.0,
513            last_evidence_margin: 0.0,
514        }
515    }
516
517    /// Feed a frame time observation and get a decision.
518    ///
519    /// Call this once per frame with the measured frame duration.
520    pub fn update(&mut self, frame_time: Duration) -> BudgetDecision {
521        let target_ms = self.config.target.as_secs_f64() * 1000.0;
522        let frame_ms = frame_time.as_secs_f64() * 1000.0;
523
524        // Compute normalized error (positive = over budget)
525        let error = (frame_ms - target_ms) / target_ms;
526
527        // Update PID
528        let u = self.pid.update(error, &self.config.pid);
529        self.last_pid_output = u;
530        self.last_frame_ms = frame_ms;
531
532        // Update e-process
533        self.eprocess
534            .update(frame_ms, target_ms, &self.config.eprocess);
535
536        // Increment cooldown counter
537        self.frames_since_change = self.frames_since_change.saturating_add(1);
538
539        let mut decision = BudgetDecision::Hold;
540        let mut reason = BudgetDecisionReason::WithinThresholdBand;
541        let mut pid_gate_threshold = 0.0;
542        let mut pid_gate_margin = 0.0;
543        let mut evidence_threshold = 0.0;
544        let mut evidence_margin = 0.0;
545
546        // Decision logic with hysteresis + e-process gating + explainable reason/evidence.
547        if self.frames_since_change < self.config.cooldown_frames {
548            reason = BudgetDecisionReason::CooldownActive;
549        } else if u > self.config.degrade_threshold {
550            pid_gate_threshold = self.config.degrade_threshold;
551            pid_gate_margin = u - pid_gate_threshold;
552            evidence_threshold = 1.0 / self.config.eprocess.alpha;
553            evidence_margin = self.eprocess.e_value - evidence_threshold;
554
555            if self.current_level.is_max() {
556                reason = BudgetDecisionReason::AtMaxDegradation;
557            } else if self.current_level >= self.config.degradation_floor {
558                reason = BudgetDecisionReason::AtDegradationFloor;
559            } else if self.eprocess.should_degrade(&self.config.eprocess) {
560                decision = BudgetDecision::Degrade;
561                reason = BudgetDecisionReason::OverloadEvidencePassed;
562            } else {
563                reason = BudgetDecisionReason::OverloadEvidenceInsufficient;
564            }
565        } else if u < -self.config.upgrade_threshold {
566            pid_gate_threshold = -self.config.upgrade_threshold;
567            pid_gate_margin = (-u) - self.config.upgrade_threshold;
568            evidence_threshold = self.config.eprocess.beta;
569            evidence_margin = evidence_threshold - self.eprocess.e_value;
570
571            if self.current_level.is_full() {
572                reason = BudgetDecisionReason::AtFullQuality;
573            } else if self.eprocess.should_upgrade(&self.config.eprocess) {
574                decision = BudgetDecision::Upgrade;
575                reason = BudgetDecisionReason::UnderloadEvidencePassed;
576            } else {
577                reason = BudgetDecisionReason::UnderloadEvidenceInsufficient;
578            }
579        }
580
581        // Record decision for telemetry
582        self.last_decision = decision;
583        self.last_decision_reason = reason;
584        self.last_pid_gate_threshold = pid_gate_threshold;
585        self.last_pid_gate_margin = pid_gate_margin;
586        self.last_evidence_threshold = evidence_threshold;
587        self.last_evidence_margin = evidence_margin;
588
589        // Apply decision
590        match decision {
591            BudgetDecision::Degrade => {
592                self.transition_seq = self.transition_seq.saturating_add(1);
593                self.last_transition_correlation_id =
594                    (self.transition_seq << 32) ^ u64::from(self.eprocess.frames_observed);
595                let next = self.current_level.next();
596                // Clamp to degradation floor: never degrade past the configured minimum quality.
597                self.current_level = if next > self.config.degradation_floor {
598                    self.config.degradation_floor
599                } else {
600                    next
601                };
602                self.frames_since_change = 0;
603
604                #[cfg(feature = "tracing")]
605                warn!(
606                    level = self.current_level.as_str(),
607                    pid_output = u,
608                    e_value = self.eprocess.e_value,
609                    "budget controller: degrade"
610                );
611            }
612            BudgetDecision::Upgrade => {
613                self.transition_seq = self.transition_seq.saturating_add(1);
614                self.last_transition_correlation_id =
615                    (self.transition_seq << 32) ^ u64::from(self.eprocess.frames_observed);
616                self.current_level = self.current_level.prev();
617                self.frames_since_change = 0;
618
619                #[cfg(feature = "tracing")]
620                trace!(
621                    level = self.current_level.as_str(),
622                    pid_output = u,
623                    e_value = self.eprocess.e_value,
624                    "budget controller: upgrade"
625                );
626            }
627            BudgetDecision::Hold => {}
628        }
629
630        decision
631    }
632
633    /// Get the current degradation level.
634    #[inline]
635    pub fn level(&self) -> DegradationLevel {
636        self.current_level
637    }
638
639    /// Get the current e-process value (for diagnostics/logging).
640    #[inline]
641    pub fn e_value(&self) -> f64 {
642        self.eprocess.e_value
643    }
644
645    /// Get the current e-process sigma estimate (ms).
646    #[inline]
647    pub fn eprocess_sigma_ms(&self) -> f64 {
648        self.eprocess
649            .sigma_ema
650            .max(self.config.eprocess.sigma_floor_ms)
651    }
652
653    /// Get the current PID integral term (for diagnostics/logging).
654    #[inline]
655    pub fn pid_integral(&self) -> f64 {
656        self.pid.integral
657    }
658
659    /// Get the number of frames observed by the e-process.
660    #[inline]
661    pub fn frames_observed(&self) -> u32 {
662        self.eprocess.frames_observed
663    }
664
665    /// Capture a telemetry snapshot of the controller state.
666    ///
667    /// This is allocation-free and suitable for calling every frame.
668    /// Forward the result to a debug overlay or structured logger.
669    #[inline]
670    pub fn telemetry(&self) -> BudgetTelemetry {
671        BudgetTelemetry {
672            schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
673            level: self.current_level,
674            pid_output: self.last_pid_output,
675            pid_p: self.pid.last_p,
676            pid_i: self.pid.last_i,
677            pid_d: self.pid.last_d,
678            e_value: self.eprocess.e_value,
679            frames_observed: self.eprocess.frames_observed,
680            frames_since_change: self.frames_since_change,
681            last_decision: self.last_decision,
682            decision_reason: self.last_decision_reason,
683            transition_seq: self.transition_seq,
684            transition_correlation_id: self.last_transition_correlation_id,
685            frame_time_ms: self.last_frame_ms,
686            target_ms: self.config.target.as_secs_f64() * 1000.0,
687            pid_gate_threshold: self.last_pid_gate_threshold,
688            pid_gate_margin: self.last_pid_gate_margin,
689            evidence_threshold: self.last_evidence_threshold,
690            evidence_margin: self.last_evidence_margin,
691            in_warmup: self.eprocess.frames_observed < self.config.eprocess.warmup_frames,
692        }
693    }
694
695    /// Reset the controller to initial state.
696    pub fn reset(&mut self) {
697        self.pid.reset();
698        self.eprocess.reset();
699        self.current_level = DegradationLevel::Full;
700        self.frames_since_change = 0;
701        self.last_pid_output = 0.0;
702        self.last_decision = BudgetDecision::Hold;
703        self.last_decision_reason = BudgetDecisionReason::WithinThresholdBand;
704        self.last_frame_ms = 0.0;
705        self.transition_seq = 0;
706        self.last_transition_correlation_id = 0;
707        self.last_pid_gate_threshold = 0.0;
708        self.last_pid_gate_margin = 0.0;
709        self.last_evidence_threshold = 0.0;
710        self.last_evidence_margin = 0.0;
711    }
712
713    /// Get a reference to the controller configuration.
714    #[inline]
715    #[must_use]
716    pub fn config(&self) -> &BudgetControllerConfig {
717        &self.config
718    }
719}
720
721/// Snapshot of budget controller telemetry for diagnostics and debug overlay.
722///
723/// All fields are `Copy` — no allocations. Intended to be cheaply captured
724/// once per frame and forwarded to a tracing subscriber or debug overlay widget.
725#[derive(Debug, Clone, Copy, PartialEq)]
726pub struct BudgetTelemetry {
727    /// Telemetry schema version for CI/E2E consumers.
728    pub schema_version: u16,
729    /// Current degradation level.
730    pub level: DegradationLevel,
731    /// Last PID control signal (positive = over budget).
732    pub pid_output: f64,
733    /// Last PID proportional term.
734    pub pid_p: f64,
735    /// Last PID integral term.
736    pub pid_i: f64,
737    /// Last PID derivative term.
738    pub pid_d: f64,
739    /// Current e-process value E_t.
740    pub e_value: f64,
741    /// Frames observed by the e-process.
742    pub frames_observed: u32,
743    /// Frames since last level change.
744    pub frames_since_change: u32,
745    /// Last decision made by the controller.
746    pub last_decision: BudgetDecision,
747    /// Rationale code describing why the last decision was taken.
748    pub decision_reason: BudgetDecisionReason,
749    /// Monotonic transition sequence number (increments on degrade/upgrade).
750    pub transition_seq: u64,
751    /// Correlation ID for the most recent transition event (0 if none yet).
752    pub transition_correlation_id: u64,
753    /// Last observed frame time in milliseconds.
754    pub frame_time_ms: f64,
755    /// Current target frame budget in milliseconds.
756    pub target_ms: f64,
757    /// PID gate threshold used for the last decision path.
758    pub pid_gate_threshold: f64,
759    /// PID gate margin (positive values indicate stronger gate pass).
760    pub pid_gate_margin: f64,
761    /// Evidence (e-process) threshold used for the last decision path.
762    pub evidence_threshold: f64,
763    /// Evidence gate margin (positive values indicate stronger gate pass).
764    pub evidence_margin: f64,
765    /// Whether the controller is in warmup (e-process not yet active).
766    pub in_warmup: bool,
767}
768
769/// Progressive degradation levels for render quality.
770///
771/// Higher levels mean less visual fidelity but faster rendering.
772/// The ordering is significant: `Full` < `SimpleBorders` < ... < `SkipFrame`.
773#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
774#[repr(u8)]
775pub enum DegradationLevel {
776    /// All visual features enabled.
777    #[default]
778    Full = 0,
779    /// Unicode box-drawing replaced with ASCII (+--+).
780    SimpleBorders = 1,
781    /// Colors disabled, monochrome output.
782    NoStyling = 2,
783    /// Skip decorative widgets, essential content only.
784    EssentialOnly = 3,
785    /// Just layout boxes, no content.
786    Skeleton = 4,
787    /// Emergency: skip frame entirely.
788    SkipFrame = 5,
789}
790
791impl DegradationLevel {
792    /// Move to the next degradation level.
793    ///
794    /// Returns `SkipFrame` if already at maximum degradation.
795    #[inline]
796    #[must_use]
797    pub fn next(self) -> Self {
798        match self {
799            Self::Full => Self::SimpleBorders,
800            Self::SimpleBorders => Self::NoStyling,
801            Self::NoStyling => Self::EssentialOnly,
802            Self::EssentialOnly => Self::Skeleton,
803            Self::Skeleton | Self::SkipFrame => Self::SkipFrame,
804        }
805    }
806
807    /// Move to the previous (better quality) degradation level.
808    ///
809    /// Returns `Full` if already at minimum degradation.
810    #[inline]
811    #[must_use]
812    pub fn prev(self) -> Self {
813        match self {
814            Self::SkipFrame => Self::Skeleton,
815            Self::Skeleton => Self::EssentialOnly,
816            Self::EssentialOnly => Self::NoStyling,
817            Self::NoStyling => Self::SimpleBorders,
818            Self::SimpleBorders | Self::Full => Self::Full,
819        }
820    }
821
822    /// Check if this is the maximum degradation level.
823    #[inline]
824    pub fn is_max(self) -> bool {
825        self == Self::SkipFrame
826    }
827
828    /// Check if this is full quality (no degradation).
829    #[inline]
830    pub fn is_full(self) -> bool {
831        self == Self::Full
832    }
833
834    /// Get a human-readable name for logging.
835    #[inline]
836    pub fn as_str(self) -> &'static str {
837        match self {
838            Self::Full => "Full",
839            Self::SimpleBorders => "SimpleBorders",
840            Self::NoStyling => "NoStyling",
841            Self::EssentialOnly => "EssentialOnly",
842            Self::Skeleton => "Skeleton",
843            Self::SkipFrame => "SkipFrame",
844        }
845    }
846
847    /// Number of levels from Full (0) to this level.
848    #[inline]
849    pub fn level(self) -> u8 {
850        self as u8
851    }
852
853    // ---- Widget convenience queries ----
854
855    /// Whether to use Unicode box-drawing characters.
856    ///
857    /// Returns `false` at `SimpleBorders` and above (use ASCII instead).
858    #[inline]
859    pub fn use_unicode_borders(self) -> bool {
860        self < Self::SimpleBorders
861    }
862
863    /// Whether to apply colors and style attributes to cells.
864    ///
865    /// Returns `false` at `NoStyling` and above.
866    #[inline]
867    pub fn apply_styling(self) -> bool {
868        self < Self::NoStyling
869    }
870
871    /// Whether to render decorative (non-essential) elements.
872    ///
873    /// Returns `false` at `EssentialOnly` and above.
874    /// Decorative elements include borders, scrollbars, spinners, rules.
875    #[inline]
876    pub fn render_decorative(self) -> bool {
877        self < Self::EssentialOnly
878    }
879
880    /// Whether to render content text.
881    ///
882    /// Returns `false` at `Skeleton` and above.
883    #[inline]
884    pub fn render_content(self) -> bool {
885        self < Self::Skeleton
886    }
887}
888
889/// Per-phase time budgets within a frame.
890#[derive(Debug, Clone, Copy, PartialEq, Eq)]
891pub struct PhaseBudgets {
892    /// Budget for diff computation.
893    pub diff: Duration,
894    /// Budget for ANSI presentation/emission.
895    pub present: Duration,
896    /// Budget for widget rendering.
897    pub render: Duration,
898}
899
900impl Default for PhaseBudgets {
901    fn default() -> Self {
902        Self {
903            diff: Duration::from_millis(2),
904            present: Duration::from_millis(4),
905            render: Duration::from_millis(8),
906        }
907    }
908}
909
910/// Configuration for frame budget behavior.
911#[derive(Debug, Clone, PartialEq)]
912pub struct FrameBudgetConfig {
913    /// Total time budget per frame.
914    pub total: Duration,
915    /// Per-phase budgets.
916    pub phase_budgets: PhaseBudgets,
917    /// Allow skipping frames entirely when severely over budget.
918    pub allow_frame_skip: bool,
919    /// Frames to wait between degradation level changes.
920    pub degradation_cooldown: u32,
921    /// Threshold (as fraction of total) above which we consider upgrading.
922    /// Default: 0.5 (upgrade when >50% budget remains).
923    pub upgrade_threshold: f32,
924}
925
926impl Default for FrameBudgetConfig {
927    fn default() -> Self {
928        Self {
929            total: Duration::from_millis(16), // ~60fps feel
930            phase_budgets: PhaseBudgets::default(),
931            allow_frame_skip: true,
932            degradation_cooldown: 3,
933            upgrade_threshold: 0.5,
934        }
935    }
936}
937
938impl FrameBudgetConfig {
939    /// Create a new config with the specified total budget.
940    pub fn with_total(total: Duration) -> Self {
941        Self {
942            total,
943            ..Default::default()
944        }
945    }
946
947    /// Create a strict config that never skips frames.
948    pub fn strict(total: Duration) -> Self {
949        Self {
950            total,
951            allow_frame_skip: false,
952            ..Default::default()
953        }
954    }
955
956    /// Create a relaxed config for slower refresh rates.
957    pub fn relaxed() -> Self {
958        Self {
959            total: Duration::from_millis(33), // ~30fps
960            degradation_cooldown: 5,
961            ..Default::default()
962        }
963    }
964}
965
966/// Render time budget with graceful degradation.
967///
968/// Tracks elapsed time within a frame and manages degradation level
969/// to maintain responsive rendering under load.
970#[derive(Debug, Clone)]
971pub struct RenderBudget {
972    /// Total time budget for this frame.
973    total: Duration,
974    /// When this frame started.
975    start: Instant,
976    /// Measured render+present time for the last frame (if recorded).
977    last_frame_time: Option<Duration>,
978    /// Current degradation level.
979    degradation: DegradationLevel,
980    /// Per-phase budgets.
981    phase_budgets: PhaseBudgets,
982    /// Allow frame skip at maximum degradation.
983    allow_frame_skip: bool,
984    /// Upgrade threshold fraction.
985    upgrade_threshold: f32,
986    /// Frames since last degradation change (for cooldown).
987    frames_since_change: u32,
988    /// Cooldown frames required between changes.
989    cooldown: u32,
990    /// Optional adaptive budget controller (PID + e-process).
991    /// When present, `next_frame()` delegates degradation decisions to the controller.
992    controller: Option<BudgetController>,
993}
994
995impl RenderBudget {
996    /// Create a new budget with the specified total time.
997    pub fn new(total: Duration) -> Self {
998        Self {
999            total,
1000            start: Instant::now(),
1001            last_frame_time: None,
1002            degradation: DegradationLevel::Full,
1003            phase_budgets: PhaseBudgets::default(),
1004            allow_frame_skip: true,
1005            upgrade_threshold: 0.5,
1006            frames_since_change: 0,
1007            cooldown: 3,
1008            controller: None,
1009        }
1010    }
1011
1012    /// Create a budget from configuration.
1013    pub fn from_config(config: &FrameBudgetConfig) -> Self {
1014        Self {
1015            total: config.total,
1016            start: Instant::now(),
1017            last_frame_time: None,
1018            degradation: DegradationLevel::Full,
1019            phase_budgets: config.phase_budgets,
1020            allow_frame_skip: config.allow_frame_skip,
1021            upgrade_threshold: config.upgrade_threshold,
1022            frames_since_change: 0,
1023            cooldown: config.degradation_cooldown,
1024            controller: None,
1025        }
1026    }
1027
1028    /// Attach an adaptive budget controller to this render budget.
1029    ///
1030    /// When a controller is attached, `next_frame()` feeds the measured frame
1031    /// duration to the controller and applies its degradation decisions
1032    /// instead of the simple threshold-based upgrade logic.
1033    ///
1034    /// # Example
1035    ///
1036    /// ```
1037    /// use ftui_render::budget::{RenderBudget, BudgetControllerConfig};
1038    /// use std::time::Duration;
1039    ///
1040    /// let budget = RenderBudget::new(Duration::from_millis(16))
1041    ///     .with_controller(BudgetControllerConfig::default());
1042    /// ```
1043    #[must_use]
1044    pub fn with_controller(mut self, config: BudgetControllerConfig) -> Self {
1045        self.controller = Some(BudgetController::new(config));
1046        self
1047    }
1048
1049    /// Get the total budget duration.
1050    #[inline]
1051    pub fn total(&self) -> Duration {
1052        self.total
1053    }
1054
1055    /// Get the elapsed time since budget started.
1056    #[inline]
1057    pub fn elapsed(&self) -> Duration {
1058        self.start.elapsed()
1059    }
1060
1061    /// Get the remaining time in the budget.
1062    #[inline]
1063    pub fn remaining(&self) -> Duration {
1064        self.total.saturating_sub(self.start.elapsed())
1065    }
1066
1067    /// Get the remaining time as a fraction of total (0.0 to 1.0).
1068    #[inline]
1069    pub fn remaining_fraction(&self) -> f32 {
1070        if self.total.is_zero() {
1071            return 0.0;
1072        }
1073        let remaining = self.remaining().as_secs_f32();
1074        let total = self.total.as_secs_f32();
1075        (remaining / total).clamp(0.0, 1.0)
1076    }
1077
1078    /// Check if we should degrade given an estimated operation cost.
1079    ///
1080    /// Returns `true` if the estimated cost exceeds remaining budget.
1081    #[inline]
1082    pub fn should_degrade(&self, estimated_cost: Duration) -> bool {
1083        self.remaining() < estimated_cost
1084    }
1085
1086    /// Degrade to the next level.
1087    ///
1088    /// Logs a warning when degradation occurs.
1089    pub fn degrade(&mut self) {
1090        let from = self.degradation;
1091        self.degradation = self.degradation.next();
1092        self.frames_since_change = 0;
1093
1094        #[cfg(feature = "tracing")]
1095        if from != self.degradation {
1096            warn!(
1097                from = from.as_str(),
1098                to = self.degradation.as_str(),
1099                remaining_ms = self.remaining().as_millis() as u32,
1100                "render budget degradation"
1101            );
1102        }
1103        let _ = from; // Suppress unused warning when tracing is disabled
1104    }
1105
1106    /// Get the current degradation level.
1107    #[inline]
1108    pub fn degradation(&self) -> DegradationLevel {
1109        self.degradation
1110    }
1111
1112    /// Set the degradation level directly.
1113    ///
1114    /// Use with caution - prefer `degrade()` and `upgrade()` for gradual changes.
1115    pub fn set_degradation(&mut self, level: DegradationLevel) {
1116        if self.degradation != level {
1117            self.degradation = level;
1118            self.frames_since_change = 0;
1119        }
1120    }
1121
1122    /// Check if the budget is exhausted.
1123    ///
1124    /// Returns `true` if no time remains OR if at SkipFrame level.
1125    #[inline]
1126    pub fn exhausted(&self) -> bool {
1127        self.remaining().is_zero()
1128            || (self.degradation == DegradationLevel::SkipFrame && self.allow_frame_skip)
1129    }
1130
1131    /// Check if we should attempt to upgrade quality.
1132    ///
1133    /// Returns `true` if more than `upgrade_threshold` of budget remains
1134    /// and we're not already at full quality, and cooldown has passed.
1135    pub fn should_upgrade(&self) -> bool {
1136        !self.degradation.is_full()
1137            && self.remaining_fraction() > self.upgrade_threshold
1138            && self.frames_since_change >= self.cooldown
1139    }
1140
1141    /// Check if we should upgrade using a measured frame time.
1142    fn should_upgrade_with_elapsed(&self, elapsed: Duration) -> bool {
1143        if self.degradation.is_full() || self.frames_since_change < self.cooldown {
1144            return false;
1145        }
1146        self.remaining_fraction_for_elapsed(elapsed) > self.upgrade_threshold
1147    }
1148
1149    /// Remaining fraction computed from an elapsed frame time.
1150    fn remaining_fraction_for_elapsed(&self, elapsed: Duration) -> f32 {
1151        if self.total.is_zero() {
1152            return 0.0;
1153        }
1154        let remaining = self.total.saturating_sub(elapsed);
1155        let remaining = remaining.as_secs_f32();
1156        let total = self.total.as_secs_f32();
1157        (remaining / total).clamp(0.0, 1.0)
1158    }
1159
1160    /// Upgrade to the previous (better quality) level.
1161    ///
1162    /// Logs when upgrade occurs.
1163    pub fn upgrade(&mut self) {
1164        let from = self.degradation;
1165        self.degradation = self.degradation.prev();
1166        self.frames_since_change = 0;
1167
1168        #[cfg(feature = "tracing")]
1169        if from != self.degradation {
1170            trace!(
1171                from = from.as_str(),
1172                to = self.degradation.as_str(),
1173                remaining_fraction = self.remaining_fraction(),
1174                "render budget upgrade"
1175            );
1176        }
1177        let _ = from; // Suppress unused warning when tracing is disabled
1178    }
1179
1180    /// Reset the budget for a new frame.
1181    ///
1182    /// Keeps the current degradation level but resets timing.
1183    pub fn reset(&mut self) {
1184        self.start = Instant::now();
1185        self.frames_since_change = self.frames_since_change.saturating_add(1);
1186    }
1187
1188    /// Reset the budget and attempt upgrade if conditions are met.
1189    ///
1190    /// Call this at the start of each frame to enable recovery.
1191    ///
1192    /// When an adaptive controller is attached (via [`with_controller`](Self::with_controller)),
1193    /// the measured frame duration is fed to the controller and its decision
1194    /// (degrade / upgrade / hold) is applied automatically. The simple
1195    /// threshold-based upgrade path is skipped in that case.
1196    pub fn next_frame(&mut self) {
1197        let frame_time = self.last_frame_time.unwrap_or_else(|| self.start.elapsed());
1198
1199        if self.controller.is_some() {
1200            // Measure how long the previous frame took
1201
1202            // SAFETY: we just checked is_some; this avoids a borrow-checker
1203            // conflict with `&mut self` needed for degrade/upgrade below.
1204            let decision = self
1205                .controller
1206                .as_mut()
1207                .expect("controller guaranteed by is_some guard")
1208                .update(frame_time);
1209
1210            match decision {
1211                BudgetDecision::Degrade => self.degrade(),
1212                BudgetDecision::Upgrade => self.upgrade(),
1213                BudgetDecision::Hold => {}
1214            }
1215        } else {
1216            // Legacy path: simple threshold-based upgrade
1217            if self.should_upgrade_with_elapsed(frame_time) {
1218                self.upgrade();
1219            }
1220        }
1221        self.reset();
1222    }
1223
1224    /// Record the measured render+present time for the last frame.
1225    pub fn record_frame_time(&mut self, elapsed: Duration) {
1226        self.last_frame_time = Some(elapsed);
1227    }
1228
1229    /// Get a telemetry snapshot from the adaptive controller, if attached.
1230    ///
1231    /// Returns `None` if no controller is attached.
1232    /// This is allocation-free and safe to call every frame.
1233    #[inline]
1234    pub fn telemetry(&self) -> Option<BudgetTelemetry> {
1235        self.controller.as_ref().map(BudgetController::telemetry)
1236    }
1237
1238    /// Get a reference to the adaptive controller, if attached.
1239    #[inline]
1240    pub fn controller(&self) -> Option<&BudgetController> {
1241        self.controller.as_ref()
1242    }
1243
1244    /// Get the phase budgets.
1245    #[inline]
1246    #[must_use]
1247    pub fn phase_budgets(&self) -> &PhaseBudgets {
1248        &self.phase_budgets
1249    }
1250
1251    /// Check if a specific phase has budget remaining.
1252    pub fn phase_has_budget(&self, phase: Phase) -> bool {
1253        let phase_budget = match phase {
1254            Phase::Diff => self.phase_budgets.diff,
1255            Phase::Present => self.phase_budgets.present,
1256            Phase::Render => self.phase_budgets.render,
1257        };
1258        self.remaining() >= phase_budget
1259    }
1260
1261    /// Create a sub-budget for a specific phase.
1262    ///
1263    /// The sub-budget shares the same start time but has a phase-specific total.
1264    #[must_use]
1265    pub fn phase_budget(&self, phase: Phase) -> Self {
1266        let phase_total = match phase {
1267            Phase::Diff => self.phase_budgets.diff,
1268            Phase::Present => self.phase_budgets.present,
1269            Phase::Render => self.phase_budgets.render,
1270        };
1271        Self {
1272            total: phase_total.min(self.remaining()),
1273            start: self.start,
1274            last_frame_time: self.last_frame_time,
1275            degradation: self.degradation,
1276            phase_budgets: self.phase_budgets,
1277            allow_frame_skip: self.allow_frame_skip,
1278            upgrade_threshold: self.upgrade_threshold,
1279            frames_since_change: self.frames_since_change,
1280            cooldown: self.cooldown,
1281            controller: None, // Phase sub-budgets don't carry the controller
1282        }
1283    }
1284}
1285
1286/// Render phases for budget allocation.
1287#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1288pub enum Phase {
1289    /// Buffer diff computation.
1290    Diff,
1291    /// ANSI sequence presentation.
1292    Present,
1293    /// Widget tree rendering.
1294    Render,
1295}
1296
1297impl Phase {
1298    /// Get a human-readable name.
1299    pub fn as_str(self) -> &'static str {
1300        match self {
1301            Self::Diff => "diff",
1302            Self::Present => "present",
1303            Self::Render => "render",
1304        }
1305    }
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310    use super::*;
1311    use std::thread;
1312
1313    #[test]
1314    fn degradation_level_ordering() {
1315        assert!(DegradationLevel::Full < DegradationLevel::SimpleBorders);
1316        assert!(DegradationLevel::SimpleBorders < DegradationLevel::NoStyling);
1317        assert!(DegradationLevel::NoStyling < DegradationLevel::EssentialOnly);
1318        assert!(DegradationLevel::EssentialOnly < DegradationLevel::Skeleton);
1319        assert!(DegradationLevel::Skeleton < DegradationLevel::SkipFrame);
1320    }
1321
1322    #[test]
1323    fn degradation_level_next() {
1324        assert_eq!(
1325            DegradationLevel::Full.next(),
1326            DegradationLevel::SimpleBorders
1327        );
1328        assert_eq!(
1329            DegradationLevel::SimpleBorders.next(),
1330            DegradationLevel::NoStyling
1331        );
1332        assert_eq!(
1333            DegradationLevel::NoStyling.next(),
1334            DegradationLevel::EssentialOnly
1335        );
1336        assert_eq!(
1337            DegradationLevel::EssentialOnly.next(),
1338            DegradationLevel::Skeleton
1339        );
1340        assert_eq!(
1341            DegradationLevel::Skeleton.next(),
1342            DegradationLevel::SkipFrame
1343        );
1344        assert_eq!(
1345            DegradationLevel::SkipFrame.next(),
1346            DegradationLevel::SkipFrame
1347        );
1348    }
1349
1350    #[test]
1351    fn degradation_level_prev() {
1352        assert_eq!(
1353            DegradationLevel::SkipFrame.prev(),
1354            DegradationLevel::Skeleton
1355        );
1356        assert_eq!(
1357            DegradationLevel::Skeleton.prev(),
1358            DegradationLevel::EssentialOnly
1359        );
1360        assert_eq!(
1361            DegradationLevel::EssentialOnly.prev(),
1362            DegradationLevel::NoStyling
1363        );
1364        assert_eq!(
1365            DegradationLevel::NoStyling.prev(),
1366            DegradationLevel::SimpleBorders
1367        );
1368        assert_eq!(
1369            DegradationLevel::SimpleBorders.prev(),
1370            DegradationLevel::Full
1371        );
1372        assert_eq!(DegradationLevel::Full.prev(), DegradationLevel::Full);
1373    }
1374
1375    #[test]
1376    fn degradation_level_is_max() {
1377        assert!(!DegradationLevel::Full.is_max());
1378        assert!(!DegradationLevel::Skeleton.is_max());
1379        assert!(DegradationLevel::SkipFrame.is_max());
1380    }
1381
1382    #[test]
1383    fn degradation_level_is_full() {
1384        assert!(DegradationLevel::Full.is_full());
1385        assert!(!DegradationLevel::SimpleBorders.is_full());
1386        assert!(!DegradationLevel::SkipFrame.is_full());
1387    }
1388
1389    #[test]
1390    fn degradation_level_as_str() {
1391        assert_eq!(DegradationLevel::Full.as_str(), "Full");
1392        assert_eq!(DegradationLevel::SimpleBorders.as_str(), "SimpleBorders");
1393        assert_eq!(DegradationLevel::NoStyling.as_str(), "NoStyling");
1394        assert_eq!(DegradationLevel::EssentialOnly.as_str(), "EssentialOnly");
1395        assert_eq!(DegradationLevel::Skeleton.as_str(), "Skeleton");
1396        assert_eq!(DegradationLevel::SkipFrame.as_str(), "SkipFrame");
1397    }
1398
1399    #[test]
1400    fn degradation_level_values() {
1401        assert_eq!(DegradationLevel::Full.level(), 0);
1402        assert_eq!(DegradationLevel::SimpleBorders.level(), 1);
1403        assert_eq!(DegradationLevel::NoStyling.level(), 2);
1404        assert_eq!(DegradationLevel::EssentialOnly.level(), 3);
1405        assert_eq!(DegradationLevel::Skeleton.level(), 4);
1406        assert_eq!(DegradationLevel::SkipFrame.level(), 5);
1407    }
1408
1409    #[test]
1410    fn budget_remaining_decreases() {
1411        let budget = RenderBudget::new(Duration::from_millis(100));
1412        let initial = budget.remaining();
1413
1414        thread::sleep(Duration::from_millis(10));
1415
1416        let later = budget.remaining();
1417        assert!(later < initial);
1418    }
1419
1420    #[test]
1421    fn budget_remaining_fraction() {
1422        let mut budget = RenderBudget::new(Duration::from_millis(100));
1423
1424        // Initially should be close to 1.0
1425        let initial = budget.remaining_fraction();
1426        assert!(initial > 0.9);
1427
1428        // Pin elapsed to ~50ms deterministically instead of `thread::sleep`,
1429        // which overshoots under CI scheduling jitter and made this test flaky
1430        // on contended runners (macOS CI). The few microseconds between setting
1431        // `start` and reading are negligible against the 0.3..0.6 margins.
1432        budget.start = Instant::now() - Duration::from_millis(50);
1433
1434        // Should be around 0.5 now
1435        let later = budget.remaining_fraction();
1436        assert!(
1437            later < 0.6,
1438            "expected remaining_fraction < 0.6, got {later}"
1439        );
1440        assert!(
1441            later > 0.3,
1442            "expected remaining_fraction > 0.3, got {later}"
1443        );
1444    }
1445
1446    #[test]
1447    fn should_degrade_when_cost_exceeds_remaining() {
1448        // Pin elapsed to ~50ms deterministically (no `thread::sleep`, which is
1449        // flaky under CI scheduling jitter), leaving ~50ms of the 100ms budget.
1450        let mut budget = RenderBudget::new(Duration::from_millis(100));
1451        budget.start = Instant::now() - Duration::from_millis(50);
1452
1453        // Should degrade for expensive operations (80ms > ~50ms remaining)
1454        assert!(budget.should_degrade(Duration::from_millis(80)));
1455        // Should not degrade for cheap operations (10ms < ~50ms remaining)
1456        assert!(!budget.should_degrade(Duration::from_millis(10)));
1457    }
1458
1459    #[test]
1460    fn degrade_advances_level() {
1461        let mut budget = RenderBudget::new(Duration::from_millis(16));
1462
1463        assert_eq!(budget.degradation(), DegradationLevel::Full);
1464
1465        budget.degrade();
1466        assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
1467
1468        budget.degrade();
1469        assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1470    }
1471
1472    #[test]
1473    fn exhausted_when_no_time_left() {
1474        let budget = RenderBudget::new(Duration::from_millis(5));
1475
1476        assert!(!budget.exhausted());
1477
1478        thread::sleep(Duration::from_millis(10));
1479
1480        assert!(budget.exhausted());
1481    }
1482
1483    #[test]
1484    fn exhausted_at_skip_frame() {
1485        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1486
1487        // Set to SkipFrame
1488        budget.set_degradation(DegradationLevel::SkipFrame);
1489
1490        // Should be exhausted even with time remaining
1491        assert!(budget.exhausted());
1492    }
1493
1494    #[test]
1495    fn should_upgrade_with_remaining_budget() {
1496        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1497
1498        // At Full, should not upgrade
1499        assert!(!budget.should_upgrade());
1500
1501        // Degrade and set cooldown frames
1502        budget.degrade();
1503        budget.frames_since_change = 5;
1504
1505        // With lots of budget remaining, should upgrade
1506        assert!(budget.should_upgrade());
1507    }
1508
1509    #[test]
1510    fn upgrade_improves_level() {
1511        let mut budget = RenderBudget::new(Duration::from_millis(16));
1512
1513        budget.set_degradation(DegradationLevel::Skeleton);
1514        assert_eq!(budget.degradation(), DegradationLevel::Skeleton);
1515
1516        budget.upgrade();
1517        assert_eq!(budget.degradation(), DegradationLevel::EssentialOnly);
1518
1519        budget.upgrade();
1520        assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1521    }
1522
1523    #[test]
1524    fn upgrade_downgrade_symmetric() {
1525        let mut budget = RenderBudget::new(Duration::from_millis(16));
1526
1527        // Degrade all the way
1528        while !budget.degradation().is_max() {
1529            budget.degrade();
1530        }
1531        assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
1532
1533        // Upgrade all the way
1534        while !budget.degradation().is_full() {
1535            budget.upgrade();
1536        }
1537        assert_eq!(budget.degradation(), DegradationLevel::Full);
1538    }
1539
1540    #[test]
1541    fn reset_preserves_degradation() {
1542        let mut budget = RenderBudget::new(Duration::from_millis(16));
1543
1544        budget.degrade();
1545        budget.degrade();
1546        let level = budget.degradation();
1547
1548        budget.reset();
1549
1550        assert_eq!(budget.degradation(), level);
1551        // Remaining should be close to full again
1552        assert!(budget.remaining_fraction() > 0.9);
1553    }
1554
1555    #[test]
1556    fn next_frame_upgrades_when_possible() {
1557        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1558
1559        // Degrade and simulate several frames
1560        budget.degrade();
1561        for _ in 0..5 {
1562            budget.reset();
1563        }
1564
1565        let before = budget.degradation();
1566        budget.next_frame();
1567
1568        // Should have upgraded
1569        assert!(budget.degradation() < before);
1570    }
1571
1572    #[test]
1573    fn next_frame_prefers_recorded_frame_time_for_upgrade() {
1574        let mut budget = RenderBudget::new(Duration::from_millis(16));
1575
1576        budget.degrade();
1577        for _ in 0..5 {
1578            budget.reset();
1579        }
1580
1581        // Record a fast frame, then wait long enough that start.elapsed()
1582        // would otherwise exceed the budget.
1583        budget.record_frame_time(Duration::from_millis(1));
1584        std::thread::sleep(Duration::from_millis(25));
1585
1586        let before = budget.degradation();
1587        budget.next_frame();
1588
1589        assert!(budget.degradation() < before);
1590    }
1591
1592    #[test]
1593    fn config_defaults() {
1594        let config = FrameBudgetConfig::default();
1595
1596        assert_eq!(config.total, Duration::from_millis(16));
1597        assert!(config.allow_frame_skip);
1598        assert_eq!(config.degradation_cooldown, 3);
1599        assert!((config.upgrade_threshold - 0.5).abs() < f32::EPSILON);
1600    }
1601
1602    #[test]
1603    fn config_with_total() {
1604        let config = FrameBudgetConfig::with_total(Duration::from_millis(33));
1605
1606        assert_eq!(config.total, Duration::from_millis(33));
1607        // Other defaults preserved
1608        assert!(config.allow_frame_skip);
1609    }
1610
1611    #[test]
1612    fn config_strict() {
1613        let config = FrameBudgetConfig::strict(Duration::from_millis(16));
1614
1615        assert!(!config.allow_frame_skip);
1616    }
1617
1618    #[test]
1619    fn config_relaxed() {
1620        let config = FrameBudgetConfig::relaxed();
1621
1622        assert_eq!(config.total, Duration::from_millis(33));
1623        assert_eq!(config.degradation_cooldown, 5);
1624    }
1625
1626    #[test]
1627    fn from_config() {
1628        let config = FrameBudgetConfig {
1629            total: Duration::from_millis(20),
1630            allow_frame_skip: false,
1631            ..Default::default()
1632        };
1633
1634        let budget = RenderBudget::from_config(&config);
1635
1636        assert_eq!(budget.total(), Duration::from_millis(20));
1637        assert!(!budget.exhausted()); // allow_frame_skip is false
1638
1639        // Set to SkipFrame - should NOT be exhausted since frame skip disabled
1640        let mut budget = RenderBudget::from_config(&config);
1641        budget.set_degradation(DegradationLevel::SkipFrame);
1642        assert!(!budget.exhausted());
1643    }
1644
1645    #[test]
1646    fn phase_budgets_default() {
1647        let budgets = PhaseBudgets::default();
1648
1649        assert_eq!(budgets.diff, Duration::from_millis(2));
1650        assert_eq!(budgets.present, Duration::from_millis(4));
1651        assert_eq!(budgets.render, Duration::from_millis(8));
1652    }
1653
1654    #[test]
1655    fn phase_has_budget() {
1656        let budget = RenderBudget::new(Duration::from_millis(100));
1657
1658        assert!(budget.phase_has_budget(Phase::Diff));
1659        assert!(budget.phase_has_budget(Phase::Present));
1660        assert!(budget.phase_has_budget(Phase::Render));
1661    }
1662
1663    #[test]
1664    fn phase_budget_respects_remaining() {
1665        let budget = RenderBudget::new(Duration::from_millis(100));
1666
1667        let diff_budget = budget.phase_budget(Phase::Diff);
1668        assert_eq!(diff_budget.total(), Duration::from_millis(2));
1669
1670        let present_budget = budget.phase_budget(Phase::Present);
1671        assert_eq!(present_budget.total(), Duration::from_millis(4));
1672    }
1673
1674    #[test]
1675    fn phase_as_str() {
1676        assert_eq!(Phase::Diff.as_str(), "diff");
1677        assert_eq!(Phase::Present.as_str(), "present");
1678        assert_eq!(Phase::Render.as_str(), "render");
1679    }
1680
1681    #[test]
1682    fn zero_budget_is_immediately_exhausted() {
1683        let budget = RenderBudget::new(Duration::ZERO);
1684        assert!(budget.exhausted());
1685        assert_eq!(budget.remaining_fraction(), 0.0);
1686    }
1687
1688    #[test]
1689    fn degradation_level_never_exceeds_skip_frame() {
1690        let mut level = DegradationLevel::Full;
1691
1692        for _ in 0..100 {
1693            level = level.next();
1694        }
1695
1696        assert_eq!(level, DegradationLevel::SkipFrame);
1697    }
1698
1699    #[test]
1700    fn budget_remaining_never_negative() {
1701        let budget = RenderBudget::new(Duration::from_millis(1));
1702
1703        // Wait well past the budget
1704        thread::sleep(Duration::from_millis(10));
1705
1706        // Should be zero, not negative
1707        assert_eq!(budget.remaining(), Duration::ZERO);
1708        assert_eq!(budget.remaining_fraction(), 0.0);
1709    }
1710
1711    #[test]
1712    fn infinite_budget_stays_at_full() {
1713        let mut budget = RenderBudget::new(Duration::from_secs(1000));
1714
1715        // With huge budget, should never need to degrade
1716        assert!(!budget.should_degrade(Duration::from_millis(100)));
1717        assert_eq!(budget.degradation(), DegradationLevel::Full);
1718
1719        // Next frame should not upgrade since already at full
1720        budget.next_frame();
1721        assert_eq!(budget.degradation(), DegradationLevel::Full);
1722    }
1723
1724    #[test]
1725    fn cooldown_prevents_immediate_upgrade() {
1726        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1727        budget.cooldown = 3;
1728
1729        // Degrade
1730        budget.degrade();
1731        assert_eq!(budget.frames_since_change, 0);
1732
1733        // Should not upgrade immediately (cooldown not met)
1734        assert!(!budget.should_upgrade());
1735
1736        // Simulate frames
1737        budget.frames_since_change = 3;
1738
1739        // Now should be able to upgrade
1740        assert!(budget.should_upgrade());
1741    }
1742
1743    #[test]
1744    fn set_degradation_resets_cooldown() {
1745        let mut budget = RenderBudget::new(Duration::from_millis(16));
1746        budget.frames_since_change = 10;
1747
1748        budget.set_degradation(DegradationLevel::NoStyling);
1749
1750        assert_eq!(budget.frames_since_change, 0);
1751    }
1752
1753    #[test]
1754    fn set_degradation_same_level_preserves_cooldown() {
1755        let mut budget = RenderBudget::new(Duration::from_millis(16));
1756        budget.frames_since_change = 10;
1757
1758        // Set to same level
1759        budget.set_degradation(DegradationLevel::Full);
1760
1761        // Cooldown preserved since level didn't change
1762        assert_eq!(budget.frames_since_change, 10);
1763    }
1764
1765    // -----------------------------------------------------------------------
1766    // Budget Controller Tests (bd-4kq0.3.1)
1767    // -----------------------------------------------------------------------
1768
1769    mod controller_tests {
1770        use super::super::*;
1771
1772        fn make_controller() -> BudgetController {
1773            BudgetController::new(BudgetControllerConfig::default())
1774        }
1775
1776        fn make_controller_with_config(
1777            target_ms: u64,
1778            warmup: u32,
1779            cooldown: u32,
1780        ) -> BudgetController {
1781            BudgetController::new(BudgetControllerConfig {
1782                target: Duration::from_millis(target_ms),
1783                eprocess: EProcessConfig {
1784                    warmup_frames: warmup,
1785                    ..Default::default()
1786                },
1787                cooldown_frames: cooldown,
1788                ..Default::default()
1789            })
1790        }
1791
1792        // --- PID response tests ---
1793
1794        #[test]
1795        fn pid_step_input_yields_nonzero_output() {
1796            let mut state = PidState::default();
1797            let gains = PidGains::default();
1798
1799            // Step input: constant error of 1.0
1800            let u = state.update(1.0, &gains);
1801            // Kp*1.0 + Ki*1.0 + Kd*(1.0 - 0.0) = 0.5 + 0.05 + 0.2 = 0.75
1802            assert!(
1803                (u - 0.75).abs() < 1e-10,
1804                "First PID output should be 0.75, got {}",
1805                u
1806            );
1807        }
1808
1809        #[test]
1810        fn pid_zero_error_zero_output() {
1811            let mut state = PidState::default();
1812            let gains = PidGains::default();
1813
1814            let u = state.update(0.0, &gains);
1815            assert!(
1816                u.abs() < 1e-10,
1817                "Zero error should produce zero output, got {}",
1818                u
1819            );
1820        }
1821
1822        #[test]
1823        fn pid_integral_accumulates() {
1824            let mut state = PidState::default();
1825            let gains = PidGains::default();
1826
1827            // Feed constant error
1828            state.update(1.0, &gains);
1829            state.update(1.0, &gains);
1830            state.update(1.0, &gains);
1831
1832            assert!(
1833                state.integral > 2.5,
1834                "Integral should accumulate: {}",
1835                state.integral
1836            );
1837        }
1838
1839        #[test]
1840        fn pid_integral_anti_windup() {
1841            let mut state = PidState::default();
1842            let gains = PidGains {
1843                integral_max: 2.0,
1844                ..Default::default()
1845            };
1846
1847            // Feed many frames of error to saturate integral
1848            for _ in 0..100 {
1849                state.update(10.0, &gains);
1850            }
1851
1852            assert!(
1853                state.integral <= 2.0 + f64::EPSILON,
1854                "Integral should be clamped to max: {}",
1855                state.integral
1856            );
1857            assert!(
1858                state.integral >= -2.0 - f64::EPSILON,
1859                "Integral should be clamped to -max: {}",
1860                state.integral
1861            );
1862        }
1863
1864        #[test]
1865        fn pid_derivative_responds_to_change() {
1866            let mut state = PidState::default();
1867            let gains = PidGains::default();
1868
1869            // First frame: error=0
1870            let u1 = state.update(0.0, &gains);
1871            // Second frame: error=1.0 (step change)
1872            let u2 = state.update(1.0, &gains);
1873
1874            // u2 should include derivative component Kd*(1.0 - 0.0) = 0.2
1875            assert!(
1876                u2 > u1,
1877                "Step change should produce larger output: u1={}, u2={}",
1878                u1,
1879                u2
1880            );
1881        }
1882
1883        #[test]
1884        fn pid_settling_after_step() {
1885            let mut state = PidState::default();
1886            let gains = PidGains::default();
1887
1888            // Apply step error then zero error (simulate settling)
1889            state.update(1.0, &gains);
1890            state.update(1.0, &gains);
1891            state.update(1.0, &gains);
1892
1893            // Now remove the error
1894            let mut outputs = Vec::new();
1895            for _ in 0..20 {
1896                outputs.push(state.update(0.0, &gains));
1897            }
1898
1899            // Output should trend toward zero (settling)
1900            let last = *outputs.last().unwrap();
1901            assert!(
1902                last.abs() < 0.5,
1903                "PID should settle toward zero: last={}",
1904                last
1905            );
1906        }
1907
1908        #[test]
1909        fn pid_reset_clears_state() {
1910            let mut state = PidState::default();
1911            let gains = PidGains::default();
1912
1913            state.update(5.0, &gains);
1914            state.update(5.0, &gains);
1915            assert!(state.integral.abs() > 0.0);
1916
1917            state.reset();
1918            assert_eq!(state.integral, 0.0);
1919            assert_eq!(state.prev_error, 0.0);
1920        }
1921
1922        // --- E-process tests ---
1923
1924        #[test]
1925        fn eprocess_starts_at_one() {
1926            let state = EProcessState::default();
1927            assert!(
1928                (state.e_value - 1.0).abs() < f64::EPSILON,
1929                "E-process should start at 1.0"
1930            );
1931        }
1932
1933        #[test]
1934        fn eprocess_grows_under_overload() {
1935            let mut state = EProcessState::default();
1936            let config = EProcessConfig {
1937                warmup_frames: 0,
1938                ..Default::default()
1939            };
1940
1941            // Feed sustained overload (30ms vs 16ms target)
1942            for _ in 0..20 {
1943                state.update(30.0, 16.0, &config);
1944            }
1945
1946            assert!(
1947                state.e_value > 1.0,
1948                "E-value should grow under overload: {}",
1949                state.e_value
1950            );
1951        }
1952
1953        #[test]
1954        fn eprocess_shrinks_under_underload() {
1955            let mut state = EProcessState::default();
1956            let config = EProcessConfig {
1957                warmup_frames: 0,
1958                ..Default::default()
1959            };
1960
1961            // Feed fast frames (8ms vs 16ms target)
1962            for _ in 0..20 {
1963                state.update(8.0, 16.0, &config);
1964            }
1965
1966            assert!(
1967                state.e_value < 1.0,
1968                "E-value should shrink under underload: {}",
1969                state.e_value
1970            );
1971        }
1972
1973        #[test]
1974        fn eprocess_gate_blocks_during_warmup() {
1975            let mut state = EProcessState::default();
1976            let config = EProcessConfig {
1977                warmup_frames: 10,
1978                ..Default::default()
1979            };
1980
1981            // Feed overload during warmup
1982            for _ in 0..5 {
1983                state.update(50.0, 16.0, &config);
1984            }
1985
1986            assert!(
1987                !state.should_degrade(&config),
1988                "E-process should not permit degradation during warmup"
1989            );
1990        }
1991
1992        #[test]
1993        fn eprocess_gate_allows_after_warmup() {
1994            let mut state = EProcessState::default();
1995            let config = EProcessConfig {
1996                warmup_frames: 5,
1997                alpha: 0.05,
1998                ..Default::default()
1999            };
2000
2001            // Feed severe overload past warmup
2002            for _ in 0..50 {
2003                state.update(80.0, 16.0, &config);
2004            }
2005
2006            assert!(
2007                state.should_degrade(&config),
2008                "E-process should permit degradation after sustained overload: E={}",
2009                state.e_value
2010            );
2011        }
2012
2013        #[test]
2014        fn eprocess_recovery_after_overload() {
2015            let mut state = EProcessState::default();
2016            let config = EProcessConfig {
2017                warmup_frames: 0,
2018                ..Default::default()
2019            };
2020
2021            // Overload phase
2022            for _ in 0..30 {
2023                state.update(40.0, 16.0, &config);
2024            }
2025            let peak = state.e_value;
2026
2027            // Recovery phase (fast frames)
2028            for _ in 0..100 {
2029                state.update(8.0, 16.0, &config);
2030            }
2031
2032            assert!(
2033                state.e_value < peak,
2034                "E-value should decrease after recovery: peak={}, now={}",
2035                peak,
2036                state.e_value
2037            );
2038        }
2039
2040        #[test]
2041        fn eprocess_sigma_floor_prevents_instability() {
2042            let mut state = EProcessState::default();
2043            let config = EProcessConfig {
2044                sigma_floor_ms: 1.0,
2045                warmup_frames: 0,
2046                ..Default::default()
2047            };
2048
2049            // Feed identical frames (zero variance)
2050            for _ in 0..20 {
2051                state.update(16.0, 16.0, &config);
2052            }
2053
2054            // sigma_ema should not be below floor
2055            assert!(
2056                state.sigma_ema >= 0.0,
2057                "Sigma should be non-negative: {}",
2058                state.sigma_ema
2059            );
2060            // E-value should remain finite
2061            assert!(
2062                state.e_value.is_finite(),
2063                "E-value should be finite: {}",
2064                state.e_value
2065            );
2066        }
2067
2068        #[test]
2069        fn eprocess_reset_returns_to_initial() {
2070            let mut state = EProcessState::default();
2071            let config = EProcessConfig::default();
2072
2073            state.update(50.0, 16.0, &config);
2074            state.update(50.0, 16.0, &config);
2075
2076            state.reset();
2077            assert!((state.e_value - 1.0).abs() < f64::EPSILON);
2078            assert_eq!(state.frames_observed, 0);
2079        }
2080
2081        // --- Controller integration tests ---
2082
2083        #[test]
2084        fn controller_holds_under_normal_load() {
2085            let mut ctrl = make_controller_with_config(16, 0, 0);
2086
2087            // Feed on-target frames
2088            for _ in 0..20 {
2089                let decision = ctrl.update(Duration::from_millis(16));
2090                assert_eq!(
2091                    decision,
2092                    BudgetDecision::Hold,
2093                    "On-target frames should hold"
2094                );
2095            }
2096            assert_eq!(ctrl.level(), DegradationLevel::Full);
2097        }
2098
2099        #[test]
2100        fn controller_degrades_under_sustained_overload() {
2101            let mut ctrl = make_controller_with_config(16, 0, 0);
2102
2103            let mut degraded = false;
2104            // Feed severe overload
2105            for _ in 0..50 {
2106                let decision = ctrl.update(Duration::from_millis(40));
2107                if decision == BudgetDecision::Degrade {
2108                    degraded = true;
2109                }
2110            }
2111
2112            assert!(
2113                degraded,
2114                "Controller should degrade under sustained overload"
2115            );
2116            assert!(
2117                ctrl.level() > DegradationLevel::Full,
2118                "Level should be degraded: {:?}",
2119                ctrl.level()
2120            );
2121        }
2122
2123        #[test]
2124        fn controller_upgrades_after_recovery() {
2125            let mut ctrl = make_controller_with_config(16, 0, 0);
2126
2127            // Overload to degrade
2128            for _ in 0..50 {
2129                ctrl.update(Duration::from_millis(40));
2130            }
2131            let degraded_level = ctrl.level();
2132            assert!(degraded_level > DegradationLevel::Full);
2133
2134            // Recovery: fast frames
2135            let mut upgraded = false;
2136            for _ in 0..200 {
2137                let decision = ctrl.update(Duration::from_millis(4));
2138                if decision == BudgetDecision::Upgrade {
2139                    upgraded = true;
2140                }
2141            }
2142
2143            assert!(upgraded, "Controller should upgrade after recovery");
2144            assert!(
2145                ctrl.level() < degraded_level,
2146                "Level should improve: before={:?}, after={:?}",
2147                degraded_level,
2148                ctrl.level()
2149            );
2150        }
2151
2152        #[test]
2153        fn controller_cooldown_prevents_oscillation() {
2154            let mut ctrl = make_controller_with_config(16, 0, 5);
2155
2156            // Trigger degradation
2157            for _ in 0..50 {
2158                ctrl.update(Duration::from_millis(40));
2159            }
2160
2161            // Immediately try fast frames
2162            let mut decisions_during_cooldown = Vec::new();
2163            for _ in 0..4 {
2164                decisions_during_cooldown.push(ctrl.update(Duration::from_millis(4)));
2165            }
2166
2167            // During cooldown (frames 0-4), should all be Hold
2168            assert!(
2169                decisions_during_cooldown
2170                    .iter()
2171                    .all(|d| *d == BudgetDecision::Hold),
2172                "Cooldown should prevent changes: {:?}",
2173                decisions_during_cooldown
2174            );
2175        }
2176
2177        #[test]
2178        fn controller_no_oscillation_under_constant_load() {
2179            let mut ctrl = make_controller_with_config(16, 0, 3);
2180
2181            // Moderate overload (20ms vs 16ms)
2182            let mut transitions = 0u32;
2183            let mut prev_level = ctrl.level();
2184            for _ in 0..100 {
2185                ctrl.update(Duration::from_millis(20));
2186                if ctrl.level() != prev_level {
2187                    transitions += 1;
2188                    prev_level = ctrl.level();
2189                }
2190            }
2191
2192            // Under constant load, transitions should be limited
2193            // (progressive degradation, not oscillation)
2194            assert!(
2195                transitions < 10,
2196                "Too many transitions under constant load: {}",
2197                transitions
2198            );
2199        }
2200
2201        #[test]
2202        fn controller_reset_restores_full_quality() {
2203            let mut ctrl = make_controller();
2204
2205            // Degrade
2206            for _ in 0..50 {
2207                ctrl.update(Duration::from_millis(40));
2208            }
2209
2210            ctrl.reset();
2211
2212            assert_eq!(ctrl.level(), DegradationLevel::Full);
2213            assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
2214            assert_eq!(ctrl.pid_integral(), 0.0);
2215        }
2216
2217        #[test]
2218        fn controller_transient_spike_does_not_degrade() {
2219            let mut ctrl = make_controller_with_config(16, 5, 3);
2220
2221            // Normal frames to build history
2222            for _ in 0..20 {
2223                ctrl.update(Duration::from_millis(16));
2224            }
2225
2226            // Single spike
2227            ctrl.update(Duration::from_millis(100));
2228
2229            // Back to normal
2230            for _ in 0..5 {
2231                ctrl.update(Duration::from_millis(16));
2232            }
2233
2234            // Should still be at full quality (spike was transient)
2235            assert_eq!(
2236                ctrl.level(),
2237                DegradationLevel::Full,
2238                "Single spike should not cause degradation"
2239            );
2240        }
2241
2242        #[test]
2243        fn controller_never_exceeds_skip_frame() {
2244            let mut ctrl = make_controller_with_config(16, 0, 0);
2245
2246            // Extreme overload
2247            for _ in 0..500 {
2248                ctrl.update(Duration::from_millis(200));
2249            }
2250
2251            assert!(
2252                ctrl.level() <= DegradationLevel::SkipFrame,
2253                "Level should not exceed SkipFrame: {:?}",
2254                ctrl.level()
2255            );
2256        }
2257
2258        #[test]
2259        fn controller_never_goes_below_full() {
2260            let mut ctrl = make_controller_with_config(16, 0, 0);
2261
2262            // Extreme underload
2263            for _ in 0..200 {
2264                ctrl.update(Duration::from_millis(1));
2265            }
2266
2267            assert_eq!(
2268                ctrl.level(),
2269                DegradationLevel::Full,
2270                "Level should not go below Full"
2271            );
2272        }
2273
2274        // --- Config tests ---
2275
2276        #[test]
2277        fn pid_gains_default_valid() {
2278            let gains = PidGains::default();
2279            assert!(gains.kp > 0.0);
2280            assert!(gains.ki > 0.0);
2281            assert!(gains.kd > 0.0);
2282            assert!(gains.integral_max > 0.0);
2283        }
2284
2285        #[test]
2286        fn eprocess_config_default_valid() {
2287            let config = EProcessConfig::default();
2288            assert!(config.lambda > 0.0);
2289            assert!(config.alpha > 0.0 && config.alpha < 1.0);
2290            assert!(config.beta > 0.0 && config.beta < 1.0);
2291            assert!(config.sigma_floor_ms > 0.0);
2292        }
2293
2294        #[test]
2295        fn controller_config_default_valid() {
2296            let config = BudgetControllerConfig::default();
2297            assert!(config.degrade_threshold > 0.0);
2298            assert!(config.upgrade_threshold > 0.0);
2299            assert!(config.target > Duration::ZERO);
2300        }
2301
2302        #[test]
2303        fn budget_decision_equality() {
2304            assert_eq!(BudgetDecision::Hold, BudgetDecision::Hold);
2305            assert_ne!(BudgetDecision::Hold, BudgetDecision::Degrade);
2306            assert_ne!(BudgetDecision::Degrade, BudgetDecision::Upgrade);
2307        }
2308    }
2309
2310    // -----------------------------------------------------------------------
2311    // Budget Controller Integration + Telemetry Tests (bd-4kq0.3.2)
2312    // -----------------------------------------------------------------------
2313
2314    mod integration_tests {
2315        use super::super::*;
2316
2317        #[test]
2318        fn render_budget_without_controller_returns_no_telemetry() {
2319            let budget = RenderBudget::new(Duration::from_millis(16));
2320            assert!(budget.telemetry().is_none());
2321            assert!(budget.controller().is_none());
2322        }
2323
2324        #[test]
2325        fn render_budget_with_controller_returns_telemetry() {
2326            let budget = RenderBudget::new(Duration::from_millis(16))
2327                .with_controller(BudgetControllerConfig::default());
2328            assert!(budget.controller().is_some());
2329
2330            let telem = budget.telemetry().unwrap();
2331            assert_eq!(telem.level, DegradationLevel::Full);
2332            assert_eq!(telem.last_decision, BudgetDecision::Hold);
2333            assert_eq!(telem.frames_observed, 0);
2334            assert!(telem.in_warmup);
2335        }
2336
2337        #[test]
2338        fn telemetry_fields_update_after_next_frame() {
2339            let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2340                BudgetControllerConfig {
2341                    eprocess: EProcessConfig {
2342                        warmup_frames: 0,
2343                        ..Default::default()
2344                    },
2345                    cooldown_frames: 0,
2346                    ..Default::default()
2347                },
2348            );
2349
2350            // Simulate a few frames
2351            for _ in 0..5 {
2352                budget.next_frame();
2353            }
2354
2355            let telem = budget.telemetry().unwrap();
2356            assert_eq!(telem.frames_observed, 5);
2357            assert!(!telem.in_warmup);
2358            // PID output should be non-positive (frames are fast, under budget)
2359            // but the exact value depends on timing, so just check it's finite
2360            assert!(telem.pid_output.is_finite());
2361            assert!(telem.e_value.is_finite());
2362        }
2363
2364        #[test]
2365        fn controller_next_frame_degrades_under_simulated_overload() {
2366            // We can't easily simulate slow frames in unit tests (thread::sleep
2367            // would be flaky), so we test the controller integration by verifying
2368            // the decision path works: attach controller, manually check that
2369            // the controller's level is reflected in the budget's degradation.
2370            let config = BudgetControllerConfig {
2371                target: Duration::from_millis(16),
2372                eprocess: EProcessConfig {
2373                    warmup_frames: 0,
2374                    ..Default::default()
2375                },
2376                cooldown_frames: 0,
2377                ..Default::default()
2378            };
2379            let mut ctrl = BudgetController::new(config);
2380
2381            // Feed severe overload to the controller directly
2382            for _ in 0..50 {
2383                ctrl.update(Duration::from_millis(40));
2384            }
2385
2386            // Controller should have degraded
2387            assert!(
2388                ctrl.level() > DegradationLevel::Full,
2389                "Controller should degrade: {:?}",
2390                ctrl.level()
2391            );
2392
2393            // Telemetry should reflect the degradation
2394            let telem = ctrl.telemetry();
2395            assert!(telem.level > DegradationLevel::Full);
2396            assert!(
2397                telem.pid_output > 0.0,
2398                "PID output should be positive under overload"
2399            );
2400            assert!(telem.e_value > 1.0, "E-value should grow under overload");
2401        }
2402
2403        #[test]
2404        fn next_frame_delegates_to_controller_when_attached() {
2405            // With a controller, next_frame should not use the simple
2406            // threshold-based upgrade path
2407            let mut budget = RenderBudget::new(Duration::from_millis(1000))
2408                .with_controller(BudgetControllerConfig::default());
2409
2410            // Degrade manually
2411            budget.degrade();
2412            assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
2413
2414            // In legacy mode, next_frame would upgrade immediately (lots of budget).
2415            // With controller, it should hold because the controller hasn't seen
2416            // enough underload evidence yet.
2417            budget.next_frame();
2418
2419            // The controller may or may not upgrade depending on the single frame
2420            // measurement, but the key assertion is that the code path works.
2421            // With a fresh controller, the fast frame should eventually allow upgrade.
2422            // Just verify it doesn't panic and telemetry is populated.
2423            let telem = budget.telemetry().unwrap();
2424            assert_eq!(telem.frames_observed, 1);
2425        }
2426
2427        #[test]
2428        fn telemetry_is_copy_and_no_alloc() {
2429            let budget = RenderBudget::new(Duration::from_millis(16))
2430                .with_controller(BudgetControllerConfig::default());
2431
2432            let telem = budget.telemetry().unwrap();
2433            // BudgetTelemetry is Copy — verify by copying
2434            let telem2 = telem;
2435            assert_eq!(telem.level, telem2.level);
2436            assert_eq!(telem.e_value, telem2.e_value);
2437        }
2438
2439        #[test]
2440        fn telemetry_warmup_flag_transitions() {
2441            let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2442                BudgetControllerConfig {
2443                    eprocess: EProcessConfig {
2444                        warmup_frames: 3,
2445                        ..Default::default()
2446                    },
2447                    ..Default::default()
2448                },
2449            );
2450
2451            // During warmup
2452            budget.next_frame();
2453            budget.next_frame();
2454            let telem = budget.telemetry().unwrap();
2455            assert!(telem.in_warmup, "Should be in warmup at frame 2");
2456
2457            // After warmup
2458            budget.next_frame();
2459            let telem = budget.telemetry().unwrap();
2460            assert!(!telem.in_warmup, "Should exit warmup at frame 3");
2461        }
2462
2463        #[test]
2464        fn phase_sub_budget_does_not_carry_controller() {
2465            let budget = RenderBudget::new(Duration::from_millis(100))
2466                .with_controller(BudgetControllerConfig::default());
2467
2468            let phase = budget.phase_budget(Phase::Render);
2469            assert!(
2470                phase.controller().is_none(),
2471                "Phase sub-budgets should not carry the controller"
2472            );
2473        }
2474
2475        #[test]
2476        fn controller_telemetry_tracks_frames_since_change() {
2477            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2478                eprocess: EProcessConfig {
2479                    warmup_frames: 0,
2480                    ..Default::default()
2481                },
2482                cooldown_frames: 0,
2483                ..Default::default()
2484            });
2485
2486            // On-target frames: frames_since_change should increase
2487            for i in 1..=5 {
2488                ctrl.update(Duration::from_millis(16));
2489                let telem = ctrl.telemetry();
2490                assert_eq!(
2491                    telem.frames_since_change, i,
2492                    "frames_since_change should be {} after {} frames",
2493                    i, i
2494                );
2495            }
2496        }
2497
2498        #[test]
2499        fn telemetry_last_decision_reflects_controller_decision() {
2500            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2501                eprocess: EProcessConfig {
2502                    warmup_frames: 0,
2503                    ..Default::default()
2504                },
2505                cooldown_frames: 0,
2506                ..Default::default()
2507            });
2508
2509            // On-target: should hold
2510            ctrl.update(Duration::from_millis(16));
2511            assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Hold);
2512
2513            // Feed enough overload to trigger degrade
2514            let mut saw_degrade = false;
2515            for _ in 0..50 {
2516                let d = ctrl.update(Duration::from_millis(50));
2517                if d == BudgetDecision::Degrade {
2518                    saw_degrade = true;
2519                    assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Degrade);
2520                    break;
2521                }
2522            }
2523            assert!(saw_degrade, "Should have seen a Degrade decision");
2524        }
2525
2526        #[test]
2527        fn perf_overhead_controller_update_is_fast() {
2528            // Verify the controller update is a lightweight arithmetic operation.
2529            // We run 10_000 iterations and check they complete quickly.
2530            // This is a smoke test, not a precise benchmark (that's bd-4kq0.3.3).
2531            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2532
2533            let start = Instant::now();
2534            for _ in 0..10_000 {
2535                ctrl.update(Duration::from_millis(16));
2536            }
2537            let elapsed = start.elapsed();
2538
2539            // 10k iterations should complete in well under 10ms on any modern CPU.
2540            // At 16ms target, 2% overhead = 0.32ms per frame, so 10k frames
2541            // budget = 3.2 seconds worth of overhead budget. We check <50ms total.
2542            assert!(
2543                elapsed < Duration::from_millis(50),
2544                "10k controller updates took {:?}, expected <50ms",
2545                elapsed
2546            );
2547        }
2548
2549        #[test]
2550        fn perf_overhead_telemetry_snapshot_is_fast() {
2551            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2552            ctrl.update(Duration::from_millis(16));
2553
2554            let start = Instant::now();
2555            for _ in 0..10_000 {
2556                let _telem = ctrl.telemetry();
2557            }
2558            let elapsed = start.elapsed();
2559
2560            assert!(
2561                elapsed < Duration::from_millis(10),
2562                "10k telemetry snapshots took {:?}, expected <10ms",
2563                elapsed
2564            );
2565        }
2566    }
2567
2568    // -----------------------------------------------------------------------
2569    // Budget Stability + E2E Replay Tests (bd-4kq0.3.3)
2570    // -----------------------------------------------------------------------
2571
2572    mod stability_tests {
2573        use super::super::*;
2574
2575        #[derive(Debug, Clone)]
2576        struct CampaignFrameLog {
2577            frame_idx: u64,
2578            phase: &'static str,
2579            frame_time_us: u64,
2580            telemetry: BudgetTelemetry,
2581        }
2582
2583        /// Helper: create a controller with minimal warmup/cooldown for testing.
2584        fn fast_controller(target_ms: u64) -> BudgetController {
2585            BudgetController::new(BudgetControllerConfig {
2586                target: Duration::from_millis(target_ms),
2587                eprocess: EProcessConfig {
2588                    warmup_frames: 0,
2589                    ..Default::default()
2590                },
2591                cooldown_frames: 0,
2592                ..Default::default()
2593            })
2594        }
2595
2596        /// Helper: run a frame time trace through the controller and collect
2597        /// JSONL-style telemetry records (as structured tuples).
2598        /// Returns `(frame_index, frame_time_us, telemetry)` for each frame.
2599        fn run_trace(
2600            ctrl: &mut BudgetController,
2601            trace: &[Duration],
2602        ) -> Vec<(u64, u64, BudgetTelemetry)> {
2603            trace
2604                .iter()
2605                .enumerate()
2606                .map(|(i, &ft)| {
2607                    ctrl.update(ft);
2608                    let telem = ctrl.telemetry();
2609                    (i as u64, ft.as_micros() as u64, telem)
2610                })
2611                .collect()
2612        }
2613
2614        /// Run a labeled phase campaign and collect deterministic replay logs.
2615        fn run_campaign(
2616            ctrl: &mut BudgetController,
2617            phases: &[(&'static str, usize, Duration)],
2618        ) -> Vec<CampaignFrameLog> {
2619            let mut logs = Vec::new();
2620            let mut frame_idx: u64 = 0;
2621            for &(phase, count, frame_time) in phases {
2622                for _ in 0..count {
2623                    ctrl.update(frame_time);
2624                    logs.push(CampaignFrameLog {
2625                        frame_idx,
2626                        phase,
2627                        frame_time_us: frame_time.as_micros() as u64,
2628                        telemetry: ctrl.telemetry(),
2629                    });
2630                    frame_idx = frame_idx.saturating_add(1);
2631                }
2632            }
2633            logs
2634        }
2635
2636        /// Count level transitions in a trace log.
2637        fn count_transitions(log: &[(u64, u64, BudgetTelemetry)]) -> u32 {
2638            let mut transitions = 0u32;
2639            for pair in log.windows(2) {
2640                if pair[0].2.level != pair[1].2.level {
2641                    transitions += 1;
2642                }
2643            }
2644            transitions
2645        }
2646
2647        // --- e2e_burst_logs ---
2648
2649        #[test]
2650        fn e2e_burst_logs_no_oscillation() {
2651            // Simulate bursty output: alternating bursts of slow frames
2652            // and calm periods. Verify no oscillation (bounded transitions).
2653            let mut ctrl = fast_controller(16);
2654
2655            let mut trace = Vec::new();
2656            for _cycle in 0..5 {
2657                // Burst: 10 frames at 40ms
2658                for _ in 0..10 {
2659                    trace.push(Duration::from_millis(40));
2660                }
2661                // Calm: 20 frames at 16ms
2662                for _ in 0..20 {
2663                    trace.push(Duration::from_millis(16));
2664                }
2665            }
2666
2667            let log = run_trace(&mut ctrl, &trace);
2668
2669            // Count level transitions. Under bursty load, transitions should
2670            // be bounded — no rapid oscillation. With 5 cycles of 30 frames
2671            // each (150 total), we expect at most ~15 transitions (degrade
2672            // during each burst, upgrade during each calm).
2673            let transitions = count_transitions(&log);
2674            assert!(
2675                transitions < 20,
2676                "Too many transitions under bursty load: {} (expected <20)",
2677                transitions
2678            );
2679
2680            // Verify all telemetry fields are populated
2681            for (frame, ft_us, telem) in &log {
2682                assert!(
2683                    telem.pid_output.is_finite(),
2684                    "frame {}: NaN pid_output",
2685                    frame
2686                );
2687                assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2688                assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2689                assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2690                assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2691                assert!(*ft_us > 0, "frame {}: zero frame time", frame);
2692            }
2693        }
2694
2695        #[test]
2696        fn e2e_burst_recovers_after_moderate_overload() {
2697            // Moderate bursts (30ms vs 16ms target) followed by calm periods.
2698            // The controller may degrade during bursts, but should recover
2699            // during calm periods — final state should not be SkipFrame.
2700            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2701                target: Duration::from_millis(16),
2702                eprocess: EProcessConfig {
2703                    warmup_frames: 5,
2704                    ..Default::default()
2705                },
2706                cooldown_frames: 3,
2707                ..Default::default()
2708            });
2709
2710            let mut trace = Vec::new();
2711            for _cycle in 0..3 {
2712                // Moderate burst
2713                for _ in 0..15 {
2714                    trace.push(Duration::from_millis(30));
2715                }
2716                // Extended calm to allow recovery
2717                for _ in 0..50 {
2718                    trace.push(Duration::from_millis(10));
2719                }
2720            }
2721
2722            let log = run_trace(&mut ctrl, &trace);
2723
2724            // After each calm period, level should have recovered below Skeleton.
2725            // Check at the end of each calm phase (frames 64, 129, 194).
2726            for cycle in 0..3 {
2727                let calm_end = (cycle + 1) * 65 - 1;
2728                if calm_end < log.len() {
2729                    assert!(
2730                        log[calm_end].2.level < DegradationLevel::SkipFrame,
2731                        "cycle {}: should recover after calm period, got {:?} at frame {}",
2732                        cycle,
2733                        log[calm_end].2.level,
2734                        calm_end
2735                    );
2736                }
2737            }
2738
2739            // Final level should be better than Skeleton
2740            let final_level = log.last().unwrap().2.level;
2741            assert!(
2742                final_level < DegradationLevel::Skeleton,
2743                "Final level should recover below Skeleton: {:?}",
2744                final_level
2745            );
2746        }
2747
2748        // --- e2e_idle_to_burst ---
2749
2750        #[test]
2751        fn e2e_idle_to_burst_recovery() {
2752            // Start idle (well under budget), then sudden burst, then back to idle.
2753            // Verify: fast recovery without over-degrading.
2754            let mut ctrl = fast_controller(16);
2755
2756            let mut trace = Vec::new();
2757            // Phase 1: idle (8ms frames)
2758            for _ in 0..50 {
2759                trace.push(Duration::from_millis(8));
2760            }
2761            // Phase 2: sudden burst (50ms frames)
2762            for _ in 0..20 {
2763                trace.push(Duration::from_millis(50));
2764            }
2765            // Phase 3: recovery (8ms frames)
2766            for _ in 0..100 {
2767                trace.push(Duration::from_millis(8));
2768            }
2769
2770            let log = run_trace(&mut ctrl, &trace);
2771
2772            // After idle phase (frame 49), should still be Full
2773            assert_eq!(
2774                log[49].2.level,
2775                DegradationLevel::Full,
2776                "Should be Full during idle phase"
2777            );
2778
2779            // During burst, should degrade
2780            let max_during_burst = log[50..70].iter().map(|(_, _, t)| t.level).max().unwrap();
2781            assert!(
2782                max_during_burst > DegradationLevel::Full,
2783                "Should degrade during burst"
2784            );
2785
2786            // After recovery (last 20 frames), should have recovered toward Full
2787            let final_level = log.last().unwrap().2.level;
2788            assert!(
2789                final_level < max_during_burst,
2790                "Should recover after burst: final={:?}, max_during_burst={:?}",
2791                final_level,
2792                max_during_burst
2793            );
2794        }
2795
2796        #[test]
2797        fn e2e_idle_to_burst_no_over_degrade() {
2798            // A brief burst (5 frames) should not cause more than 1-2 levels
2799            // of degradation, even with zero warmup.
2800            let mut ctrl = fast_controller(16);
2801
2802            // Idle
2803            for _ in 0..30 {
2804                ctrl.update(Duration::from_millis(8));
2805            }
2806
2807            // Brief burst (only 5 frames)
2808            for _ in 0..5 {
2809                ctrl.update(Duration::from_millis(40));
2810            }
2811
2812            // Check degradation is modest
2813            let level = ctrl.level();
2814            assert!(
2815                level <= DegradationLevel::NoStyling,
2816                "Brief burst should not over-degrade: {:?}",
2817                level
2818            );
2819        }
2820
2821        #[test]
2822        fn e2e_overload_campaign_burst_sustained_recovery_with_replay_logs() {
2823            // bd-2vr05.15.4.5:
2824            // 1) burst overload
2825            // 2) sustained overload
2826            // 3) recovery/underload
2827            //
2828            // This test validates full-range degradation, so remove the floor.
2829            let phases: [(&str, usize, Duration); 3] = [
2830                ("burst_overload", 24, Duration::from_millis(28)),
2831                ("sustained_overload", 80, Duration::from_millis(52)),
2832                ("recovery_underload", 140, Duration::from_millis(8)),
2833            ];
2834
2835            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2836                target: Duration::from_millis(16),
2837                eprocess: EProcessConfig {
2838                    warmup_frames: 0,
2839                    ..Default::default()
2840                },
2841                cooldown_frames: 0,
2842                degradation_floor: DegradationLevel::SkipFrame,
2843                ..Default::default()
2844            });
2845            let logs = run_campaign(&mut ctrl, &phases);
2846            assert!(!logs.is_empty(), "campaign logs must be non-empty");
2847
2848            let mut burst_degrades = 0u32;
2849            let mut sustained_degrades = 0u32;
2850            let mut sustained_degraded_frames = 0u32;
2851            let mut recovery_upgrades = 0u32;
2852            let mut max_level = DegradationLevel::Full;
2853
2854            for log in &logs {
2855                let telem = &log.telemetry;
2856                if telem.level > max_level {
2857                    max_level = telem.level;
2858                }
2859                if log.phase == "burst_overload" && telem.last_decision == BudgetDecision::Degrade {
2860                    burst_degrades = burst_degrades.saturating_add(1);
2861                }
2862                if log.phase == "sustained_overload"
2863                    && telem.last_decision == BudgetDecision::Degrade
2864                {
2865                    sustained_degrades = sustained_degrades.saturating_add(1);
2866                }
2867                if log.phase == "sustained_overload" && telem.level > DegradationLevel::Full {
2868                    sustained_degraded_frames = sustained_degraded_frames.saturating_add(1);
2869                }
2870                if log.phase == "recovery_underload"
2871                    && telem.last_decision == BudgetDecision::Upgrade
2872                {
2873                    recovery_upgrades = recovery_upgrades.saturating_add(1);
2874                }
2875
2876                // Semantic integrity invariants (no corruption under degradation)
2877                assert!(
2878                    telem.level <= DegradationLevel::SkipFrame,
2879                    "frame {}: invalid degradation level {:?}",
2880                    log.frame_idx,
2881                    telem.level
2882                );
2883                assert!(
2884                    telem.e_value.is_finite() && telem.e_value > 0.0,
2885                    "frame {}: invalid e_value {}",
2886                    log.frame_idx,
2887                    telem.e_value
2888                );
2889                assert!(
2890                    telem.pid_output.is_finite(),
2891                    "frame {}: invalid pid_output {}",
2892                    log.frame_idx,
2893                    telem.pid_output
2894                );
2895            }
2896
2897            // Adjacent level changes must be stepwise (no jump corruption).
2898            for pair in logs.windows(2) {
2899                let prev = pair[0].telemetry.level.level();
2900                let curr = pair[1].telemetry.level.level();
2901                let delta = (curr as i16 - prev as i16).unsigned_abs();
2902                assert!(
2903                    delta <= 1,
2904                    "frame {}->{} level jump {}: {:?} -> {:?}",
2905                    pair[0].frame_idx,
2906                    pair[1].frame_idx,
2907                    delta,
2908                    pair[0].telemetry.level,
2909                    pair[1].telemetry.level
2910                );
2911            }
2912
2913            assert!(
2914                burst_degrades > 0,
2915                "burst phase should trigger degradation decisions"
2916            );
2917            assert!(
2918                sustained_degrades > 0 || sustained_degraded_frames > 0,
2919                "sustained overload phase should maintain degraded operation"
2920            );
2921            assert!(
2922                max_level >= DegradationLevel::Skeleton,
2923                "sustained overload should reach deep degradation (got {:?})",
2924                max_level
2925            );
2926            assert!(
2927                recovery_upgrades > 0,
2928                "recovery phase should trigger upgrade decisions"
2929            );
2930
2931            let final_level = logs
2932                .last()
2933                .map(|entry| entry.telemetry.level)
2934                .unwrap_or(DegradationLevel::SkipFrame);
2935            assert!(
2936                final_level < max_level,
2937                "final level should recover below peak degradation: final={:?} peak={:?}",
2938                final_level,
2939                max_level
2940            );
2941
2942            // Deterministic replay contract: same scenario -> same decisions/telemetry.
2943            let mut ctrl_replay = BudgetController::new(BudgetControllerConfig {
2944                target: Duration::from_millis(16),
2945                eprocess: EProcessConfig {
2946                    warmup_frames: 0,
2947                    ..Default::default()
2948                },
2949                cooldown_frames: 0,
2950                degradation_floor: DegradationLevel::SkipFrame,
2951                ..Default::default()
2952            });
2953            let replay_logs = run_campaign(&mut ctrl_replay, &phases);
2954            assert_eq!(
2955                logs.len(),
2956                replay_logs.len(),
2957                "log length mismatch in replay"
2958            );
2959            for (lhs, rhs) in logs.iter().zip(replay_logs.iter()) {
2960                assert_eq!(lhs.frame_idx, rhs.frame_idx);
2961                assert_eq!(lhs.phase, rhs.phase);
2962                assert_eq!(lhs.frame_time_us, rhs.frame_time_us);
2963                assert_eq!(lhs.telemetry.schema_version, rhs.telemetry.schema_version);
2964                assert_eq!(lhs.telemetry.level, rhs.telemetry.level);
2965                assert_eq!(lhs.telemetry.last_decision, rhs.telemetry.last_decision);
2966                assert_eq!(
2967                    lhs.telemetry.decision_reason, rhs.telemetry.decision_reason,
2968                    "decision_reason mismatch at frame {}",
2969                    lhs.frame_idx
2970                );
2971                assert_eq!(
2972                    lhs.telemetry.transition_seq, rhs.telemetry.transition_seq,
2973                    "transition_seq mismatch at frame {}",
2974                    lhs.frame_idx
2975                );
2976                assert_eq!(
2977                    lhs.telemetry.transition_correlation_id,
2978                    rhs.telemetry.transition_correlation_id,
2979                    "transition_correlation_id mismatch at frame {}",
2980                    lhs.frame_idx
2981                );
2982                assert!(
2983                    (lhs.telemetry.pid_output - rhs.telemetry.pid_output).abs() < 1e-12,
2984                    "pid_output mismatch at frame {}",
2985                    lhs.frame_idx
2986                );
2987                assert!(
2988                    (lhs.telemetry.e_value - rhs.telemetry.e_value).abs() < 1e-12,
2989                    "e_value mismatch at frame {}",
2990                    lhs.frame_idx
2991                );
2992            }
2993
2994            // Replay-grade diagnostics for controller postmortems.
2995            for entry in &logs {
2996                let t = &entry.telemetry;
2997                eprintln!(
2998                    r#"{{"event":"control_campaign_frame","schema_version":{},"scenario":"bd-2vr05.15.4.5","frame_idx":{},"phase":"{}","frame_time_us":{},"decision":"{}","decision_reason":"{}","transition_seq":{},"transition_correlation_id":{},"level":"{}","pid_output":{:.6},"pid_p":{:.6},"pid_i":{:.6},"pid_d":{:.6},"e_value":{:.6},"frame_time_ms":{:.6},"target_ms":{:.6},"pid_gate_threshold":{:.6},"pid_gate_margin":{:.6},"evidence_threshold":{:.6},"evidence_margin":{:.6},"frames_observed":{},"frames_since_change":{}}}"#,
2999                    t.schema_version,
3000                    entry.frame_idx,
3001                    entry.phase,
3002                    entry.frame_time_us,
3003                    t.last_decision.as_str(),
3004                    t.decision_reason.as_str(),
3005                    t.transition_seq,
3006                    t.transition_correlation_id,
3007                    t.level.as_str(),
3008                    t.pid_output,
3009                    t.pid_p,
3010                    t.pid_i,
3011                    t.pid_d,
3012                    t.e_value,
3013                    t.frame_time_ms,
3014                    t.target_ms,
3015                    t.pid_gate_threshold,
3016                    t.pid_gate_margin,
3017                    t.evidence_threshold,
3018                    t.evidence_margin,
3019                    t.frames_observed,
3020                    t.frames_since_change
3021                );
3022            }
3023            eprintln!(
3024                r#"{{"event":"control_campaign_summary","schema_version":{},"scenario":"bd-2vr05.15.4.5","frames":{},"burst_degrades":{},"sustained_degrades":{},"recovery_upgrades":{},"peak_level":"{}","final_level":"{}"}}"#,
3025                BUDGET_TELEMETRY_SCHEMA_VERSION,
3026                logs.len(),
3027                burst_degrades,
3028                sustained_degrades,
3029                recovery_upgrades,
3030                max_level.as_str(),
3031                final_level.as_str()
3032            );
3033        }
3034
3035        // --- property_random_load ---
3036
3037        #[test]
3038        fn property_random_load_hysteresis_bounds() {
3039            // Verify: degradation changes are bounded by hysteresis constraints.
3040            // Specifically, level can only change by 1 step per decision.
3041            let mut ctrl = fast_controller(16);
3042
3043            // Generate a deterministic pseudo-random load trace using a simple
3044            // linear congruential generator (no std::rand dependency).
3045            let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_BABE;
3046            let mut trace = Vec::new();
3047            for _ in 0..1000 {
3048                // LCG: next = (a * state + c) mod m
3049                rng_state = rng_state
3050                    .wrapping_mul(6_364_136_223_846_793_005)
3051                    .wrapping_add(1_442_695_040_888_963_407);
3052                // Map to frame time: 4ms..80ms
3053                let frame_ms = 4 + ((rng_state >> 33) % 77);
3054                trace.push(Duration::from_millis(frame_ms));
3055            }
3056
3057            let log = run_trace(&mut ctrl, &trace);
3058
3059            // Property 1: Level only changes by at most 1 step per frame
3060            for pair in log.windows(2) {
3061                let prev = pair[0].2.level.level();
3062                let curr = pair[1].2.level.level();
3063                let delta = (curr as i16 - prev as i16).unsigned_abs();
3064                assert!(
3065                    delta <= 1,
3066                    "Level jumped {} steps at frame {}: {:?} -> {:?}",
3067                    delta,
3068                    pair[1].0,
3069                    pair[0].2.level,
3070                    pair[1].2.level
3071                );
3072            }
3073
3074            // Property 2: Level never exceeds valid range
3075            for (frame, _, telem) in &log {
3076                assert!(
3077                    telem.level <= DegradationLevel::SkipFrame,
3078                    "frame {}: level out of range: {:?}",
3079                    frame,
3080                    telem.level
3081                );
3082            }
3083
3084            // Property 3: All numeric fields are finite
3085            for (frame, _, telem) in &log {
3086                assert!(
3087                    telem.pid_output.is_finite(),
3088                    "frame {}: NaN pid_output",
3089                    frame
3090                );
3091                assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
3092                assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
3093                assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
3094                assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
3095                assert!(
3096                    telem.e_value > 0.0,
3097                    "frame {}: e_value not positive: {}",
3098                    frame,
3099                    telem.e_value
3100                );
3101            }
3102        }
3103
3104        #[test]
3105        fn property_random_load_bounded_transitions() {
3106            // Under random load, transitions should be bounded and not exceed
3107            // a reasonable rate (no rapid oscillation).
3108            let mut ctrl = BudgetController::new(BudgetControllerConfig {
3109                target: Duration::from_millis(16),
3110                eprocess: EProcessConfig {
3111                    warmup_frames: 5,
3112                    ..Default::default()
3113                },
3114                cooldown_frames: 3,
3115                ..Default::default()
3116            });
3117
3118            // Deterministic pseudo-random trace
3119            let mut rng_state: u64 = 0x1234_5678_9ABC_DEF0;
3120            let mut trace = Vec::new();
3121            for _ in 0..500 {
3122                rng_state = rng_state
3123                    .wrapping_mul(6_364_136_223_846_793_005)
3124                    .wrapping_add(1_442_695_040_888_963_407);
3125                let frame_ms = 8 + ((rng_state >> 33) % 40);
3126                trace.push(Duration::from_millis(frame_ms));
3127            }
3128
3129            let log = run_trace(&mut ctrl, &trace);
3130            let transitions = count_transitions(&log);
3131
3132            // With cooldown=3 and 500 frames, max theoretical transitions = 500/4 = 125.
3133            // In practice with hysteresis + e-process gating, much less.
3134            assert!(
3135                transitions < 80,
3136                "Too many transitions under random load: {} (expected <80 with cooldown=3)",
3137                transitions
3138            );
3139        }
3140
3141        #[test]
3142        fn property_deterministic_replay() {
3143            // Same trace should produce identical telemetry every time.
3144            let trace: Vec<Duration> = (0..100)
3145                .map(|i| Duration::from_millis(10 + (i * 7 % 30)))
3146                .collect();
3147
3148            let mut ctrl1 = fast_controller(16);
3149            let log1 = run_trace(&mut ctrl1, &trace);
3150
3151            let mut ctrl2 = fast_controller(16);
3152            let log2 = run_trace(&mut ctrl2, &trace);
3153
3154            for (r1, r2) in log1.iter().zip(log2.iter()) {
3155                assert_eq!(r1.0, r2.0, "frame index mismatch");
3156                assert_eq!(r1.1, r2.1, "frame time mismatch");
3157                assert_eq!(r1.2.schema_version, r2.2.schema_version);
3158                assert_eq!(r1.2.level, r2.2.level, "level mismatch at frame {}", r1.0);
3159                assert_eq!(
3160                    r1.2.last_decision, r2.2.last_decision,
3161                    "decision mismatch at frame {}",
3162                    r1.0
3163                );
3164                assert_eq!(
3165                    r1.2.decision_reason, r2.2.decision_reason,
3166                    "decision_reason mismatch at frame {}",
3167                    r1.0
3168                );
3169                assert_eq!(
3170                    r1.2.transition_seq, r2.2.transition_seq,
3171                    "transition_seq mismatch at frame {}",
3172                    r1.0
3173                );
3174                assert_eq!(
3175                    r1.2.transition_correlation_id, r2.2.transition_correlation_id,
3176                    "transition_correlation_id mismatch at frame {}",
3177                    r1.0
3178                );
3179                assert!(
3180                    (r1.2.pid_output - r2.2.pid_output).abs() < 1e-10,
3181                    "pid_output mismatch at frame {}: {} vs {}",
3182                    r1.0,
3183                    r1.2.pid_output,
3184                    r2.2.pid_output
3185                );
3186                assert!(
3187                    (r1.2.e_value - r2.2.e_value).abs() < 1e-10,
3188                    "e_value mismatch at frame {}: {} vs {}",
3189                    r1.0,
3190                    r1.2.e_value,
3191                    r2.2.e_value
3192                );
3193            }
3194        }
3195
3196        // --- JSONL schema validation ---
3197
3198        #[test]
3199        fn telemetry_jsonl_fields_complete() {
3200            // Verify all JSONL schema fields are accessible from BudgetTelemetry.
3201            let mut ctrl = fast_controller(16);
3202            ctrl.update(Duration::from_millis(20));
3203
3204            let telem = ctrl.telemetry();
3205
3206            // All schema fields present and accessible:
3207            let _schema_version: u16 = telem.schema_version;
3208            let _degradation: &str = telem.level.as_str();
3209            let _pid_p: f64 = telem.pid_p;
3210            let _pid_i: f64 = telem.pid_i;
3211            let _pid_d: f64 = telem.pid_d;
3212            let _e_value: f64 = telem.e_value;
3213            let _decision: &str = telem.last_decision.as_str();
3214            let _reason: &str = telem.decision_reason.as_str();
3215            let _transition_seq: u64 = telem.transition_seq;
3216            let _transition_correlation_id: u64 = telem.transition_correlation_id;
3217            let _frame_time_ms: f64 = telem.frame_time_ms;
3218            let _target_ms: f64 = telem.target_ms;
3219            let _pid_gate_threshold: f64 = telem.pid_gate_threshold;
3220            let _pid_gate_margin: f64 = telem.pid_gate_margin;
3221            let _evidence_threshold: f64 = telem.evidence_threshold;
3222            let _evidence_margin: f64 = telem.evidence_margin;
3223            let _frames: u32 = telem.frames_observed;
3224
3225            // Verify decision string mapping
3226            assert_eq!(BudgetDecision::Hold.as_str(), "stay");
3227            assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
3228            assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
3229            assert_eq!(
3230                BUDGET_TELEMETRY_SCHEMA_VERSION, telem.schema_version,
3231                "schema version mismatch"
3232            );
3233        }
3234
3235        #[test]
3236        fn telemetry_transition_records_correlation_reason_and_evidence() {
3237            let mut ctrl = fast_controller(16);
3238
3239            // Drive toward a degrade transition.
3240            let mut degrade_telem = None;
3241            for _ in 0..64 {
3242                ctrl.update(Duration::from_millis(48));
3243                let telem = ctrl.telemetry();
3244                if telem.last_decision == BudgetDecision::Degrade {
3245                    degrade_telem = Some(telem);
3246                    break;
3247                }
3248            }
3249            let degrade_telem =
3250                degrade_telem.expect("expected degrade transition with correlation metadata");
3251            assert_eq!(
3252                degrade_telem.decision_reason,
3253                BudgetDecisionReason::OverloadEvidencePassed
3254            );
3255            assert!(
3256                degrade_telem.transition_seq > 0,
3257                "transition_seq should increment on transitions"
3258            );
3259            assert!(
3260                degrade_telem.transition_correlation_id > 0,
3261                "transition correlation id should be populated on transitions"
3262            );
3263            assert!(
3264                degrade_telem.pid_gate_margin > 0.0,
3265                "degrade transition should have positive PID gate margin"
3266            );
3267            assert!(
3268                degrade_telem.evidence_margin > 0.0,
3269                "degrade transition should have positive evidence margin"
3270            );
3271
3272            // Drive toward an upgrade transition.
3273            let mut upgrade_telem = None;
3274            for _ in 0..160 {
3275                ctrl.update(Duration::from_millis(4));
3276                let telem = ctrl.telemetry();
3277                if telem.last_decision == BudgetDecision::Upgrade {
3278                    upgrade_telem = Some(telem);
3279                    break;
3280                }
3281            }
3282            let upgrade_telem =
3283                upgrade_telem.expect("expected upgrade transition with correlation metadata");
3284            assert_eq!(
3285                upgrade_telem.decision_reason,
3286                BudgetDecisionReason::UnderloadEvidencePassed
3287            );
3288            assert!(
3289                upgrade_telem.transition_seq >= degrade_telem.transition_seq,
3290                "transition sequence should be monotonic"
3291            );
3292            assert!(
3293                upgrade_telem.transition_correlation_id >= degrade_telem.transition_correlation_id,
3294                "transition correlation id should be monotonic"
3295            );
3296            assert!(
3297                upgrade_telem.pid_gate_margin > 0.0,
3298                "upgrade transition should have positive PID gate margin"
3299            );
3300            assert!(
3301                upgrade_telem.evidence_margin > 0.0,
3302                "upgrade transition should have positive evidence margin"
3303            );
3304        }
3305
3306        #[test]
3307        fn telemetry_pid_components_sum_to_output() {
3308            // Verify P + I + D == total PID output.
3309            let mut ctrl = fast_controller(16);
3310
3311            for ms in [10u64, 16, 20, 30, 8, 50] {
3312                ctrl.update(Duration::from_millis(ms));
3313                let telem = ctrl.telemetry();
3314                let sum = telem.pid_p + telem.pid_i + telem.pid_d;
3315                assert!(
3316                    (sum - telem.pid_output).abs() < 1e-10,
3317                    "P+I+D != output at {}ms: {} + {} + {} = {} != {}",
3318                    ms,
3319                    telem.pid_p,
3320                    telem.pid_i,
3321                    telem.pid_d,
3322                    sum,
3323                    telem.pid_output
3324                );
3325            }
3326        }
3327    }
3328
3329    // -----------------------------------------------------------------------
3330    // Edge-case tests (bd-1x69n)
3331    // -----------------------------------------------------------------------
3332
3333    mod edge_case_tests {
3334        use super::super::*;
3335
3336        // --- PID edge cases ---
3337
3338        #[test]
3339        fn pid_negative_integral_windup() {
3340            // Sustained negative error should clamp integral at -integral_max
3341            let mut state = PidState::default();
3342            let gains = PidGains {
3343                integral_max: 3.0,
3344                ..Default::default()
3345            };
3346
3347            for _ in 0..200 {
3348                state.update(-10.0, &gains);
3349            }
3350
3351            assert!(
3352                state.integral >= -3.0 - f64::EPSILON,
3353                "Negative integral should be clamped to -max: {}",
3354                state.integral
3355            );
3356            assert!(
3357                state.integral <= -3.0 + f64::EPSILON,
3358                "Negative integral should saturate at -max: {}",
3359                state.integral
3360            );
3361        }
3362
3363        #[test]
3364        fn pid_zero_gains_zero_output() {
3365            let mut state = PidState::default();
3366            let gains = PidGains {
3367                kp: 0.0,
3368                ki: 0.0,
3369                kd: 0.0,
3370                integral_max: 5.0,
3371            };
3372
3373            let u = state.update(42.0, &gains);
3374            assert!(
3375                u.abs() < 1e-10,
3376                "Zero gains should yield zero output: {}",
3377                u
3378            );
3379        }
3380
3381        #[test]
3382        fn pid_large_error_stays_finite() {
3383            let mut state = PidState::default();
3384            let gains = PidGains::default();
3385
3386            // Very large error
3387            let u = state.update(1e12, &gains);
3388            assert!(
3389                u.is_finite(),
3390                "PID output should be finite for large error: {}",
3391                u
3392            );
3393
3394            // Integral should be clamped
3395            assert!(
3396                state.integral <= gains.integral_max + f64::EPSILON,
3397                "Integral should be clamped: {}",
3398                state.integral
3399            );
3400        }
3401
3402        #[test]
3403        fn pid_alternating_error_derivative_responds() {
3404            let mut state = PidState::default();
3405            let gains = PidGains::default();
3406
3407            // Alternating +1/-1 error
3408            let u1 = state.update(1.0, &gains);
3409            let u2 = state.update(-1.0, &gains);
3410
3411            // Derivative component for second call: Kd * (-1.0 - 1.0) = 0.2 * -2.0 = -0.4
3412            // So u2 should have negative derivative contribution
3413            assert!(
3414                u2 < u1,
3415                "Alternating error should reduce output: u1={}, u2={}",
3416                u1,
3417                u2
3418            );
3419        }
3420
3421        #[test]
3422        fn pid_telemetry_terms_match_after_update() {
3423            let mut state = PidState::default();
3424            let gains = PidGains::default();
3425
3426            state.update(2.0, &gains);
3427
3428            // P = Kp * error = 0.5 * 2.0 = 1.0
3429            assert!(
3430                (state.last_p - 1.0).abs() < 1e-10,
3431                "P term: {}",
3432                state.last_p
3433            );
3434            // I = Ki * integral = 0.05 * 2.0 = 0.1
3435            assert!(
3436                (state.last_i - 0.1).abs() < 1e-10,
3437                "I term: {}",
3438                state.last_i
3439            );
3440            // D = Kd * (error - prev_error) = 0.2 * (2.0 - 0.0) = 0.4
3441            assert!(
3442                (state.last_d - 0.4).abs() < 1e-10,
3443                "D term: {}",
3444                state.last_d
3445            );
3446        }
3447
3448        #[test]
3449        fn pid_integral_clamping_symmetric() {
3450            let mut state = PidState::default();
3451            let gains = PidGains {
3452                integral_max: 1.0,
3453                ..Default::default()
3454            };
3455
3456            // Positive saturation
3457            for _ in 0..50 {
3458                state.update(100.0, &gains);
3459            }
3460            let pos_integral = state.integral;
3461
3462            state.reset();
3463
3464            // Negative saturation
3465            for _ in 0..50 {
3466                state.update(-100.0, &gains);
3467            }
3468            let neg_integral = state.integral;
3469
3470            assert!(
3471                (pos_integral + neg_integral).abs() < f64::EPSILON,
3472                "Clamping should be symmetric: pos={}, neg={}",
3473                pos_integral,
3474                neg_integral
3475            );
3476        }
3477
3478        // --- E-process edge cases ---
3479
3480        #[test]
3481        fn eprocess_first_frame_initializes_mean() {
3482            let mut state = EProcessState::default();
3483            let config = EProcessConfig::default();
3484
3485            state.update(25.0, 16.0, &config);
3486
3487            assert!(
3488                (state.mean_ema - 25.0).abs() < f64::EPSILON,
3489                "First frame should set mean_ema directly: {}",
3490                state.mean_ema
3491            );
3492            assert!(
3493                (state.sigma_ema - config.sigma_floor_ms).abs() < f64::EPSILON,
3494                "First frame should set sigma_ema to floor: {}",
3495                state.sigma_ema
3496            );
3497            assert_eq!(state.frames_observed, 1);
3498        }
3499
3500        #[test]
3501        fn eprocess_e_value_clamped_at_upper_bound() {
3502            let mut state = EProcessState::default();
3503            let config = EProcessConfig {
3504                lambda: 2.0, // High sensitivity to force rapid growth
3505                warmup_frames: 0,
3506                sigma_floor_ms: 0.001, // Tiny floor to amplify residuals
3507                ..Default::default()
3508            };
3509
3510            // Extreme overload to push e_value toward upper clamp
3511            for _ in 0..1000 {
3512                state.update(1e6, 16.0, &config);
3513            }
3514
3515            assert!(
3516                state.e_value <= 1e10,
3517                "E-value should be clamped at 1e10: {}",
3518                state.e_value
3519            );
3520        }
3521
3522        #[test]
3523        fn eprocess_e_value_clamped_at_lower_bound() {
3524            let mut state = EProcessState::default();
3525            let config = EProcessConfig {
3526                lambda: 2.0,
3527                warmup_frames: 0,
3528                sigma_floor_ms: 0.001,
3529                ..Default::default()
3530            };
3531
3532            // Extreme underload to push e_value toward lower clamp
3533            for _ in 0..1000 {
3534                state.update(0.001, 1e6, &config);
3535            }
3536
3537            assert!(
3538                state.e_value >= 1e-10,
3539                "E-value should be clamped at 1e-10: {}",
3540                state.e_value
3541            );
3542        }
3543
3544        #[test]
3545        fn eprocess_should_upgrade_during_warmup() {
3546            let state = EProcessState::default();
3547            let config = EProcessConfig {
3548                warmup_frames: 10,
3549                ..Default::default()
3550            };
3551
3552            // During warmup, should_upgrade returns true to allow PID-driven upgrades
3553            assert!(
3554                state.should_upgrade(&config),
3555                "should_upgrade should return true during warmup"
3556            );
3557        }
3558
3559        #[test]
3560        fn eprocess_frames_observed_saturates() {
3561            let mut state = EProcessState {
3562                frames_observed: u32::MAX,
3563                ..EProcessState::default()
3564            };
3565            let config = EProcessConfig::default();
3566
3567            // Should not panic or wrap around
3568            state.update(16.0, 16.0, &config);
3569            assert_eq!(
3570                state.frames_observed,
3571                u32::MAX,
3572                "frames_observed should saturate at u32::MAX"
3573            );
3574        }
3575
3576        #[test]
3577        fn eprocess_sigma_ema_decay_boundary_zero() {
3578            let mut state = EProcessState::default();
3579            let config = EProcessConfig {
3580                sigma_ema_decay: 0.0,
3581                warmup_frames: 0,
3582                ..Default::default()
3583            };
3584
3585            // With decay=0, each update fully replaces the EMA
3586            state.update(20.0, 16.0, &config);
3587            state.update(30.0, 16.0, &config);
3588
3589            // mean_ema should be exactly the latest value
3590            assert!(
3591                (state.mean_ema - 30.0).abs() < f64::EPSILON,
3592                "decay=0 should fully replace mean_ema: {}",
3593                state.mean_ema
3594            );
3595        }
3596
3597        #[test]
3598        fn eprocess_sigma_ema_decay_boundary_one() {
3599            let mut state = EProcessState::default();
3600            let config = EProcessConfig {
3601                sigma_ema_decay: 1.0,
3602                warmup_frames: 0,
3603                ..Default::default()
3604            };
3605
3606            // With decay=1, EMA never changes from initial
3607            state.update(20.0, 16.0, &config);
3608            let first_mean = state.mean_ema;
3609            state.update(100.0, 16.0, &config);
3610
3611            assert!(
3612                (state.mean_ema - first_mean).abs() < f64::EPSILON,
3613                "decay=1 should lock mean_ema at first value: got {}, expected {}",
3614                state.mean_ema,
3615                first_mean
3616            );
3617        }
3618
3619        #[test]
3620        fn eprocess_zero_target_no_panic() {
3621            let mut state = EProcessState::default();
3622            let config = EProcessConfig {
3623                warmup_frames: 0,
3624                ..Default::default()
3625            };
3626
3627            // Zero target — residual computation divides by sigma (floored), not target
3628            let e = state.update(16.0, 0.0, &config);
3629            assert!(
3630                e.is_finite(),
3631                "E-value should be finite with zero target: {}",
3632                e
3633            );
3634        }
3635
3636        // --- DegradationLevel edge cases ---
3637
3638        #[test]
3639        fn degradation_level_default_is_full() {
3640            assert_eq!(DegradationLevel::default(), DegradationLevel::Full);
3641        }
3642
3643        #[test]
3644        fn degradation_level_hash_unique() {
3645            use std::collections::HashSet;
3646            let levels = [
3647                DegradationLevel::Full,
3648                DegradationLevel::SimpleBorders,
3649                DegradationLevel::NoStyling,
3650                DegradationLevel::EssentialOnly,
3651                DegradationLevel::Skeleton,
3652                DegradationLevel::SkipFrame,
3653            ];
3654            let set: HashSet<DegradationLevel> = levels.iter().copied().collect();
3655            assert_eq!(set.len(), 6, "All levels should hash uniquely");
3656        }
3657
3658        #[test]
3659        fn degradation_level_widget_queries_full() {
3660            let l = DegradationLevel::Full;
3661            assert!(l.use_unicode_borders());
3662            assert!(l.apply_styling());
3663            assert!(l.render_decorative());
3664            assert!(l.render_content());
3665        }
3666
3667        #[test]
3668        fn degradation_level_widget_queries_simple_borders() {
3669            let l = DegradationLevel::SimpleBorders;
3670            assert!(!l.use_unicode_borders());
3671            assert!(l.apply_styling());
3672            assert!(l.render_decorative());
3673            assert!(l.render_content());
3674        }
3675
3676        #[test]
3677        fn degradation_level_widget_queries_no_styling() {
3678            let l = DegradationLevel::NoStyling;
3679            assert!(!l.use_unicode_borders());
3680            assert!(!l.apply_styling());
3681            assert!(l.render_decorative());
3682            assert!(l.render_content());
3683        }
3684
3685        #[test]
3686        fn degradation_level_widget_queries_essential_only() {
3687            let l = DegradationLevel::EssentialOnly;
3688            assert!(!l.use_unicode_borders());
3689            assert!(!l.apply_styling());
3690            assert!(!l.render_decorative());
3691            assert!(l.render_content());
3692        }
3693
3694        #[test]
3695        fn degradation_level_widget_queries_skeleton() {
3696            let l = DegradationLevel::Skeleton;
3697            assert!(!l.use_unicode_borders());
3698            assert!(!l.apply_styling());
3699            assert!(!l.render_decorative());
3700            assert!(!l.render_content());
3701        }
3702
3703        #[test]
3704        fn degradation_level_widget_queries_skip_frame() {
3705            let l = DegradationLevel::SkipFrame;
3706            assert!(!l.use_unicode_borders());
3707            assert!(!l.apply_styling());
3708            assert!(!l.render_decorative());
3709            assert!(!l.render_content());
3710        }
3711
3712        #[test]
3713        fn degradation_level_partial_ord_consistent() {
3714            // PartialOrd should agree with Ord for all pairs
3715            let levels = [
3716                DegradationLevel::Full,
3717                DegradationLevel::SimpleBorders,
3718                DegradationLevel::NoStyling,
3719                DegradationLevel::EssentialOnly,
3720                DegradationLevel::Skeleton,
3721                DegradationLevel::SkipFrame,
3722            ];
3723            for (i, a) in levels.iter().enumerate() {
3724                for (j, b) in levels.iter().enumerate() {
3725                    let po = a.partial_cmp(b);
3726                    let o = a.cmp(b);
3727                    assert_eq!(po, Some(o), "PartialOrd != Ord for {:?} vs {:?}", a, b);
3728                    if i < j {
3729                        assert!(*a < *b, "{:?} should be < {:?}", a, b);
3730                    }
3731                }
3732            }
3733        }
3734
3735        #[test]
3736        fn degradation_level_clone_eq() {
3737            let a = DegradationLevel::NoStyling;
3738            let b = a;
3739            assert_eq!(a, b);
3740        }
3741
3742        #[test]
3743        fn degradation_level_debug() {
3744            let s = format!("{:?}", DegradationLevel::EssentialOnly);
3745            assert!(s.contains("EssentialOnly"), "Debug output: {}", s);
3746        }
3747
3748        // --- BudgetController accessor edge cases ---
3749
3750        #[test]
3751        fn controller_eprocess_sigma_ms_uses_floor() {
3752            let ctrl = BudgetController::new(BudgetControllerConfig {
3753                eprocess: EProcessConfig {
3754                    sigma_floor_ms: 2.5,
3755                    ..Default::default()
3756                },
3757                ..Default::default()
3758            });
3759
3760            // Before any updates, sigma_ema is 0.0, so should return floor
3761            assert!(
3762                (ctrl.eprocess_sigma_ms() - 2.5).abs() < f64::EPSILON,
3763                "Should return sigma_floor_ms when sigma_ema < floor: {}",
3764                ctrl.eprocess_sigma_ms()
3765            );
3766        }
3767
3768        #[test]
3769        fn controller_config_accessor() {
3770            let config = BudgetControllerConfig {
3771                degrade_threshold: 0.42,
3772                ..Default::default()
3773            };
3774            let ctrl = BudgetController::new(config.clone());
3775
3776            assert_eq!(ctrl.config().degrade_threshold, 0.42);
3777            assert_eq!(ctrl.config().target, Duration::from_millis(16));
3778        }
3779
3780        #[test]
3781        fn controller_frames_observed_accessor() {
3782            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
3783
3784            assert_eq!(ctrl.frames_observed(), 0);
3785
3786            ctrl.update(Duration::from_millis(16));
3787            assert_eq!(ctrl.frames_observed(), 1);
3788
3789            ctrl.update(Duration::from_millis(16));
3790            assert_eq!(ctrl.frames_observed(), 2);
3791        }
3792
3793        // --- RenderBudget edge cases ---
3794
3795        #[test]
3796        fn render_budget_record_frame_time_used_by_next_frame() {
3797            let mut budget = RenderBudget::new(Duration::from_millis(1000));
3798            budget.degrade();
3799
3800            // Simulate many frames to pass cooldown
3801            for _ in 0..10 {
3802                budget.reset();
3803            }
3804
3805            // Record a very fast frame time
3806            budget.record_frame_time(Duration::from_millis(1));
3807            // Sleep past the budget so start.elapsed() would be large
3808            std::thread::sleep(Duration::from_millis(15));
3809
3810            let before = budget.degradation();
3811            budget.next_frame();
3812
3813            // The recorded frame time (1ms) should trigger upgrade
3814            // since remaining_fraction_for_elapsed(1ms) > upgrade_threshold
3815            assert!(
3816                budget.degradation() < before,
3817                "Recorded frame time should enable upgrade: before={:?}, after={:?}",
3818                before,
3819                budget.degradation()
3820            );
3821        }
3822
3823        #[test]
3824        fn render_budget_phase_budget_clamped_by_remaining() {
3825            // Create a budget that has very little remaining
3826            let budget = RenderBudget::new(Duration::from_millis(1));
3827            std::thread::sleep(Duration::from_millis(5));
3828
3829            // Phase budget should be clamped to remaining (0ms)
3830            let phase = budget.phase_budget(Phase::Render);
3831            assert!(
3832                phase.total() <= Duration::from_millis(1),
3833                "Phase budget should be clamped by remaining: {:?}",
3834                phase.total()
3835            );
3836        }
3837
3838        #[test]
3839        fn render_budget_exhausted_skipframe_with_no_frame_skip() {
3840            let mut budget = RenderBudget::new(Duration::from_millis(1000));
3841            budget.allow_frame_skip = false;
3842            budget.set_degradation(DegradationLevel::SkipFrame);
3843
3844            // With allow_frame_skip = false, SkipFrame should NOT cause exhaustion
3845            // (only time-based exhaustion matters)
3846            assert!(
3847                !budget.exhausted(),
3848                "SkipFrame should not exhaust when frame skip disabled"
3849            );
3850        }
3851
3852        #[test]
3853        fn render_budget_remaining_fraction_zero_total() {
3854            let budget = RenderBudget::new(Duration::ZERO);
3855            assert_eq!(budget.remaining_fraction(), 0.0);
3856        }
3857
3858        #[test]
3859        fn render_budget_total_accessor() {
3860            let budget = RenderBudget::new(Duration::from_millis(42));
3861            assert_eq!(budget.total(), Duration::from_millis(42));
3862        }
3863
3864        #[test]
3865        fn render_budget_phase_budgets_accessor() {
3866            let budget = RenderBudget::new(Duration::from_millis(16));
3867            let pb = budget.phase_budgets();
3868            assert_eq!(pb.diff, Duration::from_millis(2));
3869            assert_eq!(pb.present, Duration::from_millis(4));
3870            assert_eq!(pb.render, Duration::from_millis(8));
3871        }
3872
3873        #[test]
3874        fn render_budget_set_degradation_no_op_preserves_cooldown() {
3875            let mut budget = RenderBudget::new(Duration::from_millis(16));
3876            budget.set_degradation(DegradationLevel::NoStyling);
3877            budget.frames_since_change = 7;
3878
3879            // Setting to same level is a no-op
3880            budget.set_degradation(DegradationLevel::NoStyling);
3881            assert_eq!(budget.frames_since_change, 7);
3882
3883            // Setting to different level resets cooldown
3884            budget.set_degradation(DegradationLevel::Skeleton);
3885            assert_eq!(budget.frames_since_change, 0);
3886        }
3887
3888        #[test]
3889        fn render_budget_should_upgrade_false_at_full() {
3890            let budget = RenderBudget::new(Duration::from_millis(1000));
3891            assert!(!budget.should_upgrade(), "Full level should never upgrade");
3892        }
3893
3894        #[test]
3895        fn render_budget_should_upgrade_false_during_cooldown() {
3896            let mut budget = RenderBudget::new(Duration::from_millis(1000));
3897            budget.degrade();
3898            // frames_since_change is 0, cooldown is 3
3899            assert!(
3900                !budget.should_upgrade(),
3901                "Should not upgrade during cooldown"
3902            );
3903        }
3904
3905        #[test]
3906        fn render_budget_degrade_at_max_stays_at_max() {
3907            let mut budget = RenderBudget::new(Duration::from_millis(16));
3908            budget.set_degradation(DegradationLevel::SkipFrame);
3909            budget.degrade();
3910            assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
3911        }
3912
3913        #[test]
3914        fn render_budget_upgrade_at_full_stays_at_full() {
3915            let mut budget = RenderBudget::new(Duration::from_millis(16));
3916            budget.upgrade();
3917            assert_eq!(budget.degradation(), DegradationLevel::Full);
3918        }
3919
3920        // --- Config edge cases ---
3921
3922        #[test]
3923        fn frame_budget_config_partial_eq() {
3924            let a = FrameBudgetConfig::default();
3925            let b = FrameBudgetConfig::default();
3926            assert_eq!(a, b);
3927
3928            let c = FrameBudgetConfig::strict(Duration::from_millis(16));
3929            assert_ne!(a, c, "Different configs should not be equal");
3930        }
3931
3932        #[test]
3933        fn phase_budgets_eq_and_copy() {
3934            let a = PhaseBudgets::default();
3935            let b = a; // Copy
3936            assert_eq!(a, b);
3937
3938            let c = PhaseBudgets {
3939                diff: Duration::from_millis(1),
3940                ..Default::default()
3941            };
3942            assert_ne!(a, c);
3943        }
3944
3945        #[test]
3946        fn budget_controller_config_partial_eq() {
3947            let a = BudgetControllerConfig::default();
3948            let b = BudgetControllerConfig::default();
3949            assert_eq!(a, b);
3950        }
3951
3952        #[test]
3953        fn pid_gains_partial_eq() {
3954            let a = PidGains::default();
3955            let b = PidGains::default();
3956            assert_eq!(a, b);
3957        }
3958
3959        #[test]
3960        fn eprocess_config_partial_eq() {
3961            let a = EProcessConfig::default();
3962            let b = EProcessConfig::default();
3963            assert_eq!(a, b);
3964        }
3965
3966        // --- BudgetDecision edge cases ---
3967
3968        #[test]
3969        fn budget_decision_debug_format() {
3970            assert!(format!("{:?}", BudgetDecision::Hold).contains("Hold"));
3971            assert!(format!("{:?}", BudgetDecision::Degrade).contains("Degrade"));
3972            assert!(format!("{:?}", BudgetDecision::Upgrade).contains("Upgrade"));
3973        }
3974
3975        #[test]
3976        fn budget_decision_clone_copy() {
3977            let d = BudgetDecision::Degrade;
3978            let d2 = d;
3979            assert_eq!(d, d2);
3980        }
3981
3982        #[test]
3983        fn budget_decision_as_str_coverage() {
3984            assert_eq!(BudgetDecision::Hold.as_str(), "stay");
3985            assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
3986            assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
3987        }
3988
3989        #[test]
3990        fn budget_decision_reason_debug_and_as_str() {
3991            assert!(
3992                format!("{:?}", BudgetDecisionReason::CooldownActive).contains("CooldownActive")
3993            );
3994            assert_eq!(
3995                BudgetDecisionReason::CooldownActive.as_str(),
3996                "cooldown_active"
3997            );
3998            assert_eq!(
3999                BudgetDecisionReason::OverloadEvidencePassed.as_str(),
4000                "overload_evidence_passed"
4001            );
4002            assert_eq!(
4003                BudgetDecisionReason::UnderloadEvidencePassed.as_str(),
4004                "underload_evidence_passed"
4005            );
4006            assert_eq!(
4007                BudgetDecisionReason::AtMaxDegradation.as_str(),
4008                "at_max_degradation"
4009            );
4010            assert_eq!(
4011                BudgetDecisionReason::AtDegradationFloor.as_str(),
4012                "at_degradation_floor"
4013            );
4014            assert_eq!(
4015                BudgetDecisionReason::AtFullQuality.as_str(),
4016                "at_full_quality"
4017            );
4018            assert_eq!(
4019                BudgetDecisionReason::WithinThresholdBand.as_str(),
4020                "within_threshold_band"
4021            );
4022        }
4023
4024        // --- Phase edge cases ---
4025
4026        #[test]
4027        fn phase_eq_and_hash() {
4028            use std::collections::HashSet;
4029            let mut set = HashSet::new();
4030            set.insert(Phase::Diff);
4031            set.insert(Phase::Present);
4032            set.insert(Phase::Render);
4033            assert_eq!(set.len(), 3);
4034
4035            // Same phase hashes to same bucket
4036            set.insert(Phase::Diff);
4037            assert_eq!(set.len(), 3);
4038        }
4039
4040        #[test]
4041        fn phase_debug() {
4042            assert!(format!("{:?}", Phase::Diff).contains("Diff"));
4043            assert!(format!("{:?}", Phase::Present).contains("Present"));
4044            assert!(format!("{:?}", Phase::Render).contains("Render"));
4045        }
4046
4047        #[test]
4048        fn phase_clone_copy() {
4049            let p = Phase::Present;
4050            let p2 = p;
4051            assert_eq!(p, p2);
4052        }
4053
4054        // --- BudgetTelemetry edge cases ---
4055
4056        #[test]
4057        fn budget_telemetry_debug() {
4058            let telem = BudgetTelemetry {
4059                schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
4060                level: DegradationLevel::Full,
4061                pid_output: 0.0,
4062                pid_p: 0.0,
4063                pid_i: 0.0,
4064                pid_d: 0.0,
4065                e_value: 1.0,
4066                frames_observed: 0,
4067                frames_since_change: 0,
4068                last_decision: BudgetDecision::Hold,
4069                decision_reason: BudgetDecisionReason::WithinThresholdBand,
4070                transition_seq: 0,
4071                transition_correlation_id: 0,
4072                frame_time_ms: 0.0,
4073                target_ms: 16.0,
4074                pid_gate_threshold: 0.0,
4075                pid_gate_margin: 0.0,
4076                evidence_threshold: 0.0,
4077                evidence_margin: 0.0,
4078                in_warmup: true,
4079            };
4080            let s = format!("{:?}", telem);
4081            assert!(s.contains("BudgetTelemetry"), "Debug output: {}", s);
4082        }
4083
4084        #[test]
4085        fn budget_telemetry_partial_eq() {
4086            let a = BudgetTelemetry {
4087                schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
4088                level: DegradationLevel::Full,
4089                pid_output: 0.5,
4090                pid_p: 0.3,
4091                pid_i: 0.1,
4092                pid_d: 0.1,
4093                e_value: 1.0,
4094                frames_observed: 5,
4095                frames_since_change: 2,
4096                last_decision: BudgetDecision::Hold,
4097                decision_reason: BudgetDecisionReason::WithinThresholdBand,
4098                transition_seq: 0,
4099                transition_correlation_id: 0,
4100                frame_time_ms: 16.0,
4101                target_ms: 16.0,
4102                pid_gate_threshold: 0.0,
4103                pid_gate_margin: 0.0,
4104                evidence_threshold: 0.0,
4105                evidence_margin: 0.0,
4106                in_warmup: false,
4107            };
4108            let b = a;
4109            assert_eq!(a, b);
4110
4111            let c = BudgetTelemetry {
4112                level: DegradationLevel::SimpleBorders,
4113                ..a
4114            };
4115            assert_ne!(a, c);
4116        }
4117
4118        // --- Controller + RenderBudget integration edge cases ---
4119
4120        #[test]
4121        fn next_frame_without_recorded_time_uses_elapsed() {
4122            let mut budget = RenderBudget::new(Duration::from_millis(1000));
4123
4124            // Don't record frame time — next_frame falls back to start.elapsed()
4125            budget.next_frame();
4126
4127            // Should not panic, remaining should reset
4128            assert!(budget.remaining_fraction() > 0.9);
4129        }
4130
4131        #[test]
4132        fn controller_at_max_degradation_holds() {
4133            let mut ctrl = BudgetController::new(BudgetControllerConfig {
4134                eprocess: EProcessConfig {
4135                    warmup_frames: 0,
4136                    ..Default::default()
4137                },
4138                cooldown_frames: 0,
4139                // Remove the floor so we can test reaching SkipFrame
4140                degradation_floor: DegradationLevel::SkipFrame,
4141                ..Default::default()
4142            });
4143
4144            // Drive to SkipFrame
4145            for _ in 0..500 {
4146                ctrl.update(Duration::from_millis(200));
4147            }
4148            assert_eq!(ctrl.level(), DegradationLevel::SkipFrame);
4149
4150            // At max level, further overload should Hold (can't degrade further)
4151            let d = ctrl.update(Duration::from_millis(200));
4152            assert_eq!(d, BudgetDecision::Hold, "At max level, should hold");
4153        }
4154
4155        #[test]
4156        fn controller_at_configured_degradation_floor_reports_floor_reason() {
4157            let mut ctrl = BudgetController::new(BudgetControllerConfig {
4158                eprocess: EProcessConfig {
4159                    warmup_frames: 0,
4160                    ..Default::default()
4161                },
4162                cooldown_frames: 0,
4163                degradation_floor: DegradationLevel::SimpleBorders,
4164                ..Default::default()
4165            });
4166
4167            ctrl.current_level = DegradationLevel::SimpleBorders;
4168
4169            let decision = ctrl.update(Duration::from_millis(200));
4170            let telemetry = ctrl.telemetry();
4171
4172            assert_eq!(decision, BudgetDecision::Hold);
4173            assert_eq!(telemetry.level, DegradationLevel::SimpleBorders);
4174            assert_eq!(
4175                telemetry.decision_reason,
4176                BudgetDecisionReason::AtDegradationFloor
4177            );
4178        }
4179
4180        #[test]
4181        fn controller_at_full_level_no_upgrade() {
4182            let mut ctrl = BudgetController::new(BudgetControllerConfig {
4183                eprocess: EProcessConfig {
4184                    warmup_frames: 0,
4185                    ..Default::default()
4186                },
4187                cooldown_frames: 0,
4188                ..Default::default()
4189            });
4190
4191            // Feed underload — already at Full, so no upgrade possible
4192            for _ in 0..50 {
4193                let d = ctrl.update(Duration::from_millis(1));
4194                assert_ne!(
4195                    d,
4196                    BudgetDecision::Upgrade,
4197                    "Full level should never upgrade"
4198                );
4199            }
4200        }
4201
4202        #[test]
4203        fn render_budget_full_degrade_cycle_with_controller() {
4204            let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
4205                BudgetControllerConfig {
4206                    eprocess: EProcessConfig {
4207                        warmup_frames: 0,
4208                        ..Default::default()
4209                    },
4210                    cooldown_frames: 0,
4211                    ..Default::default()
4212                },
4213            );
4214
4215            // Overload to degrade via controller
4216            for _ in 0..100 {
4217                budget.record_frame_time(Duration::from_millis(40));
4218                budget.next_frame();
4219            }
4220            let degraded = budget.degradation();
4221            assert!(
4222                degraded > DegradationLevel::Full,
4223                "Should degrade: {:?}",
4224                degraded
4225            );
4226
4227            // Recovery via controller
4228            for _ in 0..200 {
4229                budget.record_frame_time(Duration::from_millis(4));
4230                budget.next_frame();
4231            }
4232            let recovered = budget.degradation();
4233            assert!(
4234                recovered < degraded,
4235                "Should recover: {:?} -> {:?}",
4236                degraded,
4237                recovered
4238            );
4239        }
4240
4241        #[test]
4242        fn render_budget_phase_has_budget_exhausted() {
4243            let budget = RenderBudget::new(Duration::from_millis(1));
4244            std::thread::sleep(Duration::from_millis(10));
4245
4246            // All phases should report no budget
4247            assert!(!budget.phase_has_budget(Phase::Diff));
4248            assert!(!budget.phase_has_budget(Phase::Present));
4249            assert!(!budget.phase_has_budget(Phase::Render));
4250        }
4251
4252        #[test]
4253        fn render_budget_elapsed_increases() {
4254            let budget = RenderBudget::new(Duration::from_millis(1000));
4255            let e1 = budget.elapsed();
4256            std::thread::sleep(Duration::from_millis(5));
4257            let e2 = budget.elapsed();
4258            assert!(e2 > e1, "Elapsed should increase: {:?} vs {:?}", e1, e2);
4259        }
4260
4261        #[test]
4262        fn controller_pid_integral_accessor() {
4263            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
4264
4265            assert_eq!(ctrl.pid_integral(), 0.0);
4266
4267            // Feed overload to accumulate integral
4268            ctrl.update(Duration::from_millis(32)); // 2x target
4269            assert!(
4270                ctrl.pid_integral() > 0.0,
4271                "Integral should grow: {}",
4272                ctrl.pid_integral()
4273            );
4274        }
4275
4276        #[test]
4277        fn controller_e_value_accessor() {
4278            let ctrl = BudgetController::new(BudgetControllerConfig::default());
4279            assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
4280        }
4281    }
4282}