1#![forbid(unsafe_code)]
2
3use std::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 self.integral = (self.integral + error).clamp(-gains.integral_max, gains.integral_max);
144
145 let derivative = error - self.prev_error;
147 self.prev_error = error;
148
149 self.last_p = gains.kp * error;
151 self.last_i = gains.ki * self.integral;
152 self.last_d = gains.kd * derivative;
153
154 self.last_p + self.last_i + self.last_d
156 }
157
158 fn reset(&mut self) {
160 *self = Self::default();
161 }
162}
163
164#[derive(Debug, Clone, PartialEq)]
203pub struct EProcessConfig {
204 pub lambda: f64,
207 pub alpha: f64,
210 pub beta: f64,
213 pub sigma_ema_decay: f64,
216 pub sigma_floor_ms: f64,
219 pub warmup_frames: u32,
222}
223
224impl Default for EProcessConfig {
225 fn default() -> Self {
226 Self {
227 lambda: 0.5,
228 alpha: 0.05,
229 beta: 0.5,
230 sigma_ema_decay: 0.9,
231 sigma_floor_ms: 1.0,
232 warmup_frames: 10,
233 }
234 }
235}
236
237#[derive(Debug, Clone)]
239struct EProcessState {
240 e_value: f64,
242 sigma_ema: f64,
244 mean_ema: f64,
246 frames_observed: u32,
248}
249
250impl Default for EProcessState {
251 fn default() -> Self {
252 Self {
253 e_value: 1.0,
254 sigma_ema: 0.0,
255 mean_ema: 0.0,
256 frames_observed: 0,
257 }
258 }
259}
260
261impl EProcessState {
262 fn update(&mut self, frame_time_ms: f64, target_ms: f64, config: &EProcessConfig) -> f64 {
266 self.frames_observed = self.frames_observed.saturating_add(1);
267
268 if self.frames_observed == 1 {
270 self.mean_ema = frame_time_ms;
271 self.sigma_ema = config.sigma_floor_ms;
272 } else {
273 let decay = config.sigma_ema_decay;
274 self.mean_ema = decay * self.mean_ema + (1.0 - decay) * frame_time_ms;
275 let deviation = (frame_time_ms - self.mean_ema).abs();
277 self.sigma_ema = decay * self.sigma_ema + (1.0 - decay) * deviation;
278 }
279
280 let sigma = self.sigma_ema.max(config.sigma_floor_ms);
282
283 let residual = (frame_time_ms - target_ms) / sigma;
285
286 let lambda = config.lambda;
290 let log_factor = lambda * residual - lambda * lambda / 2.0;
291 self.e_value *= log_factor.exp();
292
293 self.e_value = self.e_value.clamp(1e-10, 1e10);
296
297 self.e_value
298 }
299
300 fn should_degrade(&self, config: &EProcessConfig) -> bool {
302 if self.frames_observed < config.warmup_frames {
303 return false; }
305 self.e_value > 1.0 / config.alpha
306 }
307
308 fn should_upgrade(&self, config: &EProcessConfig) -> bool {
310 if self.frames_observed < config.warmup_frames {
311 return true; }
313 self.e_value < config.beta
314 }
315
316 fn reset(&mut self) {
318 *self = Self::default();
319 }
320}
321
322#[derive(Debug, Clone, PartialEq)]
324pub struct BudgetControllerConfig {
325 pub pid: PidGains,
327 pub eprocess: EProcessConfig,
329 pub target: Duration,
331 pub degrade_threshold: f64,
343 pub upgrade_threshold: f64,
346 pub cooldown_frames: u32,
348}
349
350impl Default for BudgetControllerConfig {
351 fn default() -> Self {
352 Self {
353 pid: PidGains::default(),
354 eprocess: EProcessConfig::default(),
355 target: Duration::from_millis(16),
356 degrade_threshold: 0.3,
357 upgrade_threshold: 0.2,
358 cooldown_frames: 3,
359 }
360 }
361}
362
363#[derive(Debug, Clone)]
397pub struct BudgetController {
398 config: BudgetControllerConfig,
399 pid: PidState,
400 eprocess: EProcessState,
401 current_level: DegradationLevel,
402 frames_since_change: u32,
403 last_pid_output: f64,
404 last_decision: BudgetDecision,
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
409pub enum BudgetDecision {
410 Hold,
412 Degrade,
414 Upgrade,
416}
417
418impl BudgetDecision {
419 #[inline]
421 pub fn as_str(self) -> &'static str {
422 match self {
423 Self::Hold => "stay",
424 Self::Degrade => "degrade",
425 Self::Upgrade => "upgrade",
426 }
427 }
428}
429
430impl BudgetController {
431 pub fn new(config: BudgetControllerConfig) -> Self {
433 Self {
434 config,
435 pid: PidState::default(),
436 eprocess: EProcessState::default(),
437 current_level: DegradationLevel::Full,
438 frames_since_change: 0,
439 last_pid_output: 0.0,
440 last_decision: BudgetDecision::Hold,
441 }
442 }
443
444 pub fn update(&mut self, frame_time: Duration) -> BudgetDecision {
448 let target_ms = self.config.target.as_secs_f64() * 1000.0;
449 let frame_ms = frame_time.as_secs_f64() * 1000.0;
450
451 let error = (frame_ms - target_ms) / target_ms;
453
454 let u = self.pid.update(error, &self.config.pid);
456 self.last_pid_output = u;
457
458 self.eprocess
460 .update(frame_ms, target_ms, &self.config.eprocess);
461
462 self.frames_since_change = self.frames_since_change.saturating_add(1);
464
465 let decision = if self.frames_since_change < self.config.cooldown_frames {
467 BudgetDecision::Hold
469 } else if u > self.config.degrade_threshold
470 && !self.current_level.is_max()
471 && self.eprocess.should_degrade(&self.config.eprocess)
472 {
473 BudgetDecision::Degrade
474 } else if u < -self.config.upgrade_threshold
475 && !self.current_level.is_full()
476 && self.eprocess.should_upgrade(&self.config.eprocess)
477 {
478 BudgetDecision::Upgrade
479 } else {
480 BudgetDecision::Hold
481 };
482
483 self.last_decision = decision;
485
486 match decision {
488 BudgetDecision::Degrade => {
489 self.current_level = self.current_level.next();
490 self.frames_since_change = 0;
491
492 #[cfg(feature = "tracing")]
493 warn!(
494 level = self.current_level.as_str(),
495 pid_output = u,
496 e_value = self.eprocess.e_value,
497 "budget controller: degrade"
498 );
499 }
500 BudgetDecision::Upgrade => {
501 self.current_level = self.current_level.prev();
502 self.frames_since_change = 0;
503
504 #[cfg(feature = "tracing")]
505 trace!(
506 level = self.current_level.as_str(),
507 pid_output = u,
508 e_value = self.eprocess.e_value,
509 "budget controller: upgrade"
510 );
511 }
512 BudgetDecision::Hold => {}
513 }
514
515 decision
516 }
517
518 #[inline]
520 pub fn level(&self) -> DegradationLevel {
521 self.current_level
522 }
523
524 #[inline]
526 pub fn e_value(&self) -> f64 {
527 self.eprocess.e_value
528 }
529
530 #[inline]
532 pub fn eprocess_sigma_ms(&self) -> f64 {
533 self.eprocess
534 .sigma_ema
535 .max(self.config.eprocess.sigma_floor_ms)
536 }
537
538 #[inline]
540 pub fn pid_integral(&self) -> f64 {
541 self.pid.integral
542 }
543
544 #[inline]
546 pub fn frames_observed(&self) -> u32 {
547 self.eprocess.frames_observed
548 }
549
550 #[inline]
555 pub fn telemetry(&self) -> BudgetTelemetry {
556 BudgetTelemetry {
557 level: self.current_level,
558 pid_output: self.last_pid_output,
559 pid_p: self.pid.last_p,
560 pid_i: self.pid.last_i,
561 pid_d: self.pid.last_d,
562 e_value: self.eprocess.e_value,
563 frames_observed: self.eprocess.frames_observed,
564 frames_since_change: self.frames_since_change,
565 last_decision: self.last_decision,
566 in_warmup: self.eprocess.frames_observed < self.config.eprocess.warmup_frames,
567 }
568 }
569
570 pub fn reset(&mut self) {
572 self.pid.reset();
573 self.eprocess.reset();
574 self.current_level = DegradationLevel::Full;
575 self.frames_since_change = 0;
576 self.last_pid_output = 0.0;
577 self.last_decision = BudgetDecision::Hold;
578 }
579
580 #[inline]
582 pub fn config(&self) -> &BudgetControllerConfig {
583 &self.config
584 }
585}
586
587#[derive(Debug, Clone, Copy, PartialEq)]
592pub struct BudgetTelemetry {
593 pub level: DegradationLevel,
595 pub pid_output: f64,
597 pub pid_p: f64,
599 pub pid_i: f64,
601 pub pid_d: f64,
603 pub e_value: f64,
605 pub frames_observed: u32,
607 pub frames_since_change: u32,
609 pub last_decision: BudgetDecision,
611 pub in_warmup: bool,
613}
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
620#[repr(u8)]
621pub enum DegradationLevel {
622 #[default]
624 Full = 0,
625 SimpleBorders = 1,
627 NoStyling = 2,
629 EssentialOnly = 3,
631 Skeleton = 4,
633 SkipFrame = 5,
635}
636
637impl DegradationLevel {
638 #[inline]
642 pub fn next(self) -> Self {
643 match self {
644 Self::Full => Self::SimpleBorders,
645 Self::SimpleBorders => Self::NoStyling,
646 Self::NoStyling => Self::EssentialOnly,
647 Self::EssentialOnly => Self::Skeleton,
648 Self::Skeleton | Self::SkipFrame => Self::SkipFrame,
649 }
650 }
651
652 #[inline]
656 pub fn prev(self) -> Self {
657 match self {
658 Self::SkipFrame => Self::Skeleton,
659 Self::Skeleton => Self::EssentialOnly,
660 Self::EssentialOnly => Self::NoStyling,
661 Self::NoStyling => Self::SimpleBorders,
662 Self::SimpleBorders | Self::Full => Self::Full,
663 }
664 }
665
666 #[inline]
668 pub fn is_max(self) -> bool {
669 self == Self::SkipFrame
670 }
671
672 #[inline]
674 pub fn is_full(self) -> bool {
675 self == Self::Full
676 }
677
678 #[inline]
680 pub fn as_str(self) -> &'static str {
681 match self {
682 Self::Full => "Full",
683 Self::SimpleBorders => "SimpleBorders",
684 Self::NoStyling => "NoStyling",
685 Self::EssentialOnly => "EssentialOnly",
686 Self::Skeleton => "Skeleton",
687 Self::SkipFrame => "SkipFrame",
688 }
689 }
690
691 #[inline]
693 pub fn level(self) -> u8 {
694 self as u8
695 }
696
697 #[inline]
703 pub fn use_unicode_borders(self) -> bool {
704 self < Self::SimpleBorders
705 }
706
707 #[inline]
711 pub fn apply_styling(self) -> bool {
712 self < Self::NoStyling
713 }
714
715 #[inline]
720 pub fn render_decorative(self) -> bool {
721 self < Self::EssentialOnly
722 }
723
724 #[inline]
728 pub fn render_content(self) -> bool {
729 self < Self::Skeleton
730 }
731}
732
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735pub struct PhaseBudgets {
736 pub diff: Duration,
738 pub present: Duration,
740 pub render: Duration,
742}
743
744impl Default for PhaseBudgets {
745 fn default() -> Self {
746 Self {
747 diff: Duration::from_millis(2),
748 present: Duration::from_millis(4),
749 render: Duration::from_millis(8),
750 }
751 }
752}
753
754#[derive(Debug, Clone, PartialEq)]
756pub struct FrameBudgetConfig {
757 pub total: Duration,
759 pub phase_budgets: PhaseBudgets,
761 pub allow_frame_skip: bool,
763 pub degradation_cooldown: u32,
765 pub upgrade_threshold: f32,
768}
769
770impl Default for FrameBudgetConfig {
771 fn default() -> Self {
772 Self {
773 total: Duration::from_millis(16), phase_budgets: PhaseBudgets::default(),
775 allow_frame_skip: true,
776 degradation_cooldown: 3,
777 upgrade_threshold: 0.5,
778 }
779 }
780}
781
782impl FrameBudgetConfig {
783 pub fn with_total(total: Duration) -> Self {
785 Self {
786 total,
787 ..Default::default()
788 }
789 }
790
791 pub fn strict(total: Duration) -> Self {
793 Self {
794 total,
795 allow_frame_skip: false,
796 ..Default::default()
797 }
798 }
799
800 pub fn relaxed() -> Self {
802 Self {
803 total: Duration::from_millis(33), degradation_cooldown: 5,
805 ..Default::default()
806 }
807 }
808}
809
810#[derive(Debug, Clone)]
815pub struct RenderBudget {
816 total: Duration,
818 start: Instant,
820 last_frame_time: Option<Duration>,
822 degradation: DegradationLevel,
824 phase_budgets: PhaseBudgets,
826 allow_frame_skip: bool,
828 upgrade_threshold: f32,
830 frames_since_change: u32,
832 cooldown: u32,
834 controller: Option<BudgetController>,
837}
838
839impl RenderBudget {
840 pub fn new(total: Duration) -> Self {
842 Self {
843 total,
844 start: Instant::now(),
845 last_frame_time: None,
846 degradation: DegradationLevel::Full,
847 phase_budgets: PhaseBudgets::default(),
848 allow_frame_skip: true,
849 upgrade_threshold: 0.5,
850 frames_since_change: 0,
851 cooldown: 3,
852 controller: None,
853 }
854 }
855
856 pub fn from_config(config: &FrameBudgetConfig) -> Self {
858 Self {
859 total: config.total,
860 start: Instant::now(),
861 last_frame_time: None,
862 degradation: DegradationLevel::Full,
863 phase_budgets: config.phase_budgets,
864 allow_frame_skip: config.allow_frame_skip,
865 upgrade_threshold: config.upgrade_threshold,
866 frames_since_change: 0,
867 cooldown: config.degradation_cooldown,
868 controller: None,
869 }
870 }
871
872 pub fn with_controller(mut self, config: BudgetControllerConfig) -> Self {
888 self.controller = Some(BudgetController::new(config));
889 self
890 }
891
892 #[inline]
894 pub fn total(&self) -> Duration {
895 self.total
896 }
897
898 #[inline]
900 pub fn elapsed(&self) -> Duration {
901 self.start.elapsed()
902 }
903
904 #[inline]
906 pub fn remaining(&self) -> Duration {
907 self.total.saturating_sub(self.start.elapsed())
908 }
909
910 #[inline]
912 pub fn remaining_fraction(&self) -> f32 {
913 if self.total.is_zero() {
914 return 0.0;
915 }
916 let remaining = self.remaining().as_secs_f32();
917 let total = self.total.as_secs_f32();
918 (remaining / total).clamp(0.0, 1.0)
919 }
920
921 #[inline]
925 pub fn should_degrade(&self, estimated_cost: Duration) -> bool {
926 self.remaining() < estimated_cost
927 }
928
929 pub fn degrade(&mut self) {
933 let from = self.degradation;
934 self.degradation = self.degradation.next();
935 self.frames_since_change = 0;
936
937 #[cfg(feature = "tracing")]
938 if from != self.degradation {
939 warn!(
940 from = from.as_str(),
941 to = self.degradation.as_str(),
942 remaining_ms = self.remaining().as_millis() as u32,
943 "render budget degradation"
944 );
945 }
946 let _ = from; }
948
949 #[inline]
951 pub fn degradation(&self) -> DegradationLevel {
952 self.degradation
953 }
954
955 pub fn set_degradation(&mut self, level: DegradationLevel) {
959 if self.degradation != level {
960 self.degradation = level;
961 self.frames_since_change = 0;
962 }
963 }
964
965 #[inline]
969 pub fn exhausted(&self) -> bool {
970 self.remaining().is_zero()
971 || (self.degradation == DegradationLevel::SkipFrame && self.allow_frame_skip)
972 }
973
974 pub fn should_upgrade(&self) -> bool {
979 !self.degradation.is_full()
980 && self.remaining_fraction() > self.upgrade_threshold
981 && self.frames_since_change >= self.cooldown
982 }
983
984 fn should_upgrade_with_elapsed(&self, elapsed: Duration) -> bool {
986 if self.degradation.is_full() || self.frames_since_change < self.cooldown {
987 return false;
988 }
989 self.remaining_fraction_for_elapsed(elapsed) > self.upgrade_threshold
990 }
991
992 fn remaining_fraction_for_elapsed(&self, elapsed: Duration) -> f32 {
994 if self.total.is_zero() {
995 return 0.0;
996 }
997 let remaining = self.total.saturating_sub(elapsed);
998 let remaining = remaining.as_secs_f32();
999 let total = self.total.as_secs_f32();
1000 (remaining / total).clamp(0.0, 1.0)
1001 }
1002
1003 pub fn upgrade(&mut self) {
1007 let from = self.degradation;
1008 self.degradation = self.degradation.prev();
1009 self.frames_since_change = 0;
1010
1011 #[cfg(feature = "tracing")]
1012 if from != self.degradation {
1013 trace!(
1014 from = from.as_str(),
1015 to = self.degradation.as_str(),
1016 remaining_fraction = self.remaining_fraction(),
1017 "render budget upgrade"
1018 );
1019 }
1020 let _ = from; }
1022
1023 pub fn reset(&mut self) {
1027 self.start = Instant::now();
1028 self.frames_since_change = self.frames_since_change.saturating_add(1);
1029 }
1030
1031 pub fn next_frame(&mut self) {
1040 let frame_time = self.last_frame_time.unwrap_or_else(|| self.start.elapsed());
1041
1042 if self.controller.is_some() {
1043 let decision = self.controller.as_mut().unwrap().update(frame_time);
1048
1049 match decision {
1050 BudgetDecision::Degrade => self.degrade(),
1051 BudgetDecision::Upgrade => self.upgrade(),
1052 BudgetDecision::Hold => {}
1053 }
1054 } else {
1055 if self.should_upgrade_with_elapsed(frame_time) {
1057 self.upgrade();
1058 }
1059 }
1060 self.reset();
1061 }
1062
1063 pub fn record_frame_time(&mut self, elapsed: Duration) {
1065 self.last_frame_time = Some(elapsed);
1066 }
1067
1068 #[inline]
1073 pub fn telemetry(&self) -> Option<BudgetTelemetry> {
1074 self.controller.as_ref().map(BudgetController::telemetry)
1075 }
1076
1077 #[inline]
1079 pub fn controller(&self) -> Option<&BudgetController> {
1080 self.controller.as_ref()
1081 }
1082
1083 #[inline]
1085 pub fn phase_budgets(&self) -> &PhaseBudgets {
1086 &self.phase_budgets
1087 }
1088
1089 pub fn phase_has_budget(&self, phase: Phase) -> bool {
1091 let phase_budget = match phase {
1092 Phase::Diff => self.phase_budgets.diff,
1093 Phase::Present => self.phase_budgets.present,
1094 Phase::Render => self.phase_budgets.render,
1095 };
1096 self.remaining() >= phase_budget
1097 }
1098
1099 pub fn phase_budget(&self, phase: Phase) -> Self {
1103 let phase_total = match phase {
1104 Phase::Diff => self.phase_budgets.diff,
1105 Phase::Present => self.phase_budgets.present,
1106 Phase::Render => self.phase_budgets.render,
1107 };
1108 Self {
1109 total: phase_total.min(self.remaining()),
1110 start: self.start,
1111 last_frame_time: self.last_frame_time,
1112 degradation: self.degradation,
1113 phase_budgets: self.phase_budgets,
1114 allow_frame_skip: self.allow_frame_skip,
1115 upgrade_threshold: self.upgrade_threshold,
1116 frames_since_change: self.frames_since_change,
1117 cooldown: self.cooldown,
1118 controller: None, }
1120 }
1121}
1122
1123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1125pub enum Phase {
1126 Diff,
1128 Present,
1130 Render,
1132}
1133
1134impl Phase {
1135 pub fn as_str(self) -> &'static str {
1137 match self {
1138 Self::Diff => "diff",
1139 Self::Present => "present",
1140 Self::Render => "render",
1141 }
1142 }
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::*;
1148 use std::thread;
1149
1150 #[test]
1151 fn degradation_level_ordering() {
1152 assert!(DegradationLevel::Full < DegradationLevel::SimpleBorders);
1153 assert!(DegradationLevel::SimpleBorders < DegradationLevel::NoStyling);
1154 assert!(DegradationLevel::NoStyling < DegradationLevel::EssentialOnly);
1155 assert!(DegradationLevel::EssentialOnly < DegradationLevel::Skeleton);
1156 assert!(DegradationLevel::Skeleton < DegradationLevel::SkipFrame);
1157 }
1158
1159 #[test]
1160 fn degradation_level_next() {
1161 assert_eq!(
1162 DegradationLevel::Full.next(),
1163 DegradationLevel::SimpleBorders
1164 );
1165 assert_eq!(
1166 DegradationLevel::SimpleBorders.next(),
1167 DegradationLevel::NoStyling
1168 );
1169 assert_eq!(
1170 DegradationLevel::NoStyling.next(),
1171 DegradationLevel::EssentialOnly
1172 );
1173 assert_eq!(
1174 DegradationLevel::EssentialOnly.next(),
1175 DegradationLevel::Skeleton
1176 );
1177 assert_eq!(
1178 DegradationLevel::Skeleton.next(),
1179 DegradationLevel::SkipFrame
1180 );
1181 assert_eq!(
1182 DegradationLevel::SkipFrame.next(),
1183 DegradationLevel::SkipFrame
1184 );
1185 }
1186
1187 #[test]
1188 fn degradation_level_prev() {
1189 assert_eq!(
1190 DegradationLevel::SkipFrame.prev(),
1191 DegradationLevel::Skeleton
1192 );
1193 assert_eq!(
1194 DegradationLevel::Skeleton.prev(),
1195 DegradationLevel::EssentialOnly
1196 );
1197 assert_eq!(
1198 DegradationLevel::EssentialOnly.prev(),
1199 DegradationLevel::NoStyling
1200 );
1201 assert_eq!(
1202 DegradationLevel::NoStyling.prev(),
1203 DegradationLevel::SimpleBorders
1204 );
1205 assert_eq!(
1206 DegradationLevel::SimpleBorders.prev(),
1207 DegradationLevel::Full
1208 );
1209 assert_eq!(DegradationLevel::Full.prev(), DegradationLevel::Full);
1210 }
1211
1212 #[test]
1213 fn degradation_level_is_max() {
1214 assert!(!DegradationLevel::Full.is_max());
1215 assert!(!DegradationLevel::Skeleton.is_max());
1216 assert!(DegradationLevel::SkipFrame.is_max());
1217 }
1218
1219 #[test]
1220 fn degradation_level_is_full() {
1221 assert!(DegradationLevel::Full.is_full());
1222 assert!(!DegradationLevel::SimpleBorders.is_full());
1223 assert!(!DegradationLevel::SkipFrame.is_full());
1224 }
1225
1226 #[test]
1227 fn degradation_level_as_str() {
1228 assert_eq!(DegradationLevel::Full.as_str(), "Full");
1229 assert_eq!(DegradationLevel::SimpleBorders.as_str(), "SimpleBorders");
1230 assert_eq!(DegradationLevel::NoStyling.as_str(), "NoStyling");
1231 assert_eq!(DegradationLevel::EssentialOnly.as_str(), "EssentialOnly");
1232 assert_eq!(DegradationLevel::Skeleton.as_str(), "Skeleton");
1233 assert_eq!(DegradationLevel::SkipFrame.as_str(), "SkipFrame");
1234 }
1235
1236 #[test]
1237 fn degradation_level_values() {
1238 assert_eq!(DegradationLevel::Full.level(), 0);
1239 assert_eq!(DegradationLevel::SimpleBorders.level(), 1);
1240 assert_eq!(DegradationLevel::NoStyling.level(), 2);
1241 assert_eq!(DegradationLevel::EssentialOnly.level(), 3);
1242 assert_eq!(DegradationLevel::Skeleton.level(), 4);
1243 assert_eq!(DegradationLevel::SkipFrame.level(), 5);
1244 }
1245
1246 #[test]
1247 fn budget_remaining_decreases() {
1248 let budget = RenderBudget::new(Duration::from_millis(100));
1249 let initial = budget.remaining();
1250
1251 thread::sleep(Duration::from_millis(10));
1252
1253 let later = budget.remaining();
1254 assert!(later < initial);
1255 }
1256
1257 #[test]
1258 fn budget_remaining_fraction() {
1259 let budget = RenderBudget::new(Duration::from_millis(100));
1260
1261 let initial = budget.remaining_fraction();
1263 assert!(initial > 0.9);
1264
1265 thread::sleep(Duration::from_millis(50));
1266
1267 let later = budget.remaining_fraction();
1269 assert!(later < 0.6);
1270 assert!(later > 0.3);
1271 }
1272
1273 #[test]
1274 fn should_degrade_when_cost_exceeds_remaining() {
1275 let budget = RenderBudget::new(Duration::from_millis(100));
1277
1278 thread::sleep(Duration::from_millis(50));
1280
1281 assert!(budget.should_degrade(Duration::from_millis(80)));
1283 assert!(!budget.should_degrade(Duration::from_millis(10)));
1285 }
1286
1287 #[test]
1288 fn degrade_advances_level() {
1289 let mut budget = RenderBudget::new(Duration::from_millis(16));
1290
1291 assert_eq!(budget.degradation(), DegradationLevel::Full);
1292
1293 budget.degrade();
1294 assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
1295
1296 budget.degrade();
1297 assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1298 }
1299
1300 #[test]
1301 fn exhausted_when_no_time_left() {
1302 let budget = RenderBudget::new(Duration::from_millis(5));
1303
1304 assert!(!budget.exhausted());
1305
1306 thread::sleep(Duration::from_millis(10));
1307
1308 assert!(budget.exhausted());
1309 }
1310
1311 #[test]
1312 fn exhausted_at_skip_frame() {
1313 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1314
1315 budget.set_degradation(DegradationLevel::SkipFrame);
1317
1318 assert!(budget.exhausted());
1320 }
1321
1322 #[test]
1323 fn should_upgrade_with_remaining_budget() {
1324 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1325
1326 assert!(!budget.should_upgrade());
1328
1329 budget.degrade();
1331 budget.frames_since_change = 5;
1332
1333 assert!(budget.should_upgrade());
1335 }
1336
1337 #[test]
1338 fn upgrade_improves_level() {
1339 let mut budget = RenderBudget::new(Duration::from_millis(16));
1340
1341 budget.set_degradation(DegradationLevel::Skeleton);
1342 assert_eq!(budget.degradation(), DegradationLevel::Skeleton);
1343
1344 budget.upgrade();
1345 assert_eq!(budget.degradation(), DegradationLevel::EssentialOnly);
1346
1347 budget.upgrade();
1348 assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1349 }
1350
1351 #[test]
1352 fn upgrade_downgrade_symmetric() {
1353 let mut budget = RenderBudget::new(Duration::from_millis(16));
1354
1355 while !budget.degradation().is_max() {
1357 budget.degrade();
1358 }
1359 assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
1360
1361 while !budget.degradation().is_full() {
1363 budget.upgrade();
1364 }
1365 assert_eq!(budget.degradation(), DegradationLevel::Full);
1366 }
1367
1368 #[test]
1369 fn reset_preserves_degradation() {
1370 let mut budget = RenderBudget::new(Duration::from_millis(16));
1371
1372 budget.degrade();
1373 budget.degrade();
1374 let level = budget.degradation();
1375
1376 budget.reset();
1377
1378 assert_eq!(budget.degradation(), level);
1379 assert!(budget.remaining_fraction() > 0.9);
1381 }
1382
1383 #[test]
1384 fn next_frame_upgrades_when_possible() {
1385 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1386
1387 budget.degrade();
1389 for _ in 0..5 {
1390 budget.reset();
1391 }
1392
1393 let before = budget.degradation();
1394 budget.next_frame();
1395
1396 assert!(budget.degradation() < before);
1398 }
1399
1400 #[test]
1401 fn next_frame_prefers_recorded_frame_time_for_upgrade() {
1402 let mut budget = RenderBudget::new(Duration::from_millis(16));
1403
1404 budget.degrade();
1405 for _ in 0..5 {
1406 budget.reset();
1407 }
1408
1409 budget.record_frame_time(Duration::from_millis(1));
1412 std::thread::sleep(Duration::from_millis(25));
1413
1414 let before = budget.degradation();
1415 budget.next_frame();
1416
1417 assert!(budget.degradation() < before);
1418 }
1419
1420 #[test]
1421 fn config_defaults() {
1422 let config = FrameBudgetConfig::default();
1423
1424 assert_eq!(config.total, Duration::from_millis(16));
1425 assert!(config.allow_frame_skip);
1426 assert_eq!(config.degradation_cooldown, 3);
1427 assert!((config.upgrade_threshold - 0.5).abs() < f32::EPSILON);
1428 }
1429
1430 #[test]
1431 fn config_with_total() {
1432 let config = FrameBudgetConfig::with_total(Duration::from_millis(33));
1433
1434 assert_eq!(config.total, Duration::from_millis(33));
1435 assert!(config.allow_frame_skip);
1437 }
1438
1439 #[test]
1440 fn config_strict() {
1441 let config = FrameBudgetConfig::strict(Duration::from_millis(16));
1442
1443 assert!(!config.allow_frame_skip);
1444 }
1445
1446 #[test]
1447 fn config_relaxed() {
1448 let config = FrameBudgetConfig::relaxed();
1449
1450 assert_eq!(config.total, Duration::from_millis(33));
1451 assert_eq!(config.degradation_cooldown, 5);
1452 }
1453
1454 #[test]
1455 fn from_config() {
1456 let config = FrameBudgetConfig {
1457 total: Duration::from_millis(20),
1458 allow_frame_skip: false,
1459 ..Default::default()
1460 };
1461
1462 let budget = RenderBudget::from_config(&config);
1463
1464 assert_eq!(budget.total(), Duration::from_millis(20));
1465 assert!(!budget.exhausted()); let mut budget = RenderBudget::from_config(&config);
1469 budget.set_degradation(DegradationLevel::SkipFrame);
1470 assert!(!budget.exhausted());
1471 }
1472
1473 #[test]
1474 fn phase_budgets_default() {
1475 let budgets = PhaseBudgets::default();
1476
1477 assert_eq!(budgets.diff, Duration::from_millis(2));
1478 assert_eq!(budgets.present, Duration::from_millis(4));
1479 assert_eq!(budgets.render, Duration::from_millis(8));
1480 }
1481
1482 #[test]
1483 fn phase_has_budget() {
1484 let budget = RenderBudget::new(Duration::from_millis(100));
1485
1486 assert!(budget.phase_has_budget(Phase::Diff));
1487 assert!(budget.phase_has_budget(Phase::Present));
1488 assert!(budget.phase_has_budget(Phase::Render));
1489 }
1490
1491 #[test]
1492 fn phase_budget_respects_remaining() {
1493 let budget = RenderBudget::new(Duration::from_millis(100));
1494
1495 let diff_budget = budget.phase_budget(Phase::Diff);
1496 assert_eq!(diff_budget.total(), Duration::from_millis(2));
1497
1498 let present_budget = budget.phase_budget(Phase::Present);
1499 assert_eq!(present_budget.total(), Duration::from_millis(4));
1500 }
1501
1502 #[test]
1503 fn phase_as_str() {
1504 assert_eq!(Phase::Diff.as_str(), "diff");
1505 assert_eq!(Phase::Present.as_str(), "present");
1506 assert_eq!(Phase::Render.as_str(), "render");
1507 }
1508
1509 #[test]
1510 fn zero_budget_is_immediately_exhausted() {
1511 let budget = RenderBudget::new(Duration::ZERO);
1512 assert!(budget.exhausted());
1513 assert_eq!(budget.remaining_fraction(), 0.0);
1514 }
1515
1516 #[test]
1517 fn degradation_level_never_exceeds_skip_frame() {
1518 let mut level = DegradationLevel::Full;
1519
1520 for _ in 0..100 {
1521 level = level.next();
1522 }
1523
1524 assert_eq!(level, DegradationLevel::SkipFrame);
1525 }
1526
1527 #[test]
1528 fn budget_remaining_never_negative() {
1529 let budget = RenderBudget::new(Duration::from_millis(1));
1530
1531 thread::sleep(Duration::from_millis(10));
1533
1534 assert_eq!(budget.remaining(), Duration::ZERO);
1536 assert_eq!(budget.remaining_fraction(), 0.0);
1537 }
1538
1539 #[test]
1540 fn infinite_budget_stays_at_full() {
1541 let mut budget = RenderBudget::new(Duration::from_secs(1000));
1542
1543 assert!(!budget.should_degrade(Duration::from_millis(100)));
1545 assert_eq!(budget.degradation(), DegradationLevel::Full);
1546
1547 budget.next_frame();
1549 assert_eq!(budget.degradation(), DegradationLevel::Full);
1550 }
1551
1552 #[test]
1553 fn cooldown_prevents_immediate_upgrade() {
1554 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1555 budget.cooldown = 3;
1556
1557 budget.degrade();
1559 assert_eq!(budget.frames_since_change, 0);
1560
1561 assert!(!budget.should_upgrade());
1563
1564 budget.frames_since_change = 3;
1566
1567 assert!(budget.should_upgrade());
1569 }
1570
1571 #[test]
1572 fn set_degradation_resets_cooldown() {
1573 let mut budget = RenderBudget::new(Duration::from_millis(16));
1574 budget.frames_since_change = 10;
1575
1576 budget.set_degradation(DegradationLevel::NoStyling);
1577
1578 assert_eq!(budget.frames_since_change, 0);
1579 }
1580
1581 #[test]
1582 fn set_degradation_same_level_preserves_cooldown() {
1583 let mut budget = RenderBudget::new(Duration::from_millis(16));
1584 budget.frames_since_change = 10;
1585
1586 budget.set_degradation(DegradationLevel::Full);
1588
1589 assert_eq!(budget.frames_since_change, 10);
1591 }
1592
1593 mod controller_tests {
1598 use super::super::*;
1599
1600 fn make_controller() -> BudgetController {
1601 BudgetController::new(BudgetControllerConfig::default())
1602 }
1603
1604 fn make_controller_with_config(
1605 target_ms: u64,
1606 warmup: u32,
1607 cooldown: u32,
1608 ) -> BudgetController {
1609 BudgetController::new(BudgetControllerConfig {
1610 target: Duration::from_millis(target_ms),
1611 eprocess: EProcessConfig {
1612 warmup_frames: warmup,
1613 ..Default::default()
1614 },
1615 cooldown_frames: cooldown,
1616 ..Default::default()
1617 })
1618 }
1619
1620 #[test]
1623 fn pid_step_input_yields_nonzero_output() {
1624 let mut state = PidState::default();
1625 let gains = PidGains::default();
1626
1627 let u = state.update(1.0, &gains);
1629 assert!(
1631 (u - 0.75).abs() < 1e-10,
1632 "First PID output should be 0.75, got {}",
1633 u
1634 );
1635 }
1636
1637 #[test]
1638 fn pid_zero_error_zero_output() {
1639 let mut state = PidState::default();
1640 let gains = PidGains::default();
1641
1642 let u = state.update(0.0, &gains);
1643 assert!(
1644 u.abs() < 1e-10,
1645 "Zero error should produce zero output, got {}",
1646 u
1647 );
1648 }
1649
1650 #[test]
1651 fn pid_integral_accumulates() {
1652 let mut state = PidState::default();
1653 let gains = PidGains::default();
1654
1655 state.update(1.0, &gains);
1657 state.update(1.0, &gains);
1658 state.update(1.0, &gains);
1659
1660 assert!(
1661 state.integral > 2.5,
1662 "Integral should accumulate: {}",
1663 state.integral
1664 );
1665 }
1666
1667 #[test]
1668 fn pid_integral_anti_windup() {
1669 let mut state = PidState::default();
1670 let gains = PidGains {
1671 integral_max: 2.0,
1672 ..Default::default()
1673 };
1674
1675 for _ in 0..100 {
1677 state.update(10.0, &gains);
1678 }
1679
1680 assert!(
1681 state.integral <= 2.0 + f64::EPSILON,
1682 "Integral should be clamped to max: {}",
1683 state.integral
1684 );
1685 assert!(
1686 state.integral >= -2.0 - f64::EPSILON,
1687 "Integral should be clamped to -max: {}",
1688 state.integral
1689 );
1690 }
1691
1692 #[test]
1693 fn pid_derivative_responds_to_change() {
1694 let mut state = PidState::default();
1695 let gains = PidGains::default();
1696
1697 let u1 = state.update(0.0, &gains);
1699 let u2 = state.update(1.0, &gains);
1701
1702 assert!(
1704 u2 > u1,
1705 "Step change should produce larger output: u1={}, u2={}",
1706 u1,
1707 u2
1708 );
1709 }
1710
1711 #[test]
1712 fn pid_settling_after_step() {
1713 let mut state = PidState::default();
1714 let gains = PidGains::default();
1715
1716 state.update(1.0, &gains);
1718 state.update(1.0, &gains);
1719 state.update(1.0, &gains);
1720
1721 let mut outputs = Vec::new();
1723 for _ in 0..20 {
1724 outputs.push(state.update(0.0, &gains));
1725 }
1726
1727 let last = *outputs.last().unwrap();
1729 assert!(
1730 last.abs() < 0.5,
1731 "PID should settle toward zero: last={}",
1732 last
1733 );
1734 }
1735
1736 #[test]
1737 fn pid_reset_clears_state() {
1738 let mut state = PidState::default();
1739 let gains = PidGains::default();
1740
1741 state.update(5.0, &gains);
1742 state.update(5.0, &gains);
1743 assert!(state.integral.abs() > 0.0);
1744
1745 state.reset();
1746 assert_eq!(state.integral, 0.0);
1747 assert_eq!(state.prev_error, 0.0);
1748 }
1749
1750 #[test]
1753 fn eprocess_starts_at_one() {
1754 let state = EProcessState::default();
1755 assert!(
1756 (state.e_value - 1.0).abs() < f64::EPSILON,
1757 "E-process should start at 1.0"
1758 );
1759 }
1760
1761 #[test]
1762 fn eprocess_grows_under_overload() {
1763 let mut state = EProcessState::default();
1764 let config = EProcessConfig {
1765 warmup_frames: 0,
1766 ..Default::default()
1767 };
1768
1769 for _ in 0..20 {
1771 state.update(30.0, 16.0, &config);
1772 }
1773
1774 assert!(
1775 state.e_value > 1.0,
1776 "E-value should grow under overload: {}",
1777 state.e_value
1778 );
1779 }
1780
1781 #[test]
1782 fn eprocess_shrinks_under_underload() {
1783 let mut state = EProcessState::default();
1784 let config = EProcessConfig {
1785 warmup_frames: 0,
1786 ..Default::default()
1787 };
1788
1789 for _ in 0..20 {
1791 state.update(8.0, 16.0, &config);
1792 }
1793
1794 assert!(
1795 state.e_value < 1.0,
1796 "E-value should shrink under underload: {}",
1797 state.e_value
1798 );
1799 }
1800
1801 #[test]
1802 fn eprocess_gate_blocks_during_warmup() {
1803 let mut state = EProcessState::default();
1804 let config = EProcessConfig {
1805 warmup_frames: 10,
1806 ..Default::default()
1807 };
1808
1809 for _ in 0..5 {
1811 state.update(50.0, 16.0, &config);
1812 }
1813
1814 assert!(
1815 !state.should_degrade(&config),
1816 "E-process should not permit degradation during warmup"
1817 );
1818 }
1819
1820 #[test]
1821 fn eprocess_gate_allows_after_warmup() {
1822 let mut state = EProcessState::default();
1823 let config = EProcessConfig {
1824 warmup_frames: 5,
1825 alpha: 0.05,
1826 ..Default::default()
1827 };
1828
1829 for _ in 0..50 {
1831 state.update(80.0, 16.0, &config);
1832 }
1833
1834 assert!(
1835 state.should_degrade(&config),
1836 "E-process should permit degradation after sustained overload: E={}",
1837 state.e_value
1838 );
1839 }
1840
1841 #[test]
1842 fn eprocess_recovery_after_overload() {
1843 let mut state = EProcessState::default();
1844 let config = EProcessConfig {
1845 warmup_frames: 0,
1846 ..Default::default()
1847 };
1848
1849 for _ in 0..30 {
1851 state.update(40.0, 16.0, &config);
1852 }
1853 let peak = state.e_value;
1854
1855 for _ in 0..100 {
1857 state.update(8.0, 16.0, &config);
1858 }
1859
1860 assert!(
1861 state.e_value < peak,
1862 "E-value should decrease after recovery: peak={}, now={}",
1863 peak,
1864 state.e_value
1865 );
1866 }
1867
1868 #[test]
1869 fn eprocess_sigma_floor_prevents_instability() {
1870 let mut state = EProcessState::default();
1871 let config = EProcessConfig {
1872 sigma_floor_ms: 1.0,
1873 warmup_frames: 0,
1874 ..Default::default()
1875 };
1876
1877 for _ in 0..20 {
1879 state.update(16.0, 16.0, &config);
1880 }
1881
1882 assert!(
1884 state.sigma_ema >= 0.0,
1885 "Sigma should be non-negative: {}",
1886 state.sigma_ema
1887 );
1888 assert!(
1890 state.e_value.is_finite(),
1891 "E-value should be finite: {}",
1892 state.e_value
1893 );
1894 }
1895
1896 #[test]
1897 fn eprocess_reset_returns_to_initial() {
1898 let mut state = EProcessState::default();
1899 let config = EProcessConfig::default();
1900
1901 state.update(50.0, 16.0, &config);
1902 state.update(50.0, 16.0, &config);
1903
1904 state.reset();
1905 assert!((state.e_value - 1.0).abs() < f64::EPSILON);
1906 assert_eq!(state.frames_observed, 0);
1907 }
1908
1909 #[test]
1912 fn controller_holds_under_normal_load() {
1913 let mut ctrl = make_controller_with_config(16, 0, 0);
1914
1915 for _ in 0..20 {
1917 let decision = ctrl.update(Duration::from_millis(16));
1918 assert_eq!(
1919 decision,
1920 BudgetDecision::Hold,
1921 "On-target frames should hold"
1922 );
1923 }
1924 assert_eq!(ctrl.level(), DegradationLevel::Full);
1925 }
1926
1927 #[test]
1928 fn controller_degrades_under_sustained_overload() {
1929 let mut ctrl = make_controller_with_config(16, 0, 0);
1930
1931 let mut degraded = false;
1932 for _ in 0..50 {
1934 let decision = ctrl.update(Duration::from_millis(40));
1935 if decision == BudgetDecision::Degrade {
1936 degraded = true;
1937 }
1938 }
1939
1940 assert!(
1941 degraded,
1942 "Controller should degrade under sustained overload"
1943 );
1944 assert!(
1945 ctrl.level() > DegradationLevel::Full,
1946 "Level should be degraded: {:?}",
1947 ctrl.level()
1948 );
1949 }
1950
1951 #[test]
1952 fn controller_upgrades_after_recovery() {
1953 let mut ctrl = make_controller_with_config(16, 0, 0);
1954
1955 for _ in 0..50 {
1957 ctrl.update(Duration::from_millis(40));
1958 }
1959 let degraded_level = ctrl.level();
1960 assert!(degraded_level > DegradationLevel::Full);
1961
1962 let mut upgraded = false;
1964 for _ in 0..200 {
1965 let decision = ctrl.update(Duration::from_millis(4));
1966 if decision == BudgetDecision::Upgrade {
1967 upgraded = true;
1968 }
1969 }
1970
1971 assert!(upgraded, "Controller should upgrade after recovery");
1972 assert!(
1973 ctrl.level() < degraded_level,
1974 "Level should improve: before={:?}, after={:?}",
1975 degraded_level,
1976 ctrl.level()
1977 );
1978 }
1979
1980 #[test]
1981 fn controller_cooldown_prevents_oscillation() {
1982 let mut ctrl = make_controller_with_config(16, 0, 5);
1983
1984 for _ in 0..50 {
1986 ctrl.update(Duration::from_millis(40));
1987 }
1988
1989 let mut decisions_during_cooldown = Vec::new();
1991 for _ in 0..4 {
1992 decisions_during_cooldown.push(ctrl.update(Duration::from_millis(4)));
1993 }
1994
1995 assert!(
1997 decisions_during_cooldown
1998 .iter()
1999 .all(|d| *d == BudgetDecision::Hold),
2000 "Cooldown should prevent changes: {:?}",
2001 decisions_during_cooldown
2002 );
2003 }
2004
2005 #[test]
2006 fn controller_no_oscillation_under_constant_load() {
2007 let mut ctrl = make_controller_with_config(16, 0, 3);
2008
2009 let mut transitions = 0u32;
2011 let mut prev_level = ctrl.level();
2012 for _ in 0..100 {
2013 ctrl.update(Duration::from_millis(20));
2014 if ctrl.level() != prev_level {
2015 transitions += 1;
2016 prev_level = ctrl.level();
2017 }
2018 }
2019
2020 assert!(
2023 transitions < 10,
2024 "Too many transitions under constant load: {}",
2025 transitions
2026 );
2027 }
2028
2029 #[test]
2030 fn controller_reset_restores_full_quality() {
2031 let mut ctrl = make_controller();
2032
2033 for _ in 0..50 {
2035 ctrl.update(Duration::from_millis(40));
2036 }
2037
2038 ctrl.reset();
2039
2040 assert_eq!(ctrl.level(), DegradationLevel::Full);
2041 assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
2042 assert_eq!(ctrl.pid_integral(), 0.0);
2043 }
2044
2045 #[test]
2046 fn controller_transient_spike_does_not_degrade() {
2047 let mut ctrl = make_controller_with_config(16, 5, 3);
2048
2049 for _ in 0..20 {
2051 ctrl.update(Duration::from_millis(16));
2052 }
2053
2054 ctrl.update(Duration::from_millis(100));
2056
2057 for _ in 0..5 {
2059 ctrl.update(Duration::from_millis(16));
2060 }
2061
2062 assert_eq!(
2064 ctrl.level(),
2065 DegradationLevel::Full,
2066 "Single spike should not cause degradation"
2067 );
2068 }
2069
2070 #[test]
2071 fn controller_never_exceeds_skip_frame() {
2072 let mut ctrl = make_controller_with_config(16, 0, 0);
2073
2074 for _ in 0..500 {
2076 ctrl.update(Duration::from_millis(200));
2077 }
2078
2079 assert!(
2080 ctrl.level() <= DegradationLevel::SkipFrame,
2081 "Level should not exceed SkipFrame: {:?}",
2082 ctrl.level()
2083 );
2084 }
2085
2086 #[test]
2087 fn controller_never_goes_below_full() {
2088 let mut ctrl = make_controller_with_config(16, 0, 0);
2089
2090 for _ in 0..200 {
2092 ctrl.update(Duration::from_millis(1));
2093 }
2094
2095 assert_eq!(
2096 ctrl.level(),
2097 DegradationLevel::Full,
2098 "Level should not go below Full"
2099 );
2100 }
2101
2102 #[test]
2105 fn pid_gains_default_valid() {
2106 let gains = PidGains::default();
2107 assert!(gains.kp > 0.0);
2108 assert!(gains.ki > 0.0);
2109 assert!(gains.kd > 0.0);
2110 assert!(gains.integral_max > 0.0);
2111 }
2112
2113 #[test]
2114 fn eprocess_config_default_valid() {
2115 let config = EProcessConfig::default();
2116 assert!(config.lambda > 0.0);
2117 assert!(config.alpha > 0.0 && config.alpha < 1.0);
2118 assert!(config.beta > 0.0 && config.beta < 1.0);
2119 assert!(config.sigma_floor_ms > 0.0);
2120 }
2121
2122 #[test]
2123 fn controller_config_default_valid() {
2124 let config = BudgetControllerConfig::default();
2125 assert!(config.degrade_threshold > 0.0);
2126 assert!(config.upgrade_threshold > 0.0);
2127 assert!(config.target > Duration::ZERO);
2128 }
2129
2130 #[test]
2131 fn budget_decision_equality() {
2132 assert_eq!(BudgetDecision::Hold, BudgetDecision::Hold);
2133 assert_ne!(BudgetDecision::Hold, BudgetDecision::Degrade);
2134 assert_ne!(BudgetDecision::Degrade, BudgetDecision::Upgrade);
2135 }
2136 }
2137
2138 mod integration_tests {
2143 use super::super::*;
2144
2145 #[test]
2146 fn render_budget_without_controller_returns_no_telemetry() {
2147 let budget = RenderBudget::new(Duration::from_millis(16));
2148 assert!(budget.telemetry().is_none());
2149 assert!(budget.controller().is_none());
2150 }
2151
2152 #[test]
2153 fn render_budget_with_controller_returns_telemetry() {
2154 let budget = RenderBudget::new(Duration::from_millis(16))
2155 .with_controller(BudgetControllerConfig::default());
2156 assert!(budget.controller().is_some());
2157
2158 let telem = budget.telemetry().unwrap();
2159 assert_eq!(telem.level, DegradationLevel::Full);
2160 assert_eq!(telem.last_decision, BudgetDecision::Hold);
2161 assert_eq!(telem.frames_observed, 0);
2162 assert!(telem.in_warmup);
2163 }
2164
2165 #[test]
2166 fn telemetry_fields_update_after_next_frame() {
2167 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2168 BudgetControllerConfig {
2169 eprocess: EProcessConfig {
2170 warmup_frames: 0,
2171 ..Default::default()
2172 },
2173 cooldown_frames: 0,
2174 ..Default::default()
2175 },
2176 );
2177
2178 for _ in 0..5 {
2180 budget.next_frame();
2181 }
2182
2183 let telem = budget.telemetry().unwrap();
2184 assert_eq!(telem.frames_observed, 5);
2185 assert!(!telem.in_warmup);
2186 assert!(telem.pid_output.is_finite());
2189 assert!(telem.e_value.is_finite());
2190 }
2191
2192 #[test]
2193 fn controller_next_frame_degrades_under_simulated_overload() {
2194 let config = BudgetControllerConfig {
2199 target: Duration::from_millis(16),
2200 eprocess: EProcessConfig {
2201 warmup_frames: 0,
2202 ..Default::default()
2203 },
2204 cooldown_frames: 0,
2205 ..Default::default()
2206 };
2207 let mut ctrl = BudgetController::new(config);
2208
2209 for _ in 0..50 {
2211 ctrl.update(Duration::from_millis(40));
2212 }
2213
2214 assert!(
2216 ctrl.level() > DegradationLevel::Full,
2217 "Controller should degrade: {:?}",
2218 ctrl.level()
2219 );
2220
2221 let telem = ctrl.telemetry();
2223 assert!(telem.level > DegradationLevel::Full);
2224 assert!(
2225 telem.pid_output > 0.0,
2226 "PID output should be positive under overload"
2227 );
2228 assert!(telem.e_value > 1.0, "E-value should grow under overload");
2229 }
2230
2231 #[test]
2232 fn next_frame_delegates_to_controller_when_attached() {
2233 let mut budget = RenderBudget::new(Duration::from_millis(1000))
2236 .with_controller(BudgetControllerConfig::default());
2237
2238 budget.degrade();
2240 assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
2241
2242 budget.next_frame();
2246
2247 let telem = budget.telemetry().unwrap();
2252 assert_eq!(telem.frames_observed, 1);
2253 }
2254
2255 #[test]
2256 fn telemetry_is_copy_and_no_alloc() {
2257 let budget = RenderBudget::new(Duration::from_millis(16))
2258 .with_controller(BudgetControllerConfig::default());
2259
2260 let telem = budget.telemetry().unwrap();
2261 let telem2 = telem;
2263 assert_eq!(telem.level, telem2.level);
2264 assert_eq!(telem.e_value, telem2.e_value);
2265 }
2266
2267 #[test]
2268 fn telemetry_warmup_flag_transitions() {
2269 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2270 BudgetControllerConfig {
2271 eprocess: EProcessConfig {
2272 warmup_frames: 3,
2273 ..Default::default()
2274 },
2275 ..Default::default()
2276 },
2277 );
2278
2279 budget.next_frame();
2281 budget.next_frame();
2282 let telem = budget.telemetry().unwrap();
2283 assert!(telem.in_warmup, "Should be in warmup at frame 2");
2284
2285 budget.next_frame();
2287 let telem = budget.telemetry().unwrap();
2288 assert!(!telem.in_warmup, "Should exit warmup at frame 3");
2289 }
2290
2291 #[test]
2292 fn phase_sub_budget_does_not_carry_controller() {
2293 let budget = RenderBudget::new(Duration::from_millis(100))
2294 .with_controller(BudgetControllerConfig::default());
2295
2296 let phase = budget.phase_budget(Phase::Render);
2297 assert!(
2298 phase.controller().is_none(),
2299 "Phase sub-budgets should not carry the controller"
2300 );
2301 }
2302
2303 #[test]
2304 fn controller_telemetry_tracks_frames_since_change() {
2305 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2306 eprocess: EProcessConfig {
2307 warmup_frames: 0,
2308 ..Default::default()
2309 },
2310 cooldown_frames: 0,
2311 ..Default::default()
2312 });
2313
2314 for i in 1..=5 {
2316 ctrl.update(Duration::from_millis(16));
2317 let telem = ctrl.telemetry();
2318 assert_eq!(
2319 telem.frames_since_change, i,
2320 "frames_since_change should be {} after {} frames",
2321 i, i
2322 );
2323 }
2324 }
2325
2326 #[test]
2327 fn telemetry_last_decision_reflects_controller_decision() {
2328 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2329 eprocess: EProcessConfig {
2330 warmup_frames: 0,
2331 ..Default::default()
2332 },
2333 cooldown_frames: 0,
2334 ..Default::default()
2335 });
2336
2337 ctrl.update(Duration::from_millis(16));
2339 assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Hold);
2340
2341 let mut saw_degrade = false;
2343 for _ in 0..50 {
2344 let d = ctrl.update(Duration::from_millis(50));
2345 if d == BudgetDecision::Degrade {
2346 saw_degrade = true;
2347 assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Degrade);
2348 break;
2349 }
2350 }
2351 assert!(saw_degrade, "Should have seen a Degrade decision");
2352 }
2353
2354 #[test]
2355 fn perf_overhead_controller_update_is_fast() {
2356 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2360
2361 let start = Instant::now();
2362 for _ in 0..10_000 {
2363 ctrl.update(Duration::from_millis(16));
2364 }
2365 let elapsed = start.elapsed();
2366
2367 assert!(
2371 elapsed < Duration::from_millis(50),
2372 "10k controller updates took {:?}, expected <50ms",
2373 elapsed
2374 );
2375 }
2376
2377 #[test]
2378 fn perf_overhead_telemetry_snapshot_is_fast() {
2379 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2380 ctrl.update(Duration::from_millis(16));
2381
2382 let start = Instant::now();
2383 for _ in 0..10_000 {
2384 let _telem = ctrl.telemetry();
2385 }
2386 let elapsed = start.elapsed();
2387
2388 assert!(
2389 elapsed < Duration::from_millis(10),
2390 "10k telemetry snapshots took {:?}, expected <10ms",
2391 elapsed
2392 );
2393 }
2394 }
2395
2396 mod stability_tests {
2401 use super::super::*;
2402
2403 fn fast_controller(target_ms: u64) -> BudgetController {
2405 BudgetController::new(BudgetControllerConfig {
2406 target: Duration::from_millis(target_ms),
2407 eprocess: EProcessConfig {
2408 warmup_frames: 0,
2409 ..Default::default()
2410 },
2411 cooldown_frames: 0,
2412 ..Default::default()
2413 })
2414 }
2415
2416 fn run_trace(
2420 ctrl: &mut BudgetController,
2421 trace: &[Duration],
2422 ) -> Vec<(u64, u64, BudgetTelemetry)> {
2423 trace
2424 .iter()
2425 .enumerate()
2426 .map(|(i, &ft)| {
2427 ctrl.update(ft);
2428 let telem = ctrl.telemetry();
2429 (i as u64, ft.as_micros() as u64, telem)
2430 })
2431 .collect()
2432 }
2433
2434 fn count_transitions(log: &[(u64, u64, BudgetTelemetry)]) -> u32 {
2436 let mut transitions = 0u32;
2437 for pair in log.windows(2) {
2438 if pair[0].2.level != pair[1].2.level {
2439 transitions += 1;
2440 }
2441 }
2442 transitions
2443 }
2444
2445 #[test]
2448 fn e2e_burst_logs_no_oscillation() {
2449 let mut ctrl = fast_controller(16);
2452
2453 let mut trace = Vec::new();
2454 for _cycle in 0..5 {
2455 for _ in 0..10 {
2457 trace.push(Duration::from_millis(40));
2458 }
2459 for _ in 0..20 {
2461 trace.push(Duration::from_millis(16));
2462 }
2463 }
2464
2465 let log = run_trace(&mut ctrl, &trace);
2466
2467 let transitions = count_transitions(&log);
2472 assert!(
2473 transitions < 20,
2474 "Too many transitions under bursty load: {} (expected <20)",
2475 transitions
2476 );
2477
2478 for (frame, ft_us, telem) in &log {
2480 assert!(
2481 telem.pid_output.is_finite(),
2482 "frame {}: NaN pid_output",
2483 frame
2484 );
2485 assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2486 assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2487 assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2488 assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2489 assert!(*ft_us > 0, "frame {}: zero frame time", frame);
2490 }
2491 }
2492
2493 #[test]
2494 fn e2e_burst_recovers_after_moderate_overload() {
2495 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2499 target: Duration::from_millis(16),
2500 eprocess: EProcessConfig {
2501 warmup_frames: 5,
2502 ..Default::default()
2503 },
2504 cooldown_frames: 3,
2505 ..Default::default()
2506 });
2507
2508 let mut trace = Vec::new();
2509 for _cycle in 0..3 {
2510 for _ in 0..15 {
2512 trace.push(Duration::from_millis(30));
2513 }
2514 for _ in 0..50 {
2516 trace.push(Duration::from_millis(10));
2517 }
2518 }
2519
2520 let log = run_trace(&mut ctrl, &trace);
2521
2522 for cycle in 0..3 {
2525 let calm_end = (cycle + 1) * 65 - 1;
2526 if calm_end < log.len() {
2527 assert!(
2528 log[calm_end].2.level < DegradationLevel::SkipFrame,
2529 "cycle {}: should recover after calm period, got {:?} at frame {}",
2530 cycle,
2531 log[calm_end].2.level,
2532 calm_end
2533 );
2534 }
2535 }
2536
2537 let final_level = log.last().unwrap().2.level;
2539 assert!(
2540 final_level < DegradationLevel::Skeleton,
2541 "Final level should recover below Skeleton: {:?}",
2542 final_level
2543 );
2544 }
2545
2546 #[test]
2549 fn e2e_idle_to_burst_recovery() {
2550 let mut ctrl = fast_controller(16);
2553
2554 let mut trace = Vec::new();
2555 for _ in 0..50 {
2557 trace.push(Duration::from_millis(8));
2558 }
2559 for _ in 0..20 {
2561 trace.push(Duration::from_millis(50));
2562 }
2563 for _ in 0..100 {
2565 trace.push(Duration::from_millis(8));
2566 }
2567
2568 let log = run_trace(&mut ctrl, &trace);
2569
2570 assert_eq!(
2572 log[49].2.level,
2573 DegradationLevel::Full,
2574 "Should be Full during idle phase"
2575 );
2576
2577 let max_during_burst = log[50..70].iter().map(|(_, _, t)| t.level).max().unwrap();
2579 assert!(
2580 max_during_burst > DegradationLevel::Full,
2581 "Should degrade during burst"
2582 );
2583
2584 let final_level = log.last().unwrap().2.level;
2586 assert!(
2587 final_level < max_during_burst,
2588 "Should recover after burst: final={:?}, max_during_burst={:?}",
2589 final_level,
2590 max_during_burst
2591 );
2592 }
2593
2594 #[test]
2595 fn e2e_idle_to_burst_no_over_degrade() {
2596 let mut ctrl = fast_controller(16);
2599
2600 for _ in 0..30 {
2602 ctrl.update(Duration::from_millis(8));
2603 }
2604
2605 for _ in 0..5 {
2607 ctrl.update(Duration::from_millis(40));
2608 }
2609
2610 let level = ctrl.level();
2612 assert!(
2613 level <= DegradationLevel::NoStyling,
2614 "Brief burst should not over-degrade: {:?}",
2615 level
2616 );
2617 }
2618
2619 #[test]
2622 fn property_random_load_hysteresis_bounds() {
2623 let mut ctrl = fast_controller(16);
2626
2627 let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_BABE;
2630 let mut trace = Vec::new();
2631 for _ in 0..1000 {
2632 rng_state = rng_state
2634 .wrapping_mul(6_364_136_223_846_793_005)
2635 .wrapping_add(1_442_695_040_888_963_407);
2636 let frame_ms = 4 + ((rng_state >> 33) % 77);
2638 trace.push(Duration::from_millis(frame_ms));
2639 }
2640
2641 let log = run_trace(&mut ctrl, &trace);
2642
2643 for pair in log.windows(2) {
2645 let prev = pair[0].2.level.level();
2646 let curr = pair[1].2.level.level();
2647 let delta = (curr as i16 - prev as i16).unsigned_abs();
2648 assert!(
2649 delta <= 1,
2650 "Level jumped {} steps at frame {}: {:?} -> {:?}",
2651 delta,
2652 pair[1].0,
2653 pair[0].2.level,
2654 pair[1].2.level
2655 );
2656 }
2657
2658 for (frame, _, telem) in &log {
2660 assert!(
2661 telem.level <= DegradationLevel::SkipFrame,
2662 "frame {}: level out of range: {:?}",
2663 frame,
2664 telem.level
2665 );
2666 }
2667
2668 for (frame, _, telem) in &log {
2670 assert!(
2671 telem.pid_output.is_finite(),
2672 "frame {}: NaN pid_output",
2673 frame
2674 );
2675 assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2676 assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2677 assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2678 assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2679 assert!(
2680 telem.e_value > 0.0,
2681 "frame {}: e_value not positive: {}",
2682 frame,
2683 telem.e_value
2684 );
2685 }
2686 }
2687
2688 #[test]
2689 fn property_random_load_bounded_transitions() {
2690 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2693 target: Duration::from_millis(16),
2694 eprocess: EProcessConfig {
2695 warmup_frames: 5,
2696 ..Default::default()
2697 },
2698 cooldown_frames: 3,
2699 ..Default::default()
2700 });
2701
2702 let mut rng_state: u64 = 0x1234_5678_9ABC_DEF0;
2704 let mut trace = Vec::new();
2705 for _ in 0..500 {
2706 rng_state = rng_state
2707 .wrapping_mul(6_364_136_223_846_793_005)
2708 .wrapping_add(1_442_695_040_888_963_407);
2709 let frame_ms = 8 + ((rng_state >> 33) % 40);
2710 trace.push(Duration::from_millis(frame_ms));
2711 }
2712
2713 let log = run_trace(&mut ctrl, &trace);
2714 let transitions = count_transitions(&log);
2715
2716 assert!(
2719 transitions < 80,
2720 "Too many transitions under random load: {} (expected <80 with cooldown=3)",
2721 transitions
2722 );
2723 }
2724
2725 #[test]
2726 fn property_deterministic_replay() {
2727 let trace: Vec<Duration> = (0..100)
2729 .map(|i| Duration::from_millis(10 + (i * 7 % 30)))
2730 .collect();
2731
2732 let mut ctrl1 = fast_controller(16);
2733 let log1 = run_trace(&mut ctrl1, &trace);
2734
2735 let mut ctrl2 = fast_controller(16);
2736 let log2 = run_trace(&mut ctrl2, &trace);
2737
2738 for (r1, r2) in log1.iter().zip(log2.iter()) {
2739 assert_eq!(r1.0, r2.0, "frame index mismatch");
2740 assert_eq!(r1.1, r2.1, "frame time mismatch");
2741 assert_eq!(r1.2.level, r2.2.level, "level mismatch at frame {}", r1.0);
2742 assert_eq!(
2743 r1.2.last_decision, r2.2.last_decision,
2744 "decision mismatch at frame {}",
2745 r1.0
2746 );
2747 assert!(
2748 (r1.2.pid_output - r2.2.pid_output).abs() < 1e-10,
2749 "pid_output mismatch at frame {}: {} vs {}",
2750 r1.0,
2751 r1.2.pid_output,
2752 r2.2.pid_output
2753 );
2754 assert!(
2755 (r1.2.e_value - r2.2.e_value).abs() < 1e-10,
2756 "e_value mismatch at frame {}: {} vs {}",
2757 r1.0,
2758 r1.2.e_value,
2759 r2.2.e_value
2760 );
2761 }
2762 }
2763
2764 #[test]
2767 fn telemetry_jsonl_fields_complete() {
2768 let mut ctrl = fast_controller(16);
2770 ctrl.update(Duration::from_millis(20));
2771
2772 let telem = ctrl.telemetry();
2773
2774 let _degradation: &str = telem.level.as_str();
2776 let _pid_p: f64 = telem.pid_p;
2777 let _pid_i: f64 = telem.pid_i;
2778 let _pid_d: f64 = telem.pid_d;
2779 let _e_value: f64 = telem.e_value;
2780 let _decision: &str = telem.last_decision.as_str();
2781 let _frames: u32 = telem.frames_observed;
2782
2783 assert_eq!(BudgetDecision::Hold.as_str(), "stay");
2785 assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
2786 assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
2787 }
2788
2789 #[test]
2790 fn telemetry_pid_components_sum_to_output() {
2791 let mut ctrl = fast_controller(16);
2793
2794 for ms in [10u64, 16, 20, 30, 8, 50] {
2795 ctrl.update(Duration::from_millis(ms));
2796 let telem = ctrl.telemetry();
2797 let sum = telem.pid_p + telem.pid_i + telem.pid_d;
2798 assert!(
2799 (sum - telem.pid_output).abs() < 1e-10,
2800 "P+I+D != output at {}ms: {} + {} + {} = {} != {}",
2801 ms,
2802 telem.pid_p,
2803 telem.pid_i,
2804 telem.pid_d,
2805 sum,
2806 telem.pid_output
2807 );
2808 }
2809 }
2810 }
2811}