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 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 #[must_use]
583 pub fn config(&self) -> &BudgetControllerConfig {
584 &self.config
585 }
586}
587
588#[derive(Debug, Clone, Copy, PartialEq)]
593pub struct BudgetTelemetry {
594 pub level: DegradationLevel,
596 pub pid_output: f64,
598 pub pid_p: f64,
600 pub pid_i: f64,
602 pub pid_d: f64,
604 pub e_value: f64,
606 pub frames_observed: u32,
608 pub frames_since_change: u32,
610 pub last_decision: BudgetDecision,
612 pub in_warmup: bool,
614}
615
616#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
621#[repr(u8)]
622pub enum DegradationLevel {
623 #[default]
625 Full = 0,
626 SimpleBorders = 1,
628 NoStyling = 2,
630 EssentialOnly = 3,
632 Skeleton = 4,
634 SkipFrame = 5,
636}
637
638impl DegradationLevel {
639 #[inline]
643 #[must_use]
644 pub fn next(self) -> Self {
645 match self {
646 Self::Full => Self::SimpleBorders,
647 Self::SimpleBorders => Self::NoStyling,
648 Self::NoStyling => Self::EssentialOnly,
649 Self::EssentialOnly => Self::Skeleton,
650 Self::Skeleton | Self::SkipFrame => Self::SkipFrame,
651 }
652 }
653
654 #[inline]
658 #[must_use]
659 pub fn prev(self) -> Self {
660 match self {
661 Self::SkipFrame => Self::Skeleton,
662 Self::Skeleton => Self::EssentialOnly,
663 Self::EssentialOnly => Self::NoStyling,
664 Self::NoStyling => Self::SimpleBorders,
665 Self::SimpleBorders | Self::Full => Self::Full,
666 }
667 }
668
669 #[inline]
671 pub fn is_max(self) -> bool {
672 self == Self::SkipFrame
673 }
674
675 #[inline]
677 pub fn is_full(self) -> bool {
678 self == Self::Full
679 }
680
681 #[inline]
683 pub fn as_str(self) -> &'static str {
684 match self {
685 Self::Full => "Full",
686 Self::SimpleBorders => "SimpleBorders",
687 Self::NoStyling => "NoStyling",
688 Self::EssentialOnly => "EssentialOnly",
689 Self::Skeleton => "Skeleton",
690 Self::SkipFrame => "SkipFrame",
691 }
692 }
693
694 #[inline]
696 pub fn level(self) -> u8 {
697 self as u8
698 }
699
700 #[inline]
706 pub fn use_unicode_borders(self) -> bool {
707 self < Self::SimpleBorders
708 }
709
710 #[inline]
714 pub fn apply_styling(self) -> bool {
715 self < Self::NoStyling
716 }
717
718 #[inline]
723 pub fn render_decorative(self) -> bool {
724 self < Self::EssentialOnly
725 }
726
727 #[inline]
731 pub fn render_content(self) -> bool {
732 self < Self::Skeleton
733 }
734}
735
736#[derive(Debug, Clone, Copy, PartialEq, Eq)]
738pub struct PhaseBudgets {
739 pub diff: Duration,
741 pub present: Duration,
743 pub render: Duration,
745}
746
747impl Default for PhaseBudgets {
748 fn default() -> Self {
749 Self {
750 diff: Duration::from_millis(2),
751 present: Duration::from_millis(4),
752 render: Duration::from_millis(8),
753 }
754 }
755}
756
757#[derive(Debug, Clone, PartialEq)]
759pub struct FrameBudgetConfig {
760 pub total: Duration,
762 pub phase_budgets: PhaseBudgets,
764 pub allow_frame_skip: bool,
766 pub degradation_cooldown: u32,
768 pub upgrade_threshold: f32,
771}
772
773impl Default for FrameBudgetConfig {
774 fn default() -> Self {
775 Self {
776 total: Duration::from_millis(16), phase_budgets: PhaseBudgets::default(),
778 allow_frame_skip: true,
779 degradation_cooldown: 3,
780 upgrade_threshold: 0.5,
781 }
782 }
783}
784
785impl FrameBudgetConfig {
786 pub fn with_total(total: Duration) -> Self {
788 Self {
789 total,
790 ..Default::default()
791 }
792 }
793
794 pub fn strict(total: Duration) -> Self {
796 Self {
797 total,
798 allow_frame_skip: false,
799 ..Default::default()
800 }
801 }
802
803 pub fn relaxed() -> Self {
805 Self {
806 total: Duration::from_millis(33), degradation_cooldown: 5,
808 ..Default::default()
809 }
810 }
811}
812
813#[derive(Debug, Clone)]
818pub struct RenderBudget {
819 total: Duration,
821 start: Instant,
823 last_frame_time: Option<Duration>,
825 degradation: DegradationLevel,
827 phase_budgets: PhaseBudgets,
829 allow_frame_skip: bool,
831 upgrade_threshold: f32,
833 frames_since_change: u32,
835 cooldown: u32,
837 controller: Option<BudgetController>,
840}
841
842impl RenderBudget {
843 pub fn new(total: Duration) -> Self {
845 Self {
846 total,
847 start: Instant::now(),
848 last_frame_time: None,
849 degradation: DegradationLevel::Full,
850 phase_budgets: PhaseBudgets::default(),
851 allow_frame_skip: true,
852 upgrade_threshold: 0.5,
853 frames_since_change: 0,
854 cooldown: 3,
855 controller: None,
856 }
857 }
858
859 pub fn from_config(config: &FrameBudgetConfig) -> Self {
861 Self {
862 total: config.total,
863 start: Instant::now(),
864 last_frame_time: None,
865 degradation: DegradationLevel::Full,
866 phase_budgets: config.phase_budgets,
867 allow_frame_skip: config.allow_frame_skip,
868 upgrade_threshold: config.upgrade_threshold,
869 frames_since_change: 0,
870 cooldown: config.degradation_cooldown,
871 controller: None,
872 }
873 }
874
875 #[must_use]
891 pub fn with_controller(mut self, config: BudgetControllerConfig) -> Self {
892 self.controller = Some(BudgetController::new(config));
893 self
894 }
895
896 #[inline]
898 pub fn total(&self) -> Duration {
899 self.total
900 }
901
902 #[inline]
904 pub fn elapsed(&self) -> Duration {
905 self.start.elapsed()
906 }
907
908 #[inline]
910 pub fn remaining(&self) -> Duration {
911 self.total.saturating_sub(self.start.elapsed())
912 }
913
914 #[inline]
916 pub fn remaining_fraction(&self) -> f32 {
917 if self.total.is_zero() {
918 return 0.0;
919 }
920 let remaining = self.remaining().as_secs_f32();
921 let total = self.total.as_secs_f32();
922 (remaining / total).clamp(0.0, 1.0)
923 }
924
925 #[inline]
929 pub fn should_degrade(&self, estimated_cost: Duration) -> bool {
930 self.remaining() < estimated_cost
931 }
932
933 pub fn degrade(&mut self) {
937 let from = self.degradation;
938 self.degradation = self.degradation.next();
939 self.frames_since_change = 0;
940
941 #[cfg(feature = "tracing")]
942 if from != self.degradation {
943 warn!(
944 from = from.as_str(),
945 to = self.degradation.as_str(),
946 remaining_ms = self.remaining().as_millis() as u32,
947 "render budget degradation"
948 );
949 }
950 let _ = from; }
952
953 #[inline]
955 pub fn degradation(&self) -> DegradationLevel {
956 self.degradation
957 }
958
959 pub fn set_degradation(&mut self, level: DegradationLevel) {
963 if self.degradation != level {
964 self.degradation = level;
965 self.frames_since_change = 0;
966 }
967 }
968
969 #[inline]
973 pub fn exhausted(&self) -> bool {
974 self.remaining().is_zero()
975 || (self.degradation == DegradationLevel::SkipFrame && self.allow_frame_skip)
976 }
977
978 pub fn should_upgrade(&self) -> bool {
983 !self.degradation.is_full()
984 && self.remaining_fraction() > self.upgrade_threshold
985 && self.frames_since_change >= self.cooldown
986 }
987
988 fn should_upgrade_with_elapsed(&self, elapsed: Duration) -> bool {
990 if self.degradation.is_full() || self.frames_since_change < self.cooldown {
991 return false;
992 }
993 self.remaining_fraction_for_elapsed(elapsed) > self.upgrade_threshold
994 }
995
996 fn remaining_fraction_for_elapsed(&self, elapsed: Duration) -> f32 {
998 if self.total.is_zero() {
999 return 0.0;
1000 }
1001 let remaining = self.total.saturating_sub(elapsed);
1002 let remaining = remaining.as_secs_f32();
1003 let total = self.total.as_secs_f32();
1004 (remaining / total).clamp(0.0, 1.0)
1005 }
1006
1007 pub fn upgrade(&mut self) {
1011 let from = self.degradation;
1012 self.degradation = self.degradation.prev();
1013 self.frames_since_change = 0;
1014
1015 #[cfg(feature = "tracing")]
1016 if from != self.degradation {
1017 trace!(
1018 from = from.as_str(),
1019 to = self.degradation.as_str(),
1020 remaining_fraction = self.remaining_fraction(),
1021 "render budget upgrade"
1022 );
1023 }
1024 let _ = from; }
1026
1027 pub fn reset(&mut self) {
1031 self.start = Instant::now();
1032 self.frames_since_change = self.frames_since_change.saturating_add(1);
1033 }
1034
1035 pub fn next_frame(&mut self) {
1044 let frame_time = self.last_frame_time.unwrap_or_else(|| self.start.elapsed());
1045
1046 if self.controller.is_some() {
1047 let decision = self
1052 .controller
1053 .as_mut()
1054 .expect("controller guaranteed by is_some guard")
1055 .update(frame_time);
1056
1057 match decision {
1058 BudgetDecision::Degrade => self.degrade(),
1059 BudgetDecision::Upgrade => self.upgrade(),
1060 BudgetDecision::Hold => {}
1061 }
1062 } else {
1063 if self.should_upgrade_with_elapsed(frame_time) {
1065 self.upgrade();
1066 }
1067 }
1068 self.reset();
1069 }
1070
1071 pub fn record_frame_time(&mut self, elapsed: Duration) {
1073 self.last_frame_time = Some(elapsed);
1074 }
1075
1076 #[inline]
1081 pub fn telemetry(&self) -> Option<BudgetTelemetry> {
1082 self.controller.as_ref().map(BudgetController::telemetry)
1083 }
1084
1085 #[inline]
1087 pub fn controller(&self) -> Option<&BudgetController> {
1088 self.controller.as_ref()
1089 }
1090
1091 #[inline]
1093 #[must_use]
1094 pub fn phase_budgets(&self) -> &PhaseBudgets {
1095 &self.phase_budgets
1096 }
1097
1098 pub fn phase_has_budget(&self, phase: Phase) -> bool {
1100 let phase_budget = match phase {
1101 Phase::Diff => self.phase_budgets.diff,
1102 Phase::Present => self.phase_budgets.present,
1103 Phase::Render => self.phase_budgets.render,
1104 };
1105 self.remaining() >= phase_budget
1106 }
1107
1108 #[must_use]
1112 pub fn phase_budget(&self, phase: Phase) -> Self {
1113 let phase_total = match phase {
1114 Phase::Diff => self.phase_budgets.diff,
1115 Phase::Present => self.phase_budgets.present,
1116 Phase::Render => self.phase_budgets.render,
1117 };
1118 Self {
1119 total: phase_total.min(self.remaining()),
1120 start: self.start,
1121 last_frame_time: self.last_frame_time,
1122 degradation: self.degradation,
1123 phase_budgets: self.phase_budgets,
1124 allow_frame_skip: self.allow_frame_skip,
1125 upgrade_threshold: self.upgrade_threshold,
1126 frames_since_change: self.frames_since_change,
1127 cooldown: self.cooldown,
1128 controller: None, }
1130 }
1131}
1132
1133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1135pub enum Phase {
1136 Diff,
1138 Present,
1140 Render,
1142}
1143
1144impl Phase {
1145 pub fn as_str(self) -> &'static str {
1147 match self {
1148 Self::Diff => "diff",
1149 Self::Present => "present",
1150 Self::Render => "render",
1151 }
1152 }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::*;
1158 use std::thread;
1159
1160 #[test]
1161 fn degradation_level_ordering() {
1162 assert!(DegradationLevel::Full < DegradationLevel::SimpleBorders);
1163 assert!(DegradationLevel::SimpleBorders < DegradationLevel::NoStyling);
1164 assert!(DegradationLevel::NoStyling < DegradationLevel::EssentialOnly);
1165 assert!(DegradationLevel::EssentialOnly < DegradationLevel::Skeleton);
1166 assert!(DegradationLevel::Skeleton < DegradationLevel::SkipFrame);
1167 }
1168
1169 #[test]
1170 fn degradation_level_next() {
1171 assert_eq!(
1172 DegradationLevel::Full.next(),
1173 DegradationLevel::SimpleBorders
1174 );
1175 assert_eq!(
1176 DegradationLevel::SimpleBorders.next(),
1177 DegradationLevel::NoStyling
1178 );
1179 assert_eq!(
1180 DegradationLevel::NoStyling.next(),
1181 DegradationLevel::EssentialOnly
1182 );
1183 assert_eq!(
1184 DegradationLevel::EssentialOnly.next(),
1185 DegradationLevel::Skeleton
1186 );
1187 assert_eq!(
1188 DegradationLevel::Skeleton.next(),
1189 DegradationLevel::SkipFrame
1190 );
1191 assert_eq!(
1192 DegradationLevel::SkipFrame.next(),
1193 DegradationLevel::SkipFrame
1194 );
1195 }
1196
1197 #[test]
1198 fn degradation_level_prev() {
1199 assert_eq!(
1200 DegradationLevel::SkipFrame.prev(),
1201 DegradationLevel::Skeleton
1202 );
1203 assert_eq!(
1204 DegradationLevel::Skeleton.prev(),
1205 DegradationLevel::EssentialOnly
1206 );
1207 assert_eq!(
1208 DegradationLevel::EssentialOnly.prev(),
1209 DegradationLevel::NoStyling
1210 );
1211 assert_eq!(
1212 DegradationLevel::NoStyling.prev(),
1213 DegradationLevel::SimpleBorders
1214 );
1215 assert_eq!(
1216 DegradationLevel::SimpleBorders.prev(),
1217 DegradationLevel::Full
1218 );
1219 assert_eq!(DegradationLevel::Full.prev(), DegradationLevel::Full);
1220 }
1221
1222 #[test]
1223 fn degradation_level_is_max() {
1224 assert!(!DegradationLevel::Full.is_max());
1225 assert!(!DegradationLevel::Skeleton.is_max());
1226 assert!(DegradationLevel::SkipFrame.is_max());
1227 }
1228
1229 #[test]
1230 fn degradation_level_is_full() {
1231 assert!(DegradationLevel::Full.is_full());
1232 assert!(!DegradationLevel::SimpleBorders.is_full());
1233 assert!(!DegradationLevel::SkipFrame.is_full());
1234 }
1235
1236 #[test]
1237 fn degradation_level_as_str() {
1238 assert_eq!(DegradationLevel::Full.as_str(), "Full");
1239 assert_eq!(DegradationLevel::SimpleBorders.as_str(), "SimpleBorders");
1240 assert_eq!(DegradationLevel::NoStyling.as_str(), "NoStyling");
1241 assert_eq!(DegradationLevel::EssentialOnly.as_str(), "EssentialOnly");
1242 assert_eq!(DegradationLevel::Skeleton.as_str(), "Skeleton");
1243 assert_eq!(DegradationLevel::SkipFrame.as_str(), "SkipFrame");
1244 }
1245
1246 #[test]
1247 fn degradation_level_values() {
1248 assert_eq!(DegradationLevel::Full.level(), 0);
1249 assert_eq!(DegradationLevel::SimpleBorders.level(), 1);
1250 assert_eq!(DegradationLevel::NoStyling.level(), 2);
1251 assert_eq!(DegradationLevel::EssentialOnly.level(), 3);
1252 assert_eq!(DegradationLevel::Skeleton.level(), 4);
1253 assert_eq!(DegradationLevel::SkipFrame.level(), 5);
1254 }
1255
1256 #[test]
1257 fn budget_remaining_decreases() {
1258 let budget = RenderBudget::new(Duration::from_millis(100));
1259 let initial = budget.remaining();
1260
1261 thread::sleep(Duration::from_millis(10));
1262
1263 let later = budget.remaining();
1264 assert!(later < initial);
1265 }
1266
1267 #[test]
1268 fn budget_remaining_fraction() {
1269 let budget = RenderBudget::new(Duration::from_millis(100));
1270
1271 let initial = budget.remaining_fraction();
1273 assert!(initial > 0.9);
1274
1275 thread::sleep(Duration::from_millis(50));
1276
1277 let later = budget.remaining_fraction();
1279 assert!(later < 0.6);
1280 assert!(later > 0.3);
1281 }
1282
1283 #[test]
1284 fn should_degrade_when_cost_exceeds_remaining() {
1285 let budget = RenderBudget::new(Duration::from_millis(100));
1287
1288 thread::sleep(Duration::from_millis(50));
1290
1291 assert!(budget.should_degrade(Duration::from_millis(80)));
1293 assert!(!budget.should_degrade(Duration::from_millis(10)));
1295 }
1296
1297 #[test]
1298 fn degrade_advances_level() {
1299 let mut budget = RenderBudget::new(Duration::from_millis(16));
1300
1301 assert_eq!(budget.degradation(), DegradationLevel::Full);
1302
1303 budget.degrade();
1304 assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
1305
1306 budget.degrade();
1307 assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1308 }
1309
1310 #[test]
1311 fn exhausted_when_no_time_left() {
1312 let budget = RenderBudget::new(Duration::from_millis(5));
1313
1314 assert!(!budget.exhausted());
1315
1316 thread::sleep(Duration::from_millis(10));
1317
1318 assert!(budget.exhausted());
1319 }
1320
1321 #[test]
1322 fn exhausted_at_skip_frame() {
1323 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1324
1325 budget.set_degradation(DegradationLevel::SkipFrame);
1327
1328 assert!(budget.exhausted());
1330 }
1331
1332 #[test]
1333 fn should_upgrade_with_remaining_budget() {
1334 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1335
1336 assert!(!budget.should_upgrade());
1338
1339 budget.degrade();
1341 budget.frames_since_change = 5;
1342
1343 assert!(budget.should_upgrade());
1345 }
1346
1347 #[test]
1348 fn upgrade_improves_level() {
1349 let mut budget = RenderBudget::new(Duration::from_millis(16));
1350
1351 budget.set_degradation(DegradationLevel::Skeleton);
1352 assert_eq!(budget.degradation(), DegradationLevel::Skeleton);
1353
1354 budget.upgrade();
1355 assert_eq!(budget.degradation(), DegradationLevel::EssentialOnly);
1356
1357 budget.upgrade();
1358 assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1359 }
1360
1361 #[test]
1362 fn upgrade_downgrade_symmetric() {
1363 let mut budget = RenderBudget::new(Duration::from_millis(16));
1364
1365 while !budget.degradation().is_max() {
1367 budget.degrade();
1368 }
1369 assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
1370
1371 while !budget.degradation().is_full() {
1373 budget.upgrade();
1374 }
1375 assert_eq!(budget.degradation(), DegradationLevel::Full);
1376 }
1377
1378 #[test]
1379 fn reset_preserves_degradation() {
1380 let mut budget = RenderBudget::new(Duration::from_millis(16));
1381
1382 budget.degrade();
1383 budget.degrade();
1384 let level = budget.degradation();
1385
1386 budget.reset();
1387
1388 assert_eq!(budget.degradation(), level);
1389 assert!(budget.remaining_fraction() > 0.9);
1391 }
1392
1393 #[test]
1394 fn next_frame_upgrades_when_possible() {
1395 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1396
1397 budget.degrade();
1399 for _ in 0..5 {
1400 budget.reset();
1401 }
1402
1403 let before = budget.degradation();
1404 budget.next_frame();
1405
1406 assert!(budget.degradation() < before);
1408 }
1409
1410 #[test]
1411 fn next_frame_prefers_recorded_frame_time_for_upgrade() {
1412 let mut budget = RenderBudget::new(Duration::from_millis(16));
1413
1414 budget.degrade();
1415 for _ in 0..5 {
1416 budget.reset();
1417 }
1418
1419 budget.record_frame_time(Duration::from_millis(1));
1422 std::thread::sleep(Duration::from_millis(25));
1423
1424 let before = budget.degradation();
1425 budget.next_frame();
1426
1427 assert!(budget.degradation() < before);
1428 }
1429
1430 #[test]
1431 fn config_defaults() {
1432 let config = FrameBudgetConfig::default();
1433
1434 assert_eq!(config.total, Duration::from_millis(16));
1435 assert!(config.allow_frame_skip);
1436 assert_eq!(config.degradation_cooldown, 3);
1437 assert!((config.upgrade_threshold - 0.5).abs() < f32::EPSILON);
1438 }
1439
1440 #[test]
1441 fn config_with_total() {
1442 let config = FrameBudgetConfig::with_total(Duration::from_millis(33));
1443
1444 assert_eq!(config.total, Duration::from_millis(33));
1445 assert!(config.allow_frame_skip);
1447 }
1448
1449 #[test]
1450 fn config_strict() {
1451 let config = FrameBudgetConfig::strict(Duration::from_millis(16));
1452
1453 assert!(!config.allow_frame_skip);
1454 }
1455
1456 #[test]
1457 fn config_relaxed() {
1458 let config = FrameBudgetConfig::relaxed();
1459
1460 assert_eq!(config.total, Duration::from_millis(33));
1461 assert_eq!(config.degradation_cooldown, 5);
1462 }
1463
1464 #[test]
1465 fn from_config() {
1466 let config = FrameBudgetConfig {
1467 total: Duration::from_millis(20),
1468 allow_frame_skip: false,
1469 ..Default::default()
1470 };
1471
1472 let budget = RenderBudget::from_config(&config);
1473
1474 assert_eq!(budget.total(), Duration::from_millis(20));
1475 assert!(!budget.exhausted()); let mut budget = RenderBudget::from_config(&config);
1479 budget.set_degradation(DegradationLevel::SkipFrame);
1480 assert!(!budget.exhausted());
1481 }
1482
1483 #[test]
1484 fn phase_budgets_default() {
1485 let budgets = PhaseBudgets::default();
1486
1487 assert_eq!(budgets.diff, Duration::from_millis(2));
1488 assert_eq!(budgets.present, Duration::from_millis(4));
1489 assert_eq!(budgets.render, Duration::from_millis(8));
1490 }
1491
1492 #[test]
1493 fn phase_has_budget() {
1494 let budget = RenderBudget::new(Duration::from_millis(100));
1495
1496 assert!(budget.phase_has_budget(Phase::Diff));
1497 assert!(budget.phase_has_budget(Phase::Present));
1498 assert!(budget.phase_has_budget(Phase::Render));
1499 }
1500
1501 #[test]
1502 fn phase_budget_respects_remaining() {
1503 let budget = RenderBudget::new(Duration::from_millis(100));
1504
1505 let diff_budget = budget.phase_budget(Phase::Diff);
1506 assert_eq!(diff_budget.total(), Duration::from_millis(2));
1507
1508 let present_budget = budget.phase_budget(Phase::Present);
1509 assert_eq!(present_budget.total(), Duration::from_millis(4));
1510 }
1511
1512 #[test]
1513 fn phase_as_str() {
1514 assert_eq!(Phase::Diff.as_str(), "diff");
1515 assert_eq!(Phase::Present.as_str(), "present");
1516 assert_eq!(Phase::Render.as_str(), "render");
1517 }
1518
1519 #[test]
1520 fn zero_budget_is_immediately_exhausted() {
1521 let budget = RenderBudget::new(Duration::ZERO);
1522 assert!(budget.exhausted());
1523 assert_eq!(budget.remaining_fraction(), 0.0);
1524 }
1525
1526 #[test]
1527 fn degradation_level_never_exceeds_skip_frame() {
1528 let mut level = DegradationLevel::Full;
1529
1530 for _ in 0..100 {
1531 level = level.next();
1532 }
1533
1534 assert_eq!(level, DegradationLevel::SkipFrame);
1535 }
1536
1537 #[test]
1538 fn budget_remaining_never_negative() {
1539 let budget = RenderBudget::new(Duration::from_millis(1));
1540
1541 thread::sleep(Duration::from_millis(10));
1543
1544 assert_eq!(budget.remaining(), Duration::ZERO);
1546 assert_eq!(budget.remaining_fraction(), 0.0);
1547 }
1548
1549 #[test]
1550 fn infinite_budget_stays_at_full() {
1551 let mut budget = RenderBudget::new(Duration::from_secs(1000));
1552
1553 assert!(!budget.should_degrade(Duration::from_millis(100)));
1555 assert_eq!(budget.degradation(), DegradationLevel::Full);
1556
1557 budget.next_frame();
1559 assert_eq!(budget.degradation(), DegradationLevel::Full);
1560 }
1561
1562 #[test]
1563 fn cooldown_prevents_immediate_upgrade() {
1564 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1565 budget.cooldown = 3;
1566
1567 budget.degrade();
1569 assert_eq!(budget.frames_since_change, 0);
1570
1571 assert!(!budget.should_upgrade());
1573
1574 budget.frames_since_change = 3;
1576
1577 assert!(budget.should_upgrade());
1579 }
1580
1581 #[test]
1582 fn set_degradation_resets_cooldown() {
1583 let mut budget = RenderBudget::new(Duration::from_millis(16));
1584 budget.frames_since_change = 10;
1585
1586 budget.set_degradation(DegradationLevel::NoStyling);
1587
1588 assert_eq!(budget.frames_since_change, 0);
1589 }
1590
1591 #[test]
1592 fn set_degradation_same_level_preserves_cooldown() {
1593 let mut budget = RenderBudget::new(Duration::from_millis(16));
1594 budget.frames_since_change = 10;
1595
1596 budget.set_degradation(DegradationLevel::Full);
1598
1599 assert_eq!(budget.frames_since_change, 10);
1601 }
1602
1603 mod controller_tests {
1608 use super::super::*;
1609
1610 fn make_controller() -> BudgetController {
1611 BudgetController::new(BudgetControllerConfig::default())
1612 }
1613
1614 fn make_controller_with_config(
1615 target_ms: u64,
1616 warmup: u32,
1617 cooldown: u32,
1618 ) -> BudgetController {
1619 BudgetController::new(BudgetControllerConfig {
1620 target: Duration::from_millis(target_ms),
1621 eprocess: EProcessConfig {
1622 warmup_frames: warmup,
1623 ..Default::default()
1624 },
1625 cooldown_frames: cooldown,
1626 ..Default::default()
1627 })
1628 }
1629
1630 #[test]
1633 fn pid_step_input_yields_nonzero_output() {
1634 let mut state = PidState::default();
1635 let gains = PidGains::default();
1636
1637 let u = state.update(1.0, &gains);
1639 assert!(
1641 (u - 0.75).abs() < 1e-10,
1642 "First PID output should be 0.75, got {}",
1643 u
1644 );
1645 }
1646
1647 #[test]
1648 fn pid_zero_error_zero_output() {
1649 let mut state = PidState::default();
1650 let gains = PidGains::default();
1651
1652 let u = state.update(0.0, &gains);
1653 assert!(
1654 u.abs() < 1e-10,
1655 "Zero error should produce zero output, got {}",
1656 u
1657 );
1658 }
1659
1660 #[test]
1661 fn pid_integral_accumulates() {
1662 let mut state = PidState::default();
1663 let gains = PidGains::default();
1664
1665 state.update(1.0, &gains);
1667 state.update(1.0, &gains);
1668 state.update(1.0, &gains);
1669
1670 assert!(
1671 state.integral > 2.5,
1672 "Integral should accumulate: {}",
1673 state.integral
1674 );
1675 }
1676
1677 #[test]
1678 fn pid_integral_anti_windup() {
1679 let mut state = PidState::default();
1680 let gains = PidGains {
1681 integral_max: 2.0,
1682 ..Default::default()
1683 };
1684
1685 for _ in 0..100 {
1687 state.update(10.0, &gains);
1688 }
1689
1690 assert!(
1691 state.integral <= 2.0 + f64::EPSILON,
1692 "Integral should be clamped to max: {}",
1693 state.integral
1694 );
1695 assert!(
1696 state.integral >= -2.0 - f64::EPSILON,
1697 "Integral should be clamped to -max: {}",
1698 state.integral
1699 );
1700 }
1701
1702 #[test]
1703 fn pid_derivative_responds_to_change() {
1704 let mut state = PidState::default();
1705 let gains = PidGains::default();
1706
1707 let u1 = state.update(0.0, &gains);
1709 let u2 = state.update(1.0, &gains);
1711
1712 assert!(
1714 u2 > u1,
1715 "Step change should produce larger output: u1={}, u2={}",
1716 u1,
1717 u2
1718 );
1719 }
1720
1721 #[test]
1722 fn pid_settling_after_step() {
1723 let mut state = PidState::default();
1724 let gains = PidGains::default();
1725
1726 state.update(1.0, &gains);
1728 state.update(1.0, &gains);
1729 state.update(1.0, &gains);
1730
1731 let mut outputs = Vec::new();
1733 for _ in 0..20 {
1734 outputs.push(state.update(0.0, &gains));
1735 }
1736
1737 let last = *outputs.last().unwrap();
1739 assert!(
1740 last.abs() < 0.5,
1741 "PID should settle toward zero: last={}",
1742 last
1743 );
1744 }
1745
1746 #[test]
1747 fn pid_reset_clears_state() {
1748 let mut state = PidState::default();
1749 let gains = PidGains::default();
1750
1751 state.update(5.0, &gains);
1752 state.update(5.0, &gains);
1753 assert!(state.integral.abs() > 0.0);
1754
1755 state.reset();
1756 assert_eq!(state.integral, 0.0);
1757 assert_eq!(state.prev_error, 0.0);
1758 }
1759
1760 #[test]
1763 fn eprocess_starts_at_one() {
1764 let state = EProcessState::default();
1765 assert!(
1766 (state.e_value - 1.0).abs() < f64::EPSILON,
1767 "E-process should start at 1.0"
1768 );
1769 }
1770
1771 #[test]
1772 fn eprocess_grows_under_overload() {
1773 let mut state = EProcessState::default();
1774 let config = EProcessConfig {
1775 warmup_frames: 0,
1776 ..Default::default()
1777 };
1778
1779 for _ in 0..20 {
1781 state.update(30.0, 16.0, &config);
1782 }
1783
1784 assert!(
1785 state.e_value > 1.0,
1786 "E-value should grow under overload: {}",
1787 state.e_value
1788 );
1789 }
1790
1791 #[test]
1792 fn eprocess_shrinks_under_underload() {
1793 let mut state = EProcessState::default();
1794 let config = EProcessConfig {
1795 warmup_frames: 0,
1796 ..Default::default()
1797 };
1798
1799 for _ in 0..20 {
1801 state.update(8.0, 16.0, &config);
1802 }
1803
1804 assert!(
1805 state.e_value < 1.0,
1806 "E-value should shrink under underload: {}",
1807 state.e_value
1808 );
1809 }
1810
1811 #[test]
1812 fn eprocess_gate_blocks_during_warmup() {
1813 let mut state = EProcessState::default();
1814 let config = EProcessConfig {
1815 warmup_frames: 10,
1816 ..Default::default()
1817 };
1818
1819 for _ in 0..5 {
1821 state.update(50.0, 16.0, &config);
1822 }
1823
1824 assert!(
1825 !state.should_degrade(&config),
1826 "E-process should not permit degradation during warmup"
1827 );
1828 }
1829
1830 #[test]
1831 fn eprocess_gate_allows_after_warmup() {
1832 let mut state = EProcessState::default();
1833 let config = EProcessConfig {
1834 warmup_frames: 5,
1835 alpha: 0.05,
1836 ..Default::default()
1837 };
1838
1839 for _ in 0..50 {
1841 state.update(80.0, 16.0, &config);
1842 }
1843
1844 assert!(
1845 state.should_degrade(&config),
1846 "E-process should permit degradation after sustained overload: E={}",
1847 state.e_value
1848 );
1849 }
1850
1851 #[test]
1852 fn eprocess_recovery_after_overload() {
1853 let mut state = EProcessState::default();
1854 let config = EProcessConfig {
1855 warmup_frames: 0,
1856 ..Default::default()
1857 };
1858
1859 for _ in 0..30 {
1861 state.update(40.0, 16.0, &config);
1862 }
1863 let peak = state.e_value;
1864
1865 for _ in 0..100 {
1867 state.update(8.0, 16.0, &config);
1868 }
1869
1870 assert!(
1871 state.e_value < peak,
1872 "E-value should decrease after recovery: peak={}, now={}",
1873 peak,
1874 state.e_value
1875 );
1876 }
1877
1878 #[test]
1879 fn eprocess_sigma_floor_prevents_instability() {
1880 let mut state = EProcessState::default();
1881 let config = EProcessConfig {
1882 sigma_floor_ms: 1.0,
1883 warmup_frames: 0,
1884 ..Default::default()
1885 };
1886
1887 for _ in 0..20 {
1889 state.update(16.0, 16.0, &config);
1890 }
1891
1892 assert!(
1894 state.sigma_ema >= 0.0,
1895 "Sigma should be non-negative: {}",
1896 state.sigma_ema
1897 );
1898 assert!(
1900 state.e_value.is_finite(),
1901 "E-value should be finite: {}",
1902 state.e_value
1903 );
1904 }
1905
1906 #[test]
1907 fn eprocess_reset_returns_to_initial() {
1908 let mut state = EProcessState::default();
1909 let config = EProcessConfig::default();
1910
1911 state.update(50.0, 16.0, &config);
1912 state.update(50.0, 16.0, &config);
1913
1914 state.reset();
1915 assert!((state.e_value - 1.0).abs() < f64::EPSILON);
1916 assert_eq!(state.frames_observed, 0);
1917 }
1918
1919 #[test]
1922 fn controller_holds_under_normal_load() {
1923 let mut ctrl = make_controller_with_config(16, 0, 0);
1924
1925 for _ in 0..20 {
1927 let decision = ctrl.update(Duration::from_millis(16));
1928 assert_eq!(
1929 decision,
1930 BudgetDecision::Hold,
1931 "On-target frames should hold"
1932 );
1933 }
1934 assert_eq!(ctrl.level(), DegradationLevel::Full);
1935 }
1936
1937 #[test]
1938 fn controller_degrades_under_sustained_overload() {
1939 let mut ctrl = make_controller_with_config(16, 0, 0);
1940
1941 let mut degraded = false;
1942 for _ in 0..50 {
1944 let decision = ctrl.update(Duration::from_millis(40));
1945 if decision == BudgetDecision::Degrade {
1946 degraded = true;
1947 }
1948 }
1949
1950 assert!(
1951 degraded,
1952 "Controller should degrade under sustained overload"
1953 );
1954 assert!(
1955 ctrl.level() > DegradationLevel::Full,
1956 "Level should be degraded: {:?}",
1957 ctrl.level()
1958 );
1959 }
1960
1961 #[test]
1962 fn controller_upgrades_after_recovery() {
1963 let mut ctrl = make_controller_with_config(16, 0, 0);
1964
1965 for _ in 0..50 {
1967 ctrl.update(Duration::from_millis(40));
1968 }
1969 let degraded_level = ctrl.level();
1970 assert!(degraded_level > DegradationLevel::Full);
1971
1972 let mut upgraded = false;
1974 for _ in 0..200 {
1975 let decision = ctrl.update(Duration::from_millis(4));
1976 if decision == BudgetDecision::Upgrade {
1977 upgraded = true;
1978 }
1979 }
1980
1981 assert!(upgraded, "Controller should upgrade after recovery");
1982 assert!(
1983 ctrl.level() < degraded_level,
1984 "Level should improve: before={:?}, after={:?}",
1985 degraded_level,
1986 ctrl.level()
1987 );
1988 }
1989
1990 #[test]
1991 fn controller_cooldown_prevents_oscillation() {
1992 let mut ctrl = make_controller_with_config(16, 0, 5);
1993
1994 for _ in 0..50 {
1996 ctrl.update(Duration::from_millis(40));
1997 }
1998
1999 let mut decisions_during_cooldown = Vec::new();
2001 for _ in 0..4 {
2002 decisions_during_cooldown.push(ctrl.update(Duration::from_millis(4)));
2003 }
2004
2005 assert!(
2007 decisions_during_cooldown
2008 .iter()
2009 .all(|d| *d == BudgetDecision::Hold),
2010 "Cooldown should prevent changes: {:?}",
2011 decisions_during_cooldown
2012 );
2013 }
2014
2015 #[test]
2016 fn controller_no_oscillation_under_constant_load() {
2017 let mut ctrl = make_controller_with_config(16, 0, 3);
2018
2019 let mut transitions = 0u32;
2021 let mut prev_level = ctrl.level();
2022 for _ in 0..100 {
2023 ctrl.update(Duration::from_millis(20));
2024 if ctrl.level() != prev_level {
2025 transitions += 1;
2026 prev_level = ctrl.level();
2027 }
2028 }
2029
2030 assert!(
2033 transitions < 10,
2034 "Too many transitions under constant load: {}",
2035 transitions
2036 );
2037 }
2038
2039 #[test]
2040 fn controller_reset_restores_full_quality() {
2041 let mut ctrl = make_controller();
2042
2043 for _ in 0..50 {
2045 ctrl.update(Duration::from_millis(40));
2046 }
2047
2048 ctrl.reset();
2049
2050 assert_eq!(ctrl.level(), DegradationLevel::Full);
2051 assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
2052 assert_eq!(ctrl.pid_integral(), 0.0);
2053 }
2054
2055 #[test]
2056 fn controller_transient_spike_does_not_degrade() {
2057 let mut ctrl = make_controller_with_config(16, 5, 3);
2058
2059 for _ in 0..20 {
2061 ctrl.update(Duration::from_millis(16));
2062 }
2063
2064 ctrl.update(Duration::from_millis(100));
2066
2067 for _ in 0..5 {
2069 ctrl.update(Duration::from_millis(16));
2070 }
2071
2072 assert_eq!(
2074 ctrl.level(),
2075 DegradationLevel::Full,
2076 "Single spike should not cause degradation"
2077 );
2078 }
2079
2080 #[test]
2081 fn controller_never_exceeds_skip_frame() {
2082 let mut ctrl = make_controller_with_config(16, 0, 0);
2083
2084 for _ in 0..500 {
2086 ctrl.update(Duration::from_millis(200));
2087 }
2088
2089 assert!(
2090 ctrl.level() <= DegradationLevel::SkipFrame,
2091 "Level should not exceed SkipFrame: {:?}",
2092 ctrl.level()
2093 );
2094 }
2095
2096 #[test]
2097 fn controller_never_goes_below_full() {
2098 let mut ctrl = make_controller_with_config(16, 0, 0);
2099
2100 for _ in 0..200 {
2102 ctrl.update(Duration::from_millis(1));
2103 }
2104
2105 assert_eq!(
2106 ctrl.level(),
2107 DegradationLevel::Full,
2108 "Level should not go below Full"
2109 );
2110 }
2111
2112 #[test]
2115 fn pid_gains_default_valid() {
2116 let gains = PidGains::default();
2117 assert!(gains.kp > 0.0);
2118 assert!(gains.ki > 0.0);
2119 assert!(gains.kd > 0.0);
2120 assert!(gains.integral_max > 0.0);
2121 }
2122
2123 #[test]
2124 fn eprocess_config_default_valid() {
2125 let config = EProcessConfig::default();
2126 assert!(config.lambda > 0.0);
2127 assert!(config.alpha > 0.0 && config.alpha < 1.0);
2128 assert!(config.beta > 0.0 && config.beta < 1.0);
2129 assert!(config.sigma_floor_ms > 0.0);
2130 }
2131
2132 #[test]
2133 fn controller_config_default_valid() {
2134 let config = BudgetControllerConfig::default();
2135 assert!(config.degrade_threshold > 0.0);
2136 assert!(config.upgrade_threshold > 0.0);
2137 assert!(config.target > Duration::ZERO);
2138 }
2139
2140 #[test]
2141 fn budget_decision_equality() {
2142 assert_eq!(BudgetDecision::Hold, BudgetDecision::Hold);
2143 assert_ne!(BudgetDecision::Hold, BudgetDecision::Degrade);
2144 assert_ne!(BudgetDecision::Degrade, BudgetDecision::Upgrade);
2145 }
2146 }
2147
2148 mod integration_tests {
2153 use super::super::*;
2154
2155 #[test]
2156 fn render_budget_without_controller_returns_no_telemetry() {
2157 let budget = RenderBudget::new(Duration::from_millis(16));
2158 assert!(budget.telemetry().is_none());
2159 assert!(budget.controller().is_none());
2160 }
2161
2162 #[test]
2163 fn render_budget_with_controller_returns_telemetry() {
2164 let budget = RenderBudget::new(Duration::from_millis(16))
2165 .with_controller(BudgetControllerConfig::default());
2166 assert!(budget.controller().is_some());
2167
2168 let telem = budget.telemetry().unwrap();
2169 assert_eq!(telem.level, DegradationLevel::Full);
2170 assert_eq!(telem.last_decision, BudgetDecision::Hold);
2171 assert_eq!(telem.frames_observed, 0);
2172 assert!(telem.in_warmup);
2173 }
2174
2175 #[test]
2176 fn telemetry_fields_update_after_next_frame() {
2177 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2178 BudgetControllerConfig {
2179 eprocess: EProcessConfig {
2180 warmup_frames: 0,
2181 ..Default::default()
2182 },
2183 cooldown_frames: 0,
2184 ..Default::default()
2185 },
2186 );
2187
2188 for _ in 0..5 {
2190 budget.next_frame();
2191 }
2192
2193 let telem = budget.telemetry().unwrap();
2194 assert_eq!(telem.frames_observed, 5);
2195 assert!(!telem.in_warmup);
2196 assert!(telem.pid_output.is_finite());
2199 assert!(telem.e_value.is_finite());
2200 }
2201
2202 #[test]
2203 fn controller_next_frame_degrades_under_simulated_overload() {
2204 let config = BudgetControllerConfig {
2209 target: Duration::from_millis(16),
2210 eprocess: EProcessConfig {
2211 warmup_frames: 0,
2212 ..Default::default()
2213 },
2214 cooldown_frames: 0,
2215 ..Default::default()
2216 };
2217 let mut ctrl = BudgetController::new(config);
2218
2219 for _ in 0..50 {
2221 ctrl.update(Duration::from_millis(40));
2222 }
2223
2224 assert!(
2226 ctrl.level() > DegradationLevel::Full,
2227 "Controller should degrade: {:?}",
2228 ctrl.level()
2229 );
2230
2231 let telem = ctrl.telemetry();
2233 assert!(telem.level > DegradationLevel::Full);
2234 assert!(
2235 telem.pid_output > 0.0,
2236 "PID output should be positive under overload"
2237 );
2238 assert!(telem.e_value > 1.0, "E-value should grow under overload");
2239 }
2240
2241 #[test]
2242 fn next_frame_delegates_to_controller_when_attached() {
2243 let mut budget = RenderBudget::new(Duration::from_millis(1000))
2246 .with_controller(BudgetControllerConfig::default());
2247
2248 budget.degrade();
2250 assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
2251
2252 budget.next_frame();
2256
2257 let telem = budget.telemetry().unwrap();
2262 assert_eq!(telem.frames_observed, 1);
2263 }
2264
2265 #[test]
2266 fn telemetry_is_copy_and_no_alloc() {
2267 let budget = RenderBudget::new(Duration::from_millis(16))
2268 .with_controller(BudgetControllerConfig::default());
2269
2270 let telem = budget.telemetry().unwrap();
2271 let telem2 = telem;
2273 assert_eq!(telem.level, telem2.level);
2274 assert_eq!(telem.e_value, telem2.e_value);
2275 }
2276
2277 #[test]
2278 fn telemetry_warmup_flag_transitions() {
2279 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2280 BudgetControllerConfig {
2281 eprocess: EProcessConfig {
2282 warmup_frames: 3,
2283 ..Default::default()
2284 },
2285 ..Default::default()
2286 },
2287 );
2288
2289 budget.next_frame();
2291 budget.next_frame();
2292 let telem = budget.telemetry().unwrap();
2293 assert!(telem.in_warmup, "Should be in warmup at frame 2");
2294
2295 budget.next_frame();
2297 let telem = budget.telemetry().unwrap();
2298 assert!(!telem.in_warmup, "Should exit warmup at frame 3");
2299 }
2300
2301 #[test]
2302 fn phase_sub_budget_does_not_carry_controller() {
2303 let budget = RenderBudget::new(Duration::from_millis(100))
2304 .with_controller(BudgetControllerConfig::default());
2305
2306 let phase = budget.phase_budget(Phase::Render);
2307 assert!(
2308 phase.controller().is_none(),
2309 "Phase sub-budgets should not carry the controller"
2310 );
2311 }
2312
2313 #[test]
2314 fn controller_telemetry_tracks_frames_since_change() {
2315 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2316 eprocess: EProcessConfig {
2317 warmup_frames: 0,
2318 ..Default::default()
2319 },
2320 cooldown_frames: 0,
2321 ..Default::default()
2322 });
2323
2324 for i in 1..=5 {
2326 ctrl.update(Duration::from_millis(16));
2327 let telem = ctrl.telemetry();
2328 assert_eq!(
2329 telem.frames_since_change, i,
2330 "frames_since_change should be {} after {} frames",
2331 i, i
2332 );
2333 }
2334 }
2335
2336 #[test]
2337 fn telemetry_last_decision_reflects_controller_decision() {
2338 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2339 eprocess: EProcessConfig {
2340 warmup_frames: 0,
2341 ..Default::default()
2342 },
2343 cooldown_frames: 0,
2344 ..Default::default()
2345 });
2346
2347 ctrl.update(Duration::from_millis(16));
2349 assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Hold);
2350
2351 let mut saw_degrade = false;
2353 for _ in 0..50 {
2354 let d = ctrl.update(Duration::from_millis(50));
2355 if d == BudgetDecision::Degrade {
2356 saw_degrade = true;
2357 assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Degrade);
2358 break;
2359 }
2360 }
2361 assert!(saw_degrade, "Should have seen a Degrade decision");
2362 }
2363
2364 #[test]
2365 fn perf_overhead_controller_update_is_fast() {
2366 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2370
2371 let start = Instant::now();
2372 for _ in 0..10_000 {
2373 ctrl.update(Duration::from_millis(16));
2374 }
2375 let elapsed = start.elapsed();
2376
2377 assert!(
2381 elapsed < Duration::from_millis(50),
2382 "10k controller updates took {:?}, expected <50ms",
2383 elapsed
2384 );
2385 }
2386
2387 #[test]
2388 fn perf_overhead_telemetry_snapshot_is_fast() {
2389 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2390 ctrl.update(Duration::from_millis(16));
2391
2392 let start = Instant::now();
2393 for _ in 0..10_000 {
2394 let _telem = ctrl.telemetry();
2395 }
2396 let elapsed = start.elapsed();
2397
2398 assert!(
2399 elapsed < Duration::from_millis(10),
2400 "10k telemetry snapshots took {:?}, expected <10ms",
2401 elapsed
2402 );
2403 }
2404 }
2405
2406 mod stability_tests {
2411 use super::super::*;
2412
2413 fn fast_controller(target_ms: u64) -> BudgetController {
2415 BudgetController::new(BudgetControllerConfig {
2416 target: Duration::from_millis(target_ms),
2417 eprocess: EProcessConfig {
2418 warmup_frames: 0,
2419 ..Default::default()
2420 },
2421 cooldown_frames: 0,
2422 ..Default::default()
2423 })
2424 }
2425
2426 fn run_trace(
2430 ctrl: &mut BudgetController,
2431 trace: &[Duration],
2432 ) -> Vec<(u64, u64, BudgetTelemetry)> {
2433 trace
2434 .iter()
2435 .enumerate()
2436 .map(|(i, &ft)| {
2437 ctrl.update(ft);
2438 let telem = ctrl.telemetry();
2439 (i as u64, ft.as_micros() as u64, telem)
2440 })
2441 .collect()
2442 }
2443
2444 fn count_transitions(log: &[(u64, u64, BudgetTelemetry)]) -> u32 {
2446 let mut transitions = 0u32;
2447 for pair in log.windows(2) {
2448 if pair[0].2.level != pair[1].2.level {
2449 transitions += 1;
2450 }
2451 }
2452 transitions
2453 }
2454
2455 #[test]
2458 fn e2e_burst_logs_no_oscillation() {
2459 let mut ctrl = fast_controller(16);
2462
2463 let mut trace = Vec::new();
2464 for _cycle in 0..5 {
2465 for _ in 0..10 {
2467 trace.push(Duration::from_millis(40));
2468 }
2469 for _ in 0..20 {
2471 trace.push(Duration::from_millis(16));
2472 }
2473 }
2474
2475 let log = run_trace(&mut ctrl, &trace);
2476
2477 let transitions = count_transitions(&log);
2482 assert!(
2483 transitions < 20,
2484 "Too many transitions under bursty load: {} (expected <20)",
2485 transitions
2486 );
2487
2488 for (frame, ft_us, telem) in &log {
2490 assert!(
2491 telem.pid_output.is_finite(),
2492 "frame {}: NaN pid_output",
2493 frame
2494 );
2495 assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2496 assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2497 assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2498 assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2499 assert!(*ft_us > 0, "frame {}: zero frame time", frame);
2500 }
2501 }
2502
2503 #[test]
2504 fn e2e_burst_recovers_after_moderate_overload() {
2505 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2509 target: Duration::from_millis(16),
2510 eprocess: EProcessConfig {
2511 warmup_frames: 5,
2512 ..Default::default()
2513 },
2514 cooldown_frames: 3,
2515 ..Default::default()
2516 });
2517
2518 let mut trace = Vec::new();
2519 for _cycle in 0..3 {
2520 for _ in 0..15 {
2522 trace.push(Duration::from_millis(30));
2523 }
2524 for _ in 0..50 {
2526 trace.push(Duration::from_millis(10));
2527 }
2528 }
2529
2530 let log = run_trace(&mut ctrl, &trace);
2531
2532 for cycle in 0..3 {
2535 let calm_end = (cycle + 1) * 65 - 1;
2536 if calm_end < log.len() {
2537 assert!(
2538 log[calm_end].2.level < DegradationLevel::SkipFrame,
2539 "cycle {}: should recover after calm period, got {:?} at frame {}",
2540 cycle,
2541 log[calm_end].2.level,
2542 calm_end
2543 );
2544 }
2545 }
2546
2547 let final_level = log.last().unwrap().2.level;
2549 assert!(
2550 final_level < DegradationLevel::Skeleton,
2551 "Final level should recover below Skeleton: {:?}",
2552 final_level
2553 );
2554 }
2555
2556 #[test]
2559 fn e2e_idle_to_burst_recovery() {
2560 let mut ctrl = fast_controller(16);
2563
2564 let mut trace = Vec::new();
2565 for _ in 0..50 {
2567 trace.push(Duration::from_millis(8));
2568 }
2569 for _ in 0..20 {
2571 trace.push(Duration::from_millis(50));
2572 }
2573 for _ in 0..100 {
2575 trace.push(Duration::from_millis(8));
2576 }
2577
2578 let log = run_trace(&mut ctrl, &trace);
2579
2580 assert_eq!(
2582 log[49].2.level,
2583 DegradationLevel::Full,
2584 "Should be Full during idle phase"
2585 );
2586
2587 let max_during_burst = log[50..70].iter().map(|(_, _, t)| t.level).max().unwrap();
2589 assert!(
2590 max_during_burst > DegradationLevel::Full,
2591 "Should degrade during burst"
2592 );
2593
2594 let final_level = log.last().unwrap().2.level;
2596 assert!(
2597 final_level < max_during_burst,
2598 "Should recover after burst: final={:?}, max_during_burst={:?}",
2599 final_level,
2600 max_during_burst
2601 );
2602 }
2603
2604 #[test]
2605 fn e2e_idle_to_burst_no_over_degrade() {
2606 let mut ctrl = fast_controller(16);
2609
2610 for _ in 0..30 {
2612 ctrl.update(Duration::from_millis(8));
2613 }
2614
2615 for _ in 0..5 {
2617 ctrl.update(Duration::from_millis(40));
2618 }
2619
2620 let level = ctrl.level();
2622 assert!(
2623 level <= DegradationLevel::NoStyling,
2624 "Brief burst should not over-degrade: {:?}",
2625 level
2626 );
2627 }
2628
2629 #[test]
2632 fn property_random_load_hysteresis_bounds() {
2633 let mut ctrl = fast_controller(16);
2636
2637 let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_BABE;
2640 let mut trace = Vec::new();
2641 for _ in 0..1000 {
2642 rng_state = rng_state
2644 .wrapping_mul(6_364_136_223_846_793_005)
2645 .wrapping_add(1_442_695_040_888_963_407);
2646 let frame_ms = 4 + ((rng_state >> 33) % 77);
2648 trace.push(Duration::from_millis(frame_ms));
2649 }
2650
2651 let log = run_trace(&mut ctrl, &trace);
2652
2653 for pair in log.windows(2) {
2655 let prev = pair[0].2.level.level();
2656 let curr = pair[1].2.level.level();
2657 let delta = (curr as i16 - prev as i16).unsigned_abs();
2658 assert!(
2659 delta <= 1,
2660 "Level jumped {} steps at frame {}: {:?} -> {:?}",
2661 delta,
2662 pair[1].0,
2663 pair[0].2.level,
2664 pair[1].2.level
2665 );
2666 }
2667
2668 for (frame, _, telem) in &log {
2670 assert!(
2671 telem.level <= DegradationLevel::SkipFrame,
2672 "frame {}: level out of range: {:?}",
2673 frame,
2674 telem.level
2675 );
2676 }
2677
2678 for (frame, _, telem) in &log {
2680 assert!(
2681 telem.pid_output.is_finite(),
2682 "frame {}: NaN pid_output",
2683 frame
2684 );
2685 assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2686 assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2687 assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2688 assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2689 assert!(
2690 telem.e_value > 0.0,
2691 "frame {}: e_value not positive: {}",
2692 frame,
2693 telem.e_value
2694 );
2695 }
2696 }
2697
2698 #[test]
2699 fn property_random_load_bounded_transitions() {
2700 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2703 target: Duration::from_millis(16),
2704 eprocess: EProcessConfig {
2705 warmup_frames: 5,
2706 ..Default::default()
2707 },
2708 cooldown_frames: 3,
2709 ..Default::default()
2710 });
2711
2712 let mut rng_state: u64 = 0x1234_5678_9ABC_DEF0;
2714 let mut trace = Vec::new();
2715 for _ in 0..500 {
2716 rng_state = rng_state
2717 .wrapping_mul(6_364_136_223_846_793_005)
2718 .wrapping_add(1_442_695_040_888_963_407);
2719 let frame_ms = 8 + ((rng_state >> 33) % 40);
2720 trace.push(Duration::from_millis(frame_ms));
2721 }
2722
2723 let log = run_trace(&mut ctrl, &trace);
2724 let transitions = count_transitions(&log);
2725
2726 assert!(
2729 transitions < 80,
2730 "Too many transitions under random load: {} (expected <80 with cooldown=3)",
2731 transitions
2732 );
2733 }
2734
2735 #[test]
2736 fn property_deterministic_replay() {
2737 let trace: Vec<Duration> = (0..100)
2739 .map(|i| Duration::from_millis(10 + (i * 7 % 30)))
2740 .collect();
2741
2742 let mut ctrl1 = fast_controller(16);
2743 let log1 = run_trace(&mut ctrl1, &trace);
2744
2745 let mut ctrl2 = fast_controller(16);
2746 let log2 = run_trace(&mut ctrl2, &trace);
2747
2748 for (r1, r2) in log1.iter().zip(log2.iter()) {
2749 assert_eq!(r1.0, r2.0, "frame index mismatch");
2750 assert_eq!(r1.1, r2.1, "frame time mismatch");
2751 assert_eq!(r1.2.level, r2.2.level, "level mismatch at frame {}", r1.0);
2752 assert_eq!(
2753 r1.2.last_decision, r2.2.last_decision,
2754 "decision mismatch at frame {}",
2755 r1.0
2756 );
2757 assert!(
2758 (r1.2.pid_output - r2.2.pid_output).abs() < 1e-10,
2759 "pid_output mismatch at frame {}: {} vs {}",
2760 r1.0,
2761 r1.2.pid_output,
2762 r2.2.pid_output
2763 );
2764 assert!(
2765 (r1.2.e_value - r2.2.e_value).abs() < 1e-10,
2766 "e_value mismatch at frame {}: {} vs {}",
2767 r1.0,
2768 r1.2.e_value,
2769 r2.2.e_value
2770 );
2771 }
2772 }
2773
2774 #[test]
2777 fn telemetry_jsonl_fields_complete() {
2778 let mut ctrl = fast_controller(16);
2780 ctrl.update(Duration::from_millis(20));
2781
2782 let telem = ctrl.telemetry();
2783
2784 let _degradation: &str = telem.level.as_str();
2786 let _pid_p: f64 = telem.pid_p;
2787 let _pid_i: f64 = telem.pid_i;
2788 let _pid_d: f64 = telem.pid_d;
2789 let _e_value: f64 = telem.e_value;
2790 let _decision: &str = telem.last_decision.as_str();
2791 let _frames: u32 = telem.frames_observed;
2792
2793 assert_eq!(BudgetDecision::Hold.as_str(), "stay");
2795 assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
2796 assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
2797 }
2798
2799 #[test]
2800 fn telemetry_pid_components_sum_to_output() {
2801 let mut ctrl = fast_controller(16);
2803
2804 for ms in [10u64, 16, 20, 30, 8, 50] {
2805 ctrl.update(Duration::from_millis(ms));
2806 let telem = ctrl.telemetry();
2807 let sum = telem.pid_p + telem.pid_i + telem.pid_d;
2808 assert!(
2809 (sum - telem.pid_output).abs() < 1e-10,
2810 "P+I+D != output at {}ms: {} + {} + {} = {} != {}",
2811 ms,
2812 telem.pid_p,
2813 telem.pid_i,
2814 telem.pid_d,
2815 sum,
2816 telem.pid_output
2817 );
2818 }
2819 }
2820 }
2821
2822 mod edge_case_tests {
2827 use super::super::*;
2828
2829 #[test]
2832 fn pid_negative_integral_windup() {
2833 let mut state = PidState::default();
2835 let gains = PidGains {
2836 integral_max: 3.0,
2837 ..Default::default()
2838 };
2839
2840 for _ in 0..200 {
2841 state.update(-10.0, &gains);
2842 }
2843
2844 assert!(
2845 state.integral >= -3.0 - f64::EPSILON,
2846 "Negative integral should be clamped to -max: {}",
2847 state.integral
2848 );
2849 assert!(
2850 state.integral <= -3.0 + f64::EPSILON,
2851 "Negative integral should saturate at -max: {}",
2852 state.integral
2853 );
2854 }
2855
2856 #[test]
2857 fn pid_zero_gains_zero_output() {
2858 let mut state = PidState::default();
2859 let gains = PidGains {
2860 kp: 0.0,
2861 ki: 0.0,
2862 kd: 0.0,
2863 integral_max: 5.0,
2864 };
2865
2866 let u = state.update(42.0, &gains);
2867 assert!(
2868 u.abs() < 1e-10,
2869 "Zero gains should yield zero output: {}",
2870 u
2871 );
2872 }
2873
2874 #[test]
2875 fn pid_large_error_stays_finite() {
2876 let mut state = PidState::default();
2877 let gains = PidGains::default();
2878
2879 let u = state.update(1e12, &gains);
2881 assert!(
2882 u.is_finite(),
2883 "PID output should be finite for large error: {}",
2884 u
2885 );
2886
2887 assert!(
2889 state.integral <= gains.integral_max + f64::EPSILON,
2890 "Integral should be clamped: {}",
2891 state.integral
2892 );
2893 }
2894
2895 #[test]
2896 fn pid_alternating_error_derivative_responds() {
2897 let mut state = PidState::default();
2898 let gains = PidGains::default();
2899
2900 let u1 = state.update(1.0, &gains);
2902 let u2 = state.update(-1.0, &gains);
2903
2904 assert!(
2907 u2 < u1,
2908 "Alternating error should reduce output: u1={}, u2={}",
2909 u1,
2910 u2
2911 );
2912 }
2913
2914 #[test]
2915 fn pid_telemetry_terms_match_after_update() {
2916 let mut state = PidState::default();
2917 let gains = PidGains::default();
2918
2919 state.update(2.0, &gains);
2920
2921 assert!(
2923 (state.last_p - 1.0).abs() < 1e-10,
2924 "P term: {}",
2925 state.last_p
2926 );
2927 assert!(
2929 (state.last_i - 0.1).abs() < 1e-10,
2930 "I term: {}",
2931 state.last_i
2932 );
2933 assert!(
2935 (state.last_d - 0.4).abs() < 1e-10,
2936 "D term: {}",
2937 state.last_d
2938 );
2939 }
2940
2941 #[test]
2942 fn pid_integral_clamping_symmetric() {
2943 let mut state = PidState::default();
2944 let gains = PidGains {
2945 integral_max: 1.0,
2946 ..Default::default()
2947 };
2948
2949 for _ in 0..50 {
2951 state.update(100.0, &gains);
2952 }
2953 let pos_integral = state.integral;
2954
2955 state.reset();
2956
2957 for _ in 0..50 {
2959 state.update(-100.0, &gains);
2960 }
2961 let neg_integral = state.integral;
2962
2963 assert!(
2964 (pos_integral + neg_integral).abs() < f64::EPSILON,
2965 "Clamping should be symmetric: pos={}, neg={}",
2966 pos_integral,
2967 neg_integral
2968 );
2969 }
2970
2971 #[test]
2974 fn eprocess_first_frame_initializes_mean() {
2975 let mut state = EProcessState::default();
2976 let config = EProcessConfig::default();
2977
2978 state.update(25.0, 16.0, &config);
2979
2980 assert!(
2981 (state.mean_ema - 25.0).abs() < f64::EPSILON,
2982 "First frame should set mean_ema directly: {}",
2983 state.mean_ema
2984 );
2985 assert!(
2986 (state.sigma_ema - config.sigma_floor_ms).abs() < f64::EPSILON,
2987 "First frame should set sigma_ema to floor: {}",
2988 state.sigma_ema
2989 );
2990 assert_eq!(state.frames_observed, 1);
2991 }
2992
2993 #[test]
2994 fn eprocess_e_value_clamped_at_upper_bound() {
2995 let mut state = EProcessState::default();
2996 let config = EProcessConfig {
2997 lambda: 2.0, warmup_frames: 0,
2999 sigma_floor_ms: 0.001, ..Default::default()
3001 };
3002
3003 for _ in 0..1000 {
3005 state.update(1e6, 16.0, &config);
3006 }
3007
3008 assert!(
3009 state.e_value <= 1e10,
3010 "E-value should be clamped at 1e10: {}",
3011 state.e_value
3012 );
3013 }
3014
3015 #[test]
3016 fn eprocess_e_value_clamped_at_lower_bound() {
3017 let mut state = EProcessState::default();
3018 let config = EProcessConfig {
3019 lambda: 2.0,
3020 warmup_frames: 0,
3021 sigma_floor_ms: 0.001,
3022 ..Default::default()
3023 };
3024
3025 for _ in 0..1000 {
3027 state.update(0.001, 1e6, &config);
3028 }
3029
3030 assert!(
3031 state.e_value >= 1e-10,
3032 "E-value should be clamped at 1e-10: {}",
3033 state.e_value
3034 );
3035 }
3036
3037 #[test]
3038 fn eprocess_should_upgrade_during_warmup() {
3039 let state = EProcessState::default();
3040 let config = EProcessConfig {
3041 warmup_frames: 10,
3042 ..Default::default()
3043 };
3044
3045 assert!(
3047 state.should_upgrade(&config),
3048 "should_upgrade should return true during warmup"
3049 );
3050 }
3051
3052 #[test]
3053 fn eprocess_frames_observed_saturates() {
3054 let mut state = EProcessState {
3055 frames_observed: u32::MAX,
3056 ..EProcessState::default()
3057 };
3058 let config = EProcessConfig::default();
3059
3060 state.update(16.0, 16.0, &config);
3062 assert_eq!(
3063 state.frames_observed,
3064 u32::MAX,
3065 "frames_observed should saturate at u32::MAX"
3066 );
3067 }
3068
3069 #[test]
3070 fn eprocess_sigma_ema_decay_boundary_zero() {
3071 let mut state = EProcessState::default();
3072 let config = EProcessConfig {
3073 sigma_ema_decay: 0.0,
3074 warmup_frames: 0,
3075 ..Default::default()
3076 };
3077
3078 state.update(20.0, 16.0, &config);
3080 state.update(30.0, 16.0, &config);
3081
3082 assert!(
3084 (state.mean_ema - 30.0).abs() < f64::EPSILON,
3085 "decay=0 should fully replace mean_ema: {}",
3086 state.mean_ema
3087 );
3088 }
3089
3090 #[test]
3091 fn eprocess_sigma_ema_decay_boundary_one() {
3092 let mut state = EProcessState::default();
3093 let config = EProcessConfig {
3094 sigma_ema_decay: 1.0,
3095 warmup_frames: 0,
3096 ..Default::default()
3097 };
3098
3099 state.update(20.0, 16.0, &config);
3101 let first_mean = state.mean_ema;
3102 state.update(100.0, 16.0, &config);
3103
3104 assert!(
3105 (state.mean_ema - first_mean).abs() < f64::EPSILON,
3106 "decay=1 should lock mean_ema at first value: got {}, expected {}",
3107 state.mean_ema,
3108 first_mean
3109 );
3110 }
3111
3112 #[test]
3113 fn eprocess_zero_target_no_panic() {
3114 let mut state = EProcessState::default();
3115 let config = EProcessConfig {
3116 warmup_frames: 0,
3117 ..Default::default()
3118 };
3119
3120 let e = state.update(16.0, 0.0, &config);
3122 assert!(
3123 e.is_finite(),
3124 "E-value should be finite with zero target: {}",
3125 e
3126 );
3127 }
3128
3129 #[test]
3132 fn degradation_level_default_is_full() {
3133 assert_eq!(DegradationLevel::default(), DegradationLevel::Full);
3134 }
3135
3136 #[test]
3137 fn degradation_level_hash_unique() {
3138 use std::collections::HashSet;
3139 let levels = [
3140 DegradationLevel::Full,
3141 DegradationLevel::SimpleBorders,
3142 DegradationLevel::NoStyling,
3143 DegradationLevel::EssentialOnly,
3144 DegradationLevel::Skeleton,
3145 DegradationLevel::SkipFrame,
3146 ];
3147 let set: HashSet<DegradationLevel> = levels.iter().copied().collect();
3148 assert_eq!(set.len(), 6, "All levels should hash uniquely");
3149 }
3150
3151 #[test]
3152 fn degradation_level_widget_queries_full() {
3153 let l = DegradationLevel::Full;
3154 assert!(l.use_unicode_borders());
3155 assert!(l.apply_styling());
3156 assert!(l.render_decorative());
3157 assert!(l.render_content());
3158 }
3159
3160 #[test]
3161 fn degradation_level_widget_queries_simple_borders() {
3162 let l = DegradationLevel::SimpleBorders;
3163 assert!(!l.use_unicode_borders());
3164 assert!(l.apply_styling());
3165 assert!(l.render_decorative());
3166 assert!(l.render_content());
3167 }
3168
3169 #[test]
3170 fn degradation_level_widget_queries_no_styling() {
3171 let l = DegradationLevel::NoStyling;
3172 assert!(!l.use_unicode_borders());
3173 assert!(!l.apply_styling());
3174 assert!(l.render_decorative());
3175 assert!(l.render_content());
3176 }
3177
3178 #[test]
3179 fn degradation_level_widget_queries_essential_only() {
3180 let l = DegradationLevel::EssentialOnly;
3181 assert!(!l.use_unicode_borders());
3182 assert!(!l.apply_styling());
3183 assert!(!l.render_decorative());
3184 assert!(l.render_content());
3185 }
3186
3187 #[test]
3188 fn degradation_level_widget_queries_skeleton() {
3189 let l = DegradationLevel::Skeleton;
3190 assert!(!l.use_unicode_borders());
3191 assert!(!l.apply_styling());
3192 assert!(!l.render_decorative());
3193 assert!(!l.render_content());
3194 }
3195
3196 #[test]
3197 fn degradation_level_widget_queries_skip_frame() {
3198 let l = DegradationLevel::SkipFrame;
3199 assert!(!l.use_unicode_borders());
3200 assert!(!l.apply_styling());
3201 assert!(!l.render_decorative());
3202 assert!(!l.render_content());
3203 }
3204
3205 #[test]
3206 fn degradation_level_partial_ord_consistent() {
3207 let levels = [
3209 DegradationLevel::Full,
3210 DegradationLevel::SimpleBorders,
3211 DegradationLevel::NoStyling,
3212 DegradationLevel::EssentialOnly,
3213 DegradationLevel::Skeleton,
3214 DegradationLevel::SkipFrame,
3215 ];
3216 for (i, a) in levels.iter().enumerate() {
3217 for (j, b) in levels.iter().enumerate() {
3218 let po = a.partial_cmp(b);
3219 let o = a.cmp(b);
3220 assert_eq!(po, Some(o), "PartialOrd != Ord for {:?} vs {:?}", a, b);
3221 if i < j {
3222 assert!(*a < *b, "{:?} should be < {:?}", a, b);
3223 }
3224 }
3225 }
3226 }
3227
3228 #[test]
3229 fn degradation_level_clone_eq() {
3230 let a = DegradationLevel::NoStyling;
3231 let b = a;
3232 assert_eq!(a, b);
3233 }
3234
3235 #[test]
3236 fn degradation_level_debug() {
3237 let s = format!("{:?}", DegradationLevel::EssentialOnly);
3238 assert!(s.contains("EssentialOnly"), "Debug output: {}", s);
3239 }
3240
3241 #[test]
3244 fn controller_eprocess_sigma_ms_uses_floor() {
3245 let ctrl = BudgetController::new(BudgetControllerConfig {
3246 eprocess: EProcessConfig {
3247 sigma_floor_ms: 2.5,
3248 ..Default::default()
3249 },
3250 ..Default::default()
3251 });
3252
3253 assert!(
3255 (ctrl.eprocess_sigma_ms() - 2.5).abs() < f64::EPSILON,
3256 "Should return sigma_floor_ms when sigma_ema < floor: {}",
3257 ctrl.eprocess_sigma_ms()
3258 );
3259 }
3260
3261 #[test]
3262 fn controller_config_accessor() {
3263 let config = BudgetControllerConfig {
3264 degrade_threshold: 0.42,
3265 ..Default::default()
3266 };
3267 let ctrl = BudgetController::new(config.clone());
3268
3269 assert_eq!(ctrl.config().degrade_threshold, 0.42);
3270 assert_eq!(ctrl.config().target, Duration::from_millis(16));
3271 }
3272
3273 #[test]
3274 fn controller_frames_observed_accessor() {
3275 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
3276
3277 assert_eq!(ctrl.frames_observed(), 0);
3278
3279 ctrl.update(Duration::from_millis(16));
3280 assert_eq!(ctrl.frames_observed(), 1);
3281
3282 ctrl.update(Duration::from_millis(16));
3283 assert_eq!(ctrl.frames_observed(), 2);
3284 }
3285
3286 #[test]
3289 fn render_budget_record_frame_time_used_by_next_frame() {
3290 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3291 budget.degrade();
3292
3293 for _ in 0..10 {
3295 budget.reset();
3296 }
3297
3298 budget.record_frame_time(Duration::from_millis(1));
3300 std::thread::sleep(Duration::from_millis(15));
3302
3303 let before = budget.degradation();
3304 budget.next_frame();
3305
3306 assert!(
3309 budget.degradation() < before,
3310 "Recorded frame time should enable upgrade: before={:?}, after={:?}",
3311 before,
3312 budget.degradation()
3313 );
3314 }
3315
3316 #[test]
3317 fn render_budget_phase_budget_clamped_by_remaining() {
3318 let budget = RenderBudget::new(Duration::from_millis(1));
3320 std::thread::sleep(Duration::from_millis(5));
3321
3322 let phase = budget.phase_budget(Phase::Render);
3324 assert!(
3325 phase.total() <= Duration::from_millis(1),
3326 "Phase budget should be clamped by remaining: {:?}",
3327 phase.total()
3328 );
3329 }
3330
3331 #[test]
3332 fn render_budget_exhausted_skipframe_with_no_frame_skip() {
3333 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3334 budget.allow_frame_skip = false;
3335 budget.set_degradation(DegradationLevel::SkipFrame);
3336
3337 assert!(
3340 !budget.exhausted(),
3341 "SkipFrame should not exhaust when frame skip disabled"
3342 );
3343 }
3344
3345 #[test]
3346 fn render_budget_remaining_fraction_zero_total() {
3347 let budget = RenderBudget::new(Duration::ZERO);
3348 assert_eq!(budget.remaining_fraction(), 0.0);
3349 }
3350
3351 #[test]
3352 fn render_budget_total_accessor() {
3353 let budget = RenderBudget::new(Duration::from_millis(42));
3354 assert_eq!(budget.total(), Duration::from_millis(42));
3355 }
3356
3357 #[test]
3358 fn render_budget_phase_budgets_accessor() {
3359 let budget = RenderBudget::new(Duration::from_millis(16));
3360 let pb = budget.phase_budgets();
3361 assert_eq!(pb.diff, Duration::from_millis(2));
3362 assert_eq!(pb.present, Duration::from_millis(4));
3363 assert_eq!(pb.render, Duration::from_millis(8));
3364 }
3365
3366 #[test]
3367 fn render_budget_set_degradation_no_op_preserves_cooldown() {
3368 let mut budget = RenderBudget::new(Duration::from_millis(16));
3369 budget.set_degradation(DegradationLevel::NoStyling);
3370 budget.frames_since_change = 7;
3371
3372 budget.set_degradation(DegradationLevel::NoStyling);
3374 assert_eq!(budget.frames_since_change, 7);
3375
3376 budget.set_degradation(DegradationLevel::Skeleton);
3378 assert_eq!(budget.frames_since_change, 0);
3379 }
3380
3381 #[test]
3382 fn render_budget_should_upgrade_false_at_full() {
3383 let budget = RenderBudget::new(Duration::from_millis(1000));
3384 assert!(!budget.should_upgrade(), "Full level should never upgrade");
3385 }
3386
3387 #[test]
3388 fn render_budget_should_upgrade_false_during_cooldown() {
3389 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3390 budget.degrade();
3391 assert!(
3393 !budget.should_upgrade(),
3394 "Should not upgrade during cooldown"
3395 );
3396 }
3397
3398 #[test]
3399 fn render_budget_degrade_at_max_stays_at_max() {
3400 let mut budget = RenderBudget::new(Duration::from_millis(16));
3401 budget.set_degradation(DegradationLevel::SkipFrame);
3402 budget.degrade();
3403 assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
3404 }
3405
3406 #[test]
3407 fn render_budget_upgrade_at_full_stays_at_full() {
3408 let mut budget = RenderBudget::new(Duration::from_millis(16));
3409 budget.upgrade();
3410 assert_eq!(budget.degradation(), DegradationLevel::Full);
3411 }
3412
3413 #[test]
3416 fn frame_budget_config_partial_eq() {
3417 let a = FrameBudgetConfig::default();
3418 let b = FrameBudgetConfig::default();
3419 assert_eq!(a, b);
3420
3421 let c = FrameBudgetConfig::strict(Duration::from_millis(16));
3422 assert_ne!(a, c, "Different configs should not be equal");
3423 }
3424
3425 #[test]
3426 fn phase_budgets_eq_and_copy() {
3427 let a = PhaseBudgets::default();
3428 let b = a; assert_eq!(a, b);
3430
3431 let c = PhaseBudgets {
3432 diff: Duration::from_millis(1),
3433 ..Default::default()
3434 };
3435 assert_ne!(a, c);
3436 }
3437
3438 #[test]
3439 fn budget_controller_config_partial_eq() {
3440 let a = BudgetControllerConfig::default();
3441 let b = BudgetControllerConfig::default();
3442 assert_eq!(a, b);
3443 }
3444
3445 #[test]
3446 fn pid_gains_partial_eq() {
3447 let a = PidGains::default();
3448 let b = PidGains::default();
3449 assert_eq!(a, b);
3450 }
3451
3452 #[test]
3453 fn eprocess_config_partial_eq() {
3454 let a = EProcessConfig::default();
3455 let b = EProcessConfig::default();
3456 assert_eq!(a, b);
3457 }
3458
3459 #[test]
3462 fn budget_decision_debug_format() {
3463 assert!(format!("{:?}", BudgetDecision::Hold).contains("Hold"));
3464 assert!(format!("{:?}", BudgetDecision::Degrade).contains("Degrade"));
3465 assert!(format!("{:?}", BudgetDecision::Upgrade).contains("Upgrade"));
3466 }
3467
3468 #[test]
3469 fn budget_decision_clone_copy() {
3470 let d = BudgetDecision::Degrade;
3471 let d2 = d;
3472 assert_eq!(d, d2);
3473 }
3474
3475 #[test]
3476 fn budget_decision_as_str_coverage() {
3477 assert_eq!(BudgetDecision::Hold.as_str(), "stay");
3478 assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
3479 assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
3480 }
3481
3482 #[test]
3485 fn phase_eq_and_hash() {
3486 use std::collections::HashSet;
3487 let mut set = HashSet::new();
3488 set.insert(Phase::Diff);
3489 set.insert(Phase::Present);
3490 set.insert(Phase::Render);
3491 assert_eq!(set.len(), 3);
3492
3493 set.insert(Phase::Diff);
3495 assert_eq!(set.len(), 3);
3496 }
3497
3498 #[test]
3499 fn phase_debug() {
3500 assert!(format!("{:?}", Phase::Diff).contains("Diff"));
3501 assert!(format!("{:?}", Phase::Present).contains("Present"));
3502 assert!(format!("{:?}", Phase::Render).contains("Render"));
3503 }
3504
3505 #[test]
3506 fn phase_clone_copy() {
3507 let p = Phase::Present;
3508 let p2 = p;
3509 assert_eq!(p, p2);
3510 }
3511
3512 #[test]
3515 fn budget_telemetry_debug() {
3516 let telem = BudgetTelemetry {
3517 level: DegradationLevel::Full,
3518 pid_output: 0.0,
3519 pid_p: 0.0,
3520 pid_i: 0.0,
3521 pid_d: 0.0,
3522 e_value: 1.0,
3523 frames_observed: 0,
3524 frames_since_change: 0,
3525 last_decision: BudgetDecision::Hold,
3526 in_warmup: true,
3527 };
3528 let s = format!("{:?}", telem);
3529 assert!(s.contains("BudgetTelemetry"), "Debug output: {}", s);
3530 }
3531
3532 #[test]
3533 fn budget_telemetry_partial_eq() {
3534 let a = BudgetTelemetry {
3535 level: DegradationLevel::Full,
3536 pid_output: 0.5,
3537 pid_p: 0.3,
3538 pid_i: 0.1,
3539 pid_d: 0.1,
3540 e_value: 1.0,
3541 frames_observed: 5,
3542 frames_since_change: 2,
3543 last_decision: BudgetDecision::Hold,
3544 in_warmup: false,
3545 };
3546 let b = a;
3547 assert_eq!(a, b);
3548
3549 let c = BudgetTelemetry {
3550 level: DegradationLevel::SimpleBorders,
3551 ..a
3552 };
3553 assert_ne!(a, c);
3554 }
3555
3556 #[test]
3559 fn next_frame_without_recorded_time_uses_elapsed() {
3560 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3561
3562 budget.next_frame();
3564
3565 assert!(budget.remaining_fraction() > 0.9);
3567 }
3568
3569 #[test]
3570 fn controller_at_max_degradation_holds() {
3571 let mut ctrl = BudgetController::new(BudgetControllerConfig {
3572 eprocess: EProcessConfig {
3573 warmup_frames: 0,
3574 ..Default::default()
3575 },
3576 cooldown_frames: 0,
3577 ..Default::default()
3578 });
3579
3580 for _ in 0..500 {
3582 ctrl.update(Duration::from_millis(200));
3583 }
3584 assert_eq!(ctrl.level(), DegradationLevel::SkipFrame);
3585
3586 let d = ctrl.update(Duration::from_millis(200));
3588 assert_eq!(d, BudgetDecision::Hold, "At max level, should hold");
3589 }
3590
3591 #[test]
3592 fn controller_at_full_level_no_upgrade() {
3593 let mut ctrl = BudgetController::new(BudgetControllerConfig {
3594 eprocess: EProcessConfig {
3595 warmup_frames: 0,
3596 ..Default::default()
3597 },
3598 cooldown_frames: 0,
3599 ..Default::default()
3600 });
3601
3602 for _ in 0..50 {
3604 let d = ctrl.update(Duration::from_millis(1));
3605 assert_ne!(
3606 d,
3607 BudgetDecision::Upgrade,
3608 "Full level should never upgrade"
3609 );
3610 }
3611 }
3612
3613 #[test]
3614 fn render_budget_full_degrade_cycle_with_controller() {
3615 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
3616 BudgetControllerConfig {
3617 eprocess: EProcessConfig {
3618 warmup_frames: 0,
3619 ..Default::default()
3620 },
3621 cooldown_frames: 0,
3622 ..Default::default()
3623 },
3624 );
3625
3626 for _ in 0..100 {
3628 budget.record_frame_time(Duration::from_millis(40));
3629 budget.next_frame();
3630 }
3631 let degraded = budget.degradation();
3632 assert!(
3633 degraded > DegradationLevel::Full,
3634 "Should degrade: {:?}",
3635 degraded
3636 );
3637
3638 for _ in 0..200 {
3640 budget.record_frame_time(Duration::from_millis(4));
3641 budget.next_frame();
3642 }
3643 let recovered = budget.degradation();
3644 assert!(
3645 recovered < degraded,
3646 "Should recover: {:?} -> {:?}",
3647 degraded,
3648 recovered
3649 );
3650 }
3651
3652 #[test]
3653 fn render_budget_phase_has_budget_exhausted() {
3654 let budget = RenderBudget::new(Duration::from_millis(1));
3655 std::thread::sleep(Duration::from_millis(10));
3656
3657 assert!(!budget.phase_has_budget(Phase::Diff));
3659 assert!(!budget.phase_has_budget(Phase::Present));
3660 assert!(!budget.phase_has_budget(Phase::Render));
3661 }
3662
3663 #[test]
3664 fn render_budget_elapsed_increases() {
3665 let budget = RenderBudget::new(Duration::from_millis(1000));
3666 let e1 = budget.elapsed();
3667 std::thread::sleep(Duration::from_millis(5));
3668 let e2 = budget.elapsed();
3669 assert!(e2 > e1, "Elapsed should increase: {:?} vs {:?}", e1, e2);
3670 }
3671
3672 #[test]
3673 fn controller_pid_integral_accessor() {
3674 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
3675
3676 assert_eq!(ctrl.pid_integral(), 0.0);
3677
3678 ctrl.update(Duration::from_millis(32)); assert!(
3681 ctrl.pid_integral() > 0.0,
3682 "Integral should grow: {}",
3683 ctrl.pid_integral()
3684 );
3685 }
3686
3687 #[test]
3688 fn controller_e_value_accessor() {
3689 let ctrl = BudgetController::new(BudgetControllerConfig::default());
3690 assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
3691 }
3692 }
3693}