1#![forbid(unsafe_code)]
2
3use web_time::{Duration, Instant};
41
42#[cfg(feature = "tracing")]
43use tracing::{trace, warn};
44
45#[derive(Debug, Clone, PartialEq)]
86pub struct PidGains {
87 pub kp: f64,
89 pub ki: f64,
91 pub kd: f64,
93 pub integral_max: f64,
95}
96
97impl Default for PidGains {
98 fn default() -> Self {
99 Self {
100 kp: 0.5,
101 ki: 0.05,
102 kd: 0.2,
103 integral_max: 5.0,
104 }
105 }
106}
107
108#[derive(Debug, Clone)]
112struct PidState {
113 integral: f64,
115 prev_error: f64,
117 last_p: f64,
119 last_i: f64,
121 last_d: f64,
123}
124
125impl Default for PidState {
126 fn default() -> Self {
127 Self {
128 integral: 0.0,
129 prev_error: 0.0,
130 last_p: 0.0,
131 last_i: 0.0,
132 last_d: 0.0,
133 }
134 }
135}
136
137impl PidState {
138 fn update(&mut self, error: f64, gains: &PidGains) -> f64 {
142 if error.is_nan() {
143 return 0.0;
144 }
145 self.integral = (self.integral + error).clamp(-gains.integral_max, gains.integral_max);
147
148 let derivative = error - self.prev_error;
150 self.prev_error = error;
151
152 self.last_p = gains.kp * error;
154 self.last_i = gains.ki * self.integral;
155 self.last_d = gains.kd * derivative;
156
157 self.last_p + self.last_i + self.last_d
159 }
160
161 fn reset(&mut self) {
163 *self = Self::default();
164 }
165}
166
167#[derive(Debug, Clone, PartialEq)]
206pub struct EProcessConfig {
207 pub lambda: f64,
210 pub alpha: f64,
213 pub beta: f64,
216 pub sigma_ema_decay: f64,
219 pub sigma_floor_ms: f64,
222 pub warmup_frames: u32,
225}
226
227impl Default for EProcessConfig {
228 fn default() -> Self {
229 Self {
230 lambda: 0.5,
231 alpha: 0.05,
232 beta: 0.5,
233 sigma_ema_decay: 0.9,
234 sigma_floor_ms: 1.0,
235 warmup_frames: 10,
236 }
237 }
238}
239
240#[derive(Debug, Clone)]
242struct EProcessState {
243 e_value: f64,
245 sigma_ema: f64,
247 mean_ema: f64,
249 frames_observed: u32,
251}
252
253impl Default for EProcessState {
254 fn default() -> Self {
255 Self {
256 e_value: 1.0,
257 sigma_ema: 0.0,
258 mean_ema: 0.0,
259 frames_observed: 0,
260 }
261 }
262}
263
264impl EProcessState {
265 fn update(&mut self, frame_time_ms: f64, target_ms: f64, config: &EProcessConfig) -> f64 {
269 self.frames_observed = self.frames_observed.saturating_add(1);
270
271 if self.frames_observed == 1 {
273 self.mean_ema = frame_time_ms;
274 self.sigma_ema = config.sigma_floor_ms;
275 } else {
276 let decay = config.sigma_ema_decay;
277 self.mean_ema = decay * self.mean_ema + (1.0 - decay) * frame_time_ms;
278 let deviation = (frame_time_ms - self.mean_ema).abs();
280 self.sigma_ema = decay * self.sigma_ema + (1.0 - decay) * deviation;
281 }
282
283 let sigma = self.sigma_ema.max(config.sigma_floor_ms);
285
286 let residual = (frame_time_ms - target_ms) / sigma;
288
289 let lambda = config.lambda;
293 let log_factor = lambda * residual - lambda * lambda / 2.0;
294 if !log_factor.is_nan() {
295 self.e_value *= log_factor.exp();
296 self.e_value = self.e_value.clamp(1e-10, 1e10);
299 }
300
301 self.e_value
302 }
303
304 fn should_degrade(&self, config: &EProcessConfig) -> bool {
306 if self.frames_observed < config.warmup_frames {
307 return false; }
309 self.e_value > 1.0 / config.alpha
310 }
311
312 fn should_upgrade(&self, config: &EProcessConfig) -> bool {
314 if self.frames_observed < config.warmup_frames {
315 return true; }
317 self.e_value < config.beta
318 }
319
320 fn reset(&mut self) {
322 *self = Self::default();
323 }
324}
325
326#[derive(Debug, Clone, PartialEq)]
328pub struct BudgetControllerConfig {
329 pub pid: PidGains,
331 pub eprocess: EProcessConfig,
333 pub target: Duration,
335 pub degrade_threshold: f64,
347 pub upgrade_threshold: f64,
350 pub cooldown_frames: u32,
352 pub degradation_floor: DegradationLevel,
360}
361
362impl Default for BudgetControllerConfig {
363 fn default() -> Self {
364 Self {
365 pid: PidGains::default(),
366 eprocess: EProcessConfig::default(),
367 target: Duration::from_millis(16),
368 degrade_threshold: 0.3,
369 upgrade_threshold: 0.2,
370 cooldown_frames: 3,
371 degradation_floor: DegradationLevel::SimpleBorders,
372 }
373 }
374}
375
376#[derive(Debug, Clone)]
410pub struct BudgetController {
411 config: BudgetControllerConfig,
412 pid: PidState,
413 eprocess: EProcessState,
414 current_level: DegradationLevel,
415 frames_since_change: u32,
416 last_pid_output: f64,
417 last_decision: BudgetDecision,
418 last_decision_reason: BudgetDecisionReason,
419 last_frame_ms: f64,
420 transition_seq: u64,
421 last_transition_correlation_id: u64,
422 last_pid_gate_threshold: f64,
423 last_pid_gate_margin: f64,
424 last_evidence_threshold: f64,
425 last_evidence_margin: f64,
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum BudgetDecision {
431 Hold,
433 Degrade,
435 Upgrade,
437}
438
439impl BudgetDecision {
440 #[inline]
442 pub fn as_str(self) -> &'static str {
443 match self {
444 Self::Hold => "stay",
445 Self::Degrade => "degrade",
446 Self::Upgrade => "upgrade",
447 }
448 }
449}
450
451pub const BUDGET_TELEMETRY_SCHEMA_VERSION: u16 = 1;
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum BudgetDecisionReason {
457 CooldownActive,
459 OverloadEvidencePassed,
461 UnderloadEvidencePassed,
463 AtMaxDegradation,
465 AtDegradationFloor,
467 AtFullQuality,
469 OverloadEvidenceInsufficient,
471 UnderloadEvidenceInsufficient,
473 WithinThresholdBand,
475}
476
477impl BudgetDecisionReason {
478 #[inline]
480 pub fn as_str(self) -> &'static str {
481 match self {
482 Self::CooldownActive => "cooldown_active",
483 Self::OverloadEvidencePassed => "overload_evidence_passed",
484 Self::UnderloadEvidencePassed => "underload_evidence_passed",
485 Self::AtMaxDegradation => "at_max_degradation",
486 Self::AtDegradationFloor => "at_degradation_floor",
487 Self::AtFullQuality => "at_full_quality",
488 Self::OverloadEvidenceInsufficient => "overload_evidence_insufficient",
489 Self::UnderloadEvidenceInsufficient => "underload_evidence_insufficient",
490 Self::WithinThresholdBand => "within_threshold_band",
491 }
492 }
493}
494
495impl BudgetController {
496 pub fn new(config: BudgetControllerConfig) -> Self {
498 Self {
499 config,
500 pid: PidState::default(),
501 eprocess: EProcessState::default(),
502 current_level: DegradationLevel::Full,
503 frames_since_change: 0,
504 last_pid_output: 0.0,
505 last_decision: BudgetDecision::Hold,
506 last_decision_reason: BudgetDecisionReason::WithinThresholdBand,
507 last_frame_ms: 0.0,
508 transition_seq: 0,
509 last_transition_correlation_id: 0,
510 last_pid_gate_threshold: 0.0,
511 last_pid_gate_margin: 0.0,
512 last_evidence_threshold: 0.0,
513 last_evidence_margin: 0.0,
514 }
515 }
516
517 pub fn update(&mut self, frame_time: Duration) -> BudgetDecision {
521 let target_ms = self.config.target.as_secs_f64() * 1000.0;
522 let frame_ms = frame_time.as_secs_f64() * 1000.0;
523
524 let error = (frame_ms - target_ms) / target_ms;
526
527 let u = self.pid.update(error, &self.config.pid);
529 self.last_pid_output = u;
530 self.last_frame_ms = frame_ms;
531
532 self.eprocess
534 .update(frame_ms, target_ms, &self.config.eprocess);
535
536 self.frames_since_change = self.frames_since_change.saturating_add(1);
538
539 let mut decision = BudgetDecision::Hold;
540 let mut reason = BudgetDecisionReason::WithinThresholdBand;
541 let mut pid_gate_threshold = 0.0;
542 let mut pid_gate_margin = 0.0;
543 let mut evidence_threshold = 0.0;
544 let mut evidence_margin = 0.0;
545
546 if self.frames_since_change < self.config.cooldown_frames {
548 reason = BudgetDecisionReason::CooldownActive;
549 } else if u > self.config.degrade_threshold {
550 pid_gate_threshold = self.config.degrade_threshold;
551 pid_gate_margin = u - pid_gate_threshold;
552 evidence_threshold = 1.0 / self.config.eprocess.alpha;
553 evidence_margin = self.eprocess.e_value - evidence_threshold;
554
555 if self.current_level.is_max() {
556 reason = BudgetDecisionReason::AtMaxDegradation;
557 } else if self.current_level >= self.config.degradation_floor {
558 reason = BudgetDecisionReason::AtDegradationFloor;
559 } else if self.eprocess.should_degrade(&self.config.eprocess) {
560 decision = BudgetDecision::Degrade;
561 reason = BudgetDecisionReason::OverloadEvidencePassed;
562 } else {
563 reason = BudgetDecisionReason::OverloadEvidenceInsufficient;
564 }
565 } else if u < -self.config.upgrade_threshold {
566 pid_gate_threshold = -self.config.upgrade_threshold;
567 pid_gate_margin = (-u) - self.config.upgrade_threshold;
568 evidence_threshold = self.config.eprocess.beta;
569 evidence_margin = evidence_threshold - self.eprocess.e_value;
570
571 if self.current_level.is_full() {
572 reason = BudgetDecisionReason::AtFullQuality;
573 } else if self.eprocess.should_upgrade(&self.config.eprocess) {
574 decision = BudgetDecision::Upgrade;
575 reason = BudgetDecisionReason::UnderloadEvidencePassed;
576 } else {
577 reason = BudgetDecisionReason::UnderloadEvidenceInsufficient;
578 }
579 }
580
581 self.last_decision = decision;
583 self.last_decision_reason = reason;
584 self.last_pid_gate_threshold = pid_gate_threshold;
585 self.last_pid_gate_margin = pid_gate_margin;
586 self.last_evidence_threshold = evidence_threshold;
587 self.last_evidence_margin = evidence_margin;
588
589 match decision {
591 BudgetDecision::Degrade => {
592 self.transition_seq = self.transition_seq.saturating_add(1);
593 self.last_transition_correlation_id =
594 (self.transition_seq << 32) ^ u64::from(self.eprocess.frames_observed);
595 let next = self.current_level.next();
596 self.current_level = if next > self.config.degradation_floor {
598 self.config.degradation_floor
599 } else {
600 next
601 };
602 self.frames_since_change = 0;
603
604 #[cfg(feature = "tracing")]
605 warn!(
606 level = self.current_level.as_str(),
607 pid_output = u,
608 e_value = self.eprocess.e_value,
609 "budget controller: degrade"
610 );
611 }
612 BudgetDecision::Upgrade => {
613 self.transition_seq = self.transition_seq.saturating_add(1);
614 self.last_transition_correlation_id =
615 (self.transition_seq << 32) ^ u64::from(self.eprocess.frames_observed);
616 self.current_level = self.current_level.prev();
617 self.frames_since_change = 0;
618
619 #[cfg(feature = "tracing")]
620 trace!(
621 level = self.current_level.as_str(),
622 pid_output = u,
623 e_value = self.eprocess.e_value,
624 "budget controller: upgrade"
625 );
626 }
627 BudgetDecision::Hold => {}
628 }
629
630 decision
631 }
632
633 #[inline]
635 pub fn level(&self) -> DegradationLevel {
636 self.current_level
637 }
638
639 #[inline]
641 pub fn e_value(&self) -> f64 {
642 self.eprocess.e_value
643 }
644
645 #[inline]
647 pub fn eprocess_sigma_ms(&self) -> f64 {
648 self.eprocess
649 .sigma_ema
650 .max(self.config.eprocess.sigma_floor_ms)
651 }
652
653 #[inline]
655 pub fn pid_integral(&self) -> f64 {
656 self.pid.integral
657 }
658
659 #[inline]
661 pub fn frames_observed(&self) -> u32 {
662 self.eprocess.frames_observed
663 }
664
665 #[inline]
670 pub fn telemetry(&self) -> BudgetTelemetry {
671 BudgetTelemetry {
672 schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
673 level: self.current_level,
674 pid_output: self.last_pid_output,
675 pid_p: self.pid.last_p,
676 pid_i: self.pid.last_i,
677 pid_d: self.pid.last_d,
678 e_value: self.eprocess.e_value,
679 frames_observed: self.eprocess.frames_observed,
680 frames_since_change: self.frames_since_change,
681 last_decision: self.last_decision,
682 decision_reason: self.last_decision_reason,
683 transition_seq: self.transition_seq,
684 transition_correlation_id: self.last_transition_correlation_id,
685 frame_time_ms: self.last_frame_ms,
686 target_ms: self.config.target.as_secs_f64() * 1000.0,
687 pid_gate_threshold: self.last_pid_gate_threshold,
688 pid_gate_margin: self.last_pid_gate_margin,
689 evidence_threshold: self.last_evidence_threshold,
690 evidence_margin: self.last_evidence_margin,
691 in_warmup: self.eprocess.frames_observed < self.config.eprocess.warmup_frames,
692 }
693 }
694
695 pub fn reset(&mut self) {
697 self.pid.reset();
698 self.eprocess.reset();
699 self.current_level = DegradationLevel::Full;
700 self.frames_since_change = 0;
701 self.last_pid_output = 0.0;
702 self.last_decision = BudgetDecision::Hold;
703 self.last_decision_reason = BudgetDecisionReason::WithinThresholdBand;
704 self.last_frame_ms = 0.0;
705 self.transition_seq = 0;
706 self.last_transition_correlation_id = 0;
707 self.last_pid_gate_threshold = 0.0;
708 self.last_pid_gate_margin = 0.0;
709 self.last_evidence_threshold = 0.0;
710 self.last_evidence_margin = 0.0;
711 }
712
713 #[inline]
715 #[must_use]
716 pub fn config(&self) -> &BudgetControllerConfig {
717 &self.config
718 }
719}
720
721#[derive(Debug, Clone, Copy, PartialEq)]
726pub struct BudgetTelemetry {
727 pub schema_version: u16,
729 pub level: DegradationLevel,
731 pub pid_output: f64,
733 pub pid_p: f64,
735 pub pid_i: f64,
737 pub pid_d: f64,
739 pub e_value: f64,
741 pub frames_observed: u32,
743 pub frames_since_change: u32,
745 pub last_decision: BudgetDecision,
747 pub decision_reason: BudgetDecisionReason,
749 pub transition_seq: u64,
751 pub transition_correlation_id: u64,
753 pub frame_time_ms: f64,
755 pub target_ms: f64,
757 pub pid_gate_threshold: f64,
759 pub pid_gate_margin: f64,
761 pub evidence_threshold: f64,
763 pub evidence_margin: f64,
765 pub in_warmup: bool,
767}
768
769#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
774#[repr(u8)]
775pub enum DegradationLevel {
776 #[default]
778 Full = 0,
779 SimpleBorders = 1,
781 NoStyling = 2,
783 EssentialOnly = 3,
785 Skeleton = 4,
787 SkipFrame = 5,
789}
790
791impl DegradationLevel {
792 #[inline]
796 #[must_use]
797 pub fn next(self) -> Self {
798 match self {
799 Self::Full => Self::SimpleBorders,
800 Self::SimpleBorders => Self::NoStyling,
801 Self::NoStyling => Self::EssentialOnly,
802 Self::EssentialOnly => Self::Skeleton,
803 Self::Skeleton | Self::SkipFrame => Self::SkipFrame,
804 }
805 }
806
807 #[inline]
811 #[must_use]
812 pub fn prev(self) -> Self {
813 match self {
814 Self::SkipFrame => Self::Skeleton,
815 Self::Skeleton => Self::EssentialOnly,
816 Self::EssentialOnly => Self::NoStyling,
817 Self::NoStyling => Self::SimpleBorders,
818 Self::SimpleBorders | Self::Full => Self::Full,
819 }
820 }
821
822 #[inline]
824 pub fn is_max(self) -> bool {
825 self == Self::SkipFrame
826 }
827
828 #[inline]
830 pub fn is_full(self) -> bool {
831 self == Self::Full
832 }
833
834 #[inline]
836 pub fn as_str(self) -> &'static str {
837 match self {
838 Self::Full => "Full",
839 Self::SimpleBorders => "SimpleBorders",
840 Self::NoStyling => "NoStyling",
841 Self::EssentialOnly => "EssentialOnly",
842 Self::Skeleton => "Skeleton",
843 Self::SkipFrame => "SkipFrame",
844 }
845 }
846
847 #[inline]
849 pub fn level(self) -> u8 {
850 self as u8
851 }
852
853 #[inline]
859 pub fn use_unicode_borders(self) -> bool {
860 self < Self::SimpleBorders
861 }
862
863 #[inline]
867 pub fn apply_styling(self) -> bool {
868 self < Self::NoStyling
869 }
870
871 #[inline]
876 pub fn render_decorative(self) -> bool {
877 self < Self::EssentialOnly
878 }
879
880 #[inline]
884 pub fn render_content(self) -> bool {
885 self < Self::Skeleton
886 }
887}
888
889#[derive(Debug, Clone, Copy, PartialEq, Eq)]
891pub struct PhaseBudgets {
892 pub diff: Duration,
894 pub present: Duration,
896 pub render: Duration,
898}
899
900impl Default for PhaseBudgets {
901 fn default() -> Self {
902 Self {
903 diff: Duration::from_millis(2),
904 present: Duration::from_millis(4),
905 render: Duration::from_millis(8),
906 }
907 }
908}
909
910#[derive(Debug, Clone, PartialEq)]
912pub struct FrameBudgetConfig {
913 pub total: Duration,
915 pub phase_budgets: PhaseBudgets,
917 pub allow_frame_skip: bool,
919 pub degradation_cooldown: u32,
921 pub upgrade_threshold: f32,
924}
925
926impl Default for FrameBudgetConfig {
927 fn default() -> Self {
928 Self {
929 total: Duration::from_millis(16), phase_budgets: PhaseBudgets::default(),
931 allow_frame_skip: true,
932 degradation_cooldown: 3,
933 upgrade_threshold: 0.5,
934 }
935 }
936}
937
938impl FrameBudgetConfig {
939 pub fn with_total(total: Duration) -> Self {
941 Self {
942 total,
943 ..Default::default()
944 }
945 }
946
947 pub fn strict(total: Duration) -> Self {
949 Self {
950 total,
951 allow_frame_skip: false,
952 ..Default::default()
953 }
954 }
955
956 pub fn relaxed() -> Self {
958 Self {
959 total: Duration::from_millis(33), degradation_cooldown: 5,
961 ..Default::default()
962 }
963 }
964}
965
966#[derive(Debug, Clone)]
971pub struct RenderBudget {
972 total: Duration,
974 start: Instant,
976 last_frame_time: Option<Duration>,
978 degradation: DegradationLevel,
980 phase_budgets: PhaseBudgets,
982 allow_frame_skip: bool,
984 upgrade_threshold: f32,
986 frames_since_change: u32,
988 cooldown: u32,
990 controller: Option<BudgetController>,
993}
994
995impl RenderBudget {
996 pub fn new(total: Duration) -> Self {
998 Self {
999 total,
1000 start: Instant::now(),
1001 last_frame_time: None,
1002 degradation: DegradationLevel::Full,
1003 phase_budgets: PhaseBudgets::default(),
1004 allow_frame_skip: true,
1005 upgrade_threshold: 0.5,
1006 frames_since_change: 0,
1007 cooldown: 3,
1008 controller: None,
1009 }
1010 }
1011
1012 pub fn from_config(config: &FrameBudgetConfig) -> Self {
1014 Self {
1015 total: config.total,
1016 start: Instant::now(),
1017 last_frame_time: None,
1018 degradation: DegradationLevel::Full,
1019 phase_budgets: config.phase_budgets,
1020 allow_frame_skip: config.allow_frame_skip,
1021 upgrade_threshold: config.upgrade_threshold,
1022 frames_since_change: 0,
1023 cooldown: config.degradation_cooldown,
1024 controller: None,
1025 }
1026 }
1027
1028 #[must_use]
1044 pub fn with_controller(mut self, config: BudgetControllerConfig) -> Self {
1045 self.controller = Some(BudgetController::new(config));
1046 self
1047 }
1048
1049 #[inline]
1051 pub fn total(&self) -> Duration {
1052 self.total
1053 }
1054
1055 #[inline]
1057 pub fn elapsed(&self) -> Duration {
1058 self.start.elapsed()
1059 }
1060
1061 #[inline]
1063 pub fn remaining(&self) -> Duration {
1064 self.total.saturating_sub(self.start.elapsed())
1065 }
1066
1067 #[inline]
1069 pub fn remaining_fraction(&self) -> f32 {
1070 if self.total.is_zero() {
1071 return 0.0;
1072 }
1073 let remaining = self.remaining().as_secs_f32();
1074 let total = self.total.as_secs_f32();
1075 (remaining / total).clamp(0.0, 1.0)
1076 }
1077
1078 #[inline]
1082 pub fn should_degrade(&self, estimated_cost: Duration) -> bool {
1083 self.remaining() < estimated_cost
1084 }
1085
1086 pub fn degrade(&mut self) {
1090 let from = self.degradation;
1091 self.degradation = self.degradation.next();
1092 self.frames_since_change = 0;
1093
1094 #[cfg(feature = "tracing")]
1095 if from != self.degradation {
1096 warn!(
1097 from = from.as_str(),
1098 to = self.degradation.as_str(),
1099 remaining_ms = self.remaining().as_millis() as u32,
1100 "render budget degradation"
1101 );
1102 }
1103 let _ = from; }
1105
1106 #[inline]
1108 pub fn degradation(&self) -> DegradationLevel {
1109 self.degradation
1110 }
1111
1112 pub fn set_degradation(&mut self, level: DegradationLevel) {
1116 if self.degradation != level {
1117 self.degradation = level;
1118 self.frames_since_change = 0;
1119 }
1120 }
1121
1122 #[inline]
1126 pub fn exhausted(&self) -> bool {
1127 self.remaining().is_zero()
1128 || (self.degradation == DegradationLevel::SkipFrame && self.allow_frame_skip)
1129 }
1130
1131 pub fn should_upgrade(&self) -> bool {
1136 !self.degradation.is_full()
1137 && self.remaining_fraction() > self.upgrade_threshold
1138 && self.frames_since_change >= self.cooldown
1139 }
1140
1141 fn should_upgrade_with_elapsed(&self, elapsed: Duration) -> bool {
1143 if self.degradation.is_full() || self.frames_since_change < self.cooldown {
1144 return false;
1145 }
1146 self.remaining_fraction_for_elapsed(elapsed) > self.upgrade_threshold
1147 }
1148
1149 fn remaining_fraction_for_elapsed(&self, elapsed: Duration) -> f32 {
1151 if self.total.is_zero() {
1152 return 0.0;
1153 }
1154 let remaining = self.total.saturating_sub(elapsed);
1155 let remaining = remaining.as_secs_f32();
1156 let total = self.total.as_secs_f32();
1157 (remaining / total).clamp(0.0, 1.0)
1158 }
1159
1160 pub fn upgrade(&mut self) {
1164 let from = self.degradation;
1165 self.degradation = self.degradation.prev();
1166 self.frames_since_change = 0;
1167
1168 #[cfg(feature = "tracing")]
1169 if from != self.degradation {
1170 trace!(
1171 from = from.as_str(),
1172 to = self.degradation.as_str(),
1173 remaining_fraction = self.remaining_fraction(),
1174 "render budget upgrade"
1175 );
1176 }
1177 let _ = from; }
1179
1180 pub fn reset(&mut self) {
1184 self.start = Instant::now();
1185 self.frames_since_change = self.frames_since_change.saturating_add(1);
1186 }
1187
1188 pub fn next_frame(&mut self) {
1197 let frame_time = self.last_frame_time.unwrap_or_else(|| self.start.elapsed());
1198
1199 if self.controller.is_some() {
1200 let decision = self
1205 .controller
1206 .as_mut()
1207 .expect("controller guaranteed by is_some guard")
1208 .update(frame_time);
1209
1210 match decision {
1211 BudgetDecision::Degrade => self.degrade(),
1212 BudgetDecision::Upgrade => self.upgrade(),
1213 BudgetDecision::Hold => {}
1214 }
1215 } else {
1216 if self.should_upgrade_with_elapsed(frame_time) {
1218 self.upgrade();
1219 }
1220 }
1221 self.reset();
1222 }
1223
1224 pub fn record_frame_time(&mut self, elapsed: Duration) {
1226 self.last_frame_time = Some(elapsed);
1227 }
1228
1229 #[inline]
1234 pub fn telemetry(&self) -> Option<BudgetTelemetry> {
1235 self.controller.as_ref().map(BudgetController::telemetry)
1236 }
1237
1238 #[inline]
1240 pub fn controller(&self) -> Option<&BudgetController> {
1241 self.controller.as_ref()
1242 }
1243
1244 #[inline]
1246 #[must_use]
1247 pub fn phase_budgets(&self) -> &PhaseBudgets {
1248 &self.phase_budgets
1249 }
1250
1251 pub fn phase_has_budget(&self, phase: Phase) -> bool {
1253 let phase_budget = match phase {
1254 Phase::Diff => self.phase_budgets.diff,
1255 Phase::Present => self.phase_budgets.present,
1256 Phase::Render => self.phase_budgets.render,
1257 };
1258 self.remaining() >= phase_budget
1259 }
1260
1261 #[must_use]
1265 pub fn phase_budget(&self, phase: Phase) -> Self {
1266 let phase_total = match phase {
1267 Phase::Diff => self.phase_budgets.diff,
1268 Phase::Present => self.phase_budgets.present,
1269 Phase::Render => self.phase_budgets.render,
1270 };
1271 Self {
1272 total: phase_total.min(self.remaining()),
1273 start: self.start,
1274 last_frame_time: self.last_frame_time,
1275 degradation: self.degradation,
1276 phase_budgets: self.phase_budgets,
1277 allow_frame_skip: self.allow_frame_skip,
1278 upgrade_threshold: self.upgrade_threshold,
1279 frames_since_change: self.frames_since_change,
1280 cooldown: self.cooldown,
1281 controller: None, }
1283 }
1284}
1285
1286#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1288pub enum Phase {
1289 Diff,
1291 Present,
1293 Render,
1295}
1296
1297impl Phase {
1298 pub fn as_str(self) -> &'static str {
1300 match self {
1301 Self::Diff => "diff",
1302 Self::Present => "present",
1303 Self::Render => "render",
1304 }
1305 }
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310 use super::*;
1311 use std::thread;
1312
1313 #[test]
1314 fn degradation_level_ordering() {
1315 assert!(DegradationLevel::Full < DegradationLevel::SimpleBorders);
1316 assert!(DegradationLevel::SimpleBorders < DegradationLevel::NoStyling);
1317 assert!(DegradationLevel::NoStyling < DegradationLevel::EssentialOnly);
1318 assert!(DegradationLevel::EssentialOnly < DegradationLevel::Skeleton);
1319 assert!(DegradationLevel::Skeleton < DegradationLevel::SkipFrame);
1320 }
1321
1322 #[test]
1323 fn degradation_level_next() {
1324 assert_eq!(
1325 DegradationLevel::Full.next(),
1326 DegradationLevel::SimpleBorders
1327 );
1328 assert_eq!(
1329 DegradationLevel::SimpleBorders.next(),
1330 DegradationLevel::NoStyling
1331 );
1332 assert_eq!(
1333 DegradationLevel::NoStyling.next(),
1334 DegradationLevel::EssentialOnly
1335 );
1336 assert_eq!(
1337 DegradationLevel::EssentialOnly.next(),
1338 DegradationLevel::Skeleton
1339 );
1340 assert_eq!(
1341 DegradationLevel::Skeleton.next(),
1342 DegradationLevel::SkipFrame
1343 );
1344 assert_eq!(
1345 DegradationLevel::SkipFrame.next(),
1346 DegradationLevel::SkipFrame
1347 );
1348 }
1349
1350 #[test]
1351 fn degradation_level_prev() {
1352 assert_eq!(
1353 DegradationLevel::SkipFrame.prev(),
1354 DegradationLevel::Skeleton
1355 );
1356 assert_eq!(
1357 DegradationLevel::Skeleton.prev(),
1358 DegradationLevel::EssentialOnly
1359 );
1360 assert_eq!(
1361 DegradationLevel::EssentialOnly.prev(),
1362 DegradationLevel::NoStyling
1363 );
1364 assert_eq!(
1365 DegradationLevel::NoStyling.prev(),
1366 DegradationLevel::SimpleBorders
1367 );
1368 assert_eq!(
1369 DegradationLevel::SimpleBorders.prev(),
1370 DegradationLevel::Full
1371 );
1372 assert_eq!(DegradationLevel::Full.prev(), DegradationLevel::Full);
1373 }
1374
1375 #[test]
1376 fn degradation_level_is_max() {
1377 assert!(!DegradationLevel::Full.is_max());
1378 assert!(!DegradationLevel::Skeleton.is_max());
1379 assert!(DegradationLevel::SkipFrame.is_max());
1380 }
1381
1382 #[test]
1383 fn degradation_level_is_full() {
1384 assert!(DegradationLevel::Full.is_full());
1385 assert!(!DegradationLevel::SimpleBorders.is_full());
1386 assert!(!DegradationLevel::SkipFrame.is_full());
1387 }
1388
1389 #[test]
1390 fn degradation_level_as_str() {
1391 assert_eq!(DegradationLevel::Full.as_str(), "Full");
1392 assert_eq!(DegradationLevel::SimpleBorders.as_str(), "SimpleBorders");
1393 assert_eq!(DegradationLevel::NoStyling.as_str(), "NoStyling");
1394 assert_eq!(DegradationLevel::EssentialOnly.as_str(), "EssentialOnly");
1395 assert_eq!(DegradationLevel::Skeleton.as_str(), "Skeleton");
1396 assert_eq!(DegradationLevel::SkipFrame.as_str(), "SkipFrame");
1397 }
1398
1399 #[test]
1400 fn degradation_level_values() {
1401 assert_eq!(DegradationLevel::Full.level(), 0);
1402 assert_eq!(DegradationLevel::SimpleBorders.level(), 1);
1403 assert_eq!(DegradationLevel::NoStyling.level(), 2);
1404 assert_eq!(DegradationLevel::EssentialOnly.level(), 3);
1405 assert_eq!(DegradationLevel::Skeleton.level(), 4);
1406 assert_eq!(DegradationLevel::SkipFrame.level(), 5);
1407 }
1408
1409 #[test]
1410 fn budget_remaining_decreases() {
1411 let budget = RenderBudget::new(Duration::from_millis(100));
1412 let initial = budget.remaining();
1413
1414 thread::sleep(Duration::from_millis(10));
1415
1416 let later = budget.remaining();
1417 assert!(later < initial);
1418 }
1419
1420 #[test]
1421 fn budget_remaining_fraction() {
1422 let mut budget = RenderBudget::new(Duration::from_millis(100));
1423
1424 let initial = budget.remaining_fraction();
1426 assert!(initial > 0.9);
1427
1428 budget.start = Instant::now() - Duration::from_millis(50);
1433
1434 let later = budget.remaining_fraction();
1436 assert!(
1437 later < 0.6,
1438 "expected remaining_fraction < 0.6, got {later}"
1439 );
1440 assert!(
1441 later > 0.3,
1442 "expected remaining_fraction > 0.3, got {later}"
1443 );
1444 }
1445
1446 #[test]
1447 fn should_degrade_when_cost_exceeds_remaining() {
1448 let mut budget = RenderBudget::new(Duration::from_millis(100));
1451 budget.start = Instant::now() - Duration::from_millis(50);
1452
1453 assert!(budget.should_degrade(Duration::from_millis(80)));
1455 assert!(!budget.should_degrade(Duration::from_millis(10)));
1457 }
1458
1459 #[test]
1460 fn degrade_advances_level() {
1461 let mut budget = RenderBudget::new(Duration::from_millis(16));
1462
1463 assert_eq!(budget.degradation(), DegradationLevel::Full);
1464
1465 budget.degrade();
1466 assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
1467
1468 budget.degrade();
1469 assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1470 }
1471
1472 #[test]
1473 fn exhausted_when_no_time_left() {
1474 let budget = RenderBudget::new(Duration::from_millis(5));
1475
1476 assert!(!budget.exhausted());
1477
1478 thread::sleep(Duration::from_millis(10));
1479
1480 assert!(budget.exhausted());
1481 }
1482
1483 #[test]
1484 fn exhausted_at_skip_frame() {
1485 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1486
1487 budget.set_degradation(DegradationLevel::SkipFrame);
1489
1490 assert!(budget.exhausted());
1492 }
1493
1494 #[test]
1495 fn should_upgrade_with_remaining_budget() {
1496 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1497
1498 assert!(!budget.should_upgrade());
1500
1501 budget.degrade();
1503 budget.frames_since_change = 5;
1504
1505 assert!(budget.should_upgrade());
1507 }
1508
1509 #[test]
1510 fn upgrade_improves_level() {
1511 let mut budget = RenderBudget::new(Duration::from_millis(16));
1512
1513 budget.set_degradation(DegradationLevel::Skeleton);
1514 assert_eq!(budget.degradation(), DegradationLevel::Skeleton);
1515
1516 budget.upgrade();
1517 assert_eq!(budget.degradation(), DegradationLevel::EssentialOnly);
1518
1519 budget.upgrade();
1520 assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1521 }
1522
1523 #[test]
1524 fn upgrade_downgrade_symmetric() {
1525 let mut budget = RenderBudget::new(Duration::from_millis(16));
1526
1527 while !budget.degradation().is_max() {
1529 budget.degrade();
1530 }
1531 assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
1532
1533 while !budget.degradation().is_full() {
1535 budget.upgrade();
1536 }
1537 assert_eq!(budget.degradation(), DegradationLevel::Full);
1538 }
1539
1540 #[test]
1541 fn reset_preserves_degradation() {
1542 let mut budget = RenderBudget::new(Duration::from_millis(16));
1543
1544 budget.degrade();
1545 budget.degrade();
1546 let level = budget.degradation();
1547
1548 budget.reset();
1549
1550 assert_eq!(budget.degradation(), level);
1551 assert!(budget.remaining_fraction() > 0.9);
1553 }
1554
1555 #[test]
1556 fn next_frame_upgrades_when_possible() {
1557 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1558
1559 budget.degrade();
1561 for _ in 0..5 {
1562 budget.reset();
1563 }
1564
1565 let before = budget.degradation();
1566 budget.next_frame();
1567
1568 assert!(budget.degradation() < before);
1570 }
1571
1572 #[test]
1573 fn next_frame_prefers_recorded_frame_time_for_upgrade() {
1574 let mut budget = RenderBudget::new(Duration::from_millis(16));
1575
1576 budget.degrade();
1577 for _ in 0..5 {
1578 budget.reset();
1579 }
1580
1581 budget.record_frame_time(Duration::from_millis(1));
1584 std::thread::sleep(Duration::from_millis(25));
1585
1586 let before = budget.degradation();
1587 budget.next_frame();
1588
1589 assert!(budget.degradation() < before);
1590 }
1591
1592 #[test]
1593 fn config_defaults() {
1594 let config = FrameBudgetConfig::default();
1595
1596 assert_eq!(config.total, Duration::from_millis(16));
1597 assert!(config.allow_frame_skip);
1598 assert_eq!(config.degradation_cooldown, 3);
1599 assert!((config.upgrade_threshold - 0.5).abs() < f32::EPSILON);
1600 }
1601
1602 #[test]
1603 fn config_with_total() {
1604 let config = FrameBudgetConfig::with_total(Duration::from_millis(33));
1605
1606 assert_eq!(config.total, Duration::from_millis(33));
1607 assert!(config.allow_frame_skip);
1609 }
1610
1611 #[test]
1612 fn config_strict() {
1613 let config = FrameBudgetConfig::strict(Duration::from_millis(16));
1614
1615 assert!(!config.allow_frame_skip);
1616 }
1617
1618 #[test]
1619 fn config_relaxed() {
1620 let config = FrameBudgetConfig::relaxed();
1621
1622 assert_eq!(config.total, Duration::from_millis(33));
1623 assert_eq!(config.degradation_cooldown, 5);
1624 }
1625
1626 #[test]
1627 fn from_config() {
1628 let config = FrameBudgetConfig {
1629 total: Duration::from_millis(20),
1630 allow_frame_skip: false,
1631 ..Default::default()
1632 };
1633
1634 let budget = RenderBudget::from_config(&config);
1635
1636 assert_eq!(budget.total(), Duration::from_millis(20));
1637 assert!(!budget.exhausted()); let mut budget = RenderBudget::from_config(&config);
1641 budget.set_degradation(DegradationLevel::SkipFrame);
1642 assert!(!budget.exhausted());
1643 }
1644
1645 #[test]
1646 fn phase_budgets_default() {
1647 let budgets = PhaseBudgets::default();
1648
1649 assert_eq!(budgets.diff, Duration::from_millis(2));
1650 assert_eq!(budgets.present, Duration::from_millis(4));
1651 assert_eq!(budgets.render, Duration::from_millis(8));
1652 }
1653
1654 #[test]
1655 fn phase_has_budget() {
1656 let budget = RenderBudget::new(Duration::from_millis(100));
1657
1658 assert!(budget.phase_has_budget(Phase::Diff));
1659 assert!(budget.phase_has_budget(Phase::Present));
1660 assert!(budget.phase_has_budget(Phase::Render));
1661 }
1662
1663 #[test]
1664 fn phase_budget_respects_remaining() {
1665 let budget = RenderBudget::new(Duration::from_millis(100));
1666
1667 let diff_budget = budget.phase_budget(Phase::Diff);
1668 assert_eq!(diff_budget.total(), Duration::from_millis(2));
1669
1670 let present_budget = budget.phase_budget(Phase::Present);
1671 assert_eq!(present_budget.total(), Duration::from_millis(4));
1672 }
1673
1674 #[test]
1675 fn phase_as_str() {
1676 assert_eq!(Phase::Diff.as_str(), "diff");
1677 assert_eq!(Phase::Present.as_str(), "present");
1678 assert_eq!(Phase::Render.as_str(), "render");
1679 }
1680
1681 #[test]
1682 fn zero_budget_is_immediately_exhausted() {
1683 let budget = RenderBudget::new(Duration::ZERO);
1684 assert!(budget.exhausted());
1685 assert_eq!(budget.remaining_fraction(), 0.0);
1686 }
1687
1688 #[test]
1689 fn degradation_level_never_exceeds_skip_frame() {
1690 let mut level = DegradationLevel::Full;
1691
1692 for _ in 0..100 {
1693 level = level.next();
1694 }
1695
1696 assert_eq!(level, DegradationLevel::SkipFrame);
1697 }
1698
1699 #[test]
1700 fn budget_remaining_never_negative() {
1701 let budget = RenderBudget::new(Duration::from_millis(1));
1702
1703 thread::sleep(Duration::from_millis(10));
1705
1706 assert_eq!(budget.remaining(), Duration::ZERO);
1708 assert_eq!(budget.remaining_fraction(), 0.0);
1709 }
1710
1711 #[test]
1712 fn infinite_budget_stays_at_full() {
1713 let mut budget = RenderBudget::new(Duration::from_secs(1000));
1714
1715 assert!(!budget.should_degrade(Duration::from_millis(100)));
1717 assert_eq!(budget.degradation(), DegradationLevel::Full);
1718
1719 budget.next_frame();
1721 assert_eq!(budget.degradation(), DegradationLevel::Full);
1722 }
1723
1724 #[test]
1725 fn cooldown_prevents_immediate_upgrade() {
1726 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1727 budget.cooldown = 3;
1728
1729 budget.degrade();
1731 assert_eq!(budget.frames_since_change, 0);
1732
1733 assert!(!budget.should_upgrade());
1735
1736 budget.frames_since_change = 3;
1738
1739 assert!(budget.should_upgrade());
1741 }
1742
1743 #[test]
1744 fn set_degradation_resets_cooldown() {
1745 let mut budget = RenderBudget::new(Duration::from_millis(16));
1746 budget.frames_since_change = 10;
1747
1748 budget.set_degradation(DegradationLevel::NoStyling);
1749
1750 assert_eq!(budget.frames_since_change, 0);
1751 }
1752
1753 #[test]
1754 fn set_degradation_same_level_preserves_cooldown() {
1755 let mut budget = RenderBudget::new(Duration::from_millis(16));
1756 budget.frames_since_change = 10;
1757
1758 budget.set_degradation(DegradationLevel::Full);
1760
1761 assert_eq!(budget.frames_since_change, 10);
1763 }
1764
1765 mod controller_tests {
1770 use super::super::*;
1771
1772 fn make_controller() -> BudgetController {
1773 BudgetController::new(BudgetControllerConfig::default())
1774 }
1775
1776 fn make_controller_with_config(
1777 target_ms: u64,
1778 warmup: u32,
1779 cooldown: u32,
1780 ) -> BudgetController {
1781 BudgetController::new(BudgetControllerConfig {
1782 target: Duration::from_millis(target_ms),
1783 eprocess: EProcessConfig {
1784 warmup_frames: warmup,
1785 ..Default::default()
1786 },
1787 cooldown_frames: cooldown,
1788 ..Default::default()
1789 })
1790 }
1791
1792 #[test]
1795 fn pid_step_input_yields_nonzero_output() {
1796 let mut state = PidState::default();
1797 let gains = PidGains::default();
1798
1799 let u = state.update(1.0, &gains);
1801 assert!(
1803 (u - 0.75).abs() < 1e-10,
1804 "First PID output should be 0.75, got {}",
1805 u
1806 );
1807 }
1808
1809 #[test]
1810 fn pid_zero_error_zero_output() {
1811 let mut state = PidState::default();
1812 let gains = PidGains::default();
1813
1814 let u = state.update(0.0, &gains);
1815 assert!(
1816 u.abs() < 1e-10,
1817 "Zero error should produce zero output, got {}",
1818 u
1819 );
1820 }
1821
1822 #[test]
1823 fn pid_integral_accumulates() {
1824 let mut state = PidState::default();
1825 let gains = PidGains::default();
1826
1827 state.update(1.0, &gains);
1829 state.update(1.0, &gains);
1830 state.update(1.0, &gains);
1831
1832 assert!(
1833 state.integral > 2.5,
1834 "Integral should accumulate: {}",
1835 state.integral
1836 );
1837 }
1838
1839 #[test]
1840 fn pid_integral_anti_windup() {
1841 let mut state = PidState::default();
1842 let gains = PidGains {
1843 integral_max: 2.0,
1844 ..Default::default()
1845 };
1846
1847 for _ in 0..100 {
1849 state.update(10.0, &gains);
1850 }
1851
1852 assert!(
1853 state.integral <= 2.0 + f64::EPSILON,
1854 "Integral should be clamped to max: {}",
1855 state.integral
1856 );
1857 assert!(
1858 state.integral >= -2.0 - f64::EPSILON,
1859 "Integral should be clamped to -max: {}",
1860 state.integral
1861 );
1862 }
1863
1864 #[test]
1865 fn pid_derivative_responds_to_change() {
1866 let mut state = PidState::default();
1867 let gains = PidGains::default();
1868
1869 let u1 = state.update(0.0, &gains);
1871 let u2 = state.update(1.0, &gains);
1873
1874 assert!(
1876 u2 > u1,
1877 "Step change should produce larger output: u1={}, u2={}",
1878 u1,
1879 u2
1880 );
1881 }
1882
1883 #[test]
1884 fn pid_settling_after_step() {
1885 let mut state = PidState::default();
1886 let gains = PidGains::default();
1887
1888 state.update(1.0, &gains);
1890 state.update(1.0, &gains);
1891 state.update(1.0, &gains);
1892
1893 let mut outputs = Vec::new();
1895 for _ in 0..20 {
1896 outputs.push(state.update(0.0, &gains));
1897 }
1898
1899 let last = *outputs.last().unwrap();
1901 assert!(
1902 last.abs() < 0.5,
1903 "PID should settle toward zero: last={}",
1904 last
1905 );
1906 }
1907
1908 #[test]
1909 fn pid_reset_clears_state() {
1910 let mut state = PidState::default();
1911 let gains = PidGains::default();
1912
1913 state.update(5.0, &gains);
1914 state.update(5.0, &gains);
1915 assert!(state.integral.abs() > 0.0);
1916
1917 state.reset();
1918 assert_eq!(state.integral, 0.0);
1919 assert_eq!(state.prev_error, 0.0);
1920 }
1921
1922 #[test]
1925 fn eprocess_starts_at_one() {
1926 let state = EProcessState::default();
1927 assert!(
1928 (state.e_value - 1.0).abs() < f64::EPSILON,
1929 "E-process should start at 1.0"
1930 );
1931 }
1932
1933 #[test]
1934 fn eprocess_grows_under_overload() {
1935 let mut state = EProcessState::default();
1936 let config = EProcessConfig {
1937 warmup_frames: 0,
1938 ..Default::default()
1939 };
1940
1941 for _ in 0..20 {
1943 state.update(30.0, 16.0, &config);
1944 }
1945
1946 assert!(
1947 state.e_value > 1.0,
1948 "E-value should grow under overload: {}",
1949 state.e_value
1950 );
1951 }
1952
1953 #[test]
1954 fn eprocess_shrinks_under_underload() {
1955 let mut state = EProcessState::default();
1956 let config = EProcessConfig {
1957 warmup_frames: 0,
1958 ..Default::default()
1959 };
1960
1961 for _ in 0..20 {
1963 state.update(8.0, 16.0, &config);
1964 }
1965
1966 assert!(
1967 state.e_value < 1.0,
1968 "E-value should shrink under underload: {}",
1969 state.e_value
1970 );
1971 }
1972
1973 #[test]
1974 fn eprocess_gate_blocks_during_warmup() {
1975 let mut state = EProcessState::default();
1976 let config = EProcessConfig {
1977 warmup_frames: 10,
1978 ..Default::default()
1979 };
1980
1981 for _ in 0..5 {
1983 state.update(50.0, 16.0, &config);
1984 }
1985
1986 assert!(
1987 !state.should_degrade(&config),
1988 "E-process should not permit degradation during warmup"
1989 );
1990 }
1991
1992 #[test]
1993 fn eprocess_gate_allows_after_warmup() {
1994 let mut state = EProcessState::default();
1995 let config = EProcessConfig {
1996 warmup_frames: 5,
1997 alpha: 0.05,
1998 ..Default::default()
1999 };
2000
2001 for _ in 0..50 {
2003 state.update(80.0, 16.0, &config);
2004 }
2005
2006 assert!(
2007 state.should_degrade(&config),
2008 "E-process should permit degradation after sustained overload: E={}",
2009 state.e_value
2010 );
2011 }
2012
2013 #[test]
2014 fn eprocess_recovery_after_overload() {
2015 let mut state = EProcessState::default();
2016 let config = EProcessConfig {
2017 warmup_frames: 0,
2018 ..Default::default()
2019 };
2020
2021 for _ in 0..30 {
2023 state.update(40.0, 16.0, &config);
2024 }
2025 let peak = state.e_value;
2026
2027 for _ in 0..100 {
2029 state.update(8.0, 16.0, &config);
2030 }
2031
2032 assert!(
2033 state.e_value < peak,
2034 "E-value should decrease after recovery: peak={}, now={}",
2035 peak,
2036 state.e_value
2037 );
2038 }
2039
2040 #[test]
2041 fn eprocess_sigma_floor_prevents_instability() {
2042 let mut state = EProcessState::default();
2043 let config = EProcessConfig {
2044 sigma_floor_ms: 1.0,
2045 warmup_frames: 0,
2046 ..Default::default()
2047 };
2048
2049 for _ in 0..20 {
2051 state.update(16.0, 16.0, &config);
2052 }
2053
2054 assert!(
2056 state.sigma_ema >= 0.0,
2057 "Sigma should be non-negative: {}",
2058 state.sigma_ema
2059 );
2060 assert!(
2062 state.e_value.is_finite(),
2063 "E-value should be finite: {}",
2064 state.e_value
2065 );
2066 }
2067
2068 #[test]
2069 fn eprocess_reset_returns_to_initial() {
2070 let mut state = EProcessState::default();
2071 let config = EProcessConfig::default();
2072
2073 state.update(50.0, 16.0, &config);
2074 state.update(50.0, 16.0, &config);
2075
2076 state.reset();
2077 assert!((state.e_value - 1.0).abs() < f64::EPSILON);
2078 assert_eq!(state.frames_observed, 0);
2079 }
2080
2081 #[test]
2084 fn controller_holds_under_normal_load() {
2085 let mut ctrl = make_controller_with_config(16, 0, 0);
2086
2087 for _ in 0..20 {
2089 let decision = ctrl.update(Duration::from_millis(16));
2090 assert_eq!(
2091 decision,
2092 BudgetDecision::Hold,
2093 "On-target frames should hold"
2094 );
2095 }
2096 assert_eq!(ctrl.level(), DegradationLevel::Full);
2097 }
2098
2099 #[test]
2100 fn controller_degrades_under_sustained_overload() {
2101 let mut ctrl = make_controller_with_config(16, 0, 0);
2102
2103 let mut degraded = false;
2104 for _ in 0..50 {
2106 let decision = ctrl.update(Duration::from_millis(40));
2107 if decision == BudgetDecision::Degrade {
2108 degraded = true;
2109 }
2110 }
2111
2112 assert!(
2113 degraded,
2114 "Controller should degrade under sustained overload"
2115 );
2116 assert!(
2117 ctrl.level() > DegradationLevel::Full,
2118 "Level should be degraded: {:?}",
2119 ctrl.level()
2120 );
2121 }
2122
2123 #[test]
2124 fn controller_upgrades_after_recovery() {
2125 let mut ctrl = make_controller_with_config(16, 0, 0);
2126
2127 for _ in 0..50 {
2129 ctrl.update(Duration::from_millis(40));
2130 }
2131 let degraded_level = ctrl.level();
2132 assert!(degraded_level > DegradationLevel::Full);
2133
2134 let mut upgraded = false;
2136 for _ in 0..200 {
2137 let decision = ctrl.update(Duration::from_millis(4));
2138 if decision == BudgetDecision::Upgrade {
2139 upgraded = true;
2140 }
2141 }
2142
2143 assert!(upgraded, "Controller should upgrade after recovery");
2144 assert!(
2145 ctrl.level() < degraded_level,
2146 "Level should improve: before={:?}, after={:?}",
2147 degraded_level,
2148 ctrl.level()
2149 );
2150 }
2151
2152 #[test]
2153 fn controller_cooldown_prevents_oscillation() {
2154 let mut ctrl = make_controller_with_config(16, 0, 5);
2155
2156 for _ in 0..50 {
2158 ctrl.update(Duration::from_millis(40));
2159 }
2160
2161 let mut decisions_during_cooldown = Vec::new();
2163 for _ in 0..4 {
2164 decisions_during_cooldown.push(ctrl.update(Duration::from_millis(4)));
2165 }
2166
2167 assert!(
2169 decisions_during_cooldown
2170 .iter()
2171 .all(|d| *d == BudgetDecision::Hold),
2172 "Cooldown should prevent changes: {:?}",
2173 decisions_during_cooldown
2174 );
2175 }
2176
2177 #[test]
2178 fn controller_no_oscillation_under_constant_load() {
2179 let mut ctrl = make_controller_with_config(16, 0, 3);
2180
2181 let mut transitions = 0u32;
2183 let mut prev_level = ctrl.level();
2184 for _ in 0..100 {
2185 ctrl.update(Duration::from_millis(20));
2186 if ctrl.level() != prev_level {
2187 transitions += 1;
2188 prev_level = ctrl.level();
2189 }
2190 }
2191
2192 assert!(
2195 transitions < 10,
2196 "Too many transitions under constant load: {}",
2197 transitions
2198 );
2199 }
2200
2201 #[test]
2202 fn controller_reset_restores_full_quality() {
2203 let mut ctrl = make_controller();
2204
2205 for _ in 0..50 {
2207 ctrl.update(Duration::from_millis(40));
2208 }
2209
2210 ctrl.reset();
2211
2212 assert_eq!(ctrl.level(), DegradationLevel::Full);
2213 assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
2214 assert_eq!(ctrl.pid_integral(), 0.0);
2215 }
2216
2217 #[test]
2218 fn controller_transient_spike_does_not_degrade() {
2219 let mut ctrl = make_controller_with_config(16, 5, 3);
2220
2221 for _ in 0..20 {
2223 ctrl.update(Duration::from_millis(16));
2224 }
2225
2226 ctrl.update(Duration::from_millis(100));
2228
2229 for _ in 0..5 {
2231 ctrl.update(Duration::from_millis(16));
2232 }
2233
2234 assert_eq!(
2236 ctrl.level(),
2237 DegradationLevel::Full,
2238 "Single spike should not cause degradation"
2239 );
2240 }
2241
2242 #[test]
2243 fn controller_never_exceeds_skip_frame() {
2244 let mut ctrl = make_controller_with_config(16, 0, 0);
2245
2246 for _ in 0..500 {
2248 ctrl.update(Duration::from_millis(200));
2249 }
2250
2251 assert!(
2252 ctrl.level() <= DegradationLevel::SkipFrame,
2253 "Level should not exceed SkipFrame: {:?}",
2254 ctrl.level()
2255 );
2256 }
2257
2258 #[test]
2259 fn controller_never_goes_below_full() {
2260 let mut ctrl = make_controller_with_config(16, 0, 0);
2261
2262 for _ in 0..200 {
2264 ctrl.update(Duration::from_millis(1));
2265 }
2266
2267 assert_eq!(
2268 ctrl.level(),
2269 DegradationLevel::Full,
2270 "Level should not go below Full"
2271 );
2272 }
2273
2274 #[test]
2277 fn pid_gains_default_valid() {
2278 let gains = PidGains::default();
2279 assert!(gains.kp > 0.0);
2280 assert!(gains.ki > 0.0);
2281 assert!(gains.kd > 0.0);
2282 assert!(gains.integral_max > 0.0);
2283 }
2284
2285 #[test]
2286 fn eprocess_config_default_valid() {
2287 let config = EProcessConfig::default();
2288 assert!(config.lambda > 0.0);
2289 assert!(config.alpha > 0.0 && config.alpha < 1.0);
2290 assert!(config.beta > 0.0 && config.beta < 1.0);
2291 assert!(config.sigma_floor_ms > 0.0);
2292 }
2293
2294 #[test]
2295 fn controller_config_default_valid() {
2296 let config = BudgetControllerConfig::default();
2297 assert!(config.degrade_threshold > 0.0);
2298 assert!(config.upgrade_threshold > 0.0);
2299 assert!(config.target > Duration::ZERO);
2300 }
2301
2302 #[test]
2303 fn budget_decision_equality() {
2304 assert_eq!(BudgetDecision::Hold, BudgetDecision::Hold);
2305 assert_ne!(BudgetDecision::Hold, BudgetDecision::Degrade);
2306 assert_ne!(BudgetDecision::Degrade, BudgetDecision::Upgrade);
2307 }
2308 }
2309
2310 mod integration_tests {
2315 use super::super::*;
2316
2317 #[test]
2318 fn render_budget_without_controller_returns_no_telemetry() {
2319 let budget = RenderBudget::new(Duration::from_millis(16));
2320 assert!(budget.telemetry().is_none());
2321 assert!(budget.controller().is_none());
2322 }
2323
2324 #[test]
2325 fn render_budget_with_controller_returns_telemetry() {
2326 let budget = RenderBudget::new(Duration::from_millis(16))
2327 .with_controller(BudgetControllerConfig::default());
2328 assert!(budget.controller().is_some());
2329
2330 let telem = budget.telemetry().unwrap();
2331 assert_eq!(telem.level, DegradationLevel::Full);
2332 assert_eq!(telem.last_decision, BudgetDecision::Hold);
2333 assert_eq!(telem.frames_observed, 0);
2334 assert!(telem.in_warmup);
2335 }
2336
2337 #[test]
2338 fn telemetry_fields_update_after_next_frame() {
2339 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2340 BudgetControllerConfig {
2341 eprocess: EProcessConfig {
2342 warmup_frames: 0,
2343 ..Default::default()
2344 },
2345 cooldown_frames: 0,
2346 ..Default::default()
2347 },
2348 );
2349
2350 for _ in 0..5 {
2352 budget.next_frame();
2353 }
2354
2355 let telem = budget.telemetry().unwrap();
2356 assert_eq!(telem.frames_observed, 5);
2357 assert!(!telem.in_warmup);
2358 assert!(telem.pid_output.is_finite());
2361 assert!(telem.e_value.is_finite());
2362 }
2363
2364 #[test]
2365 fn controller_next_frame_degrades_under_simulated_overload() {
2366 let config = BudgetControllerConfig {
2371 target: Duration::from_millis(16),
2372 eprocess: EProcessConfig {
2373 warmup_frames: 0,
2374 ..Default::default()
2375 },
2376 cooldown_frames: 0,
2377 ..Default::default()
2378 };
2379 let mut ctrl = BudgetController::new(config);
2380
2381 for _ in 0..50 {
2383 ctrl.update(Duration::from_millis(40));
2384 }
2385
2386 assert!(
2388 ctrl.level() > DegradationLevel::Full,
2389 "Controller should degrade: {:?}",
2390 ctrl.level()
2391 );
2392
2393 let telem = ctrl.telemetry();
2395 assert!(telem.level > DegradationLevel::Full);
2396 assert!(
2397 telem.pid_output > 0.0,
2398 "PID output should be positive under overload"
2399 );
2400 assert!(telem.e_value > 1.0, "E-value should grow under overload");
2401 }
2402
2403 #[test]
2404 fn next_frame_delegates_to_controller_when_attached() {
2405 let mut budget = RenderBudget::new(Duration::from_millis(1000))
2408 .with_controller(BudgetControllerConfig::default());
2409
2410 budget.degrade();
2412 assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
2413
2414 budget.next_frame();
2418
2419 let telem = budget.telemetry().unwrap();
2424 assert_eq!(telem.frames_observed, 1);
2425 }
2426
2427 #[test]
2428 fn telemetry_is_copy_and_no_alloc() {
2429 let budget = RenderBudget::new(Duration::from_millis(16))
2430 .with_controller(BudgetControllerConfig::default());
2431
2432 let telem = budget.telemetry().unwrap();
2433 let telem2 = telem;
2435 assert_eq!(telem.level, telem2.level);
2436 assert_eq!(telem.e_value, telem2.e_value);
2437 }
2438
2439 #[test]
2440 fn telemetry_warmup_flag_transitions() {
2441 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2442 BudgetControllerConfig {
2443 eprocess: EProcessConfig {
2444 warmup_frames: 3,
2445 ..Default::default()
2446 },
2447 ..Default::default()
2448 },
2449 );
2450
2451 budget.next_frame();
2453 budget.next_frame();
2454 let telem = budget.telemetry().unwrap();
2455 assert!(telem.in_warmup, "Should be in warmup at frame 2");
2456
2457 budget.next_frame();
2459 let telem = budget.telemetry().unwrap();
2460 assert!(!telem.in_warmup, "Should exit warmup at frame 3");
2461 }
2462
2463 #[test]
2464 fn phase_sub_budget_does_not_carry_controller() {
2465 let budget = RenderBudget::new(Duration::from_millis(100))
2466 .with_controller(BudgetControllerConfig::default());
2467
2468 let phase = budget.phase_budget(Phase::Render);
2469 assert!(
2470 phase.controller().is_none(),
2471 "Phase sub-budgets should not carry the controller"
2472 );
2473 }
2474
2475 #[test]
2476 fn controller_telemetry_tracks_frames_since_change() {
2477 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2478 eprocess: EProcessConfig {
2479 warmup_frames: 0,
2480 ..Default::default()
2481 },
2482 cooldown_frames: 0,
2483 ..Default::default()
2484 });
2485
2486 for i in 1..=5 {
2488 ctrl.update(Duration::from_millis(16));
2489 let telem = ctrl.telemetry();
2490 assert_eq!(
2491 telem.frames_since_change, i,
2492 "frames_since_change should be {} after {} frames",
2493 i, i
2494 );
2495 }
2496 }
2497
2498 #[test]
2499 fn telemetry_last_decision_reflects_controller_decision() {
2500 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2501 eprocess: EProcessConfig {
2502 warmup_frames: 0,
2503 ..Default::default()
2504 },
2505 cooldown_frames: 0,
2506 ..Default::default()
2507 });
2508
2509 ctrl.update(Duration::from_millis(16));
2511 assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Hold);
2512
2513 let mut saw_degrade = false;
2515 for _ in 0..50 {
2516 let d = ctrl.update(Duration::from_millis(50));
2517 if d == BudgetDecision::Degrade {
2518 saw_degrade = true;
2519 assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Degrade);
2520 break;
2521 }
2522 }
2523 assert!(saw_degrade, "Should have seen a Degrade decision");
2524 }
2525
2526 #[test]
2527 fn perf_overhead_controller_update_is_fast() {
2528 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2532
2533 let start = Instant::now();
2534 for _ in 0..10_000 {
2535 ctrl.update(Duration::from_millis(16));
2536 }
2537 let elapsed = start.elapsed();
2538
2539 assert!(
2543 elapsed < Duration::from_millis(50),
2544 "10k controller updates took {:?}, expected <50ms",
2545 elapsed
2546 );
2547 }
2548
2549 #[test]
2550 fn perf_overhead_telemetry_snapshot_is_fast() {
2551 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2552 ctrl.update(Duration::from_millis(16));
2553
2554 let start = Instant::now();
2555 for _ in 0..10_000 {
2556 let _telem = ctrl.telemetry();
2557 }
2558 let elapsed = start.elapsed();
2559
2560 assert!(
2561 elapsed < Duration::from_millis(10),
2562 "10k telemetry snapshots took {:?}, expected <10ms",
2563 elapsed
2564 );
2565 }
2566 }
2567
2568 mod stability_tests {
2573 use super::super::*;
2574
2575 #[derive(Debug, Clone)]
2576 struct CampaignFrameLog {
2577 frame_idx: u64,
2578 phase: &'static str,
2579 frame_time_us: u64,
2580 telemetry: BudgetTelemetry,
2581 }
2582
2583 fn fast_controller(target_ms: u64) -> BudgetController {
2585 BudgetController::new(BudgetControllerConfig {
2586 target: Duration::from_millis(target_ms),
2587 eprocess: EProcessConfig {
2588 warmup_frames: 0,
2589 ..Default::default()
2590 },
2591 cooldown_frames: 0,
2592 ..Default::default()
2593 })
2594 }
2595
2596 fn run_trace(
2600 ctrl: &mut BudgetController,
2601 trace: &[Duration],
2602 ) -> Vec<(u64, u64, BudgetTelemetry)> {
2603 trace
2604 .iter()
2605 .enumerate()
2606 .map(|(i, &ft)| {
2607 ctrl.update(ft);
2608 let telem = ctrl.telemetry();
2609 (i as u64, ft.as_micros() as u64, telem)
2610 })
2611 .collect()
2612 }
2613
2614 fn run_campaign(
2616 ctrl: &mut BudgetController,
2617 phases: &[(&'static str, usize, Duration)],
2618 ) -> Vec<CampaignFrameLog> {
2619 let mut logs = Vec::new();
2620 let mut frame_idx: u64 = 0;
2621 for &(phase, count, frame_time) in phases {
2622 for _ in 0..count {
2623 ctrl.update(frame_time);
2624 logs.push(CampaignFrameLog {
2625 frame_idx,
2626 phase,
2627 frame_time_us: frame_time.as_micros() as u64,
2628 telemetry: ctrl.telemetry(),
2629 });
2630 frame_idx = frame_idx.saturating_add(1);
2631 }
2632 }
2633 logs
2634 }
2635
2636 fn count_transitions(log: &[(u64, u64, BudgetTelemetry)]) -> u32 {
2638 let mut transitions = 0u32;
2639 for pair in log.windows(2) {
2640 if pair[0].2.level != pair[1].2.level {
2641 transitions += 1;
2642 }
2643 }
2644 transitions
2645 }
2646
2647 #[test]
2650 fn e2e_burst_logs_no_oscillation() {
2651 let mut ctrl = fast_controller(16);
2654
2655 let mut trace = Vec::new();
2656 for _cycle in 0..5 {
2657 for _ in 0..10 {
2659 trace.push(Duration::from_millis(40));
2660 }
2661 for _ in 0..20 {
2663 trace.push(Duration::from_millis(16));
2664 }
2665 }
2666
2667 let log = run_trace(&mut ctrl, &trace);
2668
2669 let transitions = count_transitions(&log);
2674 assert!(
2675 transitions < 20,
2676 "Too many transitions under bursty load: {} (expected <20)",
2677 transitions
2678 );
2679
2680 for (frame, ft_us, telem) in &log {
2682 assert!(
2683 telem.pid_output.is_finite(),
2684 "frame {}: NaN pid_output",
2685 frame
2686 );
2687 assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2688 assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2689 assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2690 assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2691 assert!(*ft_us > 0, "frame {}: zero frame time", frame);
2692 }
2693 }
2694
2695 #[test]
2696 fn e2e_burst_recovers_after_moderate_overload() {
2697 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2701 target: Duration::from_millis(16),
2702 eprocess: EProcessConfig {
2703 warmup_frames: 5,
2704 ..Default::default()
2705 },
2706 cooldown_frames: 3,
2707 ..Default::default()
2708 });
2709
2710 let mut trace = Vec::new();
2711 for _cycle in 0..3 {
2712 for _ in 0..15 {
2714 trace.push(Duration::from_millis(30));
2715 }
2716 for _ in 0..50 {
2718 trace.push(Duration::from_millis(10));
2719 }
2720 }
2721
2722 let log = run_trace(&mut ctrl, &trace);
2723
2724 for cycle in 0..3 {
2727 let calm_end = (cycle + 1) * 65 - 1;
2728 if calm_end < log.len() {
2729 assert!(
2730 log[calm_end].2.level < DegradationLevel::SkipFrame,
2731 "cycle {}: should recover after calm period, got {:?} at frame {}",
2732 cycle,
2733 log[calm_end].2.level,
2734 calm_end
2735 );
2736 }
2737 }
2738
2739 let final_level = log.last().unwrap().2.level;
2741 assert!(
2742 final_level < DegradationLevel::Skeleton,
2743 "Final level should recover below Skeleton: {:?}",
2744 final_level
2745 );
2746 }
2747
2748 #[test]
2751 fn e2e_idle_to_burst_recovery() {
2752 let mut ctrl = fast_controller(16);
2755
2756 let mut trace = Vec::new();
2757 for _ in 0..50 {
2759 trace.push(Duration::from_millis(8));
2760 }
2761 for _ in 0..20 {
2763 trace.push(Duration::from_millis(50));
2764 }
2765 for _ in 0..100 {
2767 trace.push(Duration::from_millis(8));
2768 }
2769
2770 let log = run_trace(&mut ctrl, &trace);
2771
2772 assert_eq!(
2774 log[49].2.level,
2775 DegradationLevel::Full,
2776 "Should be Full during idle phase"
2777 );
2778
2779 let max_during_burst = log[50..70].iter().map(|(_, _, t)| t.level).max().unwrap();
2781 assert!(
2782 max_during_burst > DegradationLevel::Full,
2783 "Should degrade during burst"
2784 );
2785
2786 let final_level = log.last().unwrap().2.level;
2788 assert!(
2789 final_level < max_during_burst,
2790 "Should recover after burst: final={:?}, max_during_burst={:?}",
2791 final_level,
2792 max_during_burst
2793 );
2794 }
2795
2796 #[test]
2797 fn e2e_idle_to_burst_no_over_degrade() {
2798 let mut ctrl = fast_controller(16);
2801
2802 for _ in 0..30 {
2804 ctrl.update(Duration::from_millis(8));
2805 }
2806
2807 for _ in 0..5 {
2809 ctrl.update(Duration::from_millis(40));
2810 }
2811
2812 let level = ctrl.level();
2814 assert!(
2815 level <= DegradationLevel::NoStyling,
2816 "Brief burst should not over-degrade: {:?}",
2817 level
2818 );
2819 }
2820
2821 #[test]
2822 fn e2e_overload_campaign_burst_sustained_recovery_with_replay_logs() {
2823 let phases: [(&str, usize, Duration); 3] = [
2830 ("burst_overload", 24, Duration::from_millis(28)),
2831 ("sustained_overload", 80, Duration::from_millis(52)),
2832 ("recovery_underload", 140, Duration::from_millis(8)),
2833 ];
2834
2835 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2836 target: Duration::from_millis(16),
2837 eprocess: EProcessConfig {
2838 warmup_frames: 0,
2839 ..Default::default()
2840 },
2841 cooldown_frames: 0,
2842 degradation_floor: DegradationLevel::SkipFrame,
2843 ..Default::default()
2844 });
2845 let logs = run_campaign(&mut ctrl, &phases);
2846 assert!(!logs.is_empty(), "campaign logs must be non-empty");
2847
2848 let mut burst_degrades = 0u32;
2849 let mut sustained_degrades = 0u32;
2850 let mut sustained_degraded_frames = 0u32;
2851 let mut recovery_upgrades = 0u32;
2852 let mut max_level = DegradationLevel::Full;
2853
2854 for log in &logs {
2855 let telem = &log.telemetry;
2856 if telem.level > max_level {
2857 max_level = telem.level;
2858 }
2859 if log.phase == "burst_overload" && telem.last_decision == BudgetDecision::Degrade {
2860 burst_degrades = burst_degrades.saturating_add(1);
2861 }
2862 if log.phase == "sustained_overload"
2863 && telem.last_decision == BudgetDecision::Degrade
2864 {
2865 sustained_degrades = sustained_degrades.saturating_add(1);
2866 }
2867 if log.phase == "sustained_overload" && telem.level > DegradationLevel::Full {
2868 sustained_degraded_frames = sustained_degraded_frames.saturating_add(1);
2869 }
2870 if log.phase == "recovery_underload"
2871 && telem.last_decision == BudgetDecision::Upgrade
2872 {
2873 recovery_upgrades = recovery_upgrades.saturating_add(1);
2874 }
2875
2876 assert!(
2878 telem.level <= DegradationLevel::SkipFrame,
2879 "frame {}: invalid degradation level {:?}",
2880 log.frame_idx,
2881 telem.level
2882 );
2883 assert!(
2884 telem.e_value.is_finite() && telem.e_value > 0.0,
2885 "frame {}: invalid e_value {}",
2886 log.frame_idx,
2887 telem.e_value
2888 );
2889 assert!(
2890 telem.pid_output.is_finite(),
2891 "frame {}: invalid pid_output {}",
2892 log.frame_idx,
2893 telem.pid_output
2894 );
2895 }
2896
2897 for pair in logs.windows(2) {
2899 let prev = pair[0].telemetry.level.level();
2900 let curr = pair[1].telemetry.level.level();
2901 let delta = (curr as i16 - prev as i16).unsigned_abs();
2902 assert!(
2903 delta <= 1,
2904 "frame {}->{} level jump {}: {:?} -> {:?}",
2905 pair[0].frame_idx,
2906 pair[1].frame_idx,
2907 delta,
2908 pair[0].telemetry.level,
2909 pair[1].telemetry.level
2910 );
2911 }
2912
2913 assert!(
2914 burst_degrades > 0,
2915 "burst phase should trigger degradation decisions"
2916 );
2917 assert!(
2918 sustained_degrades > 0 || sustained_degraded_frames > 0,
2919 "sustained overload phase should maintain degraded operation"
2920 );
2921 assert!(
2922 max_level >= DegradationLevel::Skeleton,
2923 "sustained overload should reach deep degradation (got {:?})",
2924 max_level
2925 );
2926 assert!(
2927 recovery_upgrades > 0,
2928 "recovery phase should trigger upgrade decisions"
2929 );
2930
2931 let final_level = logs
2932 .last()
2933 .map(|entry| entry.telemetry.level)
2934 .unwrap_or(DegradationLevel::SkipFrame);
2935 assert!(
2936 final_level < max_level,
2937 "final level should recover below peak degradation: final={:?} peak={:?}",
2938 final_level,
2939 max_level
2940 );
2941
2942 let mut ctrl_replay = BudgetController::new(BudgetControllerConfig {
2944 target: Duration::from_millis(16),
2945 eprocess: EProcessConfig {
2946 warmup_frames: 0,
2947 ..Default::default()
2948 },
2949 cooldown_frames: 0,
2950 degradation_floor: DegradationLevel::SkipFrame,
2951 ..Default::default()
2952 });
2953 let replay_logs = run_campaign(&mut ctrl_replay, &phases);
2954 assert_eq!(
2955 logs.len(),
2956 replay_logs.len(),
2957 "log length mismatch in replay"
2958 );
2959 for (lhs, rhs) in logs.iter().zip(replay_logs.iter()) {
2960 assert_eq!(lhs.frame_idx, rhs.frame_idx);
2961 assert_eq!(lhs.phase, rhs.phase);
2962 assert_eq!(lhs.frame_time_us, rhs.frame_time_us);
2963 assert_eq!(lhs.telemetry.schema_version, rhs.telemetry.schema_version);
2964 assert_eq!(lhs.telemetry.level, rhs.telemetry.level);
2965 assert_eq!(lhs.telemetry.last_decision, rhs.telemetry.last_decision);
2966 assert_eq!(
2967 lhs.telemetry.decision_reason, rhs.telemetry.decision_reason,
2968 "decision_reason mismatch at frame {}",
2969 lhs.frame_idx
2970 );
2971 assert_eq!(
2972 lhs.telemetry.transition_seq, rhs.telemetry.transition_seq,
2973 "transition_seq mismatch at frame {}",
2974 lhs.frame_idx
2975 );
2976 assert_eq!(
2977 lhs.telemetry.transition_correlation_id,
2978 rhs.telemetry.transition_correlation_id,
2979 "transition_correlation_id mismatch at frame {}",
2980 lhs.frame_idx
2981 );
2982 assert!(
2983 (lhs.telemetry.pid_output - rhs.telemetry.pid_output).abs() < 1e-12,
2984 "pid_output mismatch at frame {}",
2985 lhs.frame_idx
2986 );
2987 assert!(
2988 (lhs.telemetry.e_value - rhs.telemetry.e_value).abs() < 1e-12,
2989 "e_value mismatch at frame {}",
2990 lhs.frame_idx
2991 );
2992 }
2993
2994 for entry in &logs {
2996 let t = &entry.telemetry;
2997 eprintln!(
2998 r#"{{"event":"control_campaign_frame","schema_version":{},"scenario":"bd-2vr05.15.4.5","frame_idx":{},"phase":"{}","frame_time_us":{},"decision":"{}","decision_reason":"{}","transition_seq":{},"transition_correlation_id":{},"level":"{}","pid_output":{:.6},"pid_p":{:.6},"pid_i":{:.6},"pid_d":{:.6},"e_value":{:.6},"frame_time_ms":{:.6},"target_ms":{:.6},"pid_gate_threshold":{:.6},"pid_gate_margin":{:.6},"evidence_threshold":{:.6},"evidence_margin":{:.6},"frames_observed":{},"frames_since_change":{}}}"#,
2999 t.schema_version,
3000 entry.frame_idx,
3001 entry.phase,
3002 entry.frame_time_us,
3003 t.last_decision.as_str(),
3004 t.decision_reason.as_str(),
3005 t.transition_seq,
3006 t.transition_correlation_id,
3007 t.level.as_str(),
3008 t.pid_output,
3009 t.pid_p,
3010 t.pid_i,
3011 t.pid_d,
3012 t.e_value,
3013 t.frame_time_ms,
3014 t.target_ms,
3015 t.pid_gate_threshold,
3016 t.pid_gate_margin,
3017 t.evidence_threshold,
3018 t.evidence_margin,
3019 t.frames_observed,
3020 t.frames_since_change
3021 );
3022 }
3023 eprintln!(
3024 r#"{{"event":"control_campaign_summary","schema_version":{},"scenario":"bd-2vr05.15.4.5","frames":{},"burst_degrades":{},"sustained_degrades":{},"recovery_upgrades":{},"peak_level":"{}","final_level":"{}"}}"#,
3025 BUDGET_TELEMETRY_SCHEMA_VERSION,
3026 logs.len(),
3027 burst_degrades,
3028 sustained_degrades,
3029 recovery_upgrades,
3030 max_level.as_str(),
3031 final_level.as_str()
3032 );
3033 }
3034
3035 #[test]
3038 fn property_random_load_hysteresis_bounds() {
3039 let mut ctrl = fast_controller(16);
3042
3043 let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_BABE;
3046 let mut trace = Vec::new();
3047 for _ in 0..1000 {
3048 rng_state = rng_state
3050 .wrapping_mul(6_364_136_223_846_793_005)
3051 .wrapping_add(1_442_695_040_888_963_407);
3052 let frame_ms = 4 + ((rng_state >> 33) % 77);
3054 trace.push(Duration::from_millis(frame_ms));
3055 }
3056
3057 let log = run_trace(&mut ctrl, &trace);
3058
3059 for pair in log.windows(2) {
3061 let prev = pair[0].2.level.level();
3062 let curr = pair[1].2.level.level();
3063 let delta = (curr as i16 - prev as i16).unsigned_abs();
3064 assert!(
3065 delta <= 1,
3066 "Level jumped {} steps at frame {}: {:?} -> {:?}",
3067 delta,
3068 pair[1].0,
3069 pair[0].2.level,
3070 pair[1].2.level
3071 );
3072 }
3073
3074 for (frame, _, telem) in &log {
3076 assert!(
3077 telem.level <= DegradationLevel::SkipFrame,
3078 "frame {}: level out of range: {:?}",
3079 frame,
3080 telem.level
3081 );
3082 }
3083
3084 for (frame, _, telem) in &log {
3086 assert!(
3087 telem.pid_output.is_finite(),
3088 "frame {}: NaN pid_output",
3089 frame
3090 );
3091 assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
3092 assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
3093 assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
3094 assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
3095 assert!(
3096 telem.e_value > 0.0,
3097 "frame {}: e_value not positive: {}",
3098 frame,
3099 telem.e_value
3100 );
3101 }
3102 }
3103
3104 #[test]
3105 fn property_random_load_bounded_transitions() {
3106 let mut ctrl = BudgetController::new(BudgetControllerConfig {
3109 target: Duration::from_millis(16),
3110 eprocess: EProcessConfig {
3111 warmup_frames: 5,
3112 ..Default::default()
3113 },
3114 cooldown_frames: 3,
3115 ..Default::default()
3116 });
3117
3118 let mut rng_state: u64 = 0x1234_5678_9ABC_DEF0;
3120 let mut trace = Vec::new();
3121 for _ in 0..500 {
3122 rng_state = rng_state
3123 .wrapping_mul(6_364_136_223_846_793_005)
3124 .wrapping_add(1_442_695_040_888_963_407);
3125 let frame_ms = 8 + ((rng_state >> 33) % 40);
3126 trace.push(Duration::from_millis(frame_ms));
3127 }
3128
3129 let log = run_trace(&mut ctrl, &trace);
3130 let transitions = count_transitions(&log);
3131
3132 assert!(
3135 transitions < 80,
3136 "Too many transitions under random load: {} (expected <80 with cooldown=3)",
3137 transitions
3138 );
3139 }
3140
3141 #[test]
3142 fn property_deterministic_replay() {
3143 let trace: Vec<Duration> = (0..100)
3145 .map(|i| Duration::from_millis(10 + (i * 7 % 30)))
3146 .collect();
3147
3148 let mut ctrl1 = fast_controller(16);
3149 let log1 = run_trace(&mut ctrl1, &trace);
3150
3151 let mut ctrl2 = fast_controller(16);
3152 let log2 = run_trace(&mut ctrl2, &trace);
3153
3154 for (r1, r2) in log1.iter().zip(log2.iter()) {
3155 assert_eq!(r1.0, r2.0, "frame index mismatch");
3156 assert_eq!(r1.1, r2.1, "frame time mismatch");
3157 assert_eq!(r1.2.schema_version, r2.2.schema_version);
3158 assert_eq!(r1.2.level, r2.2.level, "level mismatch at frame {}", r1.0);
3159 assert_eq!(
3160 r1.2.last_decision, r2.2.last_decision,
3161 "decision mismatch at frame {}",
3162 r1.0
3163 );
3164 assert_eq!(
3165 r1.2.decision_reason, r2.2.decision_reason,
3166 "decision_reason mismatch at frame {}",
3167 r1.0
3168 );
3169 assert_eq!(
3170 r1.2.transition_seq, r2.2.transition_seq,
3171 "transition_seq mismatch at frame {}",
3172 r1.0
3173 );
3174 assert_eq!(
3175 r1.2.transition_correlation_id, r2.2.transition_correlation_id,
3176 "transition_correlation_id mismatch at frame {}",
3177 r1.0
3178 );
3179 assert!(
3180 (r1.2.pid_output - r2.2.pid_output).abs() < 1e-10,
3181 "pid_output mismatch at frame {}: {} vs {}",
3182 r1.0,
3183 r1.2.pid_output,
3184 r2.2.pid_output
3185 );
3186 assert!(
3187 (r1.2.e_value - r2.2.e_value).abs() < 1e-10,
3188 "e_value mismatch at frame {}: {} vs {}",
3189 r1.0,
3190 r1.2.e_value,
3191 r2.2.e_value
3192 );
3193 }
3194 }
3195
3196 #[test]
3199 fn telemetry_jsonl_fields_complete() {
3200 let mut ctrl = fast_controller(16);
3202 ctrl.update(Duration::from_millis(20));
3203
3204 let telem = ctrl.telemetry();
3205
3206 let _schema_version: u16 = telem.schema_version;
3208 let _degradation: &str = telem.level.as_str();
3209 let _pid_p: f64 = telem.pid_p;
3210 let _pid_i: f64 = telem.pid_i;
3211 let _pid_d: f64 = telem.pid_d;
3212 let _e_value: f64 = telem.e_value;
3213 let _decision: &str = telem.last_decision.as_str();
3214 let _reason: &str = telem.decision_reason.as_str();
3215 let _transition_seq: u64 = telem.transition_seq;
3216 let _transition_correlation_id: u64 = telem.transition_correlation_id;
3217 let _frame_time_ms: f64 = telem.frame_time_ms;
3218 let _target_ms: f64 = telem.target_ms;
3219 let _pid_gate_threshold: f64 = telem.pid_gate_threshold;
3220 let _pid_gate_margin: f64 = telem.pid_gate_margin;
3221 let _evidence_threshold: f64 = telem.evidence_threshold;
3222 let _evidence_margin: f64 = telem.evidence_margin;
3223 let _frames: u32 = telem.frames_observed;
3224
3225 assert_eq!(BudgetDecision::Hold.as_str(), "stay");
3227 assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
3228 assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
3229 assert_eq!(
3230 BUDGET_TELEMETRY_SCHEMA_VERSION, telem.schema_version,
3231 "schema version mismatch"
3232 );
3233 }
3234
3235 #[test]
3236 fn telemetry_transition_records_correlation_reason_and_evidence() {
3237 let mut ctrl = fast_controller(16);
3238
3239 let mut degrade_telem = None;
3241 for _ in 0..64 {
3242 ctrl.update(Duration::from_millis(48));
3243 let telem = ctrl.telemetry();
3244 if telem.last_decision == BudgetDecision::Degrade {
3245 degrade_telem = Some(telem);
3246 break;
3247 }
3248 }
3249 let degrade_telem =
3250 degrade_telem.expect("expected degrade transition with correlation metadata");
3251 assert_eq!(
3252 degrade_telem.decision_reason,
3253 BudgetDecisionReason::OverloadEvidencePassed
3254 );
3255 assert!(
3256 degrade_telem.transition_seq > 0,
3257 "transition_seq should increment on transitions"
3258 );
3259 assert!(
3260 degrade_telem.transition_correlation_id > 0,
3261 "transition correlation id should be populated on transitions"
3262 );
3263 assert!(
3264 degrade_telem.pid_gate_margin > 0.0,
3265 "degrade transition should have positive PID gate margin"
3266 );
3267 assert!(
3268 degrade_telem.evidence_margin > 0.0,
3269 "degrade transition should have positive evidence margin"
3270 );
3271
3272 let mut upgrade_telem = None;
3274 for _ in 0..160 {
3275 ctrl.update(Duration::from_millis(4));
3276 let telem = ctrl.telemetry();
3277 if telem.last_decision == BudgetDecision::Upgrade {
3278 upgrade_telem = Some(telem);
3279 break;
3280 }
3281 }
3282 let upgrade_telem =
3283 upgrade_telem.expect("expected upgrade transition with correlation metadata");
3284 assert_eq!(
3285 upgrade_telem.decision_reason,
3286 BudgetDecisionReason::UnderloadEvidencePassed
3287 );
3288 assert!(
3289 upgrade_telem.transition_seq >= degrade_telem.transition_seq,
3290 "transition sequence should be monotonic"
3291 );
3292 assert!(
3293 upgrade_telem.transition_correlation_id >= degrade_telem.transition_correlation_id,
3294 "transition correlation id should be monotonic"
3295 );
3296 assert!(
3297 upgrade_telem.pid_gate_margin > 0.0,
3298 "upgrade transition should have positive PID gate margin"
3299 );
3300 assert!(
3301 upgrade_telem.evidence_margin > 0.0,
3302 "upgrade transition should have positive evidence margin"
3303 );
3304 }
3305
3306 #[test]
3307 fn telemetry_pid_components_sum_to_output() {
3308 let mut ctrl = fast_controller(16);
3310
3311 for ms in [10u64, 16, 20, 30, 8, 50] {
3312 ctrl.update(Duration::from_millis(ms));
3313 let telem = ctrl.telemetry();
3314 let sum = telem.pid_p + telem.pid_i + telem.pid_d;
3315 assert!(
3316 (sum - telem.pid_output).abs() < 1e-10,
3317 "P+I+D != output at {}ms: {} + {} + {} = {} != {}",
3318 ms,
3319 telem.pid_p,
3320 telem.pid_i,
3321 telem.pid_d,
3322 sum,
3323 telem.pid_output
3324 );
3325 }
3326 }
3327 }
3328
3329 mod edge_case_tests {
3334 use super::super::*;
3335
3336 #[test]
3339 fn pid_negative_integral_windup() {
3340 let mut state = PidState::default();
3342 let gains = PidGains {
3343 integral_max: 3.0,
3344 ..Default::default()
3345 };
3346
3347 for _ in 0..200 {
3348 state.update(-10.0, &gains);
3349 }
3350
3351 assert!(
3352 state.integral >= -3.0 - f64::EPSILON,
3353 "Negative integral should be clamped to -max: {}",
3354 state.integral
3355 );
3356 assert!(
3357 state.integral <= -3.0 + f64::EPSILON,
3358 "Negative integral should saturate at -max: {}",
3359 state.integral
3360 );
3361 }
3362
3363 #[test]
3364 fn pid_zero_gains_zero_output() {
3365 let mut state = PidState::default();
3366 let gains = PidGains {
3367 kp: 0.0,
3368 ki: 0.0,
3369 kd: 0.0,
3370 integral_max: 5.0,
3371 };
3372
3373 let u = state.update(42.0, &gains);
3374 assert!(
3375 u.abs() < 1e-10,
3376 "Zero gains should yield zero output: {}",
3377 u
3378 );
3379 }
3380
3381 #[test]
3382 fn pid_large_error_stays_finite() {
3383 let mut state = PidState::default();
3384 let gains = PidGains::default();
3385
3386 let u = state.update(1e12, &gains);
3388 assert!(
3389 u.is_finite(),
3390 "PID output should be finite for large error: {}",
3391 u
3392 );
3393
3394 assert!(
3396 state.integral <= gains.integral_max + f64::EPSILON,
3397 "Integral should be clamped: {}",
3398 state.integral
3399 );
3400 }
3401
3402 #[test]
3403 fn pid_alternating_error_derivative_responds() {
3404 let mut state = PidState::default();
3405 let gains = PidGains::default();
3406
3407 let u1 = state.update(1.0, &gains);
3409 let u2 = state.update(-1.0, &gains);
3410
3411 assert!(
3414 u2 < u1,
3415 "Alternating error should reduce output: u1={}, u2={}",
3416 u1,
3417 u2
3418 );
3419 }
3420
3421 #[test]
3422 fn pid_telemetry_terms_match_after_update() {
3423 let mut state = PidState::default();
3424 let gains = PidGains::default();
3425
3426 state.update(2.0, &gains);
3427
3428 assert!(
3430 (state.last_p - 1.0).abs() < 1e-10,
3431 "P term: {}",
3432 state.last_p
3433 );
3434 assert!(
3436 (state.last_i - 0.1).abs() < 1e-10,
3437 "I term: {}",
3438 state.last_i
3439 );
3440 assert!(
3442 (state.last_d - 0.4).abs() < 1e-10,
3443 "D term: {}",
3444 state.last_d
3445 );
3446 }
3447
3448 #[test]
3449 fn pid_integral_clamping_symmetric() {
3450 let mut state = PidState::default();
3451 let gains = PidGains {
3452 integral_max: 1.0,
3453 ..Default::default()
3454 };
3455
3456 for _ in 0..50 {
3458 state.update(100.0, &gains);
3459 }
3460 let pos_integral = state.integral;
3461
3462 state.reset();
3463
3464 for _ in 0..50 {
3466 state.update(-100.0, &gains);
3467 }
3468 let neg_integral = state.integral;
3469
3470 assert!(
3471 (pos_integral + neg_integral).abs() < f64::EPSILON,
3472 "Clamping should be symmetric: pos={}, neg={}",
3473 pos_integral,
3474 neg_integral
3475 );
3476 }
3477
3478 #[test]
3481 fn eprocess_first_frame_initializes_mean() {
3482 let mut state = EProcessState::default();
3483 let config = EProcessConfig::default();
3484
3485 state.update(25.0, 16.0, &config);
3486
3487 assert!(
3488 (state.mean_ema - 25.0).abs() < f64::EPSILON,
3489 "First frame should set mean_ema directly: {}",
3490 state.mean_ema
3491 );
3492 assert!(
3493 (state.sigma_ema - config.sigma_floor_ms).abs() < f64::EPSILON,
3494 "First frame should set sigma_ema to floor: {}",
3495 state.sigma_ema
3496 );
3497 assert_eq!(state.frames_observed, 1);
3498 }
3499
3500 #[test]
3501 fn eprocess_e_value_clamped_at_upper_bound() {
3502 let mut state = EProcessState::default();
3503 let config = EProcessConfig {
3504 lambda: 2.0, warmup_frames: 0,
3506 sigma_floor_ms: 0.001, ..Default::default()
3508 };
3509
3510 for _ in 0..1000 {
3512 state.update(1e6, 16.0, &config);
3513 }
3514
3515 assert!(
3516 state.e_value <= 1e10,
3517 "E-value should be clamped at 1e10: {}",
3518 state.e_value
3519 );
3520 }
3521
3522 #[test]
3523 fn eprocess_e_value_clamped_at_lower_bound() {
3524 let mut state = EProcessState::default();
3525 let config = EProcessConfig {
3526 lambda: 2.0,
3527 warmup_frames: 0,
3528 sigma_floor_ms: 0.001,
3529 ..Default::default()
3530 };
3531
3532 for _ in 0..1000 {
3534 state.update(0.001, 1e6, &config);
3535 }
3536
3537 assert!(
3538 state.e_value >= 1e-10,
3539 "E-value should be clamped at 1e-10: {}",
3540 state.e_value
3541 );
3542 }
3543
3544 #[test]
3545 fn eprocess_should_upgrade_during_warmup() {
3546 let state = EProcessState::default();
3547 let config = EProcessConfig {
3548 warmup_frames: 10,
3549 ..Default::default()
3550 };
3551
3552 assert!(
3554 state.should_upgrade(&config),
3555 "should_upgrade should return true during warmup"
3556 );
3557 }
3558
3559 #[test]
3560 fn eprocess_frames_observed_saturates() {
3561 let mut state = EProcessState {
3562 frames_observed: u32::MAX,
3563 ..EProcessState::default()
3564 };
3565 let config = EProcessConfig::default();
3566
3567 state.update(16.0, 16.0, &config);
3569 assert_eq!(
3570 state.frames_observed,
3571 u32::MAX,
3572 "frames_observed should saturate at u32::MAX"
3573 );
3574 }
3575
3576 #[test]
3577 fn eprocess_sigma_ema_decay_boundary_zero() {
3578 let mut state = EProcessState::default();
3579 let config = EProcessConfig {
3580 sigma_ema_decay: 0.0,
3581 warmup_frames: 0,
3582 ..Default::default()
3583 };
3584
3585 state.update(20.0, 16.0, &config);
3587 state.update(30.0, 16.0, &config);
3588
3589 assert!(
3591 (state.mean_ema - 30.0).abs() < f64::EPSILON,
3592 "decay=0 should fully replace mean_ema: {}",
3593 state.mean_ema
3594 );
3595 }
3596
3597 #[test]
3598 fn eprocess_sigma_ema_decay_boundary_one() {
3599 let mut state = EProcessState::default();
3600 let config = EProcessConfig {
3601 sigma_ema_decay: 1.0,
3602 warmup_frames: 0,
3603 ..Default::default()
3604 };
3605
3606 state.update(20.0, 16.0, &config);
3608 let first_mean = state.mean_ema;
3609 state.update(100.0, 16.0, &config);
3610
3611 assert!(
3612 (state.mean_ema - first_mean).abs() < f64::EPSILON,
3613 "decay=1 should lock mean_ema at first value: got {}, expected {}",
3614 state.mean_ema,
3615 first_mean
3616 );
3617 }
3618
3619 #[test]
3620 fn eprocess_zero_target_no_panic() {
3621 let mut state = EProcessState::default();
3622 let config = EProcessConfig {
3623 warmup_frames: 0,
3624 ..Default::default()
3625 };
3626
3627 let e = state.update(16.0, 0.0, &config);
3629 assert!(
3630 e.is_finite(),
3631 "E-value should be finite with zero target: {}",
3632 e
3633 );
3634 }
3635
3636 #[test]
3639 fn degradation_level_default_is_full() {
3640 assert_eq!(DegradationLevel::default(), DegradationLevel::Full);
3641 }
3642
3643 #[test]
3644 fn degradation_level_hash_unique() {
3645 use std::collections::HashSet;
3646 let levels = [
3647 DegradationLevel::Full,
3648 DegradationLevel::SimpleBorders,
3649 DegradationLevel::NoStyling,
3650 DegradationLevel::EssentialOnly,
3651 DegradationLevel::Skeleton,
3652 DegradationLevel::SkipFrame,
3653 ];
3654 let set: HashSet<DegradationLevel> = levels.iter().copied().collect();
3655 assert_eq!(set.len(), 6, "All levels should hash uniquely");
3656 }
3657
3658 #[test]
3659 fn degradation_level_widget_queries_full() {
3660 let l = DegradationLevel::Full;
3661 assert!(l.use_unicode_borders());
3662 assert!(l.apply_styling());
3663 assert!(l.render_decorative());
3664 assert!(l.render_content());
3665 }
3666
3667 #[test]
3668 fn degradation_level_widget_queries_simple_borders() {
3669 let l = DegradationLevel::SimpleBorders;
3670 assert!(!l.use_unicode_borders());
3671 assert!(l.apply_styling());
3672 assert!(l.render_decorative());
3673 assert!(l.render_content());
3674 }
3675
3676 #[test]
3677 fn degradation_level_widget_queries_no_styling() {
3678 let l = DegradationLevel::NoStyling;
3679 assert!(!l.use_unicode_borders());
3680 assert!(!l.apply_styling());
3681 assert!(l.render_decorative());
3682 assert!(l.render_content());
3683 }
3684
3685 #[test]
3686 fn degradation_level_widget_queries_essential_only() {
3687 let l = DegradationLevel::EssentialOnly;
3688 assert!(!l.use_unicode_borders());
3689 assert!(!l.apply_styling());
3690 assert!(!l.render_decorative());
3691 assert!(l.render_content());
3692 }
3693
3694 #[test]
3695 fn degradation_level_widget_queries_skeleton() {
3696 let l = DegradationLevel::Skeleton;
3697 assert!(!l.use_unicode_borders());
3698 assert!(!l.apply_styling());
3699 assert!(!l.render_decorative());
3700 assert!(!l.render_content());
3701 }
3702
3703 #[test]
3704 fn degradation_level_widget_queries_skip_frame() {
3705 let l = DegradationLevel::SkipFrame;
3706 assert!(!l.use_unicode_borders());
3707 assert!(!l.apply_styling());
3708 assert!(!l.render_decorative());
3709 assert!(!l.render_content());
3710 }
3711
3712 #[test]
3713 fn degradation_level_partial_ord_consistent() {
3714 let levels = [
3716 DegradationLevel::Full,
3717 DegradationLevel::SimpleBorders,
3718 DegradationLevel::NoStyling,
3719 DegradationLevel::EssentialOnly,
3720 DegradationLevel::Skeleton,
3721 DegradationLevel::SkipFrame,
3722 ];
3723 for (i, a) in levels.iter().enumerate() {
3724 for (j, b) in levels.iter().enumerate() {
3725 let po = a.partial_cmp(b);
3726 let o = a.cmp(b);
3727 assert_eq!(po, Some(o), "PartialOrd != Ord for {:?} vs {:?}", a, b);
3728 if i < j {
3729 assert!(*a < *b, "{:?} should be < {:?}", a, b);
3730 }
3731 }
3732 }
3733 }
3734
3735 #[test]
3736 fn degradation_level_clone_eq() {
3737 let a = DegradationLevel::NoStyling;
3738 let b = a;
3739 assert_eq!(a, b);
3740 }
3741
3742 #[test]
3743 fn degradation_level_debug() {
3744 let s = format!("{:?}", DegradationLevel::EssentialOnly);
3745 assert!(s.contains("EssentialOnly"), "Debug output: {}", s);
3746 }
3747
3748 #[test]
3751 fn controller_eprocess_sigma_ms_uses_floor() {
3752 let ctrl = BudgetController::new(BudgetControllerConfig {
3753 eprocess: EProcessConfig {
3754 sigma_floor_ms: 2.5,
3755 ..Default::default()
3756 },
3757 ..Default::default()
3758 });
3759
3760 assert!(
3762 (ctrl.eprocess_sigma_ms() - 2.5).abs() < f64::EPSILON,
3763 "Should return sigma_floor_ms when sigma_ema < floor: {}",
3764 ctrl.eprocess_sigma_ms()
3765 );
3766 }
3767
3768 #[test]
3769 fn controller_config_accessor() {
3770 let config = BudgetControllerConfig {
3771 degrade_threshold: 0.42,
3772 ..Default::default()
3773 };
3774 let ctrl = BudgetController::new(config.clone());
3775
3776 assert_eq!(ctrl.config().degrade_threshold, 0.42);
3777 assert_eq!(ctrl.config().target, Duration::from_millis(16));
3778 }
3779
3780 #[test]
3781 fn controller_frames_observed_accessor() {
3782 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
3783
3784 assert_eq!(ctrl.frames_observed(), 0);
3785
3786 ctrl.update(Duration::from_millis(16));
3787 assert_eq!(ctrl.frames_observed(), 1);
3788
3789 ctrl.update(Duration::from_millis(16));
3790 assert_eq!(ctrl.frames_observed(), 2);
3791 }
3792
3793 #[test]
3796 fn render_budget_record_frame_time_used_by_next_frame() {
3797 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3798 budget.degrade();
3799
3800 for _ in 0..10 {
3802 budget.reset();
3803 }
3804
3805 budget.record_frame_time(Duration::from_millis(1));
3807 std::thread::sleep(Duration::from_millis(15));
3809
3810 let before = budget.degradation();
3811 budget.next_frame();
3812
3813 assert!(
3816 budget.degradation() < before,
3817 "Recorded frame time should enable upgrade: before={:?}, after={:?}",
3818 before,
3819 budget.degradation()
3820 );
3821 }
3822
3823 #[test]
3824 fn render_budget_phase_budget_clamped_by_remaining() {
3825 let budget = RenderBudget::new(Duration::from_millis(1));
3827 std::thread::sleep(Duration::from_millis(5));
3828
3829 let phase = budget.phase_budget(Phase::Render);
3831 assert!(
3832 phase.total() <= Duration::from_millis(1),
3833 "Phase budget should be clamped by remaining: {:?}",
3834 phase.total()
3835 );
3836 }
3837
3838 #[test]
3839 fn render_budget_exhausted_skipframe_with_no_frame_skip() {
3840 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3841 budget.allow_frame_skip = false;
3842 budget.set_degradation(DegradationLevel::SkipFrame);
3843
3844 assert!(
3847 !budget.exhausted(),
3848 "SkipFrame should not exhaust when frame skip disabled"
3849 );
3850 }
3851
3852 #[test]
3853 fn render_budget_remaining_fraction_zero_total() {
3854 let budget = RenderBudget::new(Duration::ZERO);
3855 assert_eq!(budget.remaining_fraction(), 0.0);
3856 }
3857
3858 #[test]
3859 fn render_budget_total_accessor() {
3860 let budget = RenderBudget::new(Duration::from_millis(42));
3861 assert_eq!(budget.total(), Duration::from_millis(42));
3862 }
3863
3864 #[test]
3865 fn render_budget_phase_budgets_accessor() {
3866 let budget = RenderBudget::new(Duration::from_millis(16));
3867 let pb = budget.phase_budgets();
3868 assert_eq!(pb.diff, Duration::from_millis(2));
3869 assert_eq!(pb.present, Duration::from_millis(4));
3870 assert_eq!(pb.render, Duration::from_millis(8));
3871 }
3872
3873 #[test]
3874 fn render_budget_set_degradation_no_op_preserves_cooldown() {
3875 let mut budget = RenderBudget::new(Duration::from_millis(16));
3876 budget.set_degradation(DegradationLevel::NoStyling);
3877 budget.frames_since_change = 7;
3878
3879 budget.set_degradation(DegradationLevel::NoStyling);
3881 assert_eq!(budget.frames_since_change, 7);
3882
3883 budget.set_degradation(DegradationLevel::Skeleton);
3885 assert_eq!(budget.frames_since_change, 0);
3886 }
3887
3888 #[test]
3889 fn render_budget_should_upgrade_false_at_full() {
3890 let budget = RenderBudget::new(Duration::from_millis(1000));
3891 assert!(!budget.should_upgrade(), "Full level should never upgrade");
3892 }
3893
3894 #[test]
3895 fn render_budget_should_upgrade_false_during_cooldown() {
3896 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3897 budget.degrade();
3898 assert!(
3900 !budget.should_upgrade(),
3901 "Should not upgrade during cooldown"
3902 );
3903 }
3904
3905 #[test]
3906 fn render_budget_degrade_at_max_stays_at_max() {
3907 let mut budget = RenderBudget::new(Duration::from_millis(16));
3908 budget.set_degradation(DegradationLevel::SkipFrame);
3909 budget.degrade();
3910 assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
3911 }
3912
3913 #[test]
3914 fn render_budget_upgrade_at_full_stays_at_full() {
3915 let mut budget = RenderBudget::new(Duration::from_millis(16));
3916 budget.upgrade();
3917 assert_eq!(budget.degradation(), DegradationLevel::Full);
3918 }
3919
3920 #[test]
3923 fn frame_budget_config_partial_eq() {
3924 let a = FrameBudgetConfig::default();
3925 let b = FrameBudgetConfig::default();
3926 assert_eq!(a, b);
3927
3928 let c = FrameBudgetConfig::strict(Duration::from_millis(16));
3929 assert_ne!(a, c, "Different configs should not be equal");
3930 }
3931
3932 #[test]
3933 fn phase_budgets_eq_and_copy() {
3934 let a = PhaseBudgets::default();
3935 let b = a; assert_eq!(a, b);
3937
3938 let c = PhaseBudgets {
3939 diff: Duration::from_millis(1),
3940 ..Default::default()
3941 };
3942 assert_ne!(a, c);
3943 }
3944
3945 #[test]
3946 fn budget_controller_config_partial_eq() {
3947 let a = BudgetControllerConfig::default();
3948 let b = BudgetControllerConfig::default();
3949 assert_eq!(a, b);
3950 }
3951
3952 #[test]
3953 fn pid_gains_partial_eq() {
3954 let a = PidGains::default();
3955 let b = PidGains::default();
3956 assert_eq!(a, b);
3957 }
3958
3959 #[test]
3960 fn eprocess_config_partial_eq() {
3961 let a = EProcessConfig::default();
3962 let b = EProcessConfig::default();
3963 assert_eq!(a, b);
3964 }
3965
3966 #[test]
3969 fn budget_decision_debug_format() {
3970 assert!(format!("{:?}", BudgetDecision::Hold).contains("Hold"));
3971 assert!(format!("{:?}", BudgetDecision::Degrade).contains("Degrade"));
3972 assert!(format!("{:?}", BudgetDecision::Upgrade).contains("Upgrade"));
3973 }
3974
3975 #[test]
3976 fn budget_decision_clone_copy() {
3977 let d = BudgetDecision::Degrade;
3978 let d2 = d;
3979 assert_eq!(d, d2);
3980 }
3981
3982 #[test]
3983 fn budget_decision_as_str_coverage() {
3984 assert_eq!(BudgetDecision::Hold.as_str(), "stay");
3985 assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
3986 assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
3987 }
3988
3989 #[test]
3990 fn budget_decision_reason_debug_and_as_str() {
3991 assert!(
3992 format!("{:?}", BudgetDecisionReason::CooldownActive).contains("CooldownActive")
3993 );
3994 assert_eq!(
3995 BudgetDecisionReason::CooldownActive.as_str(),
3996 "cooldown_active"
3997 );
3998 assert_eq!(
3999 BudgetDecisionReason::OverloadEvidencePassed.as_str(),
4000 "overload_evidence_passed"
4001 );
4002 assert_eq!(
4003 BudgetDecisionReason::UnderloadEvidencePassed.as_str(),
4004 "underload_evidence_passed"
4005 );
4006 assert_eq!(
4007 BudgetDecisionReason::AtMaxDegradation.as_str(),
4008 "at_max_degradation"
4009 );
4010 assert_eq!(
4011 BudgetDecisionReason::AtDegradationFloor.as_str(),
4012 "at_degradation_floor"
4013 );
4014 assert_eq!(
4015 BudgetDecisionReason::AtFullQuality.as_str(),
4016 "at_full_quality"
4017 );
4018 assert_eq!(
4019 BudgetDecisionReason::WithinThresholdBand.as_str(),
4020 "within_threshold_band"
4021 );
4022 }
4023
4024 #[test]
4027 fn phase_eq_and_hash() {
4028 use std::collections::HashSet;
4029 let mut set = HashSet::new();
4030 set.insert(Phase::Diff);
4031 set.insert(Phase::Present);
4032 set.insert(Phase::Render);
4033 assert_eq!(set.len(), 3);
4034
4035 set.insert(Phase::Diff);
4037 assert_eq!(set.len(), 3);
4038 }
4039
4040 #[test]
4041 fn phase_debug() {
4042 assert!(format!("{:?}", Phase::Diff).contains("Diff"));
4043 assert!(format!("{:?}", Phase::Present).contains("Present"));
4044 assert!(format!("{:?}", Phase::Render).contains("Render"));
4045 }
4046
4047 #[test]
4048 fn phase_clone_copy() {
4049 let p = Phase::Present;
4050 let p2 = p;
4051 assert_eq!(p, p2);
4052 }
4053
4054 #[test]
4057 fn budget_telemetry_debug() {
4058 let telem = BudgetTelemetry {
4059 schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
4060 level: DegradationLevel::Full,
4061 pid_output: 0.0,
4062 pid_p: 0.0,
4063 pid_i: 0.0,
4064 pid_d: 0.0,
4065 e_value: 1.0,
4066 frames_observed: 0,
4067 frames_since_change: 0,
4068 last_decision: BudgetDecision::Hold,
4069 decision_reason: BudgetDecisionReason::WithinThresholdBand,
4070 transition_seq: 0,
4071 transition_correlation_id: 0,
4072 frame_time_ms: 0.0,
4073 target_ms: 16.0,
4074 pid_gate_threshold: 0.0,
4075 pid_gate_margin: 0.0,
4076 evidence_threshold: 0.0,
4077 evidence_margin: 0.0,
4078 in_warmup: true,
4079 };
4080 let s = format!("{:?}", telem);
4081 assert!(s.contains("BudgetTelemetry"), "Debug output: {}", s);
4082 }
4083
4084 #[test]
4085 fn budget_telemetry_partial_eq() {
4086 let a = BudgetTelemetry {
4087 schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
4088 level: DegradationLevel::Full,
4089 pid_output: 0.5,
4090 pid_p: 0.3,
4091 pid_i: 0.1,
4092 pid_d: 0.1,
4093 e_value: 1.0,
4094 frames_observed: 5,
4095 frames_since_change: 2,
4096 last_decision: BudgetDecision::Hold,
4097 decision_reason: BudgetDecisionReason::WithinThresholdBand,
4098 transition_seq: 0,
4099 transition_correlation_id: 0,
4100 frame_time_ms: 16.0,
4101 target_ms: 16.0,
4102 pid_gate_threshold: 0.0,
4103 pid_gate_margin: 0.0,
4104 evidence_threshold: 0.0,
4105 evidence_margin: 0.0,
4106 in_warmup: false,
4107 };
4108 let b = a;
4109 assert_eq!(a, b);
4110
4111 let c = BudgetTelemetry {
4112 level: DegradationLevel::SimpleBorders,
4113 ..a
4114 };
4115 assert_ne!(a, c);
4116 }
4117
4118 #[test]
4121 fn next_frame_without_recorded_time_uses_elapsed() {
4122 let mut budget = RenderBudget::new(Duration::from_millis(1000));
4123
4124 budget.next_frame();
4126
4127 assert!(budget.remaining_fraction() > 0.9);
4129 }
4130
4131 #[test]
4132 fn controller_at_max_degradation_holds() {
4133 let mut ctrl = BudgetController::new(BudgetControllerConfig {
4134 eprocess: EProcessConfig {
4135 warmup_frames: 0,
4136 ..Default::default()
4137 },
4138 cooldown_frames: 0,
4139 degradation_floor: DegradationLevel::SkipFrame,
4141 ..Default::default()
4142 });
4143
4144 for _ in 0..500 {
4146 ctrl.update(Duration::from_millis(200));
4147 }
4148 assert_eq!(ctrl.level(), DegradationLevel::SkipFrame);
4149
4150 let d = ctrl.update(Duration::from_millis(200));
4152 assert_eq!(d, BudgetDecision::Hold, "At max level, should hold");
4153 }
4154
4155 #[test]
4156 fn controller_at_configured_degradation_floor_reports_floor_reason() {
4157 let mut ctrl = BudgetController::new(BudgetControllerConfig {
4158 eprocess: EProcessConfig {
4159 warmup_frames: 0,
4160 ..Default::default()
4161 },
4162 cooldown_frames: 0,
4163 degradation_floor: DegradationLevel::SimpleBorders,
4164 ..Default::default()
4165 });
4166
4167 ctrl.current_level = DegradationLevel::SimpleBorders;
4168
4169 let decision = ctrl.update(Duration::from_millis(200));
4170 let telemetry = ctrl.telemetry();
4171
4172 assert_eq!(decision, BudgetDecision::Hold);
4173 assert_eq!(telemetry.level, DegradationLevel::SimpleBorders);
4174 assert_eq!(
4175 telemetry.decision_reason,
4176 BudgetDecisionReason::AtDegradationFloor
4177 );
4178 }
4179
4180 #[test]
4181 fn controller_at_full_level_no_upgrade() {
4182 let mut ctrl = BudgetController::new(BudgetControllerConfig {
4183 eprocess: EProcessConfig {
4184 warmup_frames: 0,
4185 ..Default::default()
4186 },
4187 cooldown_frames: 0,
4188 ..Default::default()
4189 });
4190
4191 for _ in 0..50 {
4193 let d = ctrl.update(Duration::from_millis(1));
4194 assert_ne!(
4195 d,
4196 BudgetDecision::Upgrade,
4197 "Full level should never upgrade"
4198 );
4199 }
4200 }
4201
4202 #[test]
4203 fn render_budget_full_degrade_cycle_with_controller() {
4204 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
4205 BudgetControllerConfig {
4206 eprocess: EProcessConfig {
4207 warmup_frames: 0,
4208 ..Default::default()
4209 },
4210 cooldown_frames: 0,
4211 ..Default::default()
4212 },
4213 );
4214
4215 for _ in 0..100 {
4217 budget.record_frame_time(Duration::from_millis(40));
4218 budget.next_frame();
4219 }
4220 let degraded = budget.degradation();
4221 assert!(
4222 degraded > DegradationLevel::Full,
4223 "Should degrade: {:?}",
4224 degraded
4225 );
4226
4227 for _ in 0..200 {
4229 budget.record_frame_time(Duration::from_millis(4));
4230 budget.next_frame();
4231 }
4232 let recovered = budget.degradation();
4233 assert!(
4234 recovered < degraded,
4235 "Should recover: {:?} -> {:?}",
4236 degraded,
4237 recovered
4238 );
4239 }
4240
4241 #[test]
4242 fn render_budget_phase_has_budget_exhausted() {
4243 let budget = RenderBudget::new(Duration::from_millis(1));
4244 std::thread::sleep(Duration::from_millis(10));
4245
4246 assert!(!budget.phase_has_budget(Phase::Diff));
4248 assert!(!budget.phase_has_budget(Phase::Present));
4249 assert!(!budget.phase_has_budget(Phase::Render));
4250 }
4251
4252 #[test]
4253 fn render_budget_elapsed_increases() {
4254 let budget = RenderBudget::new(Duration::from_millis(1000));
4255 let e1 = budget.elapsed();
4256 std::thread::sleep(Duration::from_millis(5));
4257 let e2 = budget.elapsed();
4258 assert!(e2 > e1, "Elapsed should increase: {:?} vs {:?}", e1, e2);
4259 }
4260
4261 #[test]
4262 fn controller_pid_integral_accessor() {
4263 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
4264
4265 assert_eq!(ctrl.pid_integral(), 0.0);
4266
4267 ctrl.update(Duration::from_millis(32)); assert!(
4270 ctrl.pid_integral() > 0.0,
4271 "Integral should grow: {}",
4272 ctrl.pid_integral()
4273 );
4274 }
4275
4276 #[test]
4277 fn controller_e_value_accessor() {
4278 let ctrl = BudgetController::new(BudgetControllerConfig::default());
4279 assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
4280 }
4281 }
4282}