Skip to main content

ftui_render/
budget.rs

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