1#![forbid(unsafe_code)]
2
3use web_time::{Duration, Instant};
41
42#[cfg(feature = "tracing")]
43use tracing::{trace, warn};
44
45#[derive(Debug, Clone, PartialEq)]
86pub struct PidGains {
87 pub kp: f64,
89 pub ki: f64,
91 pub kd: f64,
93 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#[derive(Debug, Clone)]
112struct PidState {
113 integral: f64,
115 prev_error: f64,
117 last_p: f64,
119 last_i: f64,
121 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 fn update(&mut self, error: f64, gains: &PidGains) -> f64 {
142 if error.is_nan() {
143 return 0.0;
144 }
145 self.integral = (self.integral + error).clamp(-gains.integral_max, gains.integral_max);
147
148 let derivative = error - self.prev_error;
150 self.prev_error = error;
151
152 self.last_p = gains.kp * error;
154 self.last_i = gains.ki * self.integral;
155 self.last_d = gains.kd * derivative;
156
157 self.last_p + self.last_i + self.last_d
159 }
160
161 fn reset(&mut self) {
163 *self = Self::default();
164 }
165}
166
167#[derive(Debug, Clone, PartialEq)]
206pub struct EProcessConfig {
207 pub lambda: f64,
210 pub alpha: f64,
213 pub beta: f64,
216 pub sigma_ema_decay: f64,
219 pub sigma_floor_ms: f64,
222 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#[derive(Debug, Clone)]
242struct EProcessState {
243 e_value: f64,
245 sigma_ema: f64,
247 mean_ema: f64,
249 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 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 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 let deviation = (frame_time_ms - self.mean_ema).abs();
280 self.sigma_ema = decay * self.sigma_ema + (1.0 - decay) * deviation;
281 }
282
283 let sigma = self.sigma_ema.max(config.sigma_floor_ms);
285
286 let residual = (frame_time_ms - target_ms) / sigma;
288
289 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 self.e_value = self.e_value.clamp(1e-10, 1e10);
299 }
300
301 self.e_value
302 }
303
304 fn should_degrade(&self, config: &EProcessConfig) -> bool {
306 if self.frames_observed < config.warmup_frames {
307 return false; }
309 self.e_value > 1.0 / config.alpha
310 }
311
312 fn should_upgrade(&self, config: &EProcessConfig) -> bool {
314 if self.frames_observed < config.warmup_frames {
315 return true; }
317 self.e_value < config.beta
318 }
319
320 fn reset(&mut self) {
322 *self = Self::default();
323 }
324}
325
326#[derive(Debug, Clone, PartialEq)]
328pub struct BudgetControllerConfig {
329 pub pid: PidGains,
331 pub eprocess: EProcessConfig,
333 pub target: Duration,
335 pub degrade_threshold: f64,
347 pub upgrade_threshold: f64,
350 pub cooldown_frames: u32,
352 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum BudgetDecision {
431 Hold,
433 Degrade,
435 Upgrade,
437}
438
439impl BudgetDecision {
440 #[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
451pub const BUDGET_TELEMETRY_SCHEMA_VERSION: u16 = 1;
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum BudgetDecisionReason {
457 CooldownActive,
459 OverloadEvidencePassed,
461 UnderloadEvidencePassed,
463 AtMaxDegradation,
465 AtFullQuality,
467 OverloadEvidenceInsufficient,
469 UnderloadEvidenceInsufficient,
471 WithinThresholdBand,
473}
474
475impl BudgetDecisionReason {
476 #[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 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 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 let error = (frame_ms - target_ms) / target_ms;
523
524 let u = self.pid.update(error, &self.config.pid);
526 self.last_pid_output = u;
527 self.last_frame_ms = frame_ms;
528
529 self.eprocess
531 .update(frame_ms, target_ms, &self.config.eprocess);
532
533 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 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 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 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 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 #[inline]
630 pub fn level(&self) -> DegradationLevel {
631 self.current_level
632 }
633
634 #[inline]
636 pub fn e_value(&self) -> f64 {
637 self.eprocess.e_value
638 }
639
640 #[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 #[inline]
650 pub fn pid_integral(&self) -> f64 {
651 self.pid.integral
652 }
653
654 #[inline]
656 pub fn frames_observed(&self) -> u32 {
657 self.eprocess.frames_observed
658 }
659
660 #[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 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 #[inline]
710 #[must_use]
711 pub fn config(&self) -> &BudgetControllerConfig {
712 &self.config
713 }
714}
715
716#[derive(Debug, Clone, Copy, PartialEq)]
721pub struct BudgetTelemetry {
722 pub schema_version: u16,
724 pub level: DegradationLevel,
726 pub pid_output: f64,
728 pub pid_p: f64,
730 pub pid_i: f64,
732 pub pid_d: f64,
734 pub e_value: f64,
736 pub frames_observed: u32,
738 pub frames_since_change: u32,
740 pub last_decision: BudgetDecision,
742 pub decision_reason: BudgetDecisionReason,
744 pub transition_seq: u64,
746 pub transition_correlation_id: u64,
748 pub frame_time_ms: f64,
750 pub target_ms: f64,
752 pub pid_gate_threshold: f64,
754 pub pid_gate_margin: f64,
756 pub evidence_threshold: f64,
758 pub evidence_margin: f64,
760 pub in_warmup: bool,
762}
763
764#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
769#[repr(u8)]
770pub enum DegradationLevel {
771 #[default]
773 Full = 0,
774 SimpleBorders = 1,
776 NoStyling = 2,
778 EssentialOnly = 3,
780 Skeleton = 4,
782 SkipFrame = 5,
784}
785
786impl DegradationLevel {
787 #[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 #[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 #[inline]
819 pub fn is_max(self) -> bool {
820 self == Self::SkipFrame
821 }
822
823 #[inline]
825 pub fn is_full(self) -> bool {
826 self == Self::Full
827 }
828
829 #[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 #[inline]
844 pub fn level(self) -> u8 {
845 self as u8
846 }
847
848 #[inline]
854 pub fn use_unicode_borders(self) -> bool {
855 self < Self::SimpleBorders
856 }
857
858 #[inline]
862 pub fn apply_styling(self) -> bool {
863 self < Self::NoStyling
864 }
865
866 #[inline]
871 pub fn render_decorative(self) -> bool {
872 self < Self::EssentialOnly
873 }
874
875 #[inline]
879 pub fn render_content(self) -> bool {
880 self < Self::Skeleton
881 }
882}
883
884#[derive(Debug, Clone, Copy, PartialEq, Eq)]
886pub struct PhaseBudgets {
887 pub diff: Duration,
889 pub present: Duration,
891 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#[derive(Debug, Clone, PartialEq)]
907pub struct FrameBudgetConfig {
908 pub total: Duration,
910 pub phase_budgets: PhaseBudgets,
912 pub allow_frame_skip: bool,
914 pub degradation_cooldown: u32,
916 pub upgrade_threshold: f32,
919}
920
921impl Default for FrameBudgetConfig {
922 fn default() -> Self {
923 Self {
924 total: Duration::from_millis(16), 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 pub fn with_total(total: Duration) -> Self {
936 Self {
937 total,
938 ..Default::default()
939 }
940 }
941
942 pub fn strict(total: Duration) -> Self {
944 Self {
945 total,
946 allow_frame_skip: false,
947 ..Default::default()
948 }
949 }
950
951 pub fn relaxed() -> Self {
953 Self {
954 total: Duration::from_millis(33), degradation_cooldown: 5,
956 ..Default::default()
957 }
958 }
959}
960
961#[derive(Debug, Clone)]
966pub struct RenderBudget {
967 total: Duration,
969 start: Instant,
971 last_frame_time: Option<Duration>,
973 degradation: DegradationLevel,
975 phase_budgets: PhaseBudgets,
977 allow_frame_skip: bool,
979 upgrade_threshold: f32,
981 frames_since_change: u32,
983 cooldown: u32,
985 controller: Option<BudgetController>,
988}
989
990impl RenderBudget {
991 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 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 #[must_use]
1039 pub fn with_controller(mut self, config: BudgetControllerConfig) -> Self {
1040 self.controller = Some(BudgetController::new(config));
1041 self
1042 }
1043
1044 #[inline]
1046 pub fn total(&self) -> Duration {
1047 self.total
1048 }
1049
1050 #[inline]
1052 pub fn elapsed(&self) -> Duration {
1053 self.start.elapsed()
1054 }
1055
1056 #[inline]
1058 pub fn remaining(&self) -> Duration {
1059 self.total.saturating_sub(self.start.elapsed())
1060 }
1061
1062 #[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 #[inline]
1077 pub fn should_degrade(&self, estimated_cost: Duration) -> bool {
1078 self.remaining() < estimated_cost
1079 }
1080
1081 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; }
1100
1101 #[inline]
1103 pub fn degradation(&self) -> DegradationLevel {
1104 self.degradation
1105 }
1106
1107 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 #[inline]
1121 pub fn exhausted(&self) -> bool {
1122 self.remaining().is_zero()
1123 || (self.degradation == DegradationLevel::SkipFrame && self.allow_frame_skip)
1124 }
1125
1126 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 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 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 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; }
1174
1175 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 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 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 if self.should_upgrade_with_elapsed(frame_time) {
1213 self.upgrade();
1214 }
1215 }
1216 self.reset();
1217 }
1218
1219 pub fn record_frame_time(&mut self, elapsed: Duration) {
1221 self.last_frame_time = Some(elapsed);
1222 }
1223
1224 #[inline]
1229 pub fn telemetry(&self) -> Option<BudgetTelemetry> {
1230 self.controller.as_ref().map(BudgetController::telemetry)
1231 }
1232
1233 #[inline]
1235 pub fn controller(&self) -> Option<&BudgetController> {
1236 self.controller.as_ref()
1237 }
1238
1239 #[inline]
1241 #[must_use]
1242 pub fn phase_budgets(&self) -> &PhaseBudgets {
1243 &self.phase_budgets
1244 }
1245
1246 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 #[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, }
1278 }
1279}
1280
1281#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1283pub enum Phase {
1284 Diff,
1286 Present,
1288 Render,
1290}
1291
1292impl Phase {
1293 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 let initial = budget.remaining_fraction();
1421 assert!(initial > 0.9);
1422
1423 thread::sleep(Duration::from_millis(50));
1424
1425 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 let budget = RenderBudget::new(Duration::from_millis(100));
1435
1436 thread::sleep(Duration::from_millis(50));
1438
1439 assert!(budget.should_degrade(Duration::from_millis(80)));
1441 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 budget.set_degradation(DegradationLevel::SkipFrame);
1475
1476 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 assert!(!budget.should_upgrade());
1486
1487 budget.degrade();
1489 budget.frames_since_change = 5;
1490
1491 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 while !budget.degradation().is_max() {
1515 budget.degrade();
1516 }
1517 assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
1518
1519 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 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 budget.degrade();
1547 for _ in 0..5 {
1548 budget.reset();
1549 }
1550
1551 let before = budget.degradation();
1552 budget.next_frame();
1553
1554 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 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 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()); 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 thread::sleep(Duration::from_millis(10));
1691
1692 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 assert!(!budget.should_degrade(Duration::from_millis(100)));
1703 assert_eq!(budget.degradation(), DegradationLevel::Full);
1704
1705 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 budget.degrade();
1717 assert_eq!(budget.frames_since_change, 0);
1718
1719 assert!(!budget.should_upgrade());
1721
1722 budget.frames_since_change = 3;
1724
1725 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 budget.set_degradation(DegradationLevel::Full);
1746
1747 assert_eq!(budget.frames_since_change, 10);
1749 }
1750
1751 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 #[test]
1781 fn pid_step_input_yields_nonzero_output() {
1782 let mut state = PidState::default();
1783 let gains = PidGains::default();
1784
1785 let u = state.update(1.0, &gains);
1787 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 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 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 let u1 = state.update(0.0, &gains);
1857 let u2 = state.update(1.0, &gains);
1859
1860 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 state.update(1.0, &gains);
1876 state.update(1.0, &gains);
1877 state.update(1.0, &gains);
1878
1879 let mut outputs = Vec::new();
1881 for _ in 0..20 {
1882 outputs.push(state.update(0.0, &gains));
1883 }
1884
1885 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 #[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 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 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 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 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 for _ in 0..30 {
2009 state.update(40.0, 16.0, &config);
2010 }
2011 let peak = state.e_value;
2012
2013 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 for _ in 0..20 {
2037 state.update(16.0, 16.0, &config);
2038 }
2039
2040 assert!(
2042 state.sigma_ema >= 0.0,
2043 "Sigma should be non-negative: {}",
2044 state.sigma_ema
2045 );
2046 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 #[test]
2070 fn controller_holds_under_normal_load() {
2071 let mut ctrl = make_controller_with_config(16, 0, 0);
2072
2073 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 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 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 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 for _ in 0..50 {
2144 ctrl.update(Duration::from_millis(40));
2145 }
2146
2147 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 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 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 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 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 for _ in 0..20 {
2209 ctrl.update(Duration::from_millis(16));
2210 }
2211
2212 ctrl.update(Duration::from_millis(100));
2214
2215 for _ in 0..5 {
2217 ctrl.update(Duration::from_millis(16));
2218 }
2219
2220 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 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 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 #[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 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 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 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 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 for _ in 0..50 {
2369 ctrl.update(Duration::from_millis(40));
2370 }
2371
2372 assert!(
2374 ctrl.level() > DegradationLevel::Full,
2375 "Controller should degrade: {:?}",
2376 ctrl.level()
2377 );
2378
2379 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 let mut budget = RenderBudget::new(Duration::from_millis(1000))
2394 .with_controller(BudgetControllerConfig::default());
2395
2396 budget.degrade();
2398 assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
2399
2400 budget.next_frame();
2404
2405 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 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 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 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 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 ctrl.update(Duration::from_millis(16));
2497 assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Hold);
2498
2499 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 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 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 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 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 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 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 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 #[test]
2636 fn e2e_burst_logs_no_oscillation() {
2637 let mut ctrl = fast_controller(16);
2640
2641 let mut trace = Vec::new();
2642 for _cycle in 0..5 {
2643 for _ in 0..10 {
2645 trace.push(Duration::from_millis(40));
2646 }
2647 for _ in 0..20 {
2649 trace.push(Duration::from_millis(16));
2650 }
2651 }
2652
2653 let log = run_trace(&mut ctrl, &trace);
2654
2655 let transitions = count_transitions(&log);
2660 assert!(
2661 transitions < 20,
2662 "Too many transitions under bursty load: {} (expected <20)",
2663 transitions
2664 );
2665
2666 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 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 for _ in 0..15 {
2700 trace.push(Duration::from_millis(30));
2701 }
2702 for _ in 0..50 {
2704 trace.push(Duration::from_millis(10));
2705 }
2706 }
2707
2708 let log = run_trace(&mut ctrl, &trace);
2709
2710 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 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 #[test]
2737 fn e2e_idle_to_burst_recovery() {
2738 let mut ctrl = fast_controller(16);
2741
2742 let mut trace = Vec::new();
2743 for _ in 0..50 {
2745 trace.push(Duration::from_millis(8));
2746 }
2747 for _ in 0..20 {
2749 trace.push(Duration::from_millis(50));
2750 }
2751 for _ in 0..100 {
2753 trace.push(Duration::from_millis(8));
2754 }
2755
2756 let log = run_trace(&mut ctrl, &trace);
2757
2758 assert_eq!(
2760 log[49].2.level,
2761 DegradationLevel::Full,
2762 "Should be Full during idle phase"
2763 );
2764
2765 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 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 let mut ctrl = fast_controller(16);
2787
2788 for _ in 0..30 {
2790 ctrl.update(Duration::from_millis(8));
2791 }
2792
2793 for _ in 0..5 {
2795 ctrl.update(Duration::from_millis(40));
2796 }
2797
2798 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 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 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 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 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 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 #[test]
3024 fn property_random_load_hysteresis_bounds() {
3025 let mut ctrl = fast_controller(16);
3028
3029 let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_BABE;
3032 let mut trace = Vec::new();
3033 for _ in 0..1000 {
3034 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 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 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 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 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 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 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 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 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 #[test]
3185 fn telemetry_jsonl_fields_complete() {
3186 let mut ctrl = fast_controller(16);
3188 ctrl.update(Duration::from_millis(20));
3189
3190 let telem = ctrl.telemetry();
3191
3192 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 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 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 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 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 mod edge_case_tests {
3320 use super::super::*;
3321
3322 #[test]
3325 fn pid_negative_integral_windup() {
3326 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 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 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 let u1 = state.update(1.0, &gains);
3395 let u2 = state.update(-1.0, &gains);
3396
3397 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 assert!(
3416 (state.last_p - 1.0).abs() < 1e-10,
3417 "P term: {}",
3418 state.last_p
3419 );
3420 assert!(
3422 (state.last_i - 0.1).abs() < 1e-10,
3423 "I term: {}",
3424 state.last_i
3425 );
3426 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 for _ in 0..50 {
3444 state.update(100.0, &gains);
3445 }
3446 let pos_integral = state.integral;
3447
3448 state.reset();
3449
3450 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 #[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, warmup_frames: 0,
3492 sigma_floor_ms: 0.001, ..Default::default()
3494 };
3495
3496 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 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 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 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 state.update(20.0, 16.0, &config);
3573 state.update(30.0, 16.0, &config);
3574
3575 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 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 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 #[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 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 #[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 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 #[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 for _ in 0..10 {
3788 budget.reset();
3789 }
3790
3791 budget.record_frame_time(Duration::from_millis(1));
3793 std::thread::sleep(Duration::from_millis(15));
3795
3796 let before = budget.degradation();
3797 budget.next_frame();
3798
3799 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 let budget = RenderBudget::new(Duration::from_millis(1));
3813 std::thread::sleep(Duration::from_millis(5));
3814
3815 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 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 budget.set_degradation(DegradationLevel::NoStyling);
3867 assert_eq!(budget.frames_since_change, 7);
3868
3869 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 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 #[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; 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 #[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 #[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 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 #[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 #[test]
4095 fn next_frame_without_recorded_time_uses_elapsed() {
4096 let mut budget = RenderBudget::new(Duration::from_millis(1000));
4097
4098 budget.next_frame();
4100
4101 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 degradation_floor: DegradationLevel::SkipFrame,
4115 ..Default::default()
4116 });
4117
4118 for _ in 0..500 {
4120 ctrl.update(Duration::from_millis(200));
4121 }
4122 assert_eq!(ctrl.level(), DegradationLevel::SkipFrame);
4123
4124 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 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 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 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 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 ctrl.update(Duration::from_millis(32)); 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}