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 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        thread::sleep(Duration::from_millis(50));
1429
1430        // Should be around 0.5 now
1431        let later = budget.remaining_fraction();
1432        assert!(later < 0.6);
1433        assert!(later > 0.3);
1434    }
1435
1436    #[test]
1437    fn should_degrade_when_cost_exceeds_remaining() {
1438        // Use wider margins to avoid timing flakiness
1439        let budget = RenderBudget::new(Duration::from_millis(100));
1440
1441        // Wait until ~half budget is consumed (~50ms remaining)
1442        thread::sleep(Duration::from_millis(50));
1443
1444        // Should degrade for expensive operations (80ms > ~50ms remaining)
1445        assert!(budget.should_degrade(Duration::from_millis(80)));
1446        // Should not degrade for cheap operations (10ms < ~50ms remaining)
1447        assert!(!budget.should_degrade(Duration::from_millis(10)));
1448    }
1449
1450    #[test]
1451    fn degrade_advances_level() {
1452        let mut budget = RenderBudget::new(Duration::from_millis(16));
1453
1454        assert_eq!(budget.degradation(), DegradationLevel::Full);
1455
1456        budget.degrade();
1457        assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
1458
1459        budget.degrade();
1460        assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1461    }
1462
1463    #[test]
1464    fn exhausted_when_no_time_left() {
1465        let budget = RenderBudget::new(Duration::from_millis(5));
1466
1467        assert!(!budget.exhausted());
1468
1469        thread::sleep(Duration::from_millis(10));
1470
1471        assert!(budget.exhausted());
1472    }
1473
1474    #[test]
1475    fn exhausted_at_skip_frame() {
1476        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1477
1478        // Set to SkipFrame
1479        budget.set_degradation(DegradationLevel::SkipFrame);
1480
1481        // Should be exhausted even with time remaining
1482        assert!(budget.exhausted());
1483    }
1484
1485    #[test]
1486    fn should_upgrade_with_remaining_budget() {
1487        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1488
1489        // At Full, should not upgrade
1490        assert!(!budget.should_upgrade());
1491
1492        // Degrade and set cooldown frames
1493        budget.degrade();
1494        budget.frames_since_change = 5;
1495
1496        // With lots of budget remaining, should upgrade
1497        assert!(budget.should_upgrade());
1498    }
1499
1500    #[test]
1501    fn upgrade_improves_level() {
1502        let mut budget = RenderBudget::new(Duration::from_millis(16));
1503
1504        budget.set_degradation(DegradationLevel::Skeleton);
1505        assert_eq!(budget.degradation(), DegradationLevel::Skeleton);
1506
1507        budget.upgrade();
1508        assert_eq!(budget.degradation(), DegradationLevel::EssentialOnly);
1509
1510        budget.upgrade();
1511        assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1512    }
1513
1514    #[test]
1515    fn upgrade_downgrade_symmetric() {
1516        let mut budget = RenderBudget::new(Duration::from_millis(16));
1517
1518        // Degrade all the way
1519        while !budget.degradation().is_max() {
1520            budget.degrade();
1521        }
1522        assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
1523
1524        // Upgrade all the way
1525        while !budget.degradation().is_full() {
1526            budget.upgrade();
1527        }
1528        assert_eq!(budget.degradation(), DegradationLevel::Full);
1529    }
1530
1531    #[test]
1532    fn reset_preserves_degradation() {
1533        let mut budget = RenderBudget::new(Duration::from_millis(16));
1534
1535        budget.degrade();
1536        budget.degrade();
1537        let level = budget.degradation();
1538
1539        budget.reset();
1540
1541        assert_eq!(budget.degradation(), level);
1542        // Remaining should be close to full again
1543        assert!(budget.remaining_fraction() > 0.9);
1544    }
1545
1546    #[test]
1547    fn next_frame_upgrades_when_possible() {
1548        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1549
1550        // Degrade and simulate several frames
1551        budget.degrade();
1552        for _ in 0..5 {
1553            budget.reset();
1554        }
1555
1556        let before = budget.degradation();
1557        budget.next_frame();
1558
1559        // Should have upgraded
1560        assert!(budget.degradation() < before);
1561    }
1562
1563    #[test]
1564    fn next_frame_prefers_recorded_frame_time_for_upgrade() {
1565        let mut budget = RenderBudget::new(Duration::from_millis(16));
1566
1567        budget.degrade();
1568        for _ in 0..5 {
1569            budget.reset();
1570        }
1571
1572        // Record a fast frame, then wait long enough that start.elapsed()
1573        // would otherwise exceed the budget.
1574        budget.record_frame_time(Duration::from_millis(1));
1575        std::thread::sleep(Duration::from_millis(25));
1576
1577        let before = budget.degradation();
1578        budget.next_frame();
1579
1580        assert!(budget.degradation() < before);
1581    }
1582
1583    #[test]
1584    fn config_defaults() {
1585        let config = FrameBudgetConfig::default();
1586
1587        assert_eq!(config.total, Duration::from_millis(16));
1588        assert!(config.allow_frame_skip);
1589        assert_eq!(config.degradation_cooldown, 3);
1590        assert!((config.upgrade_threshold - 0.5).abs() < f32::EPSILON);
1591    }
1592
1593    #[test]
1594    fn config_with_total() {
1595        let config = FrameBudgetConfig::with_total(Duration::from_millis(33));
1596
1597        assert_eq!(config.total, Duration::from_millis(33));
1598        // Other defaults preserved
1599        assert!(config.allow_frame_skip);
1600    }
1601
1602    #[test]
1603    fn config_strict() {
1604        let config = FrameBudgetConfig::strict(Duration::from_millis(16));
1605
1606        assert!(!config.allow_frame_skip);
1607    }
1608
1609    #[test]
1610    fn config_relaxed() {
1611        let config = FrameBudgetConfig::relaxed();
1612
1613        assert_eq!(config.total, Duration::from_millis(33));
1614        assert_eq!(config.degradation_cooldown, 5);
1615    }
1616
1617    #[test]
1618    fn from_config() {
1619        let config = FrameBudgetConfig {
1620            total: Duration::from_millis(20),
1621            allow_frame_skip: false,
1622            ..Default::default()
1623        };
1624
1625        let budget = RenderBudget::from_config(&config);
1626
1627        assert_eq!(budget.total(), Duration::from_millis(20));
1628        assert!(!budget.exhausted()); // allow_frame_skip is false
1629
1630        // Set to SkipFrame - should NOT be exhausted since frame skip disabled
1631        let mut budget = RenderBudget::from_config(&config);
1632        budget.set_degradation(DegradationLevel::SkipFrame);
1633        assert!(!budget.exhausted());
1634    }
1635
1636    #[test]
1637    fn phase_budgets_default() {
1638        let budgets = PhaseBudgets::default();
1639
1640        assert_eq!(budgets.diff, Duration::from_millis(2));
1641        assert_eq!(budgets.present, Duration::from_millis(4));
1642        assert_eq!(budgets.render, Duration::from_millis(8));
1643    }
1644
1645    #[test]
1646    fn phase_has_budget() {
1647        let budget = RenderBudget::new(Duration::from_millis(100));
1648
1649        assert!(budget.phase_has_budget(Phase::Diff));
1650        assert!(budget.phase_has_budget(Phase::Present));
1651        assert!(budget.phase_has_budget(Phase::Render));
1652    }
1653
1654    #[test]
1655    fn phase_budget_respects_remaining() {
1656        let budget = RenderBudget::new(Duration::from_millis(100));
1657
1658        let diff_budget = budget.phase_budget(Phase::Diff);
1659        assert_eq!(diff_budget.total(), Duration::from_millis(2));
1660
1661        let present_budget = budget.phase_budget(Phase::Present);
1662        assert_eq!(present_budget.total(), Duration::from_millis(4));
1663    }
1664
1665    #[test]
1666    fn phase_as_str() {
1667        assert_eq!(Phase::Diff.as_str(), "diff");
1668        assert_eq!(Phase::Present.as_str(), "present");
1669        assert_eq!(Phase::Render.as_str(), "render");
1670    }
1671
1672    #[test]
1673    fn zero_budget_is_immediately_exhausted() {
1674        let budget = RenderBudget::new(Duration::ZERO);
1675        assert!(budget.exhausted());
1676        assert_eq!(budget.remaining_fraction(), 0.0);
1677    }
1678
1679    #[test]
1680    fn degradation_level_never_exceeds_skip_frame() {
1681        let mut level = DegradationLevel::Full;
1682
1683        for _ in 0..100 {
1684            level = level.next();
1685        }
1686
1687        assert_eq!(level, DegradationLevel::SkipFrame);
1688    }
1689
1690    #[test]
1691    fn budget_remaining_never_negative() {
1692        let budget = RenderBudget::new(Duration::from_millis(1));
1693
1694        // Wait well past the budget
1695        thread::sleep(Duration::from_millis(10));
1696
1697        // Should be zero, not negative
1698        assert_eq!(budget.remaining(), Duration::ZERO);
1699        assert_eq!(budget.remaining_fraction(), 0.0);
1700    }
1701
1702    #[test]
1703    fn infinite_budget_stays_at_full() {
1704        let mut budget = RenderBudget::new(Duration::from_secs(1000));
1705
1706        // With huge budget, should never need to degrade
1707        assert!(!budget.should_degrade(Duration::from_millis(100)));
1708        assert_eq!(budget.degradation(), DegradationLevel::Full);
1709
1710        // Next frame should not upgrade since already at full
1711        budget.next_frame();
1712        assert_eq!(budget.degradation(), DegradationLevel::Full);
1713    }
1714
1715    #[test]
1716    fn cooldown_prevents_immediate_upgrade() {
1717        let mut budget = RenderBudget::new(Duration::from_millis(1000));
1718        budget.cooldown = 3;
1719
1720        // Degrade
1721        budget.degrade();
1722        assert_eq!(budget.frames_since_change, 0);
1723
1724        // Should not upgrade immediately (cooldown not met)
1725        assert!(!budget.should_upgrade());
1726
1727        // Simulate frames
1728        budget.frames_since_change = 3;
1729
1730        // Now should be able to upgrade
1731        assert!(budget.should_upgrade());
1732    }
1733
1734    #[test]
1735    fn set_degradation_resets_cooldown() {
1736        let mut budget = RenderBudget::new(Duration::from_millis(16));
1737        budget.frames_since_change = 10;
1738
1739        budget.set_degradation(DegradationLevel::NoStyling);
1740
1741        assert_eq!(budget.frames_since_change, 0);
1742    }
1743
1744    #[test]
1745    fn set_degradation_same_level_preserves_cooldown() {
1746        let mut budget = RenderBudget::new(Duration::from_millis(16));
1747        budget.frames_since_change = 10;
1748
1749        // Set to same level
1750        budget.set_degradation(DegradationLevel::Full);
1751
1752        // Cooldown preserved since level didn't change
1753        assert_eq!(budget.frames_since_change, 10);
1754    }
1755
1756    // -----------------------------------------------------------------------
1757    // Budget Controller Tests (bd-4kq0.3.1)
1758    // -----------------------------------------------------------------------
1759
1760    mod controller_tests {
1761        use super::super::*;
1762
1763        fn make_controller() -> BudgetController {
1764            BudgetController::new(BudgetControllerConfig::default())
1765        }
1766
1767        fn make_controller_with_config(
1768            target_ms: u64,
1769            warmup: u32,
1770            cooldown: u32,
1771        ) -> BudgetController {
1772            BudgetController::new(BudgetControllerConfig {
1773                target: Duration::from_millis(target_ms),
1774                eprocess: EProcessConfig {
1775                    warmup_frames: warmup,
1776                    ..Default::default()
1777                },
1778                cooldown_frames: cooldown,
1779                ..Default::default()
1780            })
1781        }
1782
1783        // --- PID response tests ---
1784
1785        #[test]
1786        fn pid_step_input_yields_nonzero_output() {
1787            let mut state = PidState::default();
1788            let gains = PidGains::default();
1789
1790            // Step input: constant error of 1.0
1791            let u = state.update(1.0, &gains);
1792            // Kp*1.0 + Ki*1.0 + Kd*(1.0 - 0.0) = 0.5 + 0.05 + 0.2 = 0.75
1793            assert!(
1794                (u - 0.75).abs() < 1e-10,
1795                "First PID output should be 0.75, got {}",
1796                u
1797            );
1798        }
1799
1800        #[test]
1801        fn pid_zero_error_zero_output() {
1802            let mut state = PidState::default();
1803            let gains = PidGains::default();
1804
1805            let u = state.update(0.0, &gains);
1806            assert!(
1807                u.abs() < 1e-10,
1808                "Zero error should produce zero output, got {}",
1809                u
1810            );
1811        }
1812
1813        #[test]
1814        fn pid_integral_accumulates() {
1815            let mut state = PidState::default();
1816            let gains = PidGains::default();
1817
1818            // Feed constant error
1819            state.update(1.0, &gains);
1820            state.update(1.0, &gains);
1821            state.update(1.0, &gains);
1822
1823            assert!(
1824                state.integral > 2.5,
1825                "Integral should accumulate: {}",
1826                state.integral
1827            );
1828        }
1829
1830        #[test]
1831        fn pid_integral_anti_windup() {
1832            let mut state = PidState::default();
1833            let gains = PidGains {
1834                integral_max: 2.0,
1835                ..Default::default()
1836            };
1837
1838            // Feed many frames of error to saturate integral
1839            for _ in 0..100 {
1840                state.update(10.0, &gains);
1841            }
1842
1843            assert!(
1844                state.integral <= 2.0 + f64::EPSILON,
1845                "Integral should be clamped to max: {}",
1846                state.integral
1847            );
1848            assert!(
1849                state.integral >= -2.0 - f64::EPSILON,
1850                "Integral should be clamped to -max: {}",
1851                state.integral
1852            );
1853        }
1854
1855        #[test]
1856        fn pid_derivative_responds_to_change() {
1857            let mut state = PidState::default();
1858            let gains = PidGains::default();
1859
1860            // First frame: error=0
1861            let u1 = state.update(0.0, &gains);
1862            // Second frame: error=1.0 (step change)
1863            let u2 = state.update(1.0, &gains);
1864
1865            // u2 should include derivative component Kd*(1.0 - 0.0) = 0.2
1866            assert!(
1867                u2 > u1,
1868                "Step change should produce larger output: u1={}, u2={}",
1869                u1,
1870                u2
1871            );
1872        }
1873
1874        #[test]
1875        fn pid_settling_after_step() {
1876            let mut state = PidState::default();
1877            let gains = PidGains::default();
1878
1879            // Apply step error then zero error (simulate settling)
1880            state.update(1.0, &gains);
1881            state.update(1.0, &gains);
1882            state.update(1.0, &gains);
1883
1884            // Now remove the error
1885            let mut outputs = Vec::new();
1886            for _ in 0..20 {
1887                outputs.push(state.update(0.0, &gains));
1888            }
1889
1890            // Output should trend toward zero (settling)
1891            let last = *outputs.last().unwrap();
1892            assert!(
1893                last.abs() < 0.5,
1894                "PID should settle toward zero: last={}",
1895                last
1896            );
1897        }
1898
1899        #[test]
1900        fn pid_reset_clears_state() {
1901            let mut state = PidState::default();
1902            let gains = PidGains::default();
1903
1904            state.update(5.0, &gains);
1905            state.update(5.0, &gains);
1906            assert!(state.integral.abs() > 0.0);
1907
1908            state.reset();
1909            assert_eq!(state.integral, 0.0);
1910            assert_eq!(state.prev_error, 0.0);
1911        }
1912
1913        // --- E-process tests ---
1914
1915        #[test]
1916        fn eprocess_starts_at_one() {
1917            let state = EProcessState::default();
1918            assert!(
1919                (state.e_value - 1.0).abs() < f64::EPSILON,
1920                "E-process should start at 1.0"
1921            );
1922        }
1923
1924        #[test]
1925        fn eprocess_grows_under_overload() {
1926            let mut state = EProcessState::default();
1927            let config = EProcessConfig {
1928                warmup_frames: 0,
1929                ..Default::default()
1930            };
1931
1932            // Feed sustained overload (30ms vs 16ms target)
1933            for _ in 0..20 {
1934                state.update(30.0, 16.0, &config);
1935            }
1936
1937            assert!(
1938                state.e_value > 1.0,
1939                "E-value should grow under overload: {}",
1940                state.e_value
1941            );
1942        }
1943
1944        #[test]
1945        fn eprocess_shrinks_under_underload() {
1946            let mut state = EProcessState::default();
1947            let config = EProcessConfig {
1948                warmup_frames: 0,
1949                ..Default::default()
1950            };
1951
1952            // Feed fast frames (8ms vs 16ms target)
1953            for _ in 0..20 {
1954                state.update(8.0, 16.0, &config);
1955            }
1956
1957            assert!(
1958                state.e_value < 1.0,
1959                "E-value should shrink under underload: {}",
1960                state.e_value
1961            );
1962        }
1963
1964        #[test]
1965        fn eprocess_gate_blocks_during_warmup() {
1966            let mut state = EProcessState::default();
1967            let config = EProcessConfig {
1968                warmup_frames: 10,
1969                ..Default::default()
1970            };
1971
1972            // Feed overload during warmup
1973            for _ in 0..5 {
1974                state.update(50.0, 16.0, &config);
1975            }
1976
1977            assert!(
1978                !state.should_degrade(&config),
1979                "E-process should not permit degradation during warmup"
1980            );
1981        }
1982
1983        #[test]
1984        fn eprocess_gate_allows_after_warmup() {
1985            let mut state = EProcessState::default();
1986            let config = EProcessConfig {
1987                warmup_frames: 5,
1988                alpha: 0.05,
1989                ..Default::default()
1990            };
1991
1992            // Feed severe overload past warmup
1993            for _ in 0..50 {
1994                state.update(80.0, 16.0, &config);
1995            }
1996
1997            assert!(
1998                state.should_degrade(&config),
1999                "E-process should permit degradation after sustained overload: E={}",
2000                state.e_value
2001            );
2002        }
2003
2004        #[test]
2005        fn eprocess_recovery_after_overload() {
2006            let mut state = EProcessState::default();
2007            let config = EProcessConfig {
2008                warmup_frames: 0,
2009                ..Default::default()
2010            };
2011
2012            // Overload phase
2013            for _ in 0..30 {
2014                state.update(40.0, 16.0, &config);
2015            }
2016            let peak = state.e_value;
2017
2018            // Recovery phase (fast frames)
2019            for _ in 0..100 {
2020                state.update(8.0, 16.0, &config);
2021            }
2022
2023            assert!(
2024                state.e_value < peak,
2025                "E-value should decrease after recovery: peak={}, now={}",
2026                peak,
2027                state.e_value
2028            );
2029        }
2030
2031        #[test]
2032        fn eprocess_sigma_floor_prevents_instability() {
2033            let mut state = EProcessState::default();
2034            let config = EProcessConfig {
2035                sigma_floor_ms: 1.0,
2036                warmup_frames: 0,
2037                ..Default::default()
2038            };
2039
2040            // Feed identical frames (zero variance)
2041            for _ in 0..20 {
2042                state.update(16.0, 16.0, &config);
2043            }
2044
2045            // sigma_ema should not be below floor
2046            assert!(
2047                state.sigma_ema >= 0.0,
2048                "Sigma should be non-negative: {}",
2049                state.sigma_ema
2050            );
2051            // E-value should remain finite
2052            assert!(
2053                state.e_value.is_finite(),
2054                "E-value should be finite: {}",
2055                state.e_value
2056            );
2057        }
2058
2059        #[test]
2060        fn eprocess_reset_returns_to_initial() {
2061            let mut state = EProcessState::default();
2062            let config = EProcessConfig::default();
2063
2064            state.update(50.0, 16.0, &config);
2065            state.update(50.0, 16.0, &config);
2066
2067            state.reset();
2068            assert!((state.e_value - 1.0).abs() < f64::EPSILON);
2069            assert_eq!(state.frames_observed, 0);
2070        }
2071
2072        // --- Controller integration tests ---
2073
2074        #[test]
2075        fn controller_holds_under_normal_load() {
2076            let mut ctrl = make_controller_with_config(16, 0, 0);
2077
2078            // Feed on-target frames
2079            for _ in 0..20 {
2080                let decision = ctrl.update(Duration::from_millis(16));
2081                assert_eq!(
2082                    decision,
2083                    BudgetDecision::Hold,
2084                    "On-target frames should hold"
2085                );
2086            }
2087            assert_eq!(ctrl.level(), DegradationLevel::Full);
2088        }
2089
2090        #[test]
2091        fn controller_degrades_under_sustained_overload() {
2092            let mut ctrl = make_controller_with_config(16, 0, 0);
2093
2094            let mut degraded = false;
2095            // Feed severe overload
2096            for _ in 0..50 {
2097                let decision = ctrl.update(Duration::from_millis(40));
2098                if decision == BudgetDecision::Degrade {
2099                    degraded = true;
2100                }
2101            }
2102
2103            assert!(
2104                degraded,
2105                "Controller should degrade under sustained overload"
2106            );
2107            assert!(
2108                ctrl.level() > DegradationLevel::Full,
2109                "Level should be degraded: {:?}",
2110                ctrl.level()
2111            );
2112        }
2113
2114        #[test]
2115        fn controller_upgrades_after_recovery() {
2116            let mut ctrl = make_controller_with_config(16, 0, 0);
2117
2118            // Overload to degrade
2119            for _ in 0..50 {
2120                ctrl.update(Duration::from_millis(40));
2121            }
2122            let degraded_level = ctrl.level();
2123            assert!(degraded_level > DegradationLevel::Full);
2124
2125            // Recovery: fast frames
2126            let mut upgraded = false;
2127            for _ in 0..200 {
2128                let decision = ctrl.update(Duration::from_millis(4));
2129                if decision == BudgetDecision::Upgrade {
2130                    upgraded = true;
2131                }
2132            }
2133
2134            assert!(upgraded, "Controller should upgrade after recovery");
2135            assert!(
2136                ctrl.level() < degraded_level,
2137                "Level should improve: before={:?}, after={:?}",
2138                degraded_level,
2139                ctrl.level()
2140            );
2141        }
2142
2143        #[test]
2144        fn controller_cooldown_prevents_oscillation() {
2145            let mut ctrl = make_controller_with_config(16, 0, 5);
2146
2147            // Trigger degradation
2148            for _ in 0..50 {
2149                ctrl.update(Duration::from_millis(40));
2150            }
2151
2152            // Immediately try fast frames
2153            let mut decisions_during_cooldown = Vec::new();
2154            for _ in 0..4 {
2155                decisions_during_cooldown.push(ctrl.update(Duration::from_millis(4)));
2156            }
2157
2158            // During cooldown (frames 0-4), should all be Hold
2159            assert!(
2160                decisions_during_cooldown
2161                    .iter()
2162                    .all(|d| *d == BudgetDecision::Hold),
2163                "Cooldown should prevent changes: {:?}",
2164                decisions_during_cooldown
2165            );
2166        }
2167
2168        #[test]
2169        fn controller_no_oscillation_under_constant_load() {
2170            let mut ctrl = make_controller_with_config(16, 0, 3);
2171
2172            // Moderate overload (20ms vs 16ms)
2173            let mut transitions = 0u32;
2174            let mut prev_level = ctrl.level();
2175            for _ in 0..100 {
2176                ctrl.update(Duration::from_millis(20));
2177                if ctrl.level() != prev_level {
2178                    transitions += 1;
2179                    prev_level = ctrl.level();
2180                }
2181            }
2182
2183            // Under constant load, transitions should be limited
2184            // (progressive degradation, not oscillation)
2185            assert!(
2186                transitions < 10,
2187                "Too many transitions under constant load: {}",
2188                transitions
2189            );
2190        }
2191
2192        #[test]
2193        fn controller_reset_restores_full_quality() {
2194            let mut ctrl = make_controller();
2195
2196            // Degrade
2197            for _ in 0..50 {
2198                ctrl.update(Duration::from_millis(40));
2199            }
2200
2201            ctrl.reset();
2202
2203            assert_eq!(ctrl.level(), DegradationLevel::Full);
2204            assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
2205            assert_eq!(ctrl.pid_integral(), 0.0);
2206        }
2207
2208        #[test]
2209        fn controller_transient_spike_does_not_degrade() {
2210            let mut ctrl = make_controller_with_config(16, 5, 3);
2211
2212            // Normal frames to build history
2213            for _ in 0..20 {
2214                ctrl.update(Duration::from_millis(16));
2215            }
2216
2217            // Single spike
2218            ctrl.update(Duration::from_millis(100));
2219
2220            // Back to normal
2221            for _ in 0..5 {
2222                ctrl.update(Duration::from_millis(16));
2223            }
2224
2225            // Should still be at full quality (spike was transient)
2226            assert_eq!(
2227                ctrl.level(),
2228                DegradationLevel::Full,
2229                "Single spike should not cause degradation"
2230            );
2231        }
2232
2233        #[test]
2234        fn controller_never_exceeds_skip_frame() {
2235            let mut ctrl = make_controller_with_config(16, 0, 0);
2236
2237            // Extreme overload
2238            for _ in 0..500 {
2239                ctrl.update(Duration::from_millis(200));
2240            }
2241
2242            assert!(
2243                ctrl.level() <= DegradationLevel::SkipFrame,
2244                "Level should not exceed SkipFrame: {:?}",
2245                ctrl.level()
2246            );
2247        }
2248
2249        #[test]
2250        fn controller_never_goes_below_full() {
2251            let mut ctrl = make_controller_with_config(16, 0, 0);
2252
2253            // Extreme underload
2254            for _ in 0..200 {
2255                ctrl.update(Duration::from_millis(1));
2256            }
2257
2258            assert_eq!(
2259                ctrl.level(),
2260                DegradationLevel::Full,
2261                "Level should not go below Full"
2262            );
2263        }
2264
2265        // --- Config tests ---
2266
2267        #[test]
2268        fn pid_gains_default_valid() {
2269            let gains = PidGains::default();
2270            assert!(gains.kp > 0.0);
2271            assert!(gains.ki > 0.0);
2272            assert!(gains.kd > 0.0);
2273            assert!(gains.integral_max > 0.0);
2274        }
2275
2276        #[test]
2277        fn eprocess_config_default_valid() {
2278            let config = EProcessConfig::default();
2279            assert!(config.lambda > 0.0);
2280            assert!(config.alpha > 0.0 && config.alpha < 1.0);
2281            assert!(config.beta > 0.0 && config.beta < 1.0);
2282            assert!(config.sigma_floor_ms > 0.0);
2283        }
2284
2285        #[test]
2286        fn controller_config_default_valid() {
2287            let config = BudgetControllerConfig::default();
2288            assert!(config.degrade_threshold > 0.0);
2289            assert!(config.upgrade_threshold > 0.0);
2290            assert!(config.target > Duration::ZERO);
2291        }
2292
2293        #[test]
2294        fn budget_decision_equality() {
2295            assert_eq!(BudgetDecision::Hold, BudgetDecision::Hold);
2296            assert_ne!(BudgetDecision::Hold, BudgetDecision::Degrade);
2297            assert_ne!(BudgetDecision::Degrade, BudgetDecision::Upgrade);
2298        }
2299    }
2300
2301    // -----------------------------------------------------------------------
2302    // Budget Controller Integration + Telemetry Tests (bd-4kq0.3.2)
2303    // -----------------------------------------------------------------------
2304
2305    mod integration_tests {
2306        use super::super::*;
2307
2308        #[test]
2309        fn render_budget_without_controller_returns_no_telemetry() {
2310            let budget = RenderBudget::new(Duration::from_millis(16));
2311            assert!(budget.telemetry().is_none());
2312            assert!(budget.controller().is_none());
2313        }
2314
2315        #[test]
2316        fn render_budget_with_controller_returns_telemetry() {
2317            let budget = RenderBudget::new(Duration::from_millis(16))
2318                .with_controller(BudgetControllerConfig::default());
2319            assert!(budget.controller().is_some());
2320
2321            let telem = budget.telemetry().unwrap();
2322            assert_eq!(telem.level, DegradationLevel::Full);
2323            assert_eq!(telem.last_decision, BudgetDecision::Hold);
2324            assert_eq!(telem.frames_observed, 0);
2325            assert!(telem.in_warmup);
2326        }
2327
2328        #[test]
2329        fn telemetry_fields_update_after_next_frame() {
2330            let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2331                BudgetControllerConfig {
2332                    eprocess: EProcessConfig {
2333                        warmup_frames: 0,
2334                        ..Default::default()
2335                    },
2336                    cooldown_frames: 0,
2337                    ..Default::default()
2338                },
2339            );
2340
2341            // Simulate a few frames
2342            for _ in 0..5 {
2343                budget.next_frame();
2344            }
2345
2346            let telem = budget.telemetry().unwrap();
2347            assert_eq!(telem.frames_observed, 5);
2348            assert!(!telem.in_warmup);
2349            // PID output should be non-positive (frames are fast, under budget)
2350            // but the exact value depends on timing, so just check it's finite
2351            assert!(telem.pid_output.is_finite());
2352            assert!(telem.e_value.is_finite());
2353        }
2354
2355        #[test]
2356        fn controller_next_frame_degrades_under_simulated_overload() {
2357            // We can't easily simulate slow frames in unit tests (thread::sleep
2358            // would be flaky), so we test the controller integration by verifying
2359            // the decision path works: attach controller, manually check that
2360            // the controller's level is reflected in the budget's degradation.
2361            let config = BudgetControllerConfig {
2362                target: Duration::from_millis(16),
2363                eprocess: EProcessConfig {
2364                    warmup_frames: 0,
2365                    ..Default::default()
2366                },
2367                cooldown_frames: 0,
2368                ..Default::default()
2369            };
2370            let mut ctrl = BudgetController::new(config);
2371
2372            // Feed severe overload to the controller directly
2373            for _ in 0..50 {
2374                ctrl.update(Duration::from_millis(40));
2375            }
2376
2377            // Controller should have degraded
2378            assert!(
2379                ctrl.level() > DegradationLevel::Full,
2380                "Controller should degrade: {:?}",
2381                ctrl.level()
2382            );
2383
2384            // Telemetry should reflect the degradation
2385            let telem = ctrl.telemetry();
2386            assert!(telem.level > DegradationLevel::Full);
2387            assert!(
2388                telem.pid_output > 0.0,
2389                "PID output should be positive under overload"
2390            );
2391            assert!(telem.e_value > 1.0, "E-value should grow under overload");
2392        }
2393
2394        #[test]
2395        fn next_frame_delegates_to_controller_when_attached() {
2396            // With a controller, next_frame should not use the simple
2397            // threshold-based upgrade path
2398            let mut budget = RenderBudget::new(Duration::from_millis(1000))
2399                .with_controller(BudgetControllerConfig::default());
2400
2401            // Degrade manually
2402            budget.degrade();
2403            assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
2404
2405            // In legacy mode, next_frame would upgrade immediately (lots of budget).
2406            // With controller, it should hold because the controller hasn't seen
2407            // enough underload evidence yet.
2408            budget.next_frame();
2409
2410            // The controller may or may not upgrade depending on the single frame
2411            // measurement, but the key assertion is that the code path works.
2412            // With a fresh controller, the fast frame should eventually allow upgrade.
2413            // Just verify it doesn't panic and telemetry is populated.
2414            let telem = budget.telemetry().unwrap();
2415            assert_eq!(telem.frames_observed, 1);
2416        }
2417
2418        #[test]
2419        fn telemetry_is_copy_and_no_alloc() {
2420            let budget = RenderBudget::new(Duration::from_millis(16))
2421                .with_controller(BudgetControllerConfig::default());
2422
2423            let telem = budget.telemetry().unwrap();
2424            // BudgetTelemetry is Copy — verify by copying
2425            let telem2 = telem;
2426            assert_eq!(telem.level, telem2.level);
2427            assert_eq!(telem.e_value, telem2.e_value);
2428        }
2429
2430        #[test]
2431        fn telemetry_warmup_flag_transitions() {
2432            let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2433                BudgetControllerConfig {
2434                    eprocess: EProcessConfig {
2435                        warmup_frames: 3,
2436                        ..Default::default()
2437                    },
2438                    ..Default::default()
2439                },
2440            );
2441
2442            // During warmup
2443            budget.next_frame();
2444            budget.next_frame();
2445            let telem = budget.telemetry().unwrap();
2446            assert!(telem.in_warmup, "Should be in warmup at frame 2");
2447
2448            // After warmup
2449            budget.next_frame();
2450            let telem = budget.telemetry().unwrap();
2451            assert!(!telem.in_warmup, "Should exit warmup at frame 3");
2452        }
2453
2454        #[test]
2455        fn phase_sub_budget_does_not_carry_controller() {
2456            let budget = RenderBudget::new(Duration::from_millis(100))
2457                .with_controller(BudgetControllerConfig::default());
2458
2459            let phase = budget.phase_budget(Phase::Render);
2460            assert!(
2461                phase.controller().is_none(),
2462                "Phase sub-budgets should not carry the controller"
2463            );
2464        }
2465
2466        #[test]
2467        fn controller_telemetry_tracks_frames_since_change() {
2468            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2469                eprocess: EProcessConfig {
2470                    warmup_frames: 0,
2471                    ..Default::default()
2472                },
2473                cooldown_frames: 0,
2474                ..Default::default()
2475            });
2476
2477            // On-target frames: frames_since_change should increase
2478            for i in 1..=5 {
2479                ctrl.update(Duration::from_millis(16));
2480                let telem = ctrl.telemetry();
2481                assert_eq!(
2482                    telem.frames_since_change, i,
2483                    "frames_since_change should be {} after {} frames",
2484                    i, i
2485                );
2486            }
2487        }
2488
2489        #[test]
2490        fn telemetry_last_decision_reflects_controller_decision() {
2491            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2492                eprocess: EProcessConfig {
2493                    warmup_frames: 0,
2494                    ..Default::default()
2495                },
2496                cooldown_frames: 0,
2497                ..Default::default()
2498            });
2499
2500            // On-target: should hold
2501            ctrl.update(Duration::from_millis(16));
2502            assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Hold);
2503
2504            // Feed enough overload to trigger degrade
2505            let mut saw_degrade = false;
2506            for _ in 0..50 {
2507                let d = ctrl.update(Duration::from_millis(50));
2508                if d == BudgetDecision::Degrade {
2509                    saw_degrade = true;
2510                    assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Degrade);
2511                    break;
2512                }
2513            }
2514            assert!(saw_degrade, "Should have seen a Degrade decision");
2515        }
2516
2517        #[test]
2518        fn perf_overhead_controller_update_is_fast() {
2519            // Verify the controller update is a lightweight arithmetic operation.
2520            // We run 10_000 iterations and check they complete quickly.
2521            // This is a smoke test, not a precise benchmark (that's bd-4kq0.3.3).
2522            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2523
2524            let start = Instant::now();
2525            for _ in 0..10_000 {
2526                ctrl.update(Duration::from_millis(16));
2527            }
2528            let elapsed = start.elapsed();
2529
2530            // 10k iterations should complete in well under 10ms on any modern CPU.
2531            // At 16ms target, 2% overhead = 0.32ms per frame, so 10k frames
2532            // budget = 3.2 seconds worth of overhead budget. We check <50ms total.
2533            assert!(
2534                elapsed < Duration::from_millis(50),
2535                "10k controller updates took {:?}, expected <50ms",
2536                elapsed
2537            );
2538        }
2539
2540        #[test]
2541        fn perf_overhead_telemetry_snapshot_is_fast() {
2542            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2543            ctrl.update(Duration::from_millis(16));
2544
2545            let start = Instant::now();
2546            for _ in 0..10_000 {
2547                let _telem = ctrl.telemetry();
2548            }
2549            let elapsed = start.elapsed();
2550
2551            assert!(
2552                elapsed < Duration::from_millis(10),
2553                "10k telemetry snapshots took {:?}, expected <10ms",
2554                elapsed
2555            );
2556        }
2557    }
2558
2559    // -----------------------------------------------------------------------
2560    // Budget Stability + E2E Replay Tests (bd-4kq0.3.3)
2561    // -----------------------------------------------------------------------
2562
2563    mod stability_tests {
2564        use super::super::*;
2565
2566        #[derive(Debug, Clone)]
2567        struct CampaignFrameLog {
2568            frame_idx: u64,
2569            phase: &'static str,
2570            frame_time_us: u64,
2571            telemetry: BudgetTelemetry,
2572        }
2573
2574        /// Helper: create a controller with minimal warmup/cooldown for testing.
2575        fn fast_controller(target_ms: u64) -> BudgetController {
2576            BudgetController::new(BudgetControllerConfig {
2577                target: Duration::from_millis(target_ms),
2578                eprocess: EProcessConfig {
2579                    warmup_frames: 0,
2580                    ..Default::default()
2581                },
2582                cooldown_frames: 0,
2583                ..Default::default()
2584            })
2585        }
2586
2587        /// Helper: run a frame time trace through the controller and collect
2588        /// JSONL-style telemetry records (as structured tuples).
2589        /// Returns `(frame_index, frame_time_us, telemetry)` for each frame.
2590        fn run_trace(
2591            ctrl: &mut BudgetController,
2592            trace: &[Duration],
2593        ) -> Vec<(u64, u64, BudgetTelemetry)> {
2594            trace
2595                .iter()
2596                .enumerate()
2597                .map(|(i, &ft)| {
2598                    ctrl.update(ft);
2599                    let telem = ctrl.telemetry();
2600                    (i as u64, ft.as_micros() as u64, telem)
2601                })
2602                .collect()
2603        }
2604
2605        /// Run a labeled phase campaign and collect deterministic replay logs.
2606        fn run_campaign(
2607            ctrl: &mut BudgetController,
2608            phases: &[(&'static str, usize, Duration)],
2609        ) -> Vec<CampaignFrameLog> {
2610            let mut logs = Vec::new();
2611            let mut frame_idx: u64 = 0;
2612            for &(phase, count, frame_time) in phases {
2613                for _ in 0..count {
2614                    ctrl.update(frame_time);
2615                    logs.push(CampaignFrameLog {
2616                        frame_idx,
2617                        phase,
2618                        frame_time_us: frame_time.as_micros() as u64,
2619                        telemetry: ctrl.telemetry(),
2620                    });
2621                    frame_idx = frame_idx.saturating_add(1);
2622                }
2623            }
2624            logs
2625        }
2626
2627        /// Count level transitions in a trace log.
2628        fn count_transitions(log: &[(u64, u64, BudgetTelemetry)]) -> u32 {
2629            let mut transitions = 0u32;
2630            for pair in log.windows(2) {
2631                if pair[0].2.level != pair[1].2.level {
2632                    transitions += 1;
2633                }
2634            }
2635            transitions
2636        }
2637
2638        // --- e2e_burst_logs ---
2639
2640        #[test]
2641        fn e2e_burst_logs_no_oscillation() {
2642            // Simulate bursty output: alternating bursts of slow frames
2643            // and calm periods. Verify no oscillation (bounded transitions).
2644            let mut ctrl = fast_controller(16);
2645
2646            let mut trace = Vec::new();
2647            for _cycle in 0..5 {
2648                // Burst: 10 frames at 40ms
2649                for _ in 0..10 {
2650                    trace.push(Duration::from_millis(40));
2651                }
2652                // Calm: 20 frames at 16ms
2653                for _ in 0..20 {
2654                    trace.push(Duration::from_millis(16));
2655                }
2656            }
2657
2658            let log = run_trace(&mut ctrl, &trace);
2659
2660            // Count level transitions. Under bursty load, transitions should
2661            // be bounded — no rapid oscillation. With 5 cycles of 30 frames
2662            // each (150 total), we expect at most ~15 transitions (degrade
2663            // during each burst, upgrade during each calm).
2664            let transitions = count_transitions(&log);
2665            assert!(
2666                transitions < 20,
2667                "Too many transitions under bursty load: {} (expected <20)",
2668                transitions
2669            );
2670
2671            // Verify all telemetry fields are populated
2672            for (frame, ft_us, telem) in &log {
2673                assert!(
2674                    telem.pid_output.is_finite(),
2675                    "frame {}: NaN pid_output",
2676                    frame
2677                );
2678                assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2679                assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2680                assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2681                assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2682                assert!(*ft_us > 0, "frame {}: zero frame time", frame);
2683            }
2684        }
2685
2686        #[test]
2687        fn e2e_burst_recovers_after_moderate_overload() {
2688            // Moderate bursts (30ms vs 16ms target) followed by calm periods.
2689            // The controller may degrade during bursts, but should recover
2690            // during calm periods — final state should not be SkipFrame.
2691            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2692                target: Duration::from_millis(16),
2693                eprocess: EProcessConfig {
2694                    warmup_frames: 5,
2695                    ..Default::default()
2696                },
2697                cooldown_frames: 3,
2698                ..Default::default()
2699            });
2700
2701            let mut trace = Vec::new();
2702            for _cycle in 0..3 {
2703                // Moderate burst
2704                for _ in 0..15 {
2705                    trace.push(Duration::from_millis(30));
2706                }
2707                // Extended calm to allow recovery
2708                for _ in 0..50 {
2709                    trace.push(Duration::from_millis(10));
2710                }
2711            }
2712
2713            let log = run_trace(&mut ctrl, &trace);
2714
2715            // After each calm period, level should have recovered below Skeleton.
2716            // Check at the end of each calm phase (frames 64, 129, 194).
2717            for cycle in 0..3 {
2718                let calm_end = (cycle + 1) * 65 - 1;
2719                if calm_end < log.len() {
2720                    assert!(
2721                        log[calm_end].2.level < DegradationLevel::SkipFrame,
2722                        "cycle {}: should recover after calm period, got {:?} at frame {}",
2723                        cycle,
2724                        log[calm_end].2.level,
2725                        calm_end
2726                    );
2727                }
2728            }
2729
2730            // Final level should be better than Skeleton
2731            let final_level = log.last().unwrap().2.level;
2732            assert!(
2733                final_level < DegradationLevel::Skeleton,
2734                "Final level should recover below Skeleton: {:?}",
2735                final_level
2736            );
2737        }
2738
2739        // --- e2e_idle_to_burst ---
2740
2741        #[test]
2742        fn e2e_idle_to_burst_recovery() {
2743            // Start idle (well under budget), then sudden burst, then back to idle.
2744            // Verify: fast recovery without over-degrading.
2745            let mut ctrl = fast_controller(16);
2746
2747            let mut trace = Vec::new();
2748            // Phase 1: idle (8ms frames)
2749            for _ in 0..50 {
2750                trace.push(Duration::from_millis(8));
2751            }
2752            // Phase 2: sudden burst (50ms frames)
2753            for _ in 0..20 {
2754                trace.push(Duration::from_millis(50));
2755            }
2756            // Phase 3: recovery (8ms frames)
2757            for _ in 0..100 {
2758                trace.push(Duration::from_millis(8));
2759            }
2760
2761            let log = run_trace(&mut ctrl, &trace);
2762
2763            // After idle phase (frame 49), should still be Full
2764            assert_eq!(
2765                log[49].2.level,
2766                DegradationLevel::Full,
2767                "Should be Full during idle phase"
2768            );
2769
2770            // During burst, should degrade
2771            let max_during_burst = log[50..70].iter().map(|(_, _, t)| t.level).max().unwrap();
2772            assert!(
2773                max_during_burst > DegradationLevel::Full,
2774                "Should degrade during burst"
2775            );
2776
2777            // After recovery (last 20 frames), should have recovered toward Full
2778            let final_level = log.last().unwrap().2.level;
2779            assert!(
2780                final_level < max_during_burst,
2781                "Should recover after burst: final={:?}, max_during_burst={:?}",
2782                final_level,
2783                max_during_burst
2784            );
2785        }
2786
2787        #[test]
2788        fn e2e_idle_to_burst_no_over_degrade() {
2789            // A brief burst (5 frames) should not cause more than 1-2 levels
2790            // of degradation, even with zero warmup.
2791            let mut ctrl = fast_controller(16);
2792
2793            // Idle
2794            for _ in 0..30 {
2795                ctrl.update(Duration::from_millis(8));
2796            }
2797
2798            // Brief burst (only 5 frames)
2799            for _ in 0..5 {
2800                ctrl.update(Duration::from_millis(40));
2801            }
2802
2803            // Check degradation is modest
2804            let level = ctrl.level();
2805            assert!(
2806                level <= DegradationLevel::NoStyling,
2807                "Brief burst should not over-degrade: {:?}",
2808                level
2809            );
2810        }
2811
2812        #[test]
2813        fn e2e_overload_campaign_burst_sustained_recovery_with_replay_logs() {
2814            // bd-2vr05.15.4.5:
2815            // 1) burst overload
2816            // 2) sustained overload
2817            // 3) recovery/underload
2818            //
2819            // This test validates full-range degradation, so remove the floor.
2820            let phases: [(&str, usize, Duration); 3] = [
2821                ("burst_overload", 24, Duration::from_millis(28)),
2822                ("sustained_overload", 80, Duration::from_millis(52)),
2823                ("recovery_underload", 140, Duration::from_millis(8)),
2824            ];
2825
2826            let mut ctrl = BudgetController::new(BudgetControllerConfig {
2827                target: Duration::from_millis(16),
2828                eprocess: EProcessConfig {
2829                    warmup_frames: 0,
2830                    ..Default::default()
2831                },
2832                cooldown_frames: 0,
2833                degradation_floor: DegradationLevel::SkipFrame,
2834                ..Default::default()
2835            });
2836            let logs = run_campaign(&mut ctrl, &phases);
2837            assert!(!logs.is_empty(), "campaign logs must be non-empty");
2838
2839            let mut burst_degrades = 0u32;
2840            let mut sustained_degrades = 0u32;
2841            let mut sustained_degraded_frames = 0u32;
2842            let mut recovery_upgrades = 0u32;
2843            let mut max_level = DegradationLevel::Full;
2844
2845            for log in &logs {
2846                let telem = &log.telemetry;
2847                if telem.level > max_level {
2848                    max_level = telem.level;
2849                }
2850                if log.phase == "burst_overload" && telem.last_decision == BudgetDecision::Degrade {
2851                    burst_degrades = burst_degrades.saturating_add(1);
2852                }
2853                if log.phase == "sustained_overload"
2854                    && telem.last_decision == BudgetDecision::Degrade
2855                {
2856                    sustained_degrades = sustained_degrades.saturating_add(1);
2857                }
2858                if log.phase == "sustained_overload" && telem.level > DegradationLevel::Full {
2859                    sustained_degraded_frames = sustained_degraded_frames.saturating_add(1);
2860                }
2861                if log.phase == "recovery_underload"
2862                    && telem.last_decision == BudgetDecision::Upgrade
2863                {
2864                    recovery_upgrades = recovery_upgrades.saturating_add(1);
2865                }
2866
2867                // Semantic integrity invariants (no corruption under degradation)
2868                assert!(
2869                    telem.level <= DegradationLevel::SkipFrame,
2870                    "frame {}: invalid degradation level {:?}",
2871                    log.frame_idx,
2872                    telem.level
2873                );
2874                assert!(
2875                    telem.e_value.is_finite() && telem.e_value > 0.0,
2876                    "frame {}: invalid e_value {}",
2877                    log.frame_idx,
2878                    telem.e_value
2879                );
2880                assert!(
2881                    telem.pid_output.is_finite(),
2882                    "frame {}: invalid pid_output {}",
2883                    log.frame_idx,
2884                    telem.pid_output
2885                );
2886            }
2887
2888            // Adjacent level changes must be stepwise (no jump corruption).
2889            for pair in logs.windows(2) {
2890                let prev = pair[0].telemetry.level.level();
2891                let curr = pair[1].telemetry.level.level();
2892                let delta = (curr as i16 - prev as i16).unsigned_abs();
2893                assert!(
2894                    delta <= 1,
2895                    "frame {}->{} level jump {}: {:?} -> {:?}",
2896                    pair[0].frame_idx,
2897                    pair[1].frame_idx,
2898                    delta,
2899                    pair[0].telemetry.level,
2900                    pair[1].telemetry.level
2901                );
2902            }
2903
2904            assert!(
2905                burst_degrades > 0,
2906                "burst phase should trigger degradation decisions"
2907            );
2908            assert!(
2909                sustained_degrades > 0 || sustained_degraded_frames > 0,
2910                "sustained overload phase should maintain degraded operation"
2911            );
2912            assert!(
2913                max_level >= DegradationLevel::Skeleton,
2914                "sustained overload should reach deep degradation (got {:?})",
2915                max_level
2916            );
2917            assert!(
2918                recovery_upgrades > 0,
2919                "recovery phase should trigger upgrade decisions"
2920            );
2921
2922            let final_level = logs
2923                .last()
2924                .map(|entry| entry.telemetry.level)
2925                .unwrap_or(DegradationLevel::SkipFrame);
2926            assert!(
2927                final_level < max_level,
2928                "final level should recover below peak degradation: final={:?} peak={:?}",
2929                final_level,
2930                max_level
2931            );
2932
2933            // Deterministic replay contract: same scenario -> same decisions/telemetry.
2934            let mut ctrl_replay = BudgetController::new(BudgetControllerConfig {
2935                target: Duration::from_millis(16),
2936                eprocess: EProcessConfig {
2937                    warmup_frames: 0,
2938                    ..Default::default()
2939                },
2940                cooldown_frames: 0,
2941                degradation_floor: DegradationLevel::SkipFrame,
2942                ..Default::default()
2943            });
2944            let replay_logs = run_campaign(&mut ctrl_replay, &phases);
2945            assert_eq!(
2946                logs.len(),
2947                replay_logs.len(),
2948                "log length mismatch in replay"
2949            );
2950            for (lhs, rhs) in logs.iter().zip(replay_logs.iter()) {
2951                assert_eq!(lhs.frame_idx, rhs.frame_idx);
2952                assert_eq!(lhs.phase, rhs.phase);
2953                assert_eq!(lhs.frame_time_us, rhs.frame_time_us);
2954                assert_eq!(lhs.telemetry.schema_version, rhs.telemetry.schema_version);
2955                assert_eq!(lhs.telemetry.level, rhs.telemetry.level);
2956                assert_eq!(lhs.telemetry.last_decision, rhs.telemetry.last_decision);
2957                assert_eq!(
2958                    lhs.telemetry.decision_reason, rhs.telemetry.decision_reason,
2959                    "decision_reason mismatch at frame {}",
2960                    lhs.frame_idx
2961                );
2962                assert_eq!(
2963                    lhs.telemetry.transition_seq, rhs.telemetry.transition_seq,
2964                    "transition_seq mismatch at frame {}",
2965                    lhs.frame_idx
2966                );
2967                assert_eq!(
2968                    lhs.telemetry.transition_correlation_id,
2969                    rhs.telemetry.transition_correlation_id,
2970                    "transition_correlation_id mismatch at frame {}",
2971                    lhs.frame_idx
2972                );
2973                assert!(
2974                    (lhs.telemetry.pid_output - rhs.telemetry.pid_output).abs() < 1e-12,
2975                    "pid_output mismatch at frame {}",
2976                    lhs.frame_idx
2977                );
2978                assert!(
2979                    (lhs.telemetry.e_value - rhs.telemetry.e_value).abs() < 1e-12,
2980                    "e_value mismatch at frame {}",
2981                    lhs.frame_idx
2982                );
2983            }
2984
2985            // Replay-grade diagnostics for controller postmortems.
2986            for entry in &logs {
2987                let t = &entry.telemetry;
2988                eprintln!(
2989                    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":{}}}"#,
2990                    t.schema_version,
2991                    entry.frame_idx,
2992                    entry.phase,
2993                    entry.frame_time_us,
2994                    t.last_decision.as_str(),
2995                    t.decision_reason.as_str(),
2996                    t.transition_seq,
2997                    t.transition_correlation_id,
2998                    t.level.as_str(),
2999                    t.pid_output,
3000                    t.pid_p,
3001                    t.pid_i,
3002                    t.pid_d,
3003                    t.e_value,
3004                    t.frame_time_ms,
3005                    t.target_ms,
3006                    t.pid_gate_threshold,
3007                    t.pid_gate_margin,
3008                    t.evidence_threshold,
3009                    t.evidence_margin,
3010                    t.frames_observed,
3011                    t.frames_since_change
3012                );
3013            }
3014            eprintln!(
3015                r#"{{"event":"control_campaign_summary","schema_version":{},"scenario":"bd-2vr05.15.4.5","frames":{},"burst_degrades":{},"sustained_degrades":{},"recovery_upgrades":{},"peak_level":"{}","final_level":"{}"}}"#,
3016                BUDGET_TELEMETRY_SCHEMA_VERSION,
3017                logs.len(),
3018                burst_degrades,
3019                sustained_degrades,
3020                recovery_upgrades,
3021                max_level.as_str(),
3022                final_level.as_str()
3023            );
3024        }
3025
3026        // --- property_random_load ---
3027
3028        #[test]
3029        fn property_random_load_hysteresis_bounds() {
3030            // Verify: degradation changes are bounded by hysteresis constraints.
3031            // Specifically, level can only change by 1 step per decision.
3032            let mut ctrl = fast_controller(16);
3033
3034            // Generate a deterministic pseudo-random load trace using a simple
3035            // linear congruential generator (no std::rand dependency).
3036            let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_BABE;
3037            let mut trace = Vec::new();
3038            for _ in 0..1000 {
3039                // LCG: next = (a * state + c) mod m
3040                rng_state = rng_state
3041                    .wrapping_mul(6_364_136_223_846_793_005)
3042                    .wrapping_add(1_442_695_040_888_963_407);
3043                // Map to frame time: 4ms..80ms
3044                let frame_ms = 4 + ((rng_state >> 33) % 77);
3045                trace.push(Duration::from_millis(frame_ms));
3046            }
3047
3048            let log = run_trace(&mut ctrl, &trace);
3049
3050            // Property 1: Level only changes by at most 1 step per frame
3051            for pair in log.windows(2) {
3052                let prev = pair[0].2.level.level();
3053                let curr = pair[1].2.level.level();
3054                let delta = (curr as i16 - prev as i16).unsigned_abs();
3055                assert!(
3056                    delta <= 1,
3057                    "Level jumped {} steps at frame {}: {:?} -> {:?}",
3058                    delta,
3059                    pair[1].0,
3060                    pair[0].2.level,
3061                    pair[1].2.level
3062                );
3063            }
3064
3065            // Property 2: Level never exceeds valid range
3066            for (frame, _, telem) in &log {
3067                assert!(
3068                    telem.level <= DegradationLevel::SkipFrame,
3069                    "frame {}: level out of range: {:?}",
3070                    frame,
3071                    telem.level
3072                );
3073            }
3074
3075            // Property 3: All numeric fields are finite
3076            for (frame, _, telem) in &log {
3077                assert!(
3078                    telem.pid_output.is_finite(),
3079                    "frame {}: NaN pid_output",
3080                    frame
3081                );
3082                assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
3083                assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
3084                assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
3085                assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
3086                assert!(
3087                    telem.e_value > 0.0,
3088                    "frame {}: e_value not positive: {}",
3089                    frame,
3090                    telem.e_value
3091                );
3092            }
3093        }
3094
3095        #[test]
3096        fn property_random_load_bounded_transitions() {
3097            // Under random load, transitions should be bounded and not exceed
3098            // a reasonable rate (no rapid oscillation).
3099            let mut ctrl = BudgetController::new(BudgetControllerConfig {
3100                target: Duration::from_millis(16),
3101                eprocess: EProcessConfig {
3102                    warmup_frames: 5,
3103                    ..Default::default()
3104                },
3105                cooldown_frames: 3,
3106                ..Default::default()
3107            });
3108
3109            // Deterministic pseudo-random trace
3110            let mut rng_state: u64 = 0x1234_5678_9ABC_DEF0;
3111            let mut trace = Vec::new();
3112            for _ in 0..500 {
3113                rng_state = rng_state
3114                    .wrapping_mul(6_364_136_223_846_793_005)
3115                    .wrapping_add(1_442_695_040_888_963_407);
3116                let frame_ms = 8 + ((rng_state >> 33) % 40);
3117                trace.push(Duration::from_millis(frame_ms));
3118            }
3119
3120            let log = run_trace(&mut ctrl, &trace);
3121            let transitions = count_transitions(&log);
3122
3123            // With cooldown=3 and 500 frames, max theoretical transitions = 500/4 = 125.
3124            // In practice with hysteresis + e-process gating, much less.
3125            assert!(
3126                transitions < 80,
3127                "Too many transitions under random load: {} (expected <80 with cooldown=3)",
3128                transitions
3129            );
3130        }
3131
3132        #[test]
3133        fn property_deterministic_replay() {
3134            // Same trace should produce identical telemetry every time.
3135            let trace: Vec<Duration> = (0..100)
3136                .map(|i| Duration::from_millis(10 + (i * 7 % 30)))
3137                .collect();
3138
3139            let mut ctrl1 = fast_controller(16);
3140            let log1 = run_trace(&mut ctrl1, &trace);
3141
3142            let mut ctrl2 = fast_controller(16);
3143            let log2 = run_trace(&mut ctrl2, &trace);
3144
3145            for (r1, r2) in log1.iter().zip(log2.iter()) {
3146                assert_eq!(r1.0, r2.0, "frame index mismatch");
3147                assert_eq!(r1.1, r2.1, "frame time mismatch");
3148                assert_eq!(r1.2.schema_version, r2.2.schema_version);
3149                assert_eq!(r1.2.level, r2.2.level, "level mismatch at frame {}", r1.0);
3150                assert_eq!(
3151                    r1.2.last_decision, r2.2.last_decision,
3152                    "decision mismatch at frame {}",
3153                    r1.0
3154                );
3155                assert_eq!(
3156                    r1.2.decision_reason, r2.2.decision_reason,
3157                    "decision_reason mismatch at frame {}",
3158                    r1.0
3159                );
3160                assert_eq!(
3161                    r1.2.transition_seq, r2.2.transition_seq,
3162                    "transition_seq mismatch at frame {}",
3163                    r1.0
3164                );
3165                assert_eq!(
3166                    r1.2.transition_correlation_id, r2.2.transition_correlation_id,
3167                    "transition_correlation_id mismatch at frame {}",
3168                    r1.0
3169                );
3170                assert!(
3171                    (r1.2.pid_output - r2.2.pid_output).abs() < 1e-10,
3172                    "pid_output mismatch at frame {}: {} vs {}",
3173                    r1.0,
3174                    r1.2.pid_output,
3175                    r2.2.pid_output
3176                );
3177                assert!(
3178                    (r1.2.e_value - r2.2.e_value).abs() < 1e-10,
3179                    "e_value mismatch at frame {}: {} vs {}",
3180                    r1.0,
3181                    r1.2.e_value,
3182                    r2.2.e_value
3183                );
3184            }
3185        }
3186
3187        // --- JSONL schema validation ---
3188
3189        #[test]
3190        fn telemetry_jsonl_fields_complete() {
3191            // Verify all JSONL schema fields are accessible from BudgetTelemetry.
3192            let mut ctrl = fast_controller(16);
3193            ctrl.update(Duration::from_millis(20));
3194
3195            let telem = ctrl.telemetry();
3196
3197            // All schema fields present and accessible:
3198            let _schema_version: u16 = telem.schema_version;
3199            let _degradation: &str = telem.level.as_str();
3200            let _pid_p: f64 = telem.pid_p;
3201            let _pid_i: f64 = telem.pid_i;
3202            let _pid_d: f64 = telem.pid_d;
3203            let _e_value: f64 = telem.e_value;
3204            let _decision: &str = telem.last_decision.as_str();
3205            let _reason: &str = telem.decision_reason.as_str();
3206            let _transition_seq: u64 = telem.transition_seq;
3207            let _transition_correlation_id: u64 = telem.transition_correlation_id;
3208            let _frame_time_ms: f64 = telem.frame_time_ms;
3209            let _target_ms: f64 = telem.target_ms;
3210            let _pid_gate_threshold: f64 = telem.pid_gate_threshold;
3211            let _pid_gate_margin: f64 = telem.pid_gate_margin;
3212            let _evidence_threshold: f64 = telem.evidence_threshold;
3213            let _evidence_margin: f64 = telem.evidence_margin;
3214            let _frames: u32 = telem.frames_observed;
3215
3216            // Verify decision string mapping
3217            assert_eq!(BudgetDecision::Hold.as_str(), "stay");
3218            assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
3219            assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
3220            assert_eq!(
3221                BUDGET_TELEMETRY_SCHEMA_VERSION, telem.schema_version,
3222                "schema version mismatch"
3223            );
3224        }
3225
3226        #[test]
3227        fn telemetry_transition_records_correlation_reason_and_evidence() {
3228            let mut ctrl = fast_controller(16);
3229
3230            // Drive toward a degrade transition.
3231            let mut degrade_telem = None;
3232            for _ in 0..64 {
3233                ctrl.update(Duration::from_millis(48));
3234                let telem = ctrl.telemetry();
3235                if telem.last_decision == BudgetDecision::Degrade {
3236                    degrade_telem = Some(telem);
3237                    break;
3238                }
3239            }
3240            let degrade_telem =
3241                degrade_telem.expect("expected degrade transition with correlation metadata");
3242            assert_eq!(
3243                degrade_telem.decision_reason,
3244                BudgetDecisionReason::OverloadEvidencePassed
3245            );
3246            assert!(
3247                degrade_telem.transition_seq > 0,
3248                "transition_seq should increment on transitions"
3249            );
3250            assert!(
3251                degrade_telem.transition_correlation_id > 0,
3252                "transition correlation id should be populated on transitions"
3253            );
3254            assert!(
3255                degrade_telem.pid_gate_margin > 0.0,
3256                "degrade transition should have positive PID gate margin"
3257            );
3258            assert!(
3259                degrade_telem.evidence_margin > 0.0,
3260                "degrade transition should have positive evidence margin"
3261            );
3262
3263            // Drive toward an upgrade transition.
3264            let mut upgrade_telem = None;
3265            for _ in 0..160 {
3266                ctrl.update(Duration::from_millis(4));
3267                let telem = ctrl.telemetry();
3268                if telem.last_decision == BudgetDecision::Upgrade {
3269                    upgrade_telem = Some(telem);
3270                    break;
3271                }
3272            }
3273            let upgrade_telem =
3274                upgrade_telem.expect("expected upgrade transition with correlation metadata");
3275            assert_eq!(
3276                upgrade_telem.decision_reason,
3277                BudgetDecisionReason::UnderloadEvidencePassed
3278            );
3279            assert!(
3280                upgrade_telem.transition_seq >= degrade_telem.transition_seq,
3281                "transition sequence should be monotonic"
3282            );
3283            assert!(
3284                upgrade_telem.transition_correlation_id >= degrade_telem.transition_correlation_id,
3285                "transition correlation id should be monotonic"
3286            );
3287            assert!(
3288                upgrade_telem.pid_gate_margin > 0.0,
3289                "upgrade transition should have positive PID gate margin"
3290            );
3291            assert!(
3292                upgrade_telem.evidence_margin > 0.0,
3293                "upgrade transition should have positive evidence margin"
3294            );
3295        }
3296
3297        #[test]
3298        fn telemetry_pid_components_sum_to_output() {
3299            // Verify P + I + D == total PID output.
3300            let mut ctrl = fast_controller(16);
3301
3302            for ms in [10u64, 16, 20, 30, 8, 50] {
3303                ctrl.update(Duration::from_millis(ms));
3304                let telem = ctrl.telemetry();
3305                let sum = telem.pid_p + telem.pid_i + telem.pid_d;
3306                assert!(
3307                    (sum - telem.pid_output).abs() < 1e-10,
3308                    "P+I+D != output at {}ms: {} + {} + {} = {} != {}",
3309                    ms,
3310                    telem.pid_p,
3311                    telem.pid_i,
3312                    telem.pid_d,
3313                    sum,
3314                    telem.pid_output
3315                );
3316            }
3317        }
3318    }
3319
3320    // -----------------------------------------------------------------------
3321    // Edge-case tests (bd-1x69n)
3322    // -----------------------------------------------------------------------
3323
3324    mod edge_case_tests {
3325        use super::super::*;
3326
3327        // --- PID edge cases ---
3328
3329        #[test]
3330        fn pid_negative_integral_windup() {
3331            // Sustained negative error should clamp integral at -integral_max
3332            let mut state = PidState::default();
3333            let gains = PidGains {
3334                integral_max: 3.0,
3335                ..Default::default()
3336            };
3337
3338            for _ in 0..200 {
3339                state.update(-10.0, &gains);
3340            }
3341
3342            assert!(
3343                state.integral >= -3.0 - f64::EPSILON,
3344                "Negative integral should be clamped to -max: {}",
3345                state.integral
3346            );
3347            assert!(
3348                state.integral <= -3.0 + f64::EPSILON,
3349                "Negative integral should saturate at -max: {}",
3350                state.integral
3351            );
3352        }
3353
3354        #[test]
3355        fn pid_zero_gains_zero_output() {
3356            let mut state = PidState::default();
3357            let gains = PidGains {
3358                kp: 0.0,
3359                ki: 0.0,
3360                kd: 0.0,
3361                integral_max: 5.0,
3362            };
3363
3364            let u = state.update(42.0, &gains);
3365            assert!(
3366                u.abs() < 1e-10,
3367                "Zero gains should yield zero output: {}",
3368                u
3369            );
3370        }
3371
3372        #[test]
3373        fn pid_large_error_stays_finite() {
3374            let mut state = PidState::default();
3375            let gains = PidGains::default();
3376
3377            // Very large error
3378            let u = state.update(1e12, &gains);
3379            assert!(
3380                u.is_finite(),
3381                "PID output should be finite for large error: {}",
3382                u
3383            );
3384
3385            // Integral should be clamped
3386            assert!(
3387                state.integral <= gains.integral_max + f64::EPSILON,
3388                "Integral should be clamped: {}",
3389                state.integral
3390            );
3391        }
3392
3393        #[test]
3394        fn pid_alternating_error_derivative_responds() {
3395            let mut state = PidState::default();
3396            let gains = PidGains::default();
3397
3398            // Alternating +1/-1 error
3399            let u1 = state.update(1.0, &gains);
3400            let u2 = state.update(-1.0, &gains);
3401
3402            // Derivative component for second call: Kd * (-1.0 - 1.0) = 0.2 * -2.0 = -0.4
3403            // So u2 should have negative derivative contribution
3404            assert!(
3405                u2 < u1,
3406                "Alternating error should reduce output: u1={}, u2={}",
3407                u1,
3408                u2
3409            );
3410        }
3411
3412        #[test]
3413        fn pid_telemetry_terms_match_after_update() {
3414            let mut state = PidState::default();
3415            let gains = PidGains::default();
3416
3417            state.update(2.0, &gains);
3418
3419            // P = Kp * error = 0.5 * 2.0 = 1.0
3420            assert!(
3421                (state.last_p - 1.0).abs() < 1e-10,
3422                "P term: {}",
3423                state.last_p
3424            );
3425            // I = Ki * integral = 0.05 * 2.0 = 0.1
3426            assert!(
3427                (state.last_i - 0.1).abs() < 1e-10,
3428                "I term: {}",
3429                state.last_i
3430            );
3431            // D = Kd * (error - prev_error) = 0.2 * (2.0 - 0.0) = 0.4
3432            assert!(
3433                (state.last_d - 0.4).abs() < 1e-10,
3434                "D term: {}",
3435                state.last_d
3436            );
3437        }
3438
3439        #[test]
3440        fn pid_integral_clamping_symmetric() {
3441            let mut state = PidState::default();
3442            let gains = PidGains {
3443                integral_max: 1.0,
3444                ..Default::default()
3445            };
3446
3447            // Positive saturation
3448            for _ in 0..50 {
3449                state.update(100.0, &gains);
3450            }
3451            let pos_integral = state.integral;
3452
3453            state.reset();
3454
3455            // Negative saturation
3456            for _ in 0..50 {
3457                state.update(-100.0, &gains);
3458            }
3459            let neg_integral = state.integral;
3460
3461            assert!(
3462                (pos_integral + neg_integral).abs() < f64::EPSILON,
3463                "Clamping should be symmetric: pos={}, neg={}",
3464                pos_integral,
3465                neg_integral
3466            );
3467        }
3468
3469        // --- E-process edge cases ---
3470
3471        #[test]
3472        fn eprocess_first_frame_initializes_mean() {
3473            let mut state = EProcessState::default();
3474            let config = EProcessConfig::default();
3475
3476            state.update(25.0, 16.0, &config);
3477
3478            assert!(
3479                (state.mean_ema - 25.0).abs() < f64::EPSILON,
3480                "First frame should set mean_ema directly: {}",
3481                state.mean_ema
3482            );
3483            assert!(
3484                (state.sigma_ema - config.sigma_floor_ms).abs() < f64::EPSILON,
3485                "First frame should set sigma_ema to floor: {}",
3486                state.sigma_ema
3487            );
3488            assert_eq!(state.frames_observed, 1);
3489        }
3490
3491        #[test]
3492        fn eprocess_e_value_clamped_at_upper_bound() {
3493            let mut state = EProcessState::default();
3494            let config = EProcessConfig {
3495                lambda: 2.0, // High sensitivity to force rapid growth
3496                warmup_frames: 0,
3497                sigma_floor_ms: 0.001, // Tiny floor to amplify residuals
3498                ..Default::default()
3499            };
3500
3501            // Extreme overload to push e_value toward upper clamp
3502            for _ in 0..1000 {
3503                state.update(1e6, 16.0, &config);
3504            }
3505
3506            assert!(
3507                state.e_value <= 1e10,
3508                "E-value should be clamped at 1e10: {}",
3509                state.e_value
3510            );
3511        }
3512
3513        #[test]
3514        fn eprocess_e_value_clamped_at_lower_bound() {
3515            let mut state = EProcessState::default();
3516            let config = EProcessConfig {
3517                lambda: 2.0,
3518                warmup_frames: 0,
3519                sigma_floor_ms: 0.001,
3520                ..Default::default()
3521            };
3522
3523            // Extreme underload to push e_value toward lower clamp
3524            for _ in 0..1000 {
3525                state.update(0.001, 1e6, &config);
3526            }
3527
3528            assert!(
3529                state.e_value >= 1e-10,
3530                "E-value should be clamped at 1e-10: {}",
3531                state.e_value
3532            );
3533        }
3534
3535        #[test]
3536        fn eprocess_should_upgrade_during_warmup() {
3537            let state = EProcessState::default();
3538            let config = EProcessConfig {
3539                warmup_frames: 10,
3540                ..Default::default()
3541            };
3542
3543            // During warmup, should_upgrade returns true to allow PID-driven upgrades
3544            assert!(
3545                state.should_upgrade(&config),
3546                "should_upgrade should return true during warmup"
3547            );
3548        }
3549
3550        #[test]
3551        fn eprocess_frames_observed_saturates() {
3552            let mut state = EProcessState {
3553                frames_observed: u32::MAX,
3554                ..EProcessState::default()
3555            };
3556            let config = EProcessConfig::default();
3557
3558            // Should not panic or wrap around
3559            state.update(16.0, 16.0, &config);
3560            assert_eq!(
3561                state.frames_observed,
3562                u32::MAX,
3563                "frames_observed should saturate at u32::MAX"
3564            );
3565        }
3566
3567        #[test]
3568        fn eprocess_sigma_ema_decay_boundary_zero() {
3569            let mut state = EProcessState::default();
3570            let config = EProcessConfig {
3571                sigma_ema_decay: 0.0,
3572                warmup_frames: 0,
3573                ..Default::default()
3574            };
3575
3576            // With decay=0, each update fully replaces the EMA
3577            state.update(20.0, 16.0, &config);
3578            state.update(30.0, 16.0, &config);
3579
3580            // mean_ema should be exactly the latest value
3581            assert!(
3582                (state.mean_ema - 30.0).abs() < f64::EPSILON,
3583                "decay=0 should fully replace mean_ema: {}",
3584                state.mean_ema
3585            );
3586        }
3587
3588        #[test]
3589        fn eprocess_sigma_ema_decay_boundary_one() {
3590            let mut state = EProcessState::default();
3591            let config = EProcessConfig {
3592                sigma_ema_decay: 1.0,
3593                warmup_frames: 0,
3594                ..Default::default()
3595            };
3596
3597            // With decay=1, EMA never changes from initial
3598            state.update(20.0, 16.0, &config);
3599            let first_mean = state.mean_ema;
3600            state.update(100.0, 16.0, &config);
3601
3602            assert!(
3603                (state.mean_ema - first_mean).abs() < f64::EPSILON,
3604                "decay=1 should lock mean_ema at first value: got {}, expected {}",
3605                state.mean_ema,
3606                first_mean
3607            );
3608        }
3609
3610        #[test]
3611        fn eprocess_zero_target_no_panic() {
3612            let mut state = EProcessState::default();
3613            let config = EProcessConfig {
3614                warmup_frames: 0,
3615                ..Default::default()
3616            };
3617
3618            // Zero target — residual computation divides by sigma (floored), not target
3619            let e = state.update(16.0, 0.0, &config);
3620            assert!(
3621                e.is_finite(),
3622                "E-value should be finite with zero target: {}",
3623                e
3624            );
3625        }
3626
3627        // --- DegradationLevel edge cases ---
3628
3629        #[test]
3630        fn degradation_level_default_is_full() {
3631            assert_eq!(DegradationLevel::default(), DegradationLevel::Full);
3632        }
3633
3634        #[test]
3635        fn degradation_level_hash_unique() {
3636            use std::collections::HashSet;
3637            let levels = [
3638                DegradationLevel::Full,
3639                DegradationLevel::SimpleBorders,
3640                DegradationLevel::NoStyling,
3641                DegradationLevel::EssentialOnly,
3642                DegradationLevel::Skeleton,
3643                DegradationLevel::SkipFrame,
3644            ];
3645            let set: HashSet<DegradationLevel> = levels.iter().copied().collect();
3646            assert_eq!(set.len(), 6, "All levels should hash uniquely");
3647        }
3648
3649        #[test]
3650        fn degradation_level_widget_queries_full() {
3651            let l = DegradationLevel::Full;
3652            assert!(l.use_unicode_borders());
3653            assert!(l.apply_styling());
3654            assert!(l.render_decorative());
3655            assert!(l.render_content());
3656        }
3657
3658        #[test]
3659        fn degradation_level_widget_queries_simple_borders() {
3660            let l = DegradationLevel::SimpleBorders;
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_no_styling() {
3669            let l = DegradationLevel::NoStyling;
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_essential_only() {
3678            let l = DegradationLevel::EssentialOnly;
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_skeleton() {
3687            let l = DegradationLevel::Skeleton;
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_skip_frame() {
3696            let l = DegradationLevel::SkipFrame;
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_partial_ord_consistent() {
3705            // PartialOrd should agree with Ord for all pairs
3706            let levels = [
3707                DegradationLevel::Full,
3708                DegradationLevel::SimpleBorders,
3709                DegradationLevel::NoStyling,
3710                DegradationLevel::EssentialOnly,
3711                DegradationLevel::Skeleton,
3712                DegradationLevel::SkipFrame,
3713            ];
3714            for (i, a) in levels.iter().enumerate() {
3715                for (j, b) in levels.iter().enumerate() {
3716                    let po = a.partial_cmp(b);
3717                    let o = a.cmp(b);
3718                    assert_eq!(po, Some(o), "PartialOrd != Ord for {:?} vs {:?}", a, b);
3719                    if i < j {
3720                        assert!(*a < *b, "{:?} should be < {:?}", a, b);
3721                    }
3722                }
3723            }
3724        }
3725
3726        #[test]
3727        fn degradation_level_clone_eq() {
3728            let a = DegradationLevel::NoStyling;
3729            let b = a;
3730            assert_eq!(a, b);
3731        }
3732
3733        #[test]
3734        fn degradation_level_debug() {
3735            let s = format!("{:?}", DegradationLevel::EssentialOnly);
3736            assert!(s.contains("EssentialOnly"), "Debug output: {}", s);
3737        }
3738
3739        // --- BudgetController accessor edge cases ---
3740
3741        #[test]
3742        fn controller_eprocess_sigma_ms_uses_floor() {
3743            let ctrl = BudgetController::new(BudgetControllerConfig {
3744                eprocess: EProcessConfig {
3745                    sigma_floor_ms: 2.5,
3746                    ..Default::default()
3747                },
3748                ..Default::default()
3749            });
3750
3751            // Before any updates, sigma_ema is 0.0, so should return floor
3752            assert!(
3753                (ctrl.eprocess_sigma_ms() - 2.5).abs() < f64::EPSILON,
3754                "Should return sigma_floor_ms when sigma_ema < floor: {}",
3755                ctrl.eprocess_sigma_ms()
3756            );
3757        }
3758
3759        #[test]
3760        fn controller_config_accessor() {
3761            let config = BudgetControllerConfig {
3762                degrade_threshold: 0.42,
3763                ..Default::default()
3764            };
3765            let ctrl = BudgetController::new(config.clone());
3766
3767            assert_eq!(ctrl.config().degrade_threshold, 0.42);
3768            assert_eq!(ctrl.config().target, Duration::from_millis(16));
3769        }
3770
3771        #[test]
3772        fn controller_frames_observed_accessor() {
3773            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
3774
3775            assert_eq!(ctrl.frames_observed(), 0);
3776
3777            ctrl.update(Duration::from_millis(16));
3778            assert_eq!(ctrl.frames_observed(), 1);
3779
3780            ctrl.update(Duration::from_millis(16));
3781            assert_eq!(ctrl.frames_observed(), 2);
3782        }
3783
3784        // --- RenderBudget edge cases ---
3785
3786        #[test]
3787        fn render_budget_record_frame_time_used_by_next_frame() {
3788            let mut budget = RenderBudget::new(Duration::from_millis(1000));
3789            budget.degrade();
3790
3791            // Simulate many frames to pass cooldown
3792            for _ in 0..10 {
3793                budget.reset();
3794            }
3795
3796            // Record a very fast frame time
3797            budget.record_frame_time(Duration::from_millis(1));
3798            // Sleep past the budget so start.elapsed() would be large
3799            std::thread::sleep(Duration::from_millis(15));
3800
3801            let before = budget.degradation();
3802            budget.next_frame();
3803
3804            // The recorded frame time (1ms) should trigger upgrade
3805            // since remaining_fraction_for_elapsed(1ms) > upgrade_threshold
3806            assert!(
3807                budget.degradation() < before,
3808                "Recorded frame time should enable upgrade: before={:?}, after={:?}",
3809                before,
3810                budget.degradation()
3811            );
3812        }
3813
3814        #[test]
3815        fn render_budget_phase_budget_clamped_by_remaining() {
3816            // Create a budget that has very little remaining
3817            let budget = RenderBudget::new(Duration::from_millis(1));
3818            std::thread::sleep(Duration::from_millis(5));
3819
3820            // Phase budget should be clamped to remaining (0ms)
3821            let phase = budget.phase_budget(Phase::Render);
3822            assert!(
3823                phase.total() <= Duration::from_millis(1),
3824                "Phase budget should be clamped by remaining: {:?}",
3825                phase.total()
3826            );
3827        }
3828
3829        #[test]
3830        fn render_budget_exhausted_skipframe_with_no_frame_skip() {
3831            let mut budget = RenderBudget::new(Duration::from_millis(1000));
3832            budget.allow_frame_skip = false;
3833            budget.set_degradation(DegradationLevel::SkipFrame);
3834
3835            // With allow_frame_skip = false, SkipFrame should NOT cause exhaustion
3836            // (only time-based exhaustion matters)
3837            assert!(
3838                !budget.exhausted(),
3839                "SkipFrame should not exhaust when frame skip disabled"
3840            );
3841        }
3842
3843        #[test]
3844        fn render_budget_remaining_fraction_zero_total() {
3845            let budget = RenderBudget::new(Duration::ZERO);
3846            assert_eq!(budget.remaining_fraction(), 0.0);
3847        }
3848
3849        #[test]
3850        fn render_budget_total_accessor() {
3851            let budget = RenderBudget::new(Duration::from_millis(42));
3852            assert_eq!(budget.total(), Duration::from_millis(42));
3853        }
3854
3855        #[test]
3856        fn render_budget_phase_budgets_accessor() {
3857            let budget = RenderBudget::new(Duration::from_millis(16));
3858            let pb = budget.phase_budgets();
3859            assert_eq!(pb.diff, Duration::from_millis(2));
3860            assert_eq!(pb.present, Duration::from_millis(4));
3861            assert_eq!(pb.render, Duration::from_millis(8));
3862        }
3863
3864        #[test]
3865        fn render_budget_set_degradation_no_op_preserves_cooldown() {
3866            let mut budget = RenderBudget::new(Duration::from_millis(16));
3867            budget.set_degradation(DegradationLevel::NoStyling);
3868            budget.frames_since_change = 7;
3869
3870            // Setting to same level is a no-op
3871            budget.set_degradation(DegradationLevel::NoStyling);
3872            assert_eq!(budget.frames_since_change, 7);
3873
3874            // Setting to different level resets cooldown
3875            budget.set_degradation(DegradationLevel::Skeleton);
3876            assert_eq!(budget.frames_since_change, 0);
3877        }
3878
3879        #[test]
3880        fn render_budget_should_upgrade_false_at_full() {
3881            let budget = RenderBudget::new(Duration::from_millis(1000));
3882            assert!(!budget.should_upgrade(), "Full level should never upgrade");
3883        }
3884
3885        #[test]
3886        fn render_budget_should_upgrade_false_during_cooldown() {
3887            let mut budget = RenderBudget::new(Duration::from_millis(1000));
3888            budget.degrade();
3889            // frames_since_change is 0, cooldown is 3
3890            assert!(
3891                !budget.should_upgrade(),
3892                "Should not upgrade during cooldown"
3893            );
3894        }
3895
3896        #[test]
3897        fn render_budget_degrade_at_max_stays_at_max() {
3898            let mut budget = RenderBudget::new(Duration::from_millis(16));
3899            budget.set_degradation(DegradationLevel::SkipFrame);
3900            budget.degrade();
3901            assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
3902        }
3903
3904        #[test]
3905        fn render_budget_upgrade_at_full_stays_at_full() {
3906            let mut budget = RenderBudget::new(Duration::from_millis(16));
3907            budget.upgrade();
3908            assert_eq!(budget.degradation(), DegradationLevel::Full);
3909        }
3910
3911        // --- Config edge cases ---
3912
3913        #[test]
3914        fn frame_budget_config_partial_eq() {
3915            let a = FrameBudgetConfig::default();
3916            let b = FrameBudgetConfig::default();
3917            assert_eq!(a, b);
3918
3919            let c = FrameBudgetConfig::strict(Duration::from_millis(16));
3920            assert_ne!(a, c, "Different configs should not be equal");
3921        }
3922
3923        #[test]
3924        fn phase_budgets_eq_and_copy() {
3925            let a = PhaseBudgets::default();
3926            let b = a; // Copy
3927            assert_eq!(a, b);
3928
3929            let c = PhaseBudgets {
3930                diff: Duration::from_millis(1),
3931                ..Default::default()
3932            };
3933            assert_ne!(a, c);
3934        }
3935
3936        #[test]
3937        fn budget_controller_config_partial_eq() {
3938            let a = BudgetControllerConfig::default();
3939            let b = BudgetControllerConfig::default();
3940            assert_eq!(a, b);
3941        }
3942
3943        #[test]
3944        fn pid_gains_partial_eq() {
3945            let a = PidGains::default();
3946            let b = PidGains::default();
3947            assert_eq!(a, b);
3948        }
3949
3950        #[test]
3951        fn eprocess_config_partial_eq() {
3952            let a = EProcessConfig::default();
3953            let b = EProcessConfig::default();
3954            assert_eq!(a, b);
3955        }
3956
3957        // --- BudgetDecision edge cases ---
3958
3959        #[test]
3960        fn budget_decision_debug_format() {
3961            assert!(format!("{:?}", BudgetDecision::Hold).contains("Hold"));
3962            assert!(format!("{:?}", BudgetDecision::Degrade).contains("Degrade"));
3963            assert!(format!("{:?}", BudgetDecision::Upgrade).contains("Upgrade"));
3964        }
3965
3966        #[test]
3967        fn budget_decision_clone_copy() {
3968            let d = BudgetDecision::Degrade;
3969            let d2 = d;
3970            assert_eq!(d, d2);
3971        }
3972
3973        #[test]
3974        fn budget_decision_as_str_coverage() {
3975            assert_eq!(BudgetDecision::Hold.as_str(), "stay");
3976            assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
3977            assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
3978        }
3979
3980        #[test]
3981        fn budget_decision_reason_debug_and_as_str() {
3982            assert!(
3983                format!("{:?}", BudgetDecisionReason::CooldownActive).contains("CooldownActive")
3984            );
3985            assert_eq!(
3986                BudgetDecisionReason::CooldownActive.as_str(),
3987                "cooldown_active"
3988            );
3989            assert_eq!(
3990                BudgetDecisionReason::OverloadEvidencePassed.as_str(),
3991                "overload_evidence_passed"
3992            );
3993            assert_eq!(
3994                BudgetDecisionReason::UnderloadEvidencePassed.as_str(),
3995                "underload_evidence_passed"
3996            );
3997            assert_eq!(
3998                BudgetDecisionReason::AtMaxDegradation.as_str(),
3999                "at_max_degradation"
4000            );
4001            assert_eq!(
4002                BudgetDecisionReason::AtDegradationFloor.as_str(),
4003                "at_degradation_floor"
4004            );
4005            assert_eq!(
4006                BudgetDecisionReason::AtFullQuality.as_str(),
4007                "at_full_quality"
4008            );
4009            assert_eq!(
4010                BudgetDecisionReason::WithinThresholdBand.as_str(),
4011                "within_threshold_band"
4012            );
4013        }
4014
4015        // --- Phase edge cases ---
4016
4017        #[test]
4018        fn phase_eq_and_hash() {
4019            use std::collections::HashSet;
4020            let mut set = HashSet::new();
4021            set.insert(Phase::Diff);
4022            set.insert(Phase::Present);
4023            set.insert(Phase::Render);
4024            assert_eq!(set.len(), 3);
4025
4026            // Same phase hashes to same bucket
4027            set.insert(Phase::Diff);
4028            assert_eq!(set.len(), 3);
4029        }
4030
4031        #[test]
4032        fn phase_debug() {
4033            assert!(format!("{:?}", Phase::Diff).contains("Diff"));
4034            assert!(format!("{:?}", Phase::Present).contains("Present"));
4035            assert!(format!("{:?}", Phase::Render).contains("Render"));
4036        }
4037
4038        #[test]
4039        fn phase_clone_copy() {
4040            let p = Phase::Present;
4041            let p2 = p;
4042            assert_eq!(p, p2);
4043        }
4044
4045        // --- BudgetTelemetry edge cases ---
4046
4047        #[test]
4048        fn budget_telemetry_debug() {
4049            let telem = BudgetTelemetry {
4050                schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
4051                level: DegradationLevel::Full,
4052                pid_output: 0.0,
4053                pid_p: 0.0,
4054                pid_i: 0.0,
4055                pid_d: 0.0,
4056                e_value: 1.0,
4057                frames_observed: 0,
4058                frames_since_change: 0,
4059                last_decision: BudgetDecision::Hold,
4060                decision_reason: BudgetDecisionReason::WithinThresholdBand,
4061                transition_seq: 0,
4062                transition_correlation_id: 0,
4063                frame_time_ms: 0.0,
4064                target_ms: 16.0,
4065                pid_gate_threshold: 0.0,
4066                pid_gate_margin: 0.0,
4067                evidence_threshold: 0.0,
4068                evidence_margin: 0.0,
4069                in_warmup: true,
4070            };
4071            let s = format!("{:?}", telem);
4072            assert!(s.contains("BudgetTelemetry"), "Debug output: {}", s);
4073        }
4074
4075        #[test]
4076        fn budget_telemetry_partial_eq() {
4077            let a = BudgetTelemetry {
4078                schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
4079                level: DegradationLevel::Full,
4080                pid_output: 0.5,
4081                pid_p: 0.3,
4082                pid_i: 0.1,
4083                pid_d: 0.1,
4084                e_value: 1.0,
4085                frames_observed: 5,
4086                frames_since_change: 2,
4087                last_decision: BudgetDecision::Hold,
4088                decision_reason: BudgetDecisionReason::WithinThresholdBand,
4089                transition_seq: 0,
4090                transition_correlation_id: 0,
4091                frame_time_ms: 16.0,
4092                target_ms: 16.0,
4093                pid_gate_threshold: 0.0,
4094                pid_gate_margin: 0.0,
4095                evidence_threshold: 0.0,
4096                evidence_margin: 0.0,
4097                in_warmup: false,
4098            };
4099            let b = a;
4100            assert_eq!(a, b);
4101
4102            let c = BudgetTelemetry {
4103                level: DegradationLevel::SimpleBorders,
4104                ..a
4105            };
4106            assert_ne!(a, c);
4107        }
4108
4109        // --- Controller + RenderBudget integration edge cases ---
4110
4111        #[test]
4112        fn next_frame_without_recorded_time_uses_elapsed() {
4113            let mut budget = RenderBudget::new(Duration::from_millis(1000));
4114
4115            // Don't record frame time — next_frame falls back to start.elapsed()
4116            budget.next_frame();
4117
4118            // Should not panic, remaining should reset
4119            assert!(budget.remaining_fraction() > 0.9);
4120        }
4121
4122        #[test]
4123        fn controller_at_max_degradation_holds() {
4124            let mut ctrl = BudgetController::new(BudgetControllerConfig {
4125                eprocess: EProcessConfig {
4126                    warmup_frames: 0,
4127                    ..Default::default()
4128                },
4129                cooldown_frames: 0,
4130                // Remove the floor so we can test reaching SkipFrame
4131                degradation_floor: DegradationLevel::SkipFrame,
4132                ..Default::default()
4133            });
4134
4135            // Drive to SkipFrame
4136            for _ in 0..500 {
4137                ctrl.update(Duration::from_millis(200));
4138            }
4139            assert_eq!(ctrl.level(), DegradationLevel::SkipFrame);
4140
4141            // At max level, further overload should Hold (can't degrade further)
4142            let d = ctrl.update(Duration::from_millis(200));
4143            assert_eq!(d, BudgetDecision::Hold, "At max level, should hold");
4144        }
4145
4146        #[test]
4147        fn controller_at_configured_degradation_floor_reports_floor_reason() {
4148            let mut ctrl = BudgetController::new(BudgetControllerConfig {
4149                eprocess: EProcessConfig {
4150                    warmup_frames: 0,
4151                    ..Default::default()
4152                },
4153                cooldown_frames: 0,
4154                degradation_floor: DegradationLevel::SimpleBorders,
4155                ..Default::default()
4156            });
4157
4158            ctrl.current_level = DegradationLevel::SimpleBorders;
4159
4160            let decision = ctrl.update(Duration::from_millis(200));
4161            let telemetry = ctrl.telemetry();
4162
4163            assert_eq!(decision, BudgetDecision::Hold);
4164            assert_eq!(telemetry.level, DegradationLevel::SimpleBorders);
4165            assert_eq!(
4166                telemetry.decision_reason,
4167                BudgetDecisionReason::AtDegradationFloor
4168            );
4169        }
4170
4171        #[test]
4172        fn controller_at_full_level_no_upgrade() {
4173            let mut ctrl = BudgetController::new(BudgetControllerConfig {
4174                eprocess: EProcessConfig {
4175                    warmup_frames: 0,
4176                    ..Default::default()
4177                },
4178                cooldown_frames: 0,
4179                ..Default::default()
4180            });
4181
4182            // Feed underload — already at Full, so no upgrade possible
4183            for _ in 0..50 {
4184                let d = ctrl.update(Duration::from_millis(1));
4185                assert_ne!(
4186                    d,
4187                    BudgetDecision::Upgrade,
4188                    "Full level should never upgrade"
4189                );
4190            }
4191        }
4192
4193        #[test]
4194        fn render_budget_full_degrade_cycle_with_controller() {
4195            let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
4196                BudgetControllerConfig {
4197                    eprocess: EProcessConfig {
4198                        warmup_frames: 0,
4199                        ..Default::default()
4200                    },
4201                    cooldown_frames: 0,
4202                    ..Default::default()
4203                },
4204            );
4205
4206            // Overload to degrade via controller
4207            for _ in 0..100 {
4208                budget.record_frame_time(Duration::from_millis(40));
4209                budget.next_frame();
4210            }
4211            let degraded = budget.degradation();
4212            assert!(
4213                degraded > DegradationLevel::Full,
4214                "Should degrade: {:?}",
4215                degraded
4216            );
4217
4218            // Recovery via controller
4219            for _ in 0..200 {
4220                budget.record_frame_time(Duration::from_millis(4));
4221                budget.next_frame();
4222            }
4223            let recovered = budget.degradation();
4224            assert!(
4225                recovered < degraded,
4226                "Should recover: {:?} -> {:?}",
4227                degraded,
4228                recovered
4229            );
4230        }
4231
4232        #[test]
4233        fn render_budget_phase_has_budget_exhausted() {
4234            let budget = RenderBudget::new(Duration::from_millis(1));
4235            std::thread::sleep(Duration::from_millis(10));
4236
4237            // All phases should report no budget
4238            assert!(!budget.phase_has_budget(Phase::Diff));
4239            assert!(!budget.phase_has_budget(Phase::Present));
4240            assert!(!budget.phase_has_budget(Phase::Render));
4241        }
4242
4243        #[test]
4244        fn render_budget_elapsed_increases() {
4245            let budget = RenderBudget::new(Duration::from_millis(1000));
4246            let e1 = budget.elapsed();
4247            std::thread::sleep(Duration::from_millis(5));
4248            let e2 = budget.elapsed();
4249            assert!(e2 > e1, "Elapsed should increase: {:?} vs {:?}", e1, e2);
4250        }
4251
4252        #[test]
4253        fn controller_pid_integral_accessor() {
4254            let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
4255
4256            assert_eq!(ctrl.pid_integral(), 0.0);
4257
4258            // Feed overload to accumulate integral
4259            ctrl.update(Duration::from_millis(32)); // 2x target
4260            assert!(
4261                ctrl.pid_integral() > 0.0,
4262                "Integral should grow: {}",
4263                ctrl.pid_integral()
4264            );
4265        }
4266
4267        #[test]
4268        fn controller_e_value_accessor() {
4269            let ctrl = BudgetController::new(BudgetControllerConfig::default());
4270            assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
4271        }
4272    }
4273}