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