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)]
212pub struct EProcessConfig {
213 pub lambda: f64,
216 pub alpha: f64,
219 pub beta: f64,
222 pub sigma_ema_decay: f64,
225 pub sigma_floor_ms: f64,
228 pub warmup_frames: u32,
231}
232
233impl Default for EProcessConfig {
234 fn default() -> Self {
235 Self {
236 lambda: 0.5,
237 alpha: 0.05,
238 beta: 0.5,
239 sigma_ema_decay: 0.9,
240 sigma_floor_ms: 1.0,
241 warmup_frames: 10,
242 }
243 }
244}
245
246#[derive(Debug, Clone)]
248struct EProcessState {
249 e_value: f64,
251 sigma_ema: f64,
253 mean_ema: f64,
255 frames_observed: u32,
257}
258
259impl Default for EProcessState {
260 fn default() -> Self {
261 Self {
262 e_value: 1.0,
263 sigma_ema: 0.0,
264 mean_ema: 0.0,
265 frames_observed: 0,
266 }
267 }
268}
269
270impl EProcessState {
271 fn update(&mut self, frame_time_ms: f64, target_ms: f64, config: &EProcessConfig) -> f64 {
275 self.frames_observed = self.frames_observed.saturating_add(1);
276
277 if self.frames_observed == 1 {
279 self.mean_ema = frame_time_ms;
280 self.sigma_ema = config.sigma_floor_ms;
281 } else {
282 let decay = config.sigma_ema_decay;
283 self.mean_ema = decay * self.mean_ema + (1.0 - decay) * frame_time_ms;
284 let deviation = (frame_time_ms - self.mean_ema).abs();
286 self.sigma_ema = decay * self.sigma_ema + (1.0 - decay) * deviation;
287 }
288
289 let sigma = self.sigma_ema.max(config.sigma_floor_ms);
291
292 let residual = (frame_time_ms - target_ms) / sigma;
294
295 let lambda = config.lambda;
299 let log_factor = lambda * residual - lambda * lambda / 2.0;
300 if !log_factor.is_nan() {
301 self.e_value *= log_factor.exp();
302 self.e_value = self.e_value.clamp(1e-10, 1e10);
305 }
306
307 self.e_value
308 }
309
310 fn should_degrade(&self, config: &EProcessConfig) -> bool {
312 if self.frames_observed < config.warmup_frames {
313 return false; }
315 self.e_value > 1.0 / config.alpha
316 }
317
318 fn should_upgrade(&self, config: &EProcessConfig) -> bool {
320 if self.frames_observed < config.warmup_frames {
321 return true; }
323 self.e_value < config.beta
324 }
325
326 fn reset(&mut self) {
328 *self = Self::default();
329 }
330}
331
332#[derive(Debug, Clone, PartialEq)]
334pub struct BudgetControllerConfig {
335 pub pid: PidGains,
337 pub eprocess: EProcessConfig,
339 pub target: Duration,
341 pub degrade_threshold: f64,
353 pub upgrade_threshold: f64,
356 pub cooldown_frames: u32,
362 pub degradation_floor: DegradationLevel,
370}
371
372impl Default for BudgetControllerConfig {
373 fn default() -> Self {
374 Self {
375 pid: PidGains::default(),
376 eprocess: EProcessConfig::default(),
377 target: Duration::from_millis(16),
378 degrade_threshold: 0.3,
379 upgrade_threshold: 0.2,
380 cooldown_frames: 3,
381 degradation_floor: DegradationLevel::SimpleBorders,
382 }
383 }
384}
385
386#[derive(Debug, Clone)]
420pub struct BudgetController {
421 config: BudgetControllerConfig,
422 pid: PidState,
423 eprocess: EProcessState,
424 current_level: DegradationLevel,
425 frames_since_change: u32,
426 last_pid_output: f64,
427 last_decision: BudgetDecision,
428 last_decision_reason: BudgetDecisionReason,
429 last_frame_ms: f64,
430 transition_seq: u64,
431 last_transition_correlation_id: u64,
432 last_pid_gate_threshold: f64,
433 last_pid_gate_margin: f64,
434 last_evidence_threshold: f64,
435 last_evidence_margin: f64,
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440pub enum BudgetDecision {
441 Hold,
443 Degrade,
445 Upgrade,
447}
448
449impl BudgetDecision {
450 #[inline]
452 pub fn as_str(self) -> &'static str {
453 match self {
454 Self::Hold => "stay",
455 Self::Degrade => "degrade",
456 Self::Upgrade => "upgrade",
457 }
458 }
459}
460
461pub const BUDGET_TELEMETRY_SCHEMA_VERSION: u16 = 1;
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
466pub enum BudgetDecisionReason {
467 CooldownActive,
469 OverloadEvidencePassed,
471 UnderloadEvidencePassed,
473 AtMaxDegradation,
475 AtDegradationFloor,
477 AtFullQuality,
479 OverloadEvidenceInsufficient,
481 UnderloadEvidenceInsufficient,
483 WithinThresholdBand,
485}
486
487impl BudgetDecisionReason {
488 #[inline]
490 pub fn as_str(self) -> &'static str {
491 match self {
492 Self::CooldownActive => "cooldown_active",
493 Self::OverloadEvidencePassed => "overload_evidence_passed",
494 Self::UnderloadEvidencePassed => "underload_evidence_passed",
495 Self::AtMaxDegradation => "at_max_degradation",
496 Self::AtDegradationFloor => "at_degradation_floor",
497 Self::AtFullQuality => "at_full_quality",
498 Self::OverloadEvidenceInsufficient => "overload_evidence_insufficient",
499 Self::UnderloadEvidenceInsufficient => "underload_evidence_insufficient",
500 Self::WithinThresholdBand => "within_threshold_band",
501 }
502 }
503}
504
505impl BudgetController {
506 pub fn new(config: BudgetControllerConfig) -> Self {
508 Self {
509 config,
510 pid: PidState::default(),
511 eprocess: EProcessState::default(),
512 current_level: DegradationLevel::Full,
513 frames_since_change: 0,
514 last_pid_output: 0.0,
515 last_decision: BudgetDecision::Hold,
516 last_decision_reason: BudgetDecisionReason::WithinThresholdBand,
517 last_frame_ms: 0.0,
518 transition_seq: 0,
519 last_transition_correlation_id: 0,
520 last_pid_gate_threshold: 0.0,
521 last_pid_gate_margin: 0.0,
522 last_evidence_threshold: 0.0,
523 last_evidence_margin: 0.0,
524 }
525 }
526
527 pub fn update(&mut self, frame_time: Duration) -> BudgetDecision {
531 let target_ms = self.config.target.as_secs_f64() * 1000.0;
532 let frame_ms = frame_time.as_secs_f64() * 1000.0;
533
534 let error = (frame_ms - target_ms) / target_ms;
536
537 let u = self.pid.update(error, &self.config.pid);
539 self.last_pid_output = u;
540 self.last_frame_ms = frame_ms;
541
542 self.eprocess
544 .update(frame_ms, target_ms, &self.config.eprocess);
545
546 self.frames_since_change = self.frames_since_change.saturating_add(1);
548
549 let mut decision = BudgetDecision::Hold;
550 let mut reason = BudgetDecisionReason::WithinThresholdBand;
551 let mut pid_gate_threshold = 0.0;
552 let mut pid_gate_margin = 0.0;
553 let mut evidence_threshold = 0.0;
554 let mut evidence_margin = 0.0;
555
556 if self.frames_since_change < self.config.cooldown_frames {
558 reason = BudgetDecisionReason::CooldownActive;
559 } else if u > self.config.degrade_threshold {
560 pid_gate_threshold = self.config.degrade_threshold;
561 pid_gate_margin = u - pid_gate_threshold;
562 evidence_threshold = 1.0 / self.config.eprocess.alpha;
563 evidence_margin = self.eprocess.e_value - evidence_threshold;
564
565 if self.current_level.is_max() {
566 reason = BudgetDecisionReason::AtMaxDegradation;
567 } else if self.current_level >= self.config.degradation_floor {
568 reason = BudgetDecisionReason::AtDegradationFloor;
569 } else if self.eprocess.should_degrade(&self.config.eprocess) {
570 decision = BudgetDecision::Degrade;
571 reason = BudgetDecisionReason::OverloadEvidencePassed;
572 } else {
573 reason = BudgetDecisionReason::OverloadEvidenceInsufficient;
574 }
575 } else if u < -self.config.upgrade_threshold {
576 pid_gate_threshold = -self.config.upgrade_threshold;
577 pid_gate_margin = (-u) - self.config.upgrade_threshold;
578 evidence_threshold = self.config.eprocess.beta;
579 evidence_margin = evidence_threshold - self.eprocess.e_value;
580
581 if self.current_level.is_full() {
582 reason = BudgetDecisionReason::AtFullQuality;
583 } else if self.eprocess.should_upgrade(&self.config.eprocess) {
584 decision = BudgetDecision::Upgrade;
585 reason = BudgetDecisionReason::UnderloadEvidencePassed;
586 } else {
587 reason = BudgetDecisionReason::UnderloadEvidenceInsufficient;
588 }
589 }
590
591 self.last_decision = decision;
593 self.last_decision_reason = reason;
594 self.last_pid_gate_threshold = pid_gate_threshold;
595 self.last_pid_gate_margin = pid_gate_margin;
596 self.last_evidence_threshold = evidence_threshold;
597 self.last_evidence_margin = evidence_margin;
598
599 match decision {
601 BudgetDecision::Degrade => {
602 self.transition_seq = self.transition_seq.saturating_add(1);
603 self.last_transition_correlation_id =
604 (self.transition_seq << 32) ^ u64::from(self.eprocess.frames_observed);
605 let next = self.current_level.next();
606 self.current_level = if next > self.config.degradation_floor {
608 self.config.degradation_floor
609 } else {
610 next
611 };
612 self.frames_since_change = 0;
613
614 #[cfg(feature = "tracing")]
615 warn!(
616 level = self.current_level.as_str(),
617 pid_output = u,
618 e_value = self.eprocess.e_value,
619 "budget controller: degrade"
620 );
621 }
622 BudgetDecision::Upgrade => {
623 self.transition_seq = self.transition_seq.saturating_add(1);
624 self.last_transition_correlation_id =
625 (self.transition_seq << 32) ^ u64::from(self.eprocess.frames_observed);
626 self.current_level = self.current_level.prev();
627 self.frames_since_change = 0;
628
629 #[cfg(feature = "tracing")]
630 trace!(
631 level = self.current_level.as_str(),
632 pid_output = u,
633 e_value = self.eprocess.e_value,
634 "budget controller: upgrade"
635 );
636 }
637 BudgetDecision::Hold => {}
638 }
639
640 decision
641 }
642
643 #[inline]
645 pub fn level(&self) -> DegradationLevel {
646 self.current_level
647 }
648
649 pub fn sync_level(&mut self, level: DegradationLevel) {
660 if self.current_level != level {
661 self.current_level = level;
662 self.frames_since_change = 0;
663 }
664 }
665
666 #[inline]
668 pub fn e_value(&self) -> f64 {
669 self.eprocess.e_value
670 }
671
672 #[inline]
674 pub fn eprocess_sigma_ms(&self) -> f64 {
675 self.eprocess
676 .sigma_ema
677 .max(self.config.eprocess.sigma_floor_ms)
678 }
679
680 #[inline]
682 pub fn pid_integral(&self) -> f64 {
683 self.pid.integral
684 }
685
686 #[inline]
688 pub fn frames_observed(&self) -> u32 {
689 self.eprocess.frames_observed
690 }
691
692 #[inline]
697 pub fn telemetry(&self) -> BudgetTelemetry {
698 BudgetTelemetry {
699 schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
700 level: self.current_level,
701 pid_output: self.last_pid_output,
702 pid_p: self.pid.last_p,
703 pid_i: self.pid.last_i,
704 pid_d: self.pid.last_d,
705 e_value: self.eprocess.e_value,
706 frames_observed: self.eprocess.frames_observed,
707 frames_since_change: self.frames_since_change,
708 last_decision: self.last_decision,
709 decision_reason: self.last_decision_reason,
710 transition_seq: self.transition_seq,
711 transition_correlation_id: self.last_transition_correlation_id,
712 frame_time_ms: self.last_frame_ms,
713 target_ms: self.config.target.as_secs_f64() * 1000.0,
714 pid_gate_threshold: self.last_pid_gate_threshold,
715 pid_gate_margin: self.last_pid_gate_margin,
716 evidence_threshold: self.last_evidence_threshold,
717 evidence_margin: self.last_evidence_margin,
718 in_warmup: self.eprocess.frames_observed < self.config.eprocess.warmup_frames,
719 }
720 }
721
722 pub fn reset(&mut self) {
724 self.pid.reset();
725 self.eprocess.reset();
726 self.current_level = DegradationLevel::Full;
727 self.frames_since_change = 0;
728 self.last_pid_output = 0.0;
729 self.last_decision = BudgetDecision::Hold;
730 self.last_decision_reason = BudgetDecisionReason::WithinThresholdBand;
731 self.last_frame_ms = 0.0;
732 self.transition_seq = 0;
733 self.last_transition_correlation_id = 0;
734 self.last_pid_gate_threshold = 0.0;
735 self.last_pid_gate_margin = 0.0;
736 self.last_evidence_threshold = 0.0;
737 self.last_evidence_margin = 0.0;
738 }
739
740 #[inline]
742 #[must_use]
743 pub fn config(&self) -> &BudgetControllerConfig {
744 &self.config
745 }
746}
747
748#[derive(Debug, Clone, Copy, PartialEq)]
753pub struct BudgetTelemetry {
754 pub schema_version: u16,
756 pub level: DegradationLevel,
758 pub pid_output: f64,
760 pub pid_p: f64,
762 pub pid_i: f64,
764 pub pid_d: f64,
766 pub e_value: f64,
768 pub frames_observed: u32,
770 pub frames_since_change: u32,
772 pub last_decision: BudgetDecision,
774 pub decision_reason: BudgetDecisionReason,
776 pub transition_seq: u64,
778 pub transition_correlation_id: u64,
780 pub frame_time_ms: f64,
782 pub target_ms: f64,
784 pub pid_gate_threshold: f64,
786 pub pid_gate_margin: f64,
788 pub evidence_threshold: f64,
790 pub evidence_margin: f64,
792 pub in_warmup: bool,
794}
795
796#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
801#[repr(u8)]
802pub enum DegradationLevel {
803 #[default]
805 Full = 0,
806 SimpleBorders = 1,
808 NoStyling = 2,
810 EssentialOnly = 3,
812 Skeleton = 4,
814 SkipFrame = 5,
816}
817
818impl DegradationLevel {
819 #[inline]
823 #[must_use]
824 pub fn next(self) -> Self {
825 match self {
826 Self::Full => Self::SimpleBorders,
827 Self::SimpleBorders => Self::NoStyling,
828 Self::NoStyling => Self::EssentialOnly,
829 Self::EssentialOnly => Self::Skeleton,
830 Self::Skeleton | Self::SkipFrame => Self::SkipFrame,
831 }
832 }
833
834 #[inline]
838 #[must_use]
839 pub fn prev(self) -> Self {
840 match self {
841 Self::SkipFrame => Self::Skeleton,
842 Self::Skeleton => Self::EssentialOnly,
843 Self::EssentialOnly => Self::NoStyling,
844 Self::NoStyling => Self::SimpleBorders,
845 Self::SimpleBorders | Self::Full => Self::Full,
846 }
847 }
848
849 #[inline]
851 pub fn is_max(self) -> bool {
852 self == Self::SkipFrame
853 }
854
855 #[inline]
857 pub fn is_full(self) -> bool {
858 self == Self::Full
859 }
860
861 #[inline]
863 pub fn as_str(self) -> &'static str {
864 match self {
865 Self::Full => "Full",
866 Self::SimpleBorders => "SimpleBorders",
867 Self::NoStyling => "NoStyling",
868 Self::EssentialOnly => "EssentialOnly",
869 Self::Skeleton => "Skeleton",
870 Self::SkipFrame => "SkipFrame",
871 }
872 }
873
874 #[inline]
876 pub fn level(self) -> u8 {
877 self as u8
878 }
879
880 #[inline]
886 pub fn use_unicode_borders(self) -> bool {
887 self < Self::SimpleBorders
888 }
889
890 #[inline]
894 pub fn apply_styling(self) -> bool {
895 self < Self::NoStyling
896 }
897
898 #[inline]
903 pub fn render_decorative(self) -> bool {
904 self < Self::EssentialOnly
905 }
906
907 #[inline]
911 pub fn render_content(self) -> bool {
912 self < Self::Skeleton
913 }
914}
915
916#[derive(Debug, Clone, Copy, PartialEq, Eq)]
918pub struct PhaseBudgets {
919 pub diff: Duration,
921 pub present: Duration,
923 pub render: Duration,
925}
926
927impl Default for PhaseBudgets {
928 fn default() -> Self {
929 Self {
930 diff: Duration::from_millis(2),
931 present: Duration::from_millis(4),
932 render: Duration::from_millis(8),
933 }
934 }
935}
936
937#[derive(Debug, Clone, PartialEq)]
939pub struct FrameBudgetConfig {
940 pub total: Duration,
942 pub phase_budgets: PhaseBudgets,
944 pub allow_frame_skip: bool,
946 pub degradation_cooldown: u32,
948 pub upgrade_threshold: f32,
951}
952
953impl Default for FrameBudgetConfig {
954 fn default() -> Self {
955 Self {
956 total: Duration::from_millis(16), phase_budgets: PhaseBudgets::default(),
958 allow_frame_skip: true,
959 degradation_cooldown: 3,
960 upgrade_threshold: 0.5,
961 }
962 }
963}
964
965impl FrameBudgetConfig {
966 pub fn with_total(total: Duration) -> Self {
968 Self {
969 total,
970 ..Default::default()
971 }
972 }
973
974 pub fn strict(total: Duration) -> Self {
976 Self {
977 total,
978 allow_frame_skip: false,
979 ..Default::default()
980 }
981 }
982
983 pub fn relaxed() -> Self {
985 Self {
986 total: Duration::from_millis(33), degradation_cooldown: 5,
988 ..Default::default()
989 }
990 }
991}
992
993#[derive(Debug, Clone)]
998pub struct RenderBudget {
999 total: Duration,
1001 start: Instant,
1003 last_frame_time: Option<Duration>,
1005 degradation: DegradationLevel,
1007 phase_budgets: PhaseBudgets,
1009 allow_frame_skip: bool,
1011 upgrade_threshold: f32,
1013 frames_since_change: u32,
1015 cooldown: u32,
1017 controller: Option<BudgetController>,
1020}
1021
1022impl RenderBudget {
1023 pub fn new(total: Duration) -> Self {
1025 Self {
1026 total,
1027 start: Instant::now(),
1028 last_frame_time: None,
1029 degradation: DegradationLevel::Full,
1030 phase_budgets: PhaseBudgets::default(),
1031 allow_frame_skip: true,
1032 upgrade_threshold: 0.5,
1033 frames_since_change: 0,
1034 cooldown: 3,
1035 controller: None,
1036 }
1037 }
1038
1039 pub fn from_config(config: &FrameBudgetConfig) -> Self {
1041 Self {
1042 total: config.total,
1043 start: Instant::now(),
1044 last_frame_time: None,
1045 degradation: DegradationLevel::Full,
1046 phase_budgets: config.phase_budgets,
1047 allow_frame_skip: config.allow_frame_skip,
1048 upgrade_threshold: config.upgrade_threshold,
1049 frames_since_change: 0,
1050 cooldown: config.degradation_cooldown,
1051 controller: None,
1052 }
1053 }
1054
1055 #[must_use]
1071 pub fn with_controller(mut self, config: BudgetControllerConfig) -> Self {
1072 self.controller = Some(BudgetController::new(config));
1073 self
1074 }
1075
1076 #[inline]
1078 pub fn total(&self) -> Duration {
1079 self.total
1080 }
1081
1082 #[inline]
1084 pub fn elapsed(&self) -> Duration {
1085 self.start.elapsed()
1086 }
1087
1088 #[inline]
1090 pub fn remaining(&self) -> Duration {
1091 self.total.saturating_sub(self.start.elapsed())
1092 }
1093
1094 #[inline]
1096 pub fn remaining_fraction(&self) -> f32 {
1097 if self.total.is_zero() {
1098 return 0.0;
1099 }
1100 let remaining = self.remaining().as_secs_f32();
1101 let total = self.total.as_secs_f32();
1102 (remaining / total).clamp(0.0, 1.0)
1103 }
1104
1105 #[inline]
1109 pub fn should_degrade(&self, estimated_cost: Duration) -> bool {
1110 self.remaining() < estimated_cost
1111 }
1112
1113 pub fn degrade(&mut self) {
1122 let from = self.degradation;
1123 self.degradation = self.degradation.next();
1124 self.frames_since_change = 0;
1125 self.sync_controller_level();
1126
1127 #[cfg(feature = "tracing")]
1128 if from != self.degradation {
1129 warn!(
1130 from = from.as_str(),
1131 to = self.degradation.as_str(),
1132 remaining_ms = self.remaining().as_millis() as u32,
1133 "render budget degradation"
1134 );
1135 }
1136 let _ = from; }
1138
1139 #[inline]
1141 fn sync_controller_level(&mut self) {
1142 if let Some(controller) = self.controller.as_mut() {
1143 controller.sync_level(self.degradation);
1144 }
1145 }
1146
1147 #[inline]
1149 pub fn degradation(&self) -> DegradationLevel {
1150 self.degradation
1151 }
1152
1153 pub fn set_degradation(&mut self, level: DegradationLevel) {
1159 if self.degradation != level {
1160 self.degradation = level;
1161 self.frames_since_change = 0;
1162 self.sync_controller_level();
1163 }
1164 }
1165
1166 #[inline]
1170 pub fn exhausted(&self) -> bool {
1171 self.remaining().is_zero()
1172 || (self.degradation == DegradationLevel::SkipFrame && self.allow_frame_skip)
1173 }
1174
1175 pub fn should_upgrade(&self) -> bool {
1180 !self.degradation.is_full()
1181 && self.remaining_fraction() > self.upgrade_threshold
1182 && self.frames_since_change >= self.cooldown
1183 }
1184
1185 fn should_upgrade_with_elapsed(&self, elapsed: Duration) -> bool {
1187 if self.degradation.is_full() || self.frames_since_change < self.cooldown {
1188 return false;
1189 }
1190 self.remaining_fraction_for_elapsed(elapsed) > self.upgrade_threshold
1191 }
1192
1193 fn remaining_fraction_for_elapsed(&self, elapsed: Duration) -> f32 {
1195 if self.total.is_zero() {
1196 return 0.0;
1197 }
1198 let remaining = self.total.saturating_sub(elapsed);
1199 let remaining = remaining.as_secs_f32();
1200 let total = self.total.as_secs_f32();
1201 (remaining / total).clamp(0.0, 1.0)
1202 }
1203
1204 pub fn upgrade(&mut self) {
1208 let from = self.degradation;
1209 self.degradation = self.degradation.prev();
1210 self.frames_since_change = 0;
1211 self.sync_controller_level();
1212
1213 #[cfg(feature = "tracing")]
1214 if from != self.degradation {
1215 trace!(
1216 from = from.as_str(),
1217 to = self.degradation.as_str(),
1218 remaining_fraction = self.remaining_fraction(),
1219 "render budget upgrade"
1220 );
1221 }
1222 let _ = from; }
1224
1225 pub fn reset(&mut self) {
1229 self.start = Instant::now();
1230 self.frames_since_change = self.frames_since_change.saturating_add(1);
1231 }
1232
1233 pub fn next_frame(&mut self) {
1242 let frame_time = self
1249 .last_frame_time
1250 .take()
1251 .unwrap_or_else(|| self.start.elapsed());
1252
1253 if self.controller.is_some() {
1254 let decision = self
1259 .controller
1260 .as_mut()
1261 .expect("controller guaranteed by is_some guard")
1262 .update(frame_time);
1263
1264 match decision {
1265 BudgetDecision::Degrade => self.degrade(),
1266 BudgetDecision::Upgrade => self.upgrade(),
1267 BudgetDecision::Hold => {}
1268 }
1269 } else {
1270 if self.should_upgrade_with_elapsed(frame_time) {
1272 self.upgrade();
1273 }
1274 }
1275 self.reset();
1276 }
1277
1278 pub fn record_frame_time(&mut self, elapsed: Duration) {
1280 self.last_frame_time = Some(elapsed);
1281 }
1282
1283 #[inline]
1288 pub fn telemetry(&self) -> Option<BudgetTelemetry> {
1289 self.controller.as_ref().map(BudgetController::telemetry)
1290 }
1291
1292 #[inline]
1294 pub fn controller(&self) -> Option<&BudgetController> {
1295 self.controller.as_ref()
1296 }
1297
1298 #[inline]
1300 #[must_use]
1301 pub fn phase_budgets(&self) -> &PhaseBudgets {
1302 &self.phase_budgets
1303 }
1304
1305 pub fn phase_has_budget(&self, phase: Phase) -> bool {
1307 let phase_budget = match phase {
1308 Phase::Diff => self.phase_budgets.diff,
1309 Phase::Present => self.phase_budgets.present,
1310 Phase::Render => self.phase_budgets.render,
1311 };
1312 self.remaining() >= phase_budget
1313 }
1314
1315 #[must_use]
1324 pub fn phase_budget(&self, phase: Phase) -> Self {
1325 let phase_total = match phase {
1326 Phase::Diff => self.phase_budgets.diff,
1327 Phase::Present => self.phase_budgets.present,
1328 Phase::Render => self.phase_budgets.render,
1329 };
1330 Self {
1331 total: phase_total.min(self.remaining()),
1332 start: Instant::now(),
1333 last_frame_time: self.last_frame_time,
1334 degradation: self.degradation,
1335 phase_budgets: self.phase_budgets,
1336 allow_frame_skip: self.allow_frame_skip,
1337 upgrade_threshold: self.upgrade_threshold,
1338 frames_since_change: self.frames_since_change,
1339 cooldown: self.cooldown,
1340 controller: None, }
1342 }
1343}
1344
1345#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1347pub enum Phase {
1348 Diff,
1350 Present,
1352 Render,
1354}
1355
1356impl Phase {
1357 pub fn as_str(self) -> &'static str {
1359 match self {
1360 Self::Diff => "diff",
1361 Self::Present => "present",
1362 Self::Render => "render",
1363 }
1364 }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369 use super::*;
1370 use std::thread;
1371
1372 #[test]
1373 fn degradation_level_ordering() {
1374 assert!(DegradationLevel::Full < DegradationLevel::SimpleBorders);
1375 assert!(DegradationLevel::SimpleBorders < DegradationLevel::NoStyling);
1376 assert!(DegradationLevel::NoStyling < DegradationLevel::EssentialOnly);
1377 assert!(DegradationLevel::EssentialOnly < DegradationLevel::Skeleton);
1378 assert!(DegradationLevel::Skeleton < DegradationLevel::SkipFrame);
1379 }
1380
1381 #[test]
1382 fn degradation_level_next() {
1383 assert_eq!(
1384 DegradationLevel::Full.next(),
1385 DegradationLevel::SimpleBorders
1386 );
1387 assert_eq!(
1388 DegradationLevel::SimpleBorders.next(),
1389 DegradationLevel::NoStyling
1390 );
1391 assert_eq!(
1392 DegradationLevel::NoStyling.next(),
1393 DegradationLevel::EssentialOnly
1394 );
1395 assert_eq!(
1396 DegradationLevel::EssentialOnly.next(),
1397 DegradationLevel::Skeleton
1398 );
1399 assert_eq!(
1400 DegradationLevel::Skeleton.next(),
1401 DegradationLevel::SkipFrame
1402 );
1403 assert_eq!(
1404 DegradationLevel::SkipFrame.next(),
1405 DegradationLevel::SkipFrame
1406 );
1407 }
1408
1409 #[test]
1410 fn degradation_level_prev() {
1411 assert_eq!(
1412 DegradationLevel::SkipFrame.prev(),
1413 DegradationLevel::Skeleton
1414 );
1415 assert_eq!(
1416 DegradationLevel::Skeleton.prev(),
1417 DegradationLevel::EssentialOnly
1418 );
1419 assert_eq!(
1420 DegradationLevel::EssentialOnly.prev(),
1421 DegradationLevel::NoStyling
1422 );
1423 assert_eq!(
1424 DegradationLevel::NoStyling.prev(),
1425 DegradationLevel::SimpleBorders
1426 );
1427 assert_eq!(
1428 DegradationLevel::SimpleBorders.prev(),
1429 DegradationLevel::Full
1430 );
1431 assert_eq!(DegradationLevel::Full.prev(), DegradationLevel::Full);
1432 }
1433
1434 #[test]
1435 fn degradation_level_is_max() {
1436 assert!(!DegradationLevel::Full.is_max());
1437 assert!(!DegradationLevel::Skeleton.is_max());
1438 assert!(DegradationLevel::SkipFrame.is_max());
1439 }
1440
1441 #[test]
1442 fn degradation_level_is_full() {
1443 assert!(DegradationLevel::Full.is_full());
1444 assert!(!DegradationLevel::SimpleBorders.is_full());
1445 assert!(!DegradationLevel::SkipFrame.is_full());
1446 }
1447
1448 #[test]
1449 fn degradation_level_as_str() {
1450 assert_eq!(DegradationLevel::Full.as_str(), "Full");
1451 assert_eq!(DegradationLevel::SimpleBorders.as_str(), "SimpleBorders");
1452 assert_eq!(DegradationLevel::NoStyling.as_str(), "NoStyling");
1453 assert_eq!(DegradationLevel::EssentialOnly.as_str(), "EssentialOnly");
1454 assert_eq!(DegradationLevel::Skeleton.as_str(), "Skeleton");
1455 assert_eq!(DegradationLevel::SkipFrame.as_str(), "SkipFrame");
1456 }
1457
1458 #[test]
1459 fn degradation_level_values() {
1460 assert_eq!(DegradationLevel::Full.level(), 0);
1461 assert_eq!(DegradationLevel::SimpleBorders.level(), 1);
1462 assert_eq!(DegradationLevel::NoStyling.level(), 2);
1463 assert_eq!(DegradationLevel::EssentialOnly.level(), 3);
1464 assert_eq!(DegradationLevel::Skeleton.level(), 4);
1465 assert_eq!(DegradationLevel::SkipFrame.level(), 5);
1466 }
1467
1468 #[test]
1469 fn budget_remaining_decreases() {
1470 let budget = RenderBudget::new(Duration::from_millis(100));
1471 let initial = budget.remaining();
1472
1473 thread::sleep(Duration::from_millis(10));
1474
1475 let later = budget.remaining();
1476 assert!(later < initial);
1477 }
1478
1479 #[test]
1480 fn budget_remaining_fraction() {
1481 let mut budget = RenderBudget::new(Duration::from_millis(100));
1482
1483 let initial = budget.remaining_fraction();
1485 assert!(initial > 0.9);
1486
1487 budget.start = Instant::now() - Duration::from_millis(50);
1492
1493 let later = budget.remaining_fraction();
1495 assert!(
1496 later < 0.6,
1497 "expected remaining_fraction < 0.6, got {later}"
1498 );
1499 assert!(
1500 later > 0.3,
1501 "expected remaining_fraction > 0.3, got {later}"
1502 );
1503 }
1504
1505 #[test]
1506 fn should_degrade_when_cost_exceeds_remaining() {
1507 let mut budget = RenderBudget::new(Duration::from_millis(100));
1510 budget.start = Instant::now() - Duration::from_millis(50);
1511
1512 assert!(budget.should_degrade(Duration::from_millis(80)));
1514 assert!(!budget.should_degrade(Duration::from_millis(10)));
1516 }
1517
1518 #[test]
1519 fn degrade_advances_level() {
1520 let mut budget = RenderBudget::new(Duration::from_millis(16));
1521
1522 assert_eq!(budget.degradation(), DegradationLevel::Full);
1523
1524 budget.degrade();
1525 assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
1526
1527 budget.degrade();
1528 assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1529 }
1530
1531 #[test]
1532 fn exhausted_when_no_time_left() {
1533 let budget = RenderBudget::new(Duration::from_millis(5));
1534
1535 assert!(!budget.exhausted());
1536
1537 thread::sleep(Duration::from_millis(10));
1538
1539 assert!(budget.exhausted());
1540 }
1541
1542 #[test]
1543 fn exhausted_at_skip_frame() {
1544 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1545
1546 budget.set_degradation(DegradationLevel::SkipFrame);
1548
1549 assert!(budget.exhausted());
1551 }
1552
1553 #[test]
1554 fn should_upgrade_with_remaining_budget() {
1555 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1556
1557 assert!(!budget.should_upgrade());
1559
1560 budget.degrade();
1562 budget.frames_since_change = 5;
1563
1564 assert!(budget.should_upgrade());
1566 }
1567
1568 #[test]
1569 fn upgrade_improves_level() {
1570 let mut budget = RenderBudget::new(Duration::from_millis(16));
1571
1572 budget.set_degradation(DegradationLevel::Skeleton);
1573 assert_eq!(budget.degradation(), DegradationLevel::Skeleton);
1574
1575 budget.upgrade();
1576 assert_eq!(budget.degradation(), DegradationLevel::EssentialOnly);
1577
1578 budget.upgrade();
1579 assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1580 }
1581
1582 #[test]
1583 fn upgrade_downgrade_symmetric() {
1584 let mut budget = RenderBudget::new(Duration::from_millis(16));
1585
1586 while !budget.degradation().is_max() {
1588 budget.degrade();
1589 }
1590 assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
1591
1592 while !budget.degradation().is_full() {
1594 budget.upgrade();
1595 }
1596 assert_eq!(budget.degradation(), DegradationLevel::Full);
1597 }
1598
1599 #[test]
1600 fn reset_preserves_degradation() {
1601 let mut budget = RenderBudget::new(Duration::from_millis(16));
1602
1603 budget.degrade();
1604 budget.degrade();
1605 let level = budget.degradation();
1606
1607 budget.reset();
1608
1609 assert_eq!(budget.degradation(), level);
1610 assert!(budget.remaining_fraction() > 0.9);
1612 }
1613
1614 #[test]
1615 fn next_frame_upgrades_when_possible() {
1616 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1617
1618 budget.degrade();
1620 for _ in 0..5 {
1621 budget.reset();
1622 }
1623
1624 let before = budget.degradation();
1625 budget.next_frame();
1626
1627 assert!(budget.degradation() < before);
1629 }
1630
1631 #[test]
1632 fn next_frame_prefers_recorded_frame_time_for_upgrade() {
1633 let mut budget = RenderBudget::new(Duration::from_millis(16));
1634
1635 budget.degrade();
1636 for _ in 0..5 {
1637 budget.reset();
1638 }
1639
1640 budget.record_frame_time(Duration::from_millis(1));
1643 std::thread::sleep(Duration::from_millis(25));
1644
1645 let before = budget.degradation();
1646 budget.next_frame();
1647
1648 assert!(budget.degradation() < before);
1649 }
1650
1651 #[test]
1652 fn config_defaults() {
1653 let config = FrameBudgetConfig::default();
1654
1655 assert_eq!(config.total, Duration::from_millis(16));
1656 assert!(config.allow_frame_skip);
1657 assert_eq!(config.degradation_cooldown, 3);
1658 assert!((config.upgrade_threshold - 0.5).abs() < f32::EPSILON);
1659 }
1660
1661 #[test]
1662 fn config_with_total() {
1663 let config = FrameBudgetConfig::with_total(Duration::from_millis(33));
1664
1665 assert_eq!(config.total, Duration::from_millis(33));
1666 assert!(config.allow_frame_skip);
1668 }
1669
1670 #[test]
1671 fn config_strict() {
1672 let config = FrameBudgetConfig::strict(Duration::from_millis(16));
1673
1674 assert!(!config.allow_frame_skip);
1675 }
1676
1677 #[test]
1678 fn config_relaxed() {
1679 let config = FrameBudgetConfig::relaxed();
1680
1681 assert_eq!(config.total, Duration::from_millis(33));
1682 assert_eq!(config.degradation_cooldown, 5);
1683 }
1684
1685 #[test]
1686 fn from_config() {
1687 let config = FrameBudgetConfig {
1688 total: Duration::from_millis(20),
1689 allow_frame_skip: false,
1690 ..Default::default()
1691 };
1692
1693 let budget = RenderBudget::from_config(&config);
1694
1695 assert_eq!(budget.total(), Duration::from_millis(20));
1696 assert!(!budget.exhausted()); let mut budget = RenderBudget::from_config(&config);
1700 budget.set_degradation(DegradationLevel::SkipFrame);
1701 assert!(!budget.exhausted());
1702 }
1703
1704 #[test]
1705 fn phase_budgets_default() {
1706 let budgets = PhaseBudgets::default();
1707
1708 assert_eq!(budgets.diff, Duration::from_millis(2));
1709 assert_eq!(budgets.present, Duration::from_millis(4));
1710 assert_eq!(budgets.render, Duration::from_millis(8));
1711 }
1712
1713 #[test]
1714 fn phase_has_budget() {
1715 let budget = RenderBudget::new(Duration::from_millis(100));
1716
1717 assert!(budget.phase_has_budget(Phase::Diff));
1718 assert!(budget.phase_has_budget(Phase::Present));
1719 assert!(budget.phase_has_budget(Phase::Render));
1720 }
1721
1722 #[test]
1723 fn phase_budget_respects_remaining() {
1724 let budget = RenderBudget::new(Duration::from_millis(100));
1725
1726 let diff_budget = budget.phase_budget(Phase::Diff);
1727 assert_eq!(diff_budget.total(), Duration::from_millis(2));
1728
1729 let present_budget = budget.phase_budget(Phase::Present);
1730 assert_eq!(present_budget.total(), Duration::from_millis(4));
1731 }
1732
1733 #[test]
1734 fn phase_as_str() {
1735 assert_eq!(Phase::Diff.as_str(), "diff");
1736 assert_eq!(Phase::Present.as_str(), "present");
1737 assert_eq!(Phase::Render.as_str(), "render");
1738 }
1739
1740 #[test]
1741 fn zero_budget_is_immediately_exhausted() {
1742 let budget = RenderBudget::new(Duration::ZERO);
1743 assert!(budget.exhausted());
1744 assert_eq!(budget.remaining_fraction(), 0.0);
1745 }
1746
1747 #[test]
1748 fn degradation_level_never_exceeds_skip_frame() {
1749 let mut level = DegradationLevel::Full;
1750
1751 for _ in 0..100 {
1752 level = level.next();
1753 }
1754
1755 assert_eq!(level, DegradationLevel::SkipFrame);
1756 }
1757
1758 #[test]
1759 fn budget_remaining_never_negative() {
1760 let budget = RenderBudget::new(Duration::from_millis(1));
1761
1762 thread::sleep(Duration::from_millis(10));
1764
1765 assert_eq!(budget.remaining(), Duration::ZERO);
1767 assert_eq!(budget.remaining_fraction(), 0.0);
1768 }
1769
1770 #[test]
1771 fn infinite_budget_stays_at_full() {
1772 let mut budget = RenderBudget::new(Duration::from_secs(1000));
1773
1774 assert!(!budget.should_degrade(Duration::from_millis(100)));
1776 assert_eq!(budget.degradation(), DegradationLevel::Full);
1777
1778 budget.next_frame();
1780 assert_eq!(budget.degradation(), DegradationLevel::Full);
1781 }
1782
1783 #[test]
1784 fn cooldown_prevents_immediate_upgrade() {
1785 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1786 budget.cooldown = 3;
1787
1788 budget.degrade();
1790 assert_eq!(budget.frames_since_change, 0);
1791
1792 assert!(!budget.should_upgrade());
1794
1795 budget.frames_since_change = 3;
1797
1798 assert!(budget.should_upgrade());
1800 }
1801
1802 #[test]
1803 fn set_degradation_resets_cooldown() {
1804 let mut budget = RenderBudget::new(Duration::from_millis(16));
1805 budget.frames_since_change = 10;
1806
1807 budget.set_degradation(DegradationLevel::NoStyling);
1808
1809 assert_eq!(budget.frames_since_change, 0);
1810 }
1811
1812 #[test]
1813 fn set_degradation_same_level_preserves_cooldown() {
1814 let mut budget = RenderBudget::new(Duration::from_millis(16));
1815 budget.frames_since_change = 10;
1816
1817 budget.set_degradation(DegradationLevel::Full);
1819
1820 assert_eq!(budget.frames_since_change, 10);
1822 }
1823
1824 mod controller_tests {
1829 use super::super::*;
1830
1831 fn make_controller() -> BudgetController {
1832 BudgetController::new(BudgetControllerConfig::default())
1833 }
1834
1835 fn make_controller_with_config(
1836 target_ms: u64,
1837 warmup: u32,
1838 cooldown: u32,
1839 ) -> BudgetController {
1840 BudgetController::new(BudgetControllerConfig {
1841 target: Duration::from_millis(target_ms),
1842 eprocess: EProcessConfig {
1843 warmup_frames: warmup,
1844 ..Default::default()
1845 },
1846 cooldown_frames: cooldown,
1847 ..Default::default()
1848 })
1849 }
1850
1851 #[test]
1854 fn pid_step_input_yields_nonzero_output() {
1855 let mut state = PidState::default();
1856 let gains = PidGains::default();
1857
1858 let u = state.update(1.0, &gains);
1860 assert!(
1862 (u - 0.75).abs() < 1e-10,
1863 "First PID output should be 0.75, got {}",
1864 u
1865 );
1866 }
1867
1868 #[test]
1869 fn pid_zero_error_zero_output() {
1870 let mut state = PidState::default();
1871 let gains = PidGains::default();
1872
1873 let u = state.update(0.0, &gains);
1874 assert!(
1875 u.abs() < 1e-10,
1876 "Zero error should produce zero output, got {}",
1877 u
1878 );
1879 }
1880
1881 #[test]
1882 fn pid_integral_accumulates() {
1883 let mut state = PidState::default();
1884 let gains = PidGains::default();
1885
1886 state.update(1.0, &gains);
1888 state.update(1.0, &gains);
1889 state.update(1.0, &gains);
1890
1891 assert!(
1892 state.integral > 2.5,
1893 "Integral should accumulate: {}",
1894 state.integral
1895 );
1896 }
1897
1898 #[test]
1899 fn pid_integral_anti_windup() {
1900 let mut state = PidState::default();
1901 let gains = PidGains {
1902 integral_max: 2.0,
1903 ..Default::default()
1904 };
1905
1906 for _ in 0..100 {
1908 state.update(10.0, &gains);
1909 }
1910
1911 assert!(
1912 state.integral <= 2.0 + f64::EPSILON,
1913 "Integral should be clamped to max: {}",
1914 state.integral
1915 );
1916 assert!(
1917 state.integral >= -2.0 - f64::EPSILON,
1918 "Integral should be clamped to -max: {}",
1919 state.integral
1920 );
1921 }
1922
1923 #[test]
1924 fn pid_derivative_responds_to_change() {
1925 let mut state = PidState::default();
1926 let gains = PidGains::default();
1927
1928 let u1 = state.update(0.0, &gains);
1930 let u2 = state.update(1.0, &gains);
1932
1933 assert!(
1935 u2 > u1,
1936 "Step change should produce larger output: u1={}, u2={}",
1937 u1,
1938 u2
1939 );
1940 }
1941
1942 #[test]
1943 fn pid_settling_after_step() {
1944 let mut state = PidState::default();
1945 let gains = PidGains::default();
1946
1947 state.update(1.0, &gains);
1949 state.update(1.0, &gains);
1950 state.update(1.0, &gains);
1951
1952 let mut outputs = Vec::new();
1954 for _ in 0..20 {
1955 outputs.push(state.update(0.0, &gains));
1956 }
1957
1958 let last = *outputs.last().unwrap();
1960 assert!(
1961 last.abs() < 0.5,
1962 "PID should settle toward zero: last={}",
1963 last
1964 );
1965 }
1966
1967 #[test]
1968 fn pid_reset_clears_state() {
1969 let mut state = PidState::default();
1970 let gains = PidGains::default();
1971
1972 state.update(5.0, &gains);
1973 state.update(5.0, &gains);
1974 assert!(state.integral.abs() > 0.0);
1975
1976 state.reset();
1977 assert_eq!(state.integral, 0.0);
1978 assert_eq!(state.prev_error, 0.0);
1979 }
1980
1981 #[test]
1984 fn eprocess_starts_at_one() {
1985 let state = EProcessState::default();
1986 assert!(
1987 (state.e_value - 1.0).abs() < f64::EPSILON,
1988 "E-process should start at 1.0"
1989 );
1990 }
1991
1992 #[test]
1993 fn eprocess_grows_under_overload() {
1994 let mut state = EProcessState::default();
1995 let config = EProcessConfig {
1996 warmup_frames: 0,
1997 ..Default::default()
1998 };
1999
2000 for _ in 0..20 {
2002 state.update(30.0, 16.0, &config);
2003 }
2004
2005 assert!(
2006 state.e_value > 1.0,
2007 "E-value should grow under overload: {}",
2008 state.e_value
2009 );
2010 }
2011
2012 #[test]
2013 fn eprocess_shrinks_under_underload() {
2014 let mut state = EProcessState::default();
2015 let config = EProcessConfig {
2016 warmup_frames: 0,
2017 ..Default::default()
2018 };
2019
2020 for _ in 0..20 {
2022 state.update(8.0, 16.0, &config);
2023 }
2024
2025 assert!(
2026 state.e_value < 1.0,
2027 "E-value should shrink under underload: {}",
2028 state.e_value
2029 );
2030 }
2031
2032 #[test]
2033 fn eprocess_gate_blocks_during_warmup() {
2034 let mut state = EProcessState::default();
2035 let config = EProcessConfig {
2036 warmup_frames: 10,
2037 ..Default::default()
2038 };
2039
2040 for _ in 0..5 {
2042 state.update(50.0, 16.0, &config);
2043 }
2044
2045 assert!(
2046 !state.should_degrade(&config),
2047 "E-process should not permit degradation during warmup"
2048 );
2049 }
2050
2051 #[test]
2052 fn eprocess_gate_allows_after_warmup() {
2053 let mut state = EProcessState::default();
2054 let config = EProcessConfig {
2055 warmup_frames: 5,
2056 alpha: 0.05,
2057 ..Default::default()
2058 };
2059
2060 for _ in 0..50 {
2062 state.update(80.0, 16.0, &config);
2063 }
2064
2065 assert!(
2066 state.should_degrade(&config),
2067 "E-process should permit degradation after sustained overload: E={}",
2068 state.e_value
2069 );
2070 }
2071
2072 #[test]
2073 fn eprocess_recovery_after_overload() {
2074 let mut state = EProcessState::default();
2075 let config = EProcessConfig {
2076 warmup_frames: 0,
2077 ..Default::default()
2078 };
2079
2080 for _ in 0..30 {
2082 state.update(40.0, 16.0, &config);
2083 }
2084 let peak = state.e_value;
2085
2086 for _ in 0..100 {
2088 state.update(8.0, 16.0, &config);
2089 }
2090
2091 assert!(
2092 state.e_value < peak,
2093 "E-value should decrease after recovery: peak={}, now={}",
2094 peak,
2095 state.e_value
2096 );
2097 }
2098
2099 #[test]
2100 fn eprocess_sigma_floor_prevents_instability() {
2101 let mut state = EProcessState::default();
2102 let config = EProcessConfig {
2103 sigma_floor_ms: 1.0,
2104 warmup_frames: 0,
2105 ..Default::default()
2106 };
2107
2108 for _ in 0..20 {
2110 state.update(16.0, 16.0, &config);
2111 }
2112
2113 assert!(
2115 state.sigma_ema >= 0.0,
2116 "Sigma should be non-negative: {}",
2117 state.sigma_ema
2118 );
2119 assert!(
2121 state.e_value.is_finite(),
2122 "E-value should be finite: {}",
2123 state.e_value
2124 );
2125 }
2126
2127 #[test]
2128 fn eprocess_reset_returns_to_initial() {
2129 let mut state = EProcessState::default();
2130 let config = EProcessConfig::default();
2131
2132 state.update(50.0, 16.0, &config);
2133 state.update(50.0, 16.0, &config);
2134
2135 state.reset();
2136 assert!((state.e_value - 1.0).abs() < f64::EPSILON);
2137 assert_eq!(state.frames_observed, 0);
2138 }
2139
2140 #[test]
2143 fn controller_holds_under_normal_load() {
2144 let mut ctrl = make_controller_with_config(16, 0, 0);
2145
2146 for _ in 0..20 {
2148 let decision = ctrl.update(Duration::from_millis(16));
2149 assert_eq!(
2150 decision,
2151 BudgetDecision::Hold,
2152 "On-target frames should hold"
2153 );
2154 }
2155 assert_eq!(ctrl.level(), DegradationLevel::Full);
2156 }
2157
2158 #[test]
2159 fn controller_degrades_under_sustained_overload() {
2160 let mut ctrl = make_controller_with_config(16, 0, 0);
2161
2162 let mut degraded = false;
2163 for _ in 0..50 {
2165 let decision = ctrl.update(Duration::from_millis(40));
2166 if decision == BudgetDecision::Degrade {
2167 degraded = true;
2168 }
2169 }
2170
2171 assert!(
2172 degraded,
2173 "Controller should degrade under sustained overload"
2174 );
2175 assert!(
2176 ctrl.level() > DegradationLevel::Full,
2177 "Level should be degraded: {:?}",
2178 ctrl.level()
2179 );
2180 }
2181
2182 #[test]
2183 fn controller_upgrades_after_recovery() {
2184 let mut ctrl = make_controller_with_config(16, 0, 0);
2185
2186 for _ in 0..50 {
2188 ctrl.update(Duration::from_millis(40));
2189 }
2190 let degraded_level = ctrl.level();
2191 assert!(degraded_level > DegradationLevel::Full);
2192
2193 let mut upgraded = false;
2195 for _ in 0..200 {
2196 let decision = ctrl.update(Duration::from_millis(4));
2197 if decision == BudgetDecision::Upgrade {
2198 upgraded = true;
2199 }
2200 }
2201
2202 assert!(upgraded, "Controller should upgrade after recovery");
2203 assert!(
2204 ctrl.level() < degraded_level,
2205 "Level should improve: before={:?}, after={:?}",
2206 degraded_level,
2207 ctrl.level()
2208 );
2209 }
2210
2211 #[test]
2212 fn controller_cooldown_prevents_oscillation() {
2213 let mut ctrl = make_controller_with_config(16, 0, 5);
2214
2215 for _ in 0..50 {
2217 ctrl.update(Duration::from_millis(40));
2218 }
2219
2220 let mut decisions_during_cooldown = Vec::new();
2222 for _ in 0..4 {
2223 decisions_during_cooldown.push(ctrl.update(Duration::from_millis(4)));
2224 }
2225
2226 assert!(
2228 decisions_during_cooldown
2229 .iter()
2230 .all(|d| *d == BudgetDecision::Hold),
2231 "Cooldown should prevent changes: {:?}",
2232 decisions_during_cooldown
2233 );
2234 }
2235
2236 #[test]
2237 fn controller_no_oscillation_under_constant_load() {
2238 let mut ctrl = make_controller_with_config(16, 0, 3);
2239
2240 let mut transitions = 0u32;
2242 let mut prev_level = ctrl.level();
2243 for _ in 0..100 {
2244 ctrl.update(Duration::from_millis(20));
2245 if ctrl.level() != prev_level {
2246 transitions += 1;
2247 prev_level = ctrl.level();
2248 }
2249 }
2250
2251 assert!(
2254 transitions < 10,
2255 "Too many transitions under constant load: {}",
2256 transitions
2257 );
2258 }
2259
2260 #[test]
2261 fn controller_reset_restores_full_quality() {
2262 let mut ctrl = make_controller();
2263
2264 for _ in 0..50 {
2266 ctrl.update(Duration::from_millis(40));
2267 }
2268
2269 ctrl.reset();
2270
2271 assert_eq!(ctrl.level(), DegradationLevel::Full);
2272 assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
2273 assert_eq!(ctrl.pid_integral(), 0.0);
2274 }
2275
2276 #[test]
2277 fn controller_transient_spike_does_not_degrade() {
2278 let mut ctrl = make_controller_with_config(16, 5, 3);
2279
2280 for _ in 0..20 {
2282 ctrl.update(Duration::from_millis(16));
2283 }
2284
2285 ctrl.update(Duration::from_millis(100));
2287
2288 for _ in 0..5 {
2290 ctrl.update(Duration::from_millis(16));
2291 }
2292
2293 assert_eq!(
2295 ctrl.level(),
2296 DegradationLevel::Full,
2297 "Single spike should not cause degradation"
2298 );
2299 }
2300
2301 #[test]
2302 fn controller_never_exceeds_skip_frame() {
2303 let mut ctrl = make_controller_with_config(16, 0, 0);
2304
2305 for _ in 0..500 {
2307 ctrl.update(Duration::from_millis(200));
2308 }
2309
2310 assert!(
2311 ctrl.level() <= DegradationLevel::SkipFrame,
2312 "Level should not exceed SkipFrame: {:?}",
2313 ctrl.level()
2314 );
2315 }
2316
2317 #[test]
2318 fn controller_never_goes_below_full() {
2319 let mut ctrl = make_controller_with_config(16, 0, 0);
2320
2321 for _ in 0..200 {
2323 ctrl.update(Duration::from_millis(1));
2324 }
2325
2326 assert_eq!(
2327 ctrl.level(),
2328 DegradationLevel::Full,
2329 "Level should not go below Full"
2330 );
2331 }
2332
2333 #[test]
2336 fn pid_gains_default_valid() {
2337 let gains = PidGains::default();
2338 assert!(gains.kp > 0.0);
2339 assert!(gains.ki > 0.0);
2340 assert!(gains.kd > 0.0);
2341 assert!(gains.integral_max > 0.0);
2342 }
2343
2344 #[test]
2345 fn eprocess_config_default_valid() {
2346 let config = EProcessConfig::default();
2347 assert!(config.lambda > 0.0);
2348 assert!(config.alpha > 0.0 && config.alpha < 1.0);
2349 assert!(config.beta > 0.0 && config.beta < 1.0);
2350 assert!(config.sigma_floor_ms > 0.0);
2351 }
2352
2353 #[test]
2354 fn controller_config_default_valid() {
2355 let config = BudgetControllerConfig::default();
2356 assert!(config.degrade_threshold > 0.0);
2357 assert!(config.upgrade_threshold > 0.0);
2358 assert!(config.target > Duration::ZERO);
2359 }
2360
2361 #[test]
2362 fn budget_decision_equality() {
2363 assert_eq!(BudgetDecision::Hold, BudgetDecision::Hold);
2364 assert_ne!(BudgetDecision::Hold, BudgetDecision::Degrade);
2365 assert_ne!(BudgetDecision::Degrade, BudgetDecision::Upgrade);
2366 }
2367 }
2368
2369 mod integration_tests {
2374 use super::super::*;
2375
2376 #[test]
2377 fn render_budget_without_controller_returns_no_telemetry() {
2378 let budget = RenderBudget::new(Duration::from_millis(16));
2379 assert!(budget.telemetry().is_none());
2380 assert!(budget.controller().is_none());
2381 }
2382
2383 #[test]
2384 fn render_budget_with_controller_returns_telemetry() {
2385 let budget = RenderBudget::new(Duration::from_millis(16))
2386 .with_controller(BudgetControllerConfig::default());
2387 assert!(budget.controller().is_some());
2388
2389 let telem = budget.telemetry().unwrap();
2390 assert_eq!(telem.level, DegradationLevel::Full);
2391 assert_eq!(telem.last_decision, BudgetDecision::Hold);
2392 assert_eq!(telem.frames_observed, 0);
2393 assert!(telem.in_warmup);
2394 }
2395
2396 #[test]
2397 fn telemetry_fields_update_after_next_frame() {
2398 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2399 BudgetControllerConfig {
2400 eprocess: EProcessConfig {
2401 warmup_frames: 0,
2402 ..Default::default()
2403 },
2404 cooldown_frames: 0,
2405 ..Default::default()
2406 },
2407 );
2408
2409 for _ in 0..5 {
2411 budget.next_frame();
2412 }
2413
2414 let telem = budget.telemetry().unwrap();
2415 assert_eq!(telem.frames_observed, 5);
2416 assert!(!telem.in_warmup);
2417 assert!(telem.pid_output.is_finite());
2420 assert!(telem.e_value.is_finite());
2421 }
2422
2423 #[test]
2424 fn controller_next_frame_degrades_under_simulated_overload() {
2425 let config = BudgetControllerConfig {
2430 target: Duration::from_millis(16),
2431 eprocess: EProcessConfig {
2432 warmup_frames: 0,
2433 ..Default::default()
2434 },
2435 cooldown_frames: 0,
2436 ..Default::default()
2437 };
2438 let mut ctrl = BudgetController::new(config);
2439
2440 for _ in 0..50 {
2442 ctrl.update(Duration::from_millis(40));
2443 }
2444
2445 assert!(
2447 ctrl.level() > DegradationLevel::Full,
2448 "Controller should degrade: {:?}",
2449 ctrl.level()
2450 );
2451
2452 let telem = ctrl.telemetry();
2454 assert!(telem.level > DegradationLevel::Full);
2455 assert!(
2456 telem.pid_output > 0.0,
2457 "PID output should be positive under overload"
2458 );
2459 assert!(telem.e_value > 1.0, "E-value should grow under overload");
2460 }
2461
2462 #[test]
2463 fn next_frame_delegates_to_controller_when_attached() {
2464 let mut budget = RenderBudget::new(Duration::from_millis(1000))
2467 .with_controller(BudgetControllerConfig::default());
2468
2469 budget.degrade();
2471 assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
2472
2473 budget.next_frame();
2477
2478 let telem = budget.telemetry().unwrap();
2483 assert_eq!(telem.frames_observed, 1);
2484 }
2485
2486 #[test]
2487 fn telemetry_is_copy_and_no_alloc() {
2488 let budget = RenderBudget::new(Duration::from_millis(16))
2489 .with_controller(BudgetControllerConfig::default());
2490
2491 let telem = budget.telemetry().unwrap();
2492 let telem2 = telem;
2494 assert_eq!(telem.level, telem2.level);
2495 assert_eq!(telem.e_value, telem2.e_value);
2496 }
2497
2498 #[test]
2499 fn telemetry_warmup_flag_transitions() {
2500 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2501 BudgetControllerConfig {
2502 eprocess: EProcessConfig {
2503 warmup_frames: 3,
2504 ..Default::default()
2505 },
2506 ..Default::default()
2507 },
2508 );
2509
2510 budget.next_frame();
2512 budget.next_frame();
2513 let telem = budget.telemetry().unwrap();
2514 assert!(telem.in_warmup, "Should be in warmup at frame 2");
2515
2516 budget.next_frame();
2518 let telem = budget.telemetry().unwrap();
2519 assert!(!telem.in_warmup, "Should exit warmup at frame 3");
2520 }
2521
2522 #[test]
2523 fn phase_sub_budget_does_not_carry_controller() {
2524 let budget = RenderBudget::new(Duration::from_millis(100))
2525 .with_controller(BudgetControllerConfig::default());
2526
2527 let phase = budget.phase_budget(Phase::Render);
2528 assert!(
2529 phase.controller().is_none(),
2530 "Phase sub-budgets should not carry the controller"
2531 );
2532 }
2533
2534 #[test]
2535 fn controller_telemetry_tracks_frames_since_change() {
2536 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2537 eprocess: EProcessConfig {
2538 warmup_frames: 0,
2539 ..Default::default()
2540 },
2541 cooldown_frames: 0,
2542 ..Default::default()
2543 });
2544
2545 for i in 1..=5 {
2547 ctrl.update(Duration::from_millis(16));
2548 let telem = ctrl.telemetry();
2549 assert_eq!(
2550 telem.frames_since_change, i,
2551 "frames_since_change should be {} after {} frames",
2552 i, i
2553 );
2554 }
2555 }
2556
2557 #[test]
2558 fn telemetry_last_decision_reflects_controller_decision() {
2559 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2560 eprocess: EProcessConfig {
2561 warmup_frames: 0,
2562 ..Default::default()
2563 },
2564 cooldown_frames: 0,
2565 ..Default::default()
2566 });
2567
2568 ctrl.update(Duration::from_millis(16));
2570 assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Hold);
2571
2572 let mut saw_degrade = false;
2574 for _ in 0..50 {
2575 let d = ctrl.update(Duration::from_millis(50));
2576 if d == BudgetDecision::Degrade {
2577 saw_degrade = true;
2578 assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Degrade);
2579 break;
2580 }
2581 }
2582 assert!(saw_degrade, "Should have seen a Degrade decision");
2583 }
2584
2585 #[test]
2586 fn perf_overhead_controller_update_is_fast() {
2587 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2591
2592 let start = Instant::now();
2593 for _ in 0..10_000 {
2594 ctrl.update(Duration::from_millis(16));
2595 }
2596 let elapsed = start.elapsed();
2597
2598 assert!(
2602 elapsed < Duration::from_millis(50),
2603 "10k controller updates took {:?}, expected <50ms",
2604 elapsed
2605 );
2606 }
2607
2608 #[test]
2609 fn perf_overhead_telemetry_snapshot_is_fast() {
2610 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2611 ctrl.update(Duration::from_millis(16));
2612
2613 let start = Instant::now();
2614 for _ in 0..10_000 {
2615 let _telem = ctrl.telemetry();
2616 }
2617 let elapsed = start.elapsed();
2618
2619 assert!(
2620 elapsed < Duration::from_millis(10),
2621 "10k telemetry snapshots took {:?}, expected <10ms",
2622 elapsed
2623 );
2624 }
2625 }
2626
2627 mod stability_tests {
2632 use super::super::*;
2633
2634 #[derive(Debug, Clone)]
2635 struct CampaignFrameLog {
2636 frame_idx: u64,
2637 phase: &'static str,
2638 frame_time_us: u64,
2639 telemetry: BudgetTelemetry,
2640 }
2641
2642 fn fast_controller(target_ms: u64) -> BudgetController {
2644 BudgetController::new(BudgetControllerConfig {
2645 target: Duration::from_millis(target_ms),
2646 eprocess: EProcessConfig {
2647 warmup_frames: 0,
2648 ..Default::default()
2649 },
2650 cooldown_frames: 0,
2651 ..Default::default()
2652 })
2653 }
2654
2655 fn run_trace(
2659 ctrl: &mut BudgetController,
2660 trace: &[Duration],
2661 ) -> Vec<(u64, u64, BudgetTelemetry)> {
2662 trace
2663 .iter()
2664 .enumerate()
2665 .map(|(i, &ft)| {
2666 ctrl.update(ft);
2667 let telem = ctrl.telemetry();
2668 (i as u64, ft.as_micros() as u64, telem)
2669 })
2670 .collect()
2671 }
2672
2673 fn run_campaign(
2675 ctrl: &mut BudgetController,
2676 phases: &[(&'static str, usize, Duration)],
2677 ) -> Vec<CampaignFrameLog> {
2678 let mut logs = Vec::new();
2679 let mut frame_idx: u64 = 0;
2680 for &(phase, count, frame_time) in phases {
2681 for _ in 0..count {
2682 ctrl.update(frame_time);
2683 logs.push(CampaignFrameLog {
2684 frame_idx,
2685 phase,
2686 frame_time_us: frame_time.as_micros() as u64,
2687 telemetry: ctrl.telemetry(),
2688 });
2689 frame_idx = frame_idx.saturating_add(1);
2690 }
2691 }
2692 logs
2693 }
2694
2695 fn count_transitions(log: &[(u64, u64, BudgetTelemetry)]) -> u32 {
2697 let mut transitions = 0u32;
2698 for pair in log.windows(2) {
2699 if pair[0].2.level != pair[1].2.level {
2700 transitions += 1;
2701 }
2702 }
2703 transitions
2704 }
2705
2706 #[test]
2709 fn e2e_burst_logs_no_oscillation() {
2710 let mut ctrl = fast_controller(16);
2713
2714 let mut trace = Vec::new();
2715 for _cycle in 0..5 {
2716 for _ in 0..10 {
2718 trace.push(Duration::from_millis(40));
2719 }
2720 for _ in 0..20 {
2722 trace.push(Duration::from_millis(16));
2723 }
2724 }
2725
2726 let log = run_trace(&mut ctrl, &trace);
2727
2728 let transitions = count_transitions(&log);
2733 assert!(
2734 transitions < 20,
2735 "Too many transitions under bursty load: {} (expected <20)",
2736 transitions
2737 );
2738
2739 for (frame, ft_us, telem) in &log {
2741 assert!(
2742 telem.pid_output.is_finite(),
2743 "frame {}: NaN pid_output",
2744 frame
2745 );
2746 assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2747 assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2748 assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2749 assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2750 assert!(*ft_us > 0, "frame {}: zero frame time", frame);
2751 }
2752 }
2753
2754 #[test]
2755 fn e2e_burst_recovers_after_moderate_overload() {
2756 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2760 target: Duration::from_millis(16),
2761 eprocess: EProcessConfig {
2762 warmup_frames: 5,
2763 ..Default::default()
2764 },
2765 cooldown_frames: 3,
2766 ..Default::default()
2767 });
2768
2769 let mut trace = Vec::new();
2770 for _cycle in 0..3 {
2771 for _ in 0..15 {
2773 trace.push(Duration::from_millis(30));
2774 }
2775 for _ in 0..50 {
2777 trace.push(Duration::from_millis(10));
2778 }
2779 }
2780
2781 let log = run_trace(&mut ctrl, &trace);
2782
2783 for cycle in 0..3 {
2786 let calm_end = (cycle + 1) * 65 - 1;
2787 if calm_end < log.len() {
2788 assert!(
2789 log[calm_end].2.level < DegradationLevel::SkipFrame,
2790 "cycle {}: should recover after calm period, got {:?} at frame {}",
2791 cycle,
2792 log[calm_end].2.level,
2793 calm_end
2794 );
2795 }
2796 }
2797
2798 let final_level = log.last().unwrap().2.level;
2800 assert!(
2801 final_level < DegradationLevel::Skeleton,
2802 "Final level should recover below Skeleton: {:?}",
2803 final_level
2804 );
2805 }
2806
2807 #[test]
2810 fn e2e_idle_to_burst_recovery() {
2811 let mut ctrl = fast_controller(16);
2814
2815 let mut trace = Vec::new();
2816 for _ in 0..50 {
2818 trace.push(Duration::from_millis(8));
2819 }
2820 for _ in 0..20 {
2822 trace.push(Duration::from_millis(50));
2823 }
2824 for _ in 0..100 {
2826 trace.push(Duration::from_millis(8));
2827 }
2828
2829 let log = run_trace(&mut ctrl, &trace);
2830
2831 assert_eq!(
2833 log[49].2.level,
2834 DegradationLevel::Full,
2835 "Should be Full during idle phase"
2836 );
2837
2838 let max_during_burst = log[50..70].iter().map(|(_, _, t)| t.level).max().unwrap();
2840 assert!(
2841 max_during_burst > DegradationLevel::Full,
2842 "Should degrade during burst"
2843 );
2844
2845 let final_level = log.last().unwrap().2.level;
2847 assert!(
2848 final_level < max_during_burst,
2849 "Should recover after burst: final={:?}, max_during_burst={:?}",
2850 final_level,
2851 max_during_burst
2852 );
2853 }
2854
2855 #[test]
2856 fn e2e_idle_to_burst_no_over_degrade() {
2857 let mut ctrl = fast_controller(16);
2860
2861 for _ in 0..30 {
2863 ctrl.update(Duration::from_millis(8));
2864 }
2865
2866 for _ in 0..5 {
2868 ctrl.update(Duration::from_millis(40));
2869 }
2870
2871 let level = ctrl.level();
2873 assert!(
2874 level <= DegradationLevel::NoStyling,
2875 "Brief burst should not over-degrade: {:?}",
2876 level
2877 );
2878 }
2879
2880 #[test]
2881 fn e2e_overload_campaign_burst_sustained_recovery_with_replay_logs() {
2882 let phases: [(&str, usize, Duration); 3] = [
2889 ("burst_overload", 24, Duration::from_millis(28)),
2890 ("sustained_overload", 80, Duration::from_millis(52)),
2891 ("recovery_underload", 140, Duration::from_millis(8)),
2892 ];
2893
2894 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2895 target: Duration::from_millis(16),
2896 eprocess: EProcessConfig {
2897 warmup_frames: 0,
2898 ..Default::default()
2899 },
2900 cooldown_frames: 0,
2901 degradation_floor: DegradationLevel::SkipFrame,
2902 ..Default::default()
2903 });
2904 let logs = run_campaign(&mut ctrl, &phases);
2905 assert!(!logs.is_empty(), "campaign logs must be non-empty");
2906
2907 let mut burst_degrades = 0u32;
2908 let mut sustained_degrades = 0u32;
2909 let mut sustained_degraded_frames = 0u32;
2910 let mut recovery_upgrades = 0u32;
2911 let mut max_level = DegradationLevel::Full;
2912
2913 for log in &logs {
2914 let telem = &log.telemetry;
2915 if telem.level > max_level {
2916 max_level = telem.level;
2917 }
2918 if log.phase == "burst_overload" && telem.last_decision == BudgetDecision::Degrade {
2919 burst_degrades = burst_degrades.saturating_add(1);
2920 }
2921 if log.phase == "sustained_overload"
2922 && telem.last_decision == BudgetDecision::Degrade
2923 {
2924 sustained_degrades = sustained_degrades.saturating_add(1);
2925 }
2926 if log.phase == "sustained_overload" && telem.level > DegradationLevel::Full {
2927 sustained_degraded_frames = sustained_degraded_frames.saturating_add(1);
2928 }
2929 if log.phase == "recovery_underload"
2930 && telem.last_decision == BudgetDecision::Upgrade
2931 {
2932 recovery_upgrades = recovery_upgrades.saturating_add(1);
2933 }
2934
2935 assert!(
2937 telem.level <= DegradationLevel::SkipFrame,
2938 "frame {}: invalid degradation level {:?}",
2939 log.frame_idx,
2940 telem.level
2941 );
2942 assert!(
2943 telem.e_value.is_finite() && telem.e_value > 0.0,
2944 "frame {}: invalid e_value {}",
2945 log.frame_idx,
2946 telem.e_value
2947 );
2948 assert!(
2949 telem.pid_output.is_finite(),
2950 "frame {}: invalid pid_output {}",
2951 log.frame_idx,
2952 telem.pid_output
2953 );
2954 }
2955
2956 for pair in logs.windows(2) {
2958 let prev = pair[0].telemetry.level.level();
2959 let curr = pair[1].telemetry.level.level();
2960 let delta = (curr as i16 - prev as i16).unsigned_abs();
2961 assert!(
2962 delta <= 1,
2963 "frame {}->{} level jump {}: {:?} -> {:?}",
2964 pair[0].frame_idx,
2965 pair[1].frame_idx,
2966 delta,
2967 pair[0].telemetry.level,
2968 pair[1].telemetry.level
2969 );
2970 }
2971
2972 assert!(
2973 burst_degrades > 0,
2974 "burst phase should trigger degradation decisions"
2975 );
2976 assert!(
2977 sustained_degrades > 0 || sustained_degraded_frames > 0,
2978 "sustained overload phase should maintain degraded operation"
2979 );
2980 assert!(
2981 max_level >= DegradationLevel::Skeleton,
2982 "sustained overload should reach deep degradation (got {:?})",
2983 max_level
2984 );
2985 assert!(
2986 recovery_upgrades > 0,
2987 "recovery phase should trigger upgrade decisions"
2988 );
2989
2990 let final_level = logs
2991 .last()
2992 .map(|entry| entry.telemetry.level)
2993 .unwrap_or(DegradationLevel::SkipFrame);
2994 assert!(
2995 final_level < max_level,
2996 "final level should recover below peak degradation: final={:?} peak={:?}",
2997 final_level,
2998 max_level
2999 );
3000
3001 let mut ctrl_replay = BudgetController::new(BudgetControllerConfig {
3003 target: Duration::from_millis(16),
3004 eprocess: EProcessConfig {
3005 warmup_frames: 0,
3006 ..Default::default()
3007 },
3008 cooldown_frames: 0,
3009 degradation_floor: DegradationLevel::SkipFrame,
3010 ..Default::default()
3011 });
3012 let replay_logs = run_campaign(&mut ctrl_replay, &phases);
3013 assert_eq!(
3014 logs.len(),
3015 replay_logs.len(),
3016 "log length mismatch in replay"
3017 );
3018 for (lhs, rhs) in logs.iter().zip(replay_logs.iter()) {
3019 assert_eq!(lhs.frame_idx, rhs.frame_idx);
3020 assert_eq!(lhs.phase, rhs.phase);
3021 assert_eq!(lhs.frame_time_us, rhs.frame_time_us);
3022 assert_eq!(lhs.telemetry.schema_version, rhs.telemetry.schema_version);
3023 assert_eq!(lhs.telemetry.level, rhs.telemetry.level);
3024 assert_eq!(lhs.telemetry.last_decision, rhs.telemetry.last_decision);
3025 assert_eq!(
3026 lhs.telemetry.decision_reason, rhs.telemetry.decision_reason,
3027 "decision_reason mismatch at frame {}",
3028 lhs.frame_idx
3029 );
3030 assert_eq!(
3031 lhs.telemetry.transition_seq, rhs.telemetry.transition_seq,
3032 "transition_seq mismatch at frame {}",
3033 lhs.frame_idx
3034 );
3035 assert_eq!(
3036 lhs.telemetry.transition_correlation_id,
3037 rhs.telemetry.transition_correlation_id,
3038 "transition_correlation_id mismatch at frame {}",
3039 lhs.frame_idx
3040 );
3041 assert!(
3042 (lhs.telemetry.pid_output - rhs.telemetry.pid_output).abs() < 1e-12,
3043 "pid_output mismatch at frame {}",
3044 lhs.frame_idx
3045 );
3046 assert!(
3047 (lhs.telemetry.e_value - rhs.telemetry.e_value).abs() < 1e-12,
3048 "e_value mismatch at frame {}",
3049 lhs.frame_idx
3050 );
3051 }
3052
3053 for entry in &logs {
3055 let t = &entry.telemetry;
3056 eprintln!(
3057 r#"{{"event":"control_campaign_frame","schema_version":{},"scenario":"bd-2vr05.15.4.5","frame_idx":{},"phase":"{}","frame_time_us":{},"decision":"{}","decision_reason":"{}","transition_seq":{},"transition_correlation_id":{},"level":"{}","pid_output":{:.6},"pid_p":{:.6},"pid_i":{:.6},"pid_d":{:.6},"e_value":{:.6},"frame_time_ms":{:.6},"target_ms":{:.6},"pid_gate_threshold":{:.6},"pid_gate_margin":{:.6},"evidence_threshold":{:.6},"evidence_margin":{:.6},"frames_observed":{},"frames_since_change":{}}}"#,
3058 t.schema_version,
3059 entry.frame_idx,
3060 entry.phase,
3061 entry.frame_time_us,
3062 t.last_decision.as_str(),
3063 t.decision_reason.as_str(),
3064 t.transition_seq,
3065 t.transition_correlation_id,
3066 t.level.as_str(),
3067 t.pid_output,
3068 t.pid_p,
3069 t.pid_i,
3070 t.pid_d,
3071 t.e_value,
3072 t.frame_time_ms,
3073 t.target_ms,
3074 t.pid_gate_threshold,
3075 t.pid_gate_margin,
3076 t.evidence_threshold,
3077 t.evidence_margin,
3078 t.frames_observed,
3079 t.frames_since_change
3080 );
3081 }
3082 eprintln!(
3083 r#"{{"event":"control_campaign_summary","schema_version":{},"scenario":"bd-2vr05.15.4.5","frames":{},"burst_degrades":{},"sustained_degrades":{},"recovery_upgrades":{},"peak_level":"{}","final_level":"{}"}}"#,
3084 BUDGET_TELEMETRY_SCHEMA_VERSION,
3085 logs.len(),
3086 burst_degrades,
3087 sustained_degrades,
3088 recovery_upgrades,
3089 max_level.as_str(),
3090 final_level.as_str()
3091 );
3092 }
3093
3094 #[test]
3097 fn property_random_load_hysteresis_bounds() {
3098 let mut ctrl = fast_controller(16);
3101
3102 let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_BABE;
3105 let mut trace = Vec::new();
3106 for _ in 0..1000 {
3107 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 = 4 + ((rng_state >> 33) % 77);
3113 trace.push(Duration::from_millis(frame_ms));
3114 }
3115
3116 let log = run_trace(&mut ctrl, &trace);
3117
3118 for pair in log.windows(2) {
3120 let prev = pair[0].2.level.level();
3121 let curr = pair[1].2.level.level();
3122 let delta = (curr as i16 - prev as i16).unsigned_abs();
3123 assert!(
3124 delta <= 1,
3125 "Level jumped {} steps at frame {}: {:?} -> {:?}",
3126 delta,
3127 pair[1].0,
3128 pair[0].2.level,
3129 pair[1].2.level
3130 );
3131 }
3132
3133 for (frame, _, telem) in &log {
3135 assert!(
3136 telem.level <= DegradationLevel::SkipFrame,
3137 "frame {}: level out of range: {:?}",
3138 frame,
3139 telem.level
3140 );
3141 }
3142
3143 for (frame, _, telem) in &log {
3145 assert!(
3146 telem.pid_output.is_finite(),
3147 "frame {}: NaN pid_output",
3148 frame
3149 );
3150 assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
3151 assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
3152 assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
3153 assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
3154 assert!(
3155 telem.e_value > 0.0,
3156 "frame {}: e_value not positive: {}",
3157 frame,
3158 telem.e_value
3159 );
3160 }
3161 }
3162
3163 #[test]
3164 fn property_random_load_bounded_transitions() {
3165 let mut ctrl = BudgetController::new(BudgetControllerConfig {
3168 target: Duration::from_millis(16),
3169 eprocess: EProcessConfig {
3170 warmup_frames: 5,
3171 ..Default::default()
3172 },
3173 cooldown_frames: 3,
3174 ..Default::default()
3175 });
3176
3177 let mut rng_state: u64 = 0x1234_5678_9ABC_DEF0;
3179 let mut trace = Vec::new();
3180 for _ in 0..500 {
3181 rng_state = rng_state
3182 .wrapping_mul(6_364_136_223_846_793_005)
3183 .wrapping_add(1_442_695_040_888_963_407);
3184 let frame_ms = 8 + ((rng_state >> 33) % 40);
3185 trace.push(Duration::from_millis(frame_ms));
3186 }
3187
3188 let log = run_trace(&mut ctrl, &trace);
3189 let transitions = count_transitions(&log);
3190
3191 assert!(
3194 transitions < 80,
3195 "Too many transitions under random load: {} (expected <80 with cooldown=3)",
3196 transitions
3197 );
3198 }
3199
3200 #[test]
3201 fn property_deterministic_replay() {
3202 let trace: Vec<Duration> = (0..100)
3204 .map(|i| Duration::from_millis(10 + (i * 7 % 30)))
3205 .collect();
3206
3207 let mut ctrl1 = fast_controller(16);
3208 let log1 = run_trace(&mut ctrl1, &trace);
3209
3210 let mut ctrl2 = fast_controller(16);
3211 let log2 = run_trace(&mut ctrl2, &trace);
3212
3213 for (r1, r2) in log1.iter().zip(log2.iter()) {
3214 assert_eq!(r1.0, r2.0, "frame index mismatch");
3215 assert_eq!(r1.1, r2.1, "frame time mismatch");
3216 assert_eq!(r1.2.schema_version, r2.2.schema_version);
3217 assert_eq!(r1.2.level, r2.2.level, "level mismatch at frame {}", r1.0);
3218 assert_eq!(
3219 r1.2.last_decision, r2.2.last_decision,
3220 "decision mismatch at frame {}",
3221 r1.0
3222 );
3223 assert_eq!(
3224 r1.2.decision_reason, r2.2.decision_reason,
3225 "decision_reason mismatch at frame {}",
3226 r1.0
3227 );
3228 assert_eq!(
3229 r1.2.transition_seq, r2.2.transition_seq,
3230 "transition_seq mismatch at frame {}",
3231 r1.0
3232 );
3233 assert_eq!(
3234 r1.2.transition_correlation_id, r2.2.transition_correlation_id,
3235 "transition_correlation_id mismatch at frame {}",
3236 r1.0
3237 );
3238 assert!(
3239 (r1.2.pid_output - r2.2.pid_output).abs() < 1e-10,
3240 "pid_output mismatch at frame {}: {} vs {}",
3241 r1.0,
3242 r1.2.pid_output,
3243 r2.2.pid_output
3244 );
3245 assert!(
3246 (r1.2.e_value - r2.2.e_value).abs() < 1e-10,
3247 "e_value mismatch at frame {}: {} vs {}",
3248 r1.0,
3249 r1.2.e_value,
3250 r2.2.e_value
3251 );
3252 }
3253 }
3254
3255 #[test]
3258 fn telemetry_jsonl_fields_complete() {
3259 let mut ctrl = fast_controller(16);
3261 ctrl.update(Duration::from_millis(20));
3262
3263 let telem = ctrl.telemetry();
3264
3265 let _schema_version: u16 = telem.schema_version;
3267 let _degradation: &str = telem.level.as_str();
3268 let _pid_p: f64 = telem.pid_p;
3269 let _pid_i: f64 = telem.pid_i;
3270 let _pid_d: f64 = telem.pid_d;
3271 let _e_value: f64 = telem.e_value;
3272 let _decision: &str = telem.last_decision.as_str();
3273 let _reason: &str = telem.decision_reason.as_str();
3274 let _transition_seq: u64 = telem.transition_seq;
3275 let _transition_correlation_id: u64 = telem.transition_correlation_id;
3276 let _frame_time_ms: f64 = telem.frame_time_ms;
3277 let _target_ms: f64 = telem.target_ms;
3278 let _pid_gate_threshold: f64 = telem.pid_gate_threshold;
3279 let _pid_gate_margin: f64 = telem.pid_gate_margin;
3280 let _evidence_threshold: f64 = telem.evidence_threshold;
3281 let _evidence_margin: f64 = telem.evidence_margin;
3282 let _frames: u32 = telem.frames_observed;
3283
3284 assert_eq!(BudgetDecision::Hold.as_str(), "stay");
3286 assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
3287 assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
3288 assert_eq!(
3289 BUDGET_TELEMETRY_SCHEMA_VERSION, telem.schema_version,
3290 "schema version mismatch"
3291 );
3292 }
3293
3294 #[test]
3295 fn telemetry_transition_records_correlation_reason_and_evidence() {
3296 let mut ctrl = fast_controller(16);
3297
3298 let mut degrade_telem = None;
3300 for _ in 0..64 {
3301 ctrl.update(Duration::from_millis(48));
3302 let telem = ctrl.telemetry();
3303 if telem.last_decision == BudgetDecision::Degrade {
3304 degrade_telem = Some(telem);
3305 break;
3306 }
3307 }
3308 let degrade_telem =
3309 degrade_telem.expect("expected degrade transition with correlation metadata");
3310 assert_eq!(
3311 degrade_telem.decision_reason,
3312 BudgetDecisionReason::OverloadEvidencePassed
3313 );
3314 assert!(
3315 degrade_telem.transition_seq > 0,
3316 "transition_seq should increment on transitions"
3317 );
3318 assert!(
3319 degrade_telem.transition_correlation_id > 0,
3320 "transition correlation id should be populated on transitions"
3321 );
3322 assert!(
3323 degrade_telem.pid_gate_margin > 0.0,
3324 "degrade transition should have positive PID gate margin"
3325 );
3326 assert!(
3327 degrade_telem.evidence_margin > 0.0,
3328 "degrade transition should have positive evidence margin"
3329 );
3330
3331 let mut upgrade_telem = None;
3333 for _ in 0..160 {
3334 ctrl.update(Duration::from_millis(4));
3335 let telem = ctrl.telemetry();
3336 if telem.last_decision == BudgetDecision::Upgrade {
3337 upgrade_telem = Some(telem);
3338 break;
3339 }
3340 }
3341 let upgrade_telem =
3342 upgrade_telem.expect("expected upgrade transition with correlation metadata");
3343 assert_eq!(
3344 upgrade_telem.decision_reason,
3345 BudgetDecisionReason::UnderloadEvidencePassed
3346 );
3347 assert!(
3348 upgrade_telem.transition_seq >= degrade_telem.transition_seq,
3349 "transition sequence should be monotonic"
3350 );
3351 assert!(
3352 upgrade_telem.transition_correlation_id >= degrade_telem.transition_correlation_id,
3353 "transition correlation id should be monotonic"
3354 );
3355 assert!(
3356 upgrade_telem.pid_gate_margin > 0.0,
3357 "upgrade transition should have positive PID gate margin"
3358 );
3359 assert!(
3360 upgrade_telem.evidence_margin > 0.0,
3361 "upgrade transition should have positive evidence margin"
3362 );
3363 }
3364
3365 #[test]
3366 fn telemetry_pid_components_sum_to_output() {
3367 let mut ctrl = fast_controller(16);
3369
3370 for ms in [10u64, 16, 20, 30, 8, 50] {
3371 ctrl.update(Duration::from_millis(ms));
3372 let telem = ctrl.telemetry();
3373 let sum = telem.pid_p + telem.pid_i + telem.pid_d;
3374 assert!(
3375 (sum - telem.pid_output).abs() < 1e-10,
3376 "P+I+D != output at {}ms: {} + {} + {} = {} != {}",
3377 ms,
3378 telem.pid_p,
3379 telem.pid_i,
3380 telem.pid_d,
3381 sum,
3382 telem.pid_output
3383 );
3384 }
3385 }
3386 }
3387
3388 mod edge_case_tests {
3393 use super::super::*;
3394
3395 #[test]
3398 fn pid_negative_integral_windup() {
3399 let mut state = PidState::default();
3401 let gains = PidGains {
3402 integral_max: 3.0,
3403 ..Default::default()
3404 };
3405
3406 for _ in 0..200 {
3407 state.update(-10.0, &gains);
3408 }
3409
3410 assert!(
3411 state.integral >= -3.0 - f64::EPSILON,
3412 "Negative integral should be clamped to -max: {}",
3413 state.integral
3414 );
3415 assert!(
3416 state.integral <= -3.0 + f64::EPSILON,
3417 "Negative integral should saturate at -max: {}",
3418 state.integral
3419 );
3420 }
3421
3422 #[test]
3423 fn pid_zero_gains_zero_output() {
3424 let mut state = PidState::default();
3425 let gains = PidGains {
3426 kp: 0.0,
3427 ki: 0.0,
3428 kd: 0.0,
3429 integral_max: 5.0,
3430 };
3431
3432 let u = state.update(42.0, &gains);
3433 assert!(
3434 u.abs() < 1e-10,
3435 "Zero gains should yield zero output: {}",
3436 u
3437 );
3438 }
3439
3440 #[test]
3441 fn pid_large_error_stays_finite() {
3442 let mut state = PidState::default();
3443 let gains = PidGains::default();
3444
3445 let u = state.update(1e12, &gains);
3447 assert!(
3448 u.is_finite(),
3449 "PID output should be finite for large error: {}",
3450 u
3451 );
3452
3453 assert!(
3455 state.integral <= gains.integral_max + f64::EPSILON,
3456 "Integral should be clamped: {}",
3457 state.integral
3458 );
3459 }
3460
3461 #[test]
3462 fn pid_alternating_error_derivative_responds() {
3463 let mut state = PidState::default();
3464 let gains = PidGains::default();
3465
3466 let u1 = state.update(1.0, &gains);
3468 let u2 = state.update(-1.0, &gains);
3469
3470 assert!(
3473 u2 < u1,
3474 "Alternating error should reduce output: u1={}, u2={}",
3475 u1,
3476 u2
3477 );
3478 }
3479
3480 #[test]
3481 fn pid_telemetry_terms_match_after_update() {
3482 let mut state = PidState::default();
3483 let gains = PidGains::default();
3484
3485 state.update(2.0, &gains);
3486
3487 assert!(
3489 (state.last_p - 1.0).abs() < 1e-10,
3490 "P term: {}",
3491 state.last_p
3492 );
3493 assert!(
3495 (state.last_i - 0.1).abs() < 1e-10,
3496 "I term: {}",
3497 state.last_i
3498 );
3499 assert!(
3501 (state.last_d - 0.4).abs() < 1e-10,
3502 "D term: {}",
3503 state.last_d
3504 );
3505 }
3506
3507 #[test]
3508 fn pid_integral_clamping_symmetric() {
3509 let mut state = PidState::default();
3510 let gains = PidGains {
3511 integral_max: 1.0,
3512 ..Default::default()
3513 };
3514
3515 for _ in 0..50 {
3517 state.update(100.0, &gains);
3518 }
3519 let pos_integral = state.integral;
3520
3521 state.reset();
3522
3523 for _ in 0..50 {
3525 state.update(-100.0, &gains);
3526 }
3527 let neg_integral = state.integral;
3528
3529 assert!(
3530 (pos_integral + neg_integral).abs() < f64::EPSILON,
3531 "Clamping should be symmetric: pos={}, neg={}",
3532 pos_integral,
3533 neg_integral
3534 );
3535 }
3536
3537 #[test]
3540 fn eprocess_first_frame_initializes_mean() {
3541 let mut state = EProcessState::default();
3542 let config = EProcessConfig::default();
3543
3544 state.update(25.0, 16.0, &config);
3545
3546 assert!(
3547 (state.mean_ema - 25.0).abs() < f64::EPSILON,
3548 "First frame should set mean_ema directly: {}",
3549 state.mean_ema
3550 );
3551 assert!(
3552 (state.sigma_ema - config.sigma_floor_ms).abs() < f64::EPSILON,
3553 "First frame should set sigma_ema to floor: {}",
3554 state.sigma_ema
3555 );
3556 assert_eq!(state.frames_observed, 1);
3557 }
3558
3559 #[test]
3560 fn eprocess_e_value_clamped_at_upper_bound() {
3561 let mut state = EProcessState::default();
3562 let config = EProcessConfig {
3563 lambda: 2.0, warmup_frames: 0,
3565 sigma_floor_ms: 0.001, ..Default::default()
3567 };
3568
3569 for _ in 0..1000 {
3571 state.update(1e6, 16.0, &config);
3572 }
3573
3574 assert!(
3575 state.e_value <= 1e10,
3576 "E-value should be clamped at 1e10: {}",
3577 state.e_value
3578 );
3579 }
3580
3581 #[test]
3582 fn eprocess_e_value_clamped_at_lower_bound() {
3583 let mut state = EProcessState::default();
3584 let config = EProcessConfig {
3585 lambda: 2.0,
3586 warmup_frames: 0,
3587 sigma_floor_ms: 0.001,
3588 ..Default::default()
3589 };
3590
3591 for _ in 0..1000 {
3593 state.update(0.001, 1e6, &config);
3594 }
3595
3596 assert!(
3597 state.e_value >= 1e-10,
3598 "E-value should be clamped at 1e-10: {}",
3599 state.e_value
3600 );
3601 }
3602
3603 #[test]
3604 fn eprocess_should_upgrade_during_warmup() {
3605 let state = EProcessState::default();
3606 let config = EProcessConfig {
3607 warmup_frames: 10,
3608 ..Default::default()
3609 };
3610
3611 assert!(
3613 state.should_upgrade(&config),
3614 "should_upgrade should return true during warmup"
3615 );
3616 }
3617
3618 #[test]
3619 fn eprocess_frames_observed_saturates() {
3620 let mut state = EProcessState {
3621 frames_observed: u32::MAX,
3622 ..EProcessState::default()
3623 };
3624 let config = EProcessConfig::default();
3625
3626 state.update(16.0, 16.0, &config);
3628 assert_eq!(
3629 state.frames_observed,
3630 u32::MAX,
3631 "frames_observed should saturate at u32::MAX"
3632 );
3633 }
3634
3635 #[test]
3636 fn eprocess_sigma_ema_decay_boundary_zero() {
3637 let mut state = EProcessState::default();
3638 let config = EProcessConfig {
3639 sigma_ema_decay: 0.0,
3640 warmup_frames: 0,
3641 ..Default::default()
3642 };
3643
3644 state.update(20.0, 16.0, &config);
3646 state.update(30.0, 16.0, &config);
3647
3648 assert!(
3650 (state.mean_ema - 30.0).abs() < f64::EPSILON,
3651 "decay=0 should fully replace mean_ema: {}",
3652 state.mean_ema
3653 );
3654 }
3655
3656 #[test]
3657 fn eprocess_sigma_ema_decay_boundary_one() {
3658 let mut state = EProcessState::default();
3659 let config = EProcessConfig {
3660 sigma_ema_decay: 1.0,
3661 warmup_frames: 0,
3662 ..Default::default()
3663 };
3664
3665 state.update(20.0, 16.0, &config);
3667 let first_mean = state.mean_ema;
3668 state.update(100.0, 16.0, &config);
3669
3670 assert!(
3671 (state.mean_ema - first_mean).abs() < f64::EPSILON,
3672 "decay=1 should lock mean_ema at first value: got {}, expected {}",
3673 state.mean_ema,
3674 first_mean
3675 );
3676 }
3677
3678 #[test]
3679 fn eprocess_zero_target_no_panic() {
3680 let mut state = EProcessState::default();
3681 let config = EProcessConfig {
3682 warmup_frames: 0,
3683 ..Default::default()
3684 };
3685
3686 let e = state.update(16.0, 0.0, &config);
3688 assert!(
3689 e.is_finite(),
3690 "E-value should be finite with zero target: {}",
3691 e
3692 );
3693 }
3694
3695 #[test]
3698 fn degradation_level_default_is_full() {
3699 assert_eq!(DegradationLevel::default(), DegradationLevel::Full);
3700 }
3701
3702 #[test]
3703 fn degradation_level_hash_unique() {
3704 use std::collections::HashSet;
3705 let levels = [
3706 DegradationLevel::Full,
3707 DegradationLevel::SimpleBorders,
3708 DegradationLevel::NoStyling,
3709 DegradationLevel::EssentialOnly,
3710 DegradationLevel::Skeleton,
3711 DegradationLevel::SkipFrame,
3712 ];
3713 let set: HashSet<DegradationLevel> = levels.iter().copied().collect();
3714 assert_eq!(set.len(), 6, "All levels should hash uniquely");
3715 }
3716
3717 #[test]
3718 fn degradation_level_widget_queries_full() {
3719 let l = DegradationLevel::Full;
3720 assert!(l.use_unicode_borders());
3721 assert!(l.apply_styling());
3722 assert!(l.render_decorative());
3723 assert!(l.render_content());
3724 }
3725
3726 #[test]
3727 fn degradation_level_widget_queries_simple_borders() {
3728 let l = DegradationLevel::SimpleBorders;
3729 assert!(!l.use_unicode_borders());
3730 assert!(l.apply_styling());
3731 assert!(l.render_decorative());
3732 assert!(l.render_content());
3733 }
3734
3735 #[test]
3736 fn degradation_level_widget_queries_no_styling() {
3737 let l = DegradationLevel::NoStyling;
3738 assert!(!l.use_unicode_borders());
3739 assert!(!l.apply_styling());
3740 assert!(l.render_decorative());
3741 assert!(l.render_content());
3742 }
3743
3744 #[test]
3745 fn degradation_level_widget_queries_essential_only() {
3746 let l = DegradationLevel::EssentialOnly;
3747 assert!(!l.use_unicode_borders());
3748 assert!(!l.apply_styling());
3749 assert!(!l.render_decorative());
3750 assert!(l.render_content());
3751 }
3752
3753 #[test]
3754 fn degradation_level_widget_queries_skeleton() {
3755 let l = DegradationLevel::Skeleton;
3756 assert!(!l.use_unicode_borders());
3757 assert!(!l.apply_styling());
3758 assert!(!l.render_decorative());
3759 assert!(!l.render_content());
3760 }
3761
3762 #[test]
3763 fn degradation_level_widget_queries_skip_frame() {
3764 let l = DegradationLevel::SkipFrame;
3765 assert!(!l.use_unicode_borders());
3766 assert!(!l.apply_styling());
3767 assert!(!l.render_decorative());
3768 assert!(!l.render_content());
3769 }
3770
3771 #[test]
3772 fn degradation_level_partial_ord_consistent() {
3773 let levels = [
3775 DegradationLevel::Full,
3776 DegradationLevel::SimpleBorders,
3777 DegradationLevel::NoStyling,
3778 DegradationLevel::EssentialOnly,
3779 DegradationLevel::Skeleton,
3780 DegradationLevel::SkipFrame,
3781 ];
3782 for (i, a) in levels.iter().enumerate() {
3783 for (j, b) in levels.iter().enumerate() {
3784 let po = a.partial_cmp(b);
3785 let o = a.cmp(b);
3786 assert_eq!(po, Some(o), "PartialOrd != Ord for {:?} vs {:?}", a, b);
3787 if i < j {
3788 assert!(*a < *b, "{:?} should be < {:?}", a, b);
3789 }
3790 }
3791 }
3792 }
3793
3794 #[test]
3795 fn degradation_level_clone_eq() {
3796 let a = DegradationLevel::NoStyling;
3797 let b = a;
3798 assert_eq!(a, b);
3799 }
3800
3801 #[test]
3802 fn degradation_level_debug() {
3803 let s = format!("{:?}", DegradationLevel::EssentialOnly);
3804 assert!(s.contains("EssentialOnly"), "Debug output: {}", s);
3805 }
3806
3807 #[test]
3810 fn controller_eprocess_sigma_ms_uses_floor() {
3811 let ctrl = BudgetController::new(BudgetControllerConfig {
3812 eprocess: EProcessConfig {
3813 sigma_floor_ms: 2.5,
3814 ..Default::default()
3815 },
3816 ..Default::default()
3817 });
3818
3819 assert!(
3821 (ctrl.eprocess_sigma_ms() - 2.5).abs() < f64::EPSILON,
3822 "Should return sigma_floor_ms when sigma_ema < floor: {}",
3823 ctrl.eprocess_sigma_ms()
3824 );
3825 }
3826
3827 #[test]
3828 fn controller_config_accessor() {
3829 let config = BudgetControllerConfig {
3830 degrade_threshold: 0.42,
3831 ..Default::default()
3832 };
3833 let ctrl = BudgetController::new(config.clone());
3834
3835 assert_eq!(ctrl.config().degrade_threshold, 0.42);
3836 assert_eq!(ctrl.config().target, Duration::from_millis(16));
3837 }
3838
3839 #[test]
3840 fn controller_frames_observed_accessor() {
3841 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
3842
3843 assert_eq!(ctrl.frames_observed(), 0);
3844
3845 ctrl.update(Duration::from_millis(16));
3846 assert_eq!(ctrl.frames_observed(), 1);
3847
3848 ctrl.update(Duration::from_millis(16));
3849 assert_eq!(ctrl.frames_observed(), 2);
3850 }
3851
3852 #[test]
3855 fn render_budget_record_frame_time_used_by_next_frame() {
3856 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3857 budget.degrade();
3858
3859 for _ in 0..10 {
3861 budget.reset();
3862 }
3863
3864 budget.record_frame_time(Duration::from_millis(1));
3866 std::thread::sleep(Duration::from_millis(15));
3868
3869 let before = budget.degradation();
3870 budget.next_frame();
3871
3872 assert!(
3875 budget.degradation() < before,
3876 "Recorded frame time should enable upgrade: before={:?}, after={:?}",
3877 before,
3878 budget.degradation()
3879 );
3880 }
3881
3882 #[test]
3883 fn render_budget_phase_budget_clamped_by_remaining() {
3884 let budget = RenderBudget::new(Duration::from_millis(1));
3886 std::thread::sleep(Duration::from_millis(5));
3887
3888 let phase = budget.phase_budget(Phase::Render);
3890 assert!(
3891 phase.total() <= Duration::from_millis(1),
3892 "Phase budget should be clamped by remaining: {:?}",
3893 phase.total()
3894 );
3895 }
3896
3897 #[test]
3898 fn render_budget_exhausted_skipframe_with_no_frame_skip() {
3899 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3900 budget.allow_frame_skip = false;
3901 budget.set_degradation(DegradationLevel::SkipFrame);
3902
3903 assert!(
3906 !budget.exhausted(),
3907 "SkipFrame should not exhaust when frame skip disabled"
3908 );
3909 }
3910
3911 #[test]
3912 fn render_budget_remaining_fraction_zero_total() {
3913 let budget = RenderBudget::new(Duration::ZERO);
3914 assert_eq!(budget.remaining_fraction(), 0.0);
3915 }
3916
3917 #[test]
3918 fn render_budget_total_accessor() {
3919 let budget = RenderBudget::new(Duration::from_millis(42));
3920 assert_eq!(budget.total(), Duration::from_millis(42));
3921 }
3922
3923 #[test]
3924 fn render_budget_phase_budgets_accessor() {
3925 let budget = RenderBudget::new(Duration::from_millis(16));
3926 let pb = budget.phase_budgets();
3927 assert_eq!(pb.diff, Duration::from_millis(2));
3928 assert_eq!(pb.present, Duration::from_millis(4));
3929 assert_eq!(pb.render, Duration::from_millis(8));
3930 }
3931
3932 #[test]
3933 fn render_budget_set_degradation_no_op_preserves_cooldown() {
3934 let mut budget = RenderBudget::new(Duration::from_millis(16));
3935 budget.set_degradation(DegradationLevel::NoStyling);
3936 budget.frames_since_change = 7;
3937
3938 budget.set_degradation(DegradationLevel::NoStyling);
3940 assert_eq!(budget.frames_since_change, 7);
3941
3942 budget.set_degradation(DegradationLevel::Skeleton);
3944 assert_eq!(budget.frames_since_change, 0);
3945 }
3946
3947 #[test]
3948 fn render_budget_should_upgrade_false_at_full() {
3949 let budget = RenderBudget::new(Duration::from_millis(1000));
3950 assert!(!budget.should_upgrade(), "Full level should never upgrade");
3951 }
3952
3953 #[test]
3954 fn render_budget_should_upgrade_false_during_cooldown() {
3955 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3956 budget.degrade();
3957 assert!(
3959 !budget.should_upgrade(),
3960 "Should not upgrade during cooldown"
3961 );
3962 }
3963
3964 #[test]
3965 fn render_budget_degrade_at_max_stays_at_max() {
3966 let mut budget = RenderBudget::new(Duration::from_millis(16));
3967 budget.set_degradation(DegradationLevel::SkipFrame);
3968 budget.degrade();
3969 assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
3970 }
3971
3972 #[test]
3973 fn render_budget_upgrade_at_full_stays_at_full() {
3974 let mut budget = RenderBudget::new(Duration::from_millis(16));
3975 budget.upgrade();
3976 assert_eq!(budget.degradation(), DegradationLevel::Full);
3977 }
3978
3979 #[test]
3982 fn frame_budget_config_partial_eq() {
3983 let a = FrameBudgetConfig::default();
3984 let b = FrameBudgetConfig::default();
3985 assert_eq!(a, b);
3986
3987 let c = FrameBudgetConfig::strict(Duration::from_millis(16));
3988 assert_ne!(a, c, "Different configs should not be equal");
3989 }
3990
3991 #[test]
3992 fn phase_budgets_eq_and_copy() {
3993 let a = PhaseBudgets::default();
3994 let b = a; assert_eq!(a, b);
3996
3997 let c = PhaseBudgets {
3998 diff: Duration::from_millis(1),
3999 ..Default::default()
4000 };
4001 assert_ne!(a, c);
4002 }
4003
4004 #[test]
4005 fn budget_controller_config_partial_eq() {
4006 let a = BudgetControllerConfig::default();
4007 let b = BudgetControllerConfig::default();
4008 assert_eq!(a, b);
4009 }
4010
4011 #[test]
4012 fn pid_gains_partial_eq() {
4013 let a = PidGains::default();
4014 let b = PidGains::default();
4015 assert_eq!(a, b);
4016 }
4017
4018 #[test]
4019 fn eprocess_config_partial_eq() {
4020 let a = EProcessConfig::default();
4021 let b = EProcessConfig::default();
4022 assert_eq!(a, b);
4023 }
4024
4025 #[test]
4028 fn budget_decision_debug_format() {
4029 assert!(format!("{:?}", BudgetDecision::Hold).contains("Hold"));
4030 assert!(format!("{:?}", BudgetDecision::Degrade).contains("Degrade"));
4031 assert!(format!("{:?}", BudgetDecision::Upgrade).contains("Upgrade"));
4032 }
4033
4034 #[test]
4035 fn budget_decision_clone_copy() {
4036 let d = BudgetDecision::Degrade;
4037 let d2 = d;
4038 assert_eq!(d, d2);
4039 }
4040
4041 #[test]
4042 fn budget_decision_as_str_coverage() {
4043 assert_eq!(BudgetDecision::Hold.as_str(), "stay");
4044 assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
4045 assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
4046 }
4047
4048 #[test]
4049 fn budget_decision_reason_debug_and_as_str() {
4050 assert!(
4051 format!("{:?}", BudgetDecisionReason::CooldownActive).contains("CooldownActive")
4052 );
4053 assert_eq!(
4054 BudgetDecisionReason::CooldownActive.as_str(),
4055 "cooldown_active"
4056 );
4057 assert_eq!(
4058 BudgetDecisionReason::OverloadEvidencePassed.as_str(),
4059 "overload_evidence_passed"
4060 );
4061 assert_eq!(
4062 BudgetDecisionReason::UnderloadEvidencePassed.as_str(),
4063 "underload_evidence_passed"
4064 );
4065 assert_eq!(
4066 BudgetDecisionReason::AtMaxDegradation.as_str(),
4067 "at_max_degradation"
4068 );
4069 assert_eq!(
4070 BudgetDecisionReason::AtDegradationFloor.as_str(),
4071 "at_degradation_floor"
4072 );
4073 assert_eq!(
4074 BudgetDecisionReason::AtFullQuality.as_str(),
4075 "at_full_quality"
4076 );
4077 assert_eq!(
4078 BudgetDecisionReason::WithinThresholdBand.as_str(),
4079 "within_threshold_band"
4080 );
4081 }
4082
4083 #[test]
4086 fn phase_eq_and_hash() {
4087 use std::collections::HashSet;
4088 let mut set = HashSet::new();
4089 set.insert(Phase::Diff);
4090 set.insert(Phase::Present);
4091 set.insert(Phase::Render);
4092 assert_eq!(set.len(), 3);
4093
4094 set.insert(Phase::Diff);
4096 assert_eq!(set.len(), 3);
4097 }
4098
4099 #[test]
4100 fn phase_debug() {
4101 assert!(format!("{:?}", Phase::Diff).contains("Diff"));
4102 assert!(format!("{:?}", Phase::Present).contains("Present"));
4103 assert!(format!("{:?}", Phase::Render).contains("Render"));
4104 }
4105
4106 #[test]
4107 fn phase_clone_copy() {
4108 let p = Phase::Present;
4109 let p2 = p;
4110 assert_eq!(p, p2);
4111 }
4112
4113 #[test]
4116 fn budget_telemetry_debug() {
4117 let telem = BudgetTelemetry {
4118 schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
4119 level: DegradationLevel::Full,
4120 pid_output: 0.0,
4121 pid_p: 0.0,
4122 pid_i: 0.0,
4123 pid_d: 0.0,
4124 e_value: 1.0,
4125 frames_observed: 0,
4126 frames_since_change: 0,
4127 last_decision: BudgetDecision::Hold,
4128 decision_reason: BudgetDecisionReason::WithinThresholdBand,
4129 transition_seq: 0,
4130 transition_correlation_id: 0,
4131 frame_time_ms: 0.0,
4132 target_ms: 16.0,
4133 pid_gate_threshold: 0.0,
4134 pid_gate_margin: 0.0,
4135 evidence_threshold: 0.0,
4136 evidence_margin: 0.0,
4137 in_warmup: true,
4138 };
4139 let s = format!("{:?}", telem);
4140 assert!(s.contains("BudgetTelemetry"), "Debug output: {}", s);
4141 }
4142
4143 #[test]
4144 fn budget_telemetry_partial_eq() {
4145 let a = BudgetTelemetry {
4146 schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
4147 level: DegradationLevel::Full,
4148 pid_output: 0.5,
4149 pid_p: 0.3,
4150 pid_i: 0.1,
4151 pid_d: 0.1,
4152 e_value: 1.0,
4153 frames_observed: 5,
4154 frames_since_change: 2,
4155 last_decision: BudgetDecision::Hold,
4156 decision_reason: BudgetDecisionReason::WithinThresholdBand,
4157 transition_seq: 0,
4158 transition_correlation_id: 0,
4159 frame_time_ms: 16.0,
4160 target_ms: 16.0,
4161 pid_gate_threshold: 0.0,
4162 pid_gate_margin: 0.0,
4163 evidence_threshold: 0.0,
4164 evidence_margin: 0.0,
4165 in_warmup: false,
4166 };
4167 let b = a;
4168 assert_eq!(a, b);
4169
4170 let c = BudgetTelemetry {
4171 level: DegradationLevel::SimpleBorders,
4172 ..a
4173 };
4174 assert_ne!(a, c);
4175 }
4176
4177 #[test]
4180 fn next_frame_without_recorded_time_uses_elapsed() {
4181 let mut budget = RenderBudget::new(Duration::from_millis(1000));
4182
4183 budget.next_frame();
4185
4186 assert!(budget.remaining_fraction() > 0.9);
4188 }
4189
4190 #[test]
4191 fn controller_at_max_degradation_holds() {
4192 let mut ctrl = BudgetController::new(BudgetControllerConfig {
4193 eprocess: EProcessConfig {
4194 warmup_frames: 0,
4195 ..Default::default()
4196 },
4197 cooldown_frames: 0,
4198 degradation_floor: DegradationLevel::SkipFrame,
4200 ..Default::default()
4201 });
4202
4203 for _ in 0..500 {
4205 ctrl.update(Duration::from_millis(200));
4206 }
4207 assert_eq!(ctrl.level(), DegradationLevel::SkipFrame);
4208
4209 let d = ctrl.update(Duration::from_millis(200));
4211 assert_eq!(d, BudgetDecision::Hold, "At max level, should hold");
4212 }
4213
4214 #[test]
4215 fn controller_at_configured_degradation_floor_reports_floor_reason() {
4216 let mut ctrl = BudgetController::new(BudgetControllerConfig {
4217 eprocess: EProcessConfig {
4218 warmup_frames: 0,
4219 ..Default::default()
4220 },
4221 cooldown_frames: 0,
4222 degradation_floor: DegradationLevel::SimpleBorders,
4223 ..Default::default()
4224 });
4225
4226 ctrl.current_level = DegradationLevel::SimpleBorders;
4227
4228 let decision = ctrl.update(Duration::from_millis(200));
4229 let telemetry = ctrl.telemetry();
4230
4231 assert_eq!(decision, BudgetDecision::Hold);
4232 assert_eq!(telemetry.level, DegradationLevel::SimpleBorders);
4233 assert_eq!(
4234 telemetry.decision_reason,
4235 BudgetDecisionReason::AtDegradationFloor
4236 );
4237 }
4238
4239 #[test]
4240 fn controller_at_full_level_no_upgrade() {
4241 let mut ctrl = BudgetController::new(BudgetControllerConfig {
4242 eprocess: EProcessConfig {
4243 warmup_frames: 0,
4244 ..Default::default()
4245 },
4246 cooldown_frames: 0,
4247 ..Default::default()
4248 });
4249
4250 for _ in 0..50 {
4252 let d = ctrl.update(Duration::from_millis(1));
4253 assert_ne!(
4254 d,
4255 BudgetDecision::Upgrade,
4256 "Full level should never upgrade"
4257 );
4258 }
4259 }
4260
4261 #[test]
4262 fn controller_recovers_after_external_degradation() {
4263 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
4269 BudgetControllerConfig {
4270 eprocess: EProcessConfig {
4271 warmup_frames: 0,
4272 ..Default::default()
4273 },
4274 cooldown_frames: 0,
4275 ..Default::default()
4276 },
4277 );
4278
4279 budget.set_degradation(DegradationLevel::EssentialOnly);
4281 assert_eq!(
4282 budget.controller().expect("controller attached").level(),
4283 DegradationLevel::EssentialOnly,
4284 "controller must observe the external level change"
4285 );
4286
4287 for _ in 0..300 {
4289 budget.record_frame_time(Duration::from_millis(2));
4290 budget.next_frame();
4291 }
4292 assert!(
4293 budget.degradation() < DegradationLevel::EssentialOnly,
4294 "externally degraded budget must recover under healthy load, got {:?}",
4295 budget.degradation()
4296 );
4297
4298 assert_eq!(
4300 budget.controller().expect("controller attached").level(),
4301 budget.degradation(),
4302 "controller and budget level must stay in lockstep"
4303 );
4304 }
4305
4306 #[test]
4307 fn external_conformal_style_degrade_syncs_controller() {
4308 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
4309 BudgetControllerConfig {
4310 eprocess: EProcessConfig {
4311 warmup_frames: 0,
4312 ..Default::default()
4313 },
4314 cooldown_frames: 0,
4315 ..Default::default()
4316 },
4317 );
4318
4319 budget.degrade();
4321 assert_eq!(
4322 budget.controller().expect("controller attached").level(),
4323 budget.degradation()
4324 );
4325 }
4326
4327 #[test]
4328 fn phase_budget_starts_with_full_phase_allocation() {
4329 let budget = RenderBudget::new(Duration::from_millis(1000));
4333 std::thread::sleep(Duration::from_millis(20));
4334
4335 let sub = budget.phase_budget(Phase::Diff);
4336 assert!(
4337 !sub.exhausted(),
4338 "phase budget must not be exhausted at phase start"
4339 );
4340 assert!(sub.remaining() > Duration::ZERO);
4343 assert!(sub.remaining() <= sub.total());
4344 }
4345
4346 #[test]
4347 fn next_frame_consumes_recorded_time_once() {
4348 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
4352 BudgetControllerConfig {
4353 eprocess: EProcessConfig {
4354 warmup_frames: 0,
4355 ..Default::default()
4356 },
4357 cooldown_frames: 0,
4358 ..Default::default()
4359 },
4360 );
4361
4362 budget.record_frame_time(Duration::from_millis(40));
4363 budget.next_frame();
4364 let after_first = budget
4365 .controller()
4366 .expect("controller attached")
4367 .telemetry()
4368 .frame_time_ms;
4369 assert!((after_first - 40.0).abs() < 1.0);
4370
4371 budget.next_frame();
4374 let after_second = budget
4375 .controller()
4376 .expect("controller attached")
4377 .telemetry()
4378 .frame_time_ms;
4379 assert!(
4380 after_second < 20.0,
4381 "stale frame time re-fed to controller: {after_second}ms"
4382 );
4383 }
4384
4385 #[test]
4386 fn render_budget_full_degrade_cycle_with_controller() {
4387 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
4388 BudgetControllerConfig {
4389 eprocess: EProcessConfig {
4390 warmup_frames: 0,
4391 ..Default::default()
4392 },
4393 cooldown_frames: 0,
4394 ..Default::default()
4395 },
4396 );
4397
4398 for _ in 0..100 {
4400 budget.record_frame_time(Duration::from_millis(40));
4401 budget.next_frame();
4402 }
4403 let degraded = budget.degradation();
4404 assert!(
4405 degraded > DegradationLevel::Full,
4406 "Should degrade: {:?}",
4407 degraded
4408 );
4409
4410 for _ in 0..200 {
4412 budget.record_frame_time(Duration::from_millis(4));
4413 budget.next_frame();
4414 }
4415 let recovered = budget.degradation();
4416 assert!(
4417 recovered < degraded,
4418 "Should recover: {:?} -> {:?}",
4419 degraded,
4420 recovered
4421 );
4422 }
4423
4424 #[test]
4425 fn render_budget_phase_has_budget_exhausted() {
4426 let budget = RenderBudget::new(Duration::from_millis(1));
4427 std::thread::sleep(Duration::from_millis(10));
4428
4429 assert!(!budget.phase_has_budget(Phase::Diff));
4431 assert!(!budget.phase_has_budget(Phase::Present));
4432 assert!(!budget.phase_has_budget(Phase::Render));
4433 }
4434
4435 #[test]
4436 fn render_budget_elapsed_increases() {
4437 let budget = RenderBudget::new(Duration::from_millis(1000));
4438 let e1 = budget.elapsed();
4439 std::thread::sleep(Duration::from_millis(5));
4440 let e2 = budget.elapsed();
4441 assert!(e2 > e1, "Elapsed should increase: {:?} vs {:?}", e1, e2);
4442 }
4443
4444 #[test]
4445 fn controller_pid_integral_accessor() {
4446 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
4447
4448 assert_eq!(ctrl.pid_integral(), 0.0);
4449
4450 ctrl.update(Duration::from_millis(32)); assert!(
4453 ctrl.pid_integral() > 0.0,
4454 "Integral should grow: {}",
4455 ctrl.pid_integral()
4456 );
4457 }
4458
4459 #[test]
4460 fn controller_e_value_accessor() {
4461 let ctrl = BudgetController::new(BudgetControllerConfig::default());
4462 assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
4463 }
4464 }
4465}