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 budget = RenderBudget::new(Duration::from_millis(100));
1423
1424 let initial = budget.remaining_fraction();
1426 assert!(initial > 0.9);
1427
1428 thread::sleep(Duration::from_millis(50));
1429
1430 let later = budget.remaining_fraction();
1432 assert!(later < 0.6);
1433 assert!(later > 0.3);
1434 }
1435
1436 #[test]
1437 fn should_degrade_when_cost_exceeds_remaining() {
1438 let budget = RenderBudget::new(Duration::from_millis(100));
1440
1441 thread::sleep(Duration::from_millis(50));
1443
1444 assert!(budget.should_degrade(Duration::from_millis(80)));
1446 assert!(!budget.should_degrade(Duration::from_millis(10)));
1448 }
1449
1450 #[test]
1451 fn degrade_advances_level() {
1452 let mut budget = RenderBudget::new(Duration::from_millis(16));
1453
1454 assert_eq!(budget.degradation(), DegradationLevel::Full);
1455
1456 budget.degrade();
1457 assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
1458
1459 budget.degrade();
1460 assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1461 }
1462
1463 #[test]
1464 fn exhausted_when_no_time_left() {
1465 let budget = RenderBudget::new(Duration::from_millis(5));
1466
1467 assert!(!budget.exhausted());
1468
1469 thread::sleep(Duration::from_millis(10));
1470
1471 assert!(budget.exhausted());
1472 }
1473
1474 #[test]
1475 fn exhausted_at_skip_frame() {
1476 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1477
1478 budget.set_degradation(DegradationLevel::SkipFrame);
1480
1481 assert!(budget.exhausted());
1483 }
1484
1485 #[test]
1486 fn should_upgrade_with_remaining_budget() {
1487 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1488
1489 assert!(!budget.should_upgrade());
1491
1492 budget.degrade();
1494 budget.frames_since_change = 5;
1495
1496 assert!(budget.should_upgrade());
1498 }
1499
1500 #[test]
1501 fn upgrade_improves_level() {
1502 let mut budget = RenderBudget::new(Duration::from_millis(16));
1503
1504 budget.set_degradation(DegradationLevel::Skeleton);
1505 assert_eq!(budget.degradation(), DegradationLevel::Skeleton);
1506
1507 budget.upgrade();
1508 assert_eq!(budget.degradation(), DegradationLevel::EssentialOnly);
1509
1510 budget.upgrade();
1511 assert_eq!(budget.degradation(), DegradationLevel::NoStyling);
1512 }
1513
1514 #[test]
1515 fn upgrade_downgrade_symmetric() {
1516 let mut budget = RenderBudget::new(Duration::from_millis(16));
1517
1518 while !budget.degradation().is_max() {
1520 budget.degrade();
1521 }
1522 assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
1523
1524 while !budget.degradation().is_full() {
1526 budget.upgrade();
1527 }
1528 assert_eq!(budget.degradation(), DegradationLevel::Full);
1529 }
1530
1531 #[test]
1532 fn reset_preserves_degradation() {
1533 let mut budget = RenderBudget::new(Duration::from_millis(16));
1534
1535 budget.degrade();
1536 budget.degrade();
1537 let level = budget.degradation();
1538
1539 budget.reset();
1540
1541 assert_eq!(budget.degradation(), level);
1542 assert!(budget.remaining_fraction() > 0.9);
1544 }
1545
1546 #[test]
1547 fn next_frame_upgrades_when_possible() {
1548 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1549
1550 budget.degrade();
1552 for _ in 0..5 {
1553 budget.reset();
1554 }
1555
1556 let before = budget.degradation();
1557 budget.next_frame();
1558
1559 assert!(budget.degradation() < before);
1561 }
1562
1563 #[test]
1564 fn next_frame_prefers_recorded_frame_time_for_upgrade() {
1565 let mut budget = RenderBudget::new(Duration::from_millis(16));
1566
1567 budget.degrade();
1568 for _ in 0..5 {
1569 budget.reset();
1570 }
1571
1572 budget.record_frame_time(Duration::from_millis(1));
1575 std::thread::sleep(Duration::from_millis(25));
1576
1577 let before = budget.degradation();
1578 budget.next_frame();
1579
1580 assert!(budget.degradation() < before);
1581 }
1582
1583 #[test]
1584 fn config_defaults() {
1585 let config = FrameBudgetConfig::default();
1586
1587 assert_eq!(config.total, Duration::from_millis(16));
1588 assert!(config.allow_frame_skip);
1589 assert_eq!(config.degradation_cooldown, 3);
1590 assert!((config.upgrade_threshold - 0.5).abs() < f32::EPSILON);
1591 }
1592
1593 #[test]
1594 fn config_with_total() {
1595 let config = FrameBudgetConfig::with_total(Duration::from_millis(33));
1596
1597 assert_eq!(config.total, Duration::from_millis(33));
1598 assert!(config.allow_frame_skip);
1600 }
1601
1602 #[test]
1603 fn config_strict() {
1604 let config = FrameBudgetConfig::strict(Duration::from_millis(16));
1605
1606 assert!(!config.allow_frame_skip);
1607 }
1608
1609 #[test]
1610 fn config_relaxed() {
1611 let config = FrameBudgetConfig::relaxed();
1612
1613 assert_eq!(config.total, Duration::from_millis(33));
1614 assert_eq!(config.degradation_cooldown, 5);
1615 }
1616
1617 #[test]
1618 fn from_config() {
1619 let config = FrameBudgetConfig {
1620 total: Duration::from_millis(20),
1621 allow_frame_skip: false,
1622 ..Default::default()
1623 };
1624
1625 let budget = RenderBudget::from_config(&config);
1626
1627 assert_eq!(budget.total(), Duration::from_millis(20));
1628 assert!(!budget.exhausted()); let mut budget = RenderBudget::from_config(&config);
1632 budget.set_degradation(DegradationLevel::SkipFrame);
1633 assert!(!budget.exhausted());
1634 }
1635
1636 #[test]
1637 fn phase_budgets_default() {
1638 let budgets = PhaseBudgets::default();
1639
1640 assert_eq!(budgets.diff, Duration::from_millis(2));
1641 assert_eq!(budgets.present, Duration::from_millis(4));
1642 assert_eq!(budgets.render, Duration::from_millis(8));
1643 }
1644
1645 #[test]
1646 fn phase_has_budget() {
1647 let budget = RenderBudget::new(Duration::from_millis(100));
1648
1649 assert!(budget.phase_has_budget(Phase::Diff));
1650 assert!(budget.phase_has_budget(Phase::Present));
1651 assert!(budget.phase_has_budget(Phase::Render));
1652 }
1653
1654 #[test]
1655 fn phase_budget_respects_remaining() {
1656 let budget = RenderBudget::new(Duration::from_millis(100));
1657
1658 let diff_budget = budget.phase_budget(Phase::Diff);
1659 assert_eq!(diff_budget.total(), Duration::from_millis(2));
1660
1661 let present_budget = budget.phase_budget(Phase::Present);
1662 assert_eq!(present_budget.total(), Duration::from_millis(4));
1663 }
1664
1665 #[test]
1666 fn phase_as_str() {
1667 assert_eq!(Phase::Diff.as_str(), "diff");
1668 assert_eq!(Phase::Present.as_str(), "present");
1669 assert_eq!(Phase::Render.as_str(), "render");
1670 }
1671
1672 #[test]
1673 fn zero_budget_is_immediately_exhausted() {
1674 let budget = RenderBudget::new(Duration::ZERO);
1675 assert!(budget.exhausted());
1676 assert_eq!(budget.remaining_fraction(), 0.0);
1677 }
1678
1679 #[test]
1680 fn degradation_level_never_exceeds_skip_frame() {
1681 let mut level = DegradationLevel::Full;
1682
1683 for _ in 0..100 {
1684 level = level.next();
1685 }
1686
1687 assert_eq!(level, DegradationLevel::SkipFrame);
1688 }
1689
1690 #[test]
1691 fn budget_remaining_never_negative() {
1692 let budget = RenderBudget::new(Duration::from_millis(1));
1693
1694 thread::sleep(Duration::from_millis(10));
1696
1697 assert_eq!(budget.remaining(), Duration::ZERO);
1699 assert_eq!(budget.remaining_fraction(), 0.0);
1700 }
1701
1702 #[test]
1703 fn infinite_budget_stays_at_full() {
1704 let mut budget = RenderBudget::new(Duration::from_secs(1000));
1705
1706 assert!(!budget.should_degrade(Duration::from_millis(100)));
1708 assert_eq!(budget.degradation(), DegradationLevel::Full);
1709
1710 budget.next_frame();
1712 assert_eq!(budget.degradation(), DegradationLevel::Full);
1713 }
1714
1715 #[test]
1716 fn cooldown_prevents_immediate_upgrade() {
1717 let mut budget = RenderBudget::new(Duration::from_millis(1000));
1718 budget.cooldown = 3;
1719
1720 budget.degrade();
1722 assert_eq!(budget.frames_since_change, 0);
1723
1724 assert!(!budget.should_upgrade());
1726
1727 budget.frames_since_change = 3;
1729
1730 assert!(budget.should_upgrade());
1732 }
1733
1734 #[test]
1735 fn set_degradation_resets_cooldown() {
1736 let mut budget = RenderBudget::new(Duration::from_millis(16));
1737 budget.frames_since_change = 10;
1738
1739 budget.set_degradation(DegradationLevel::NoStyling);
1740
1741 assert_eq!(budget.frames_since_change, 0);
1742 }
1743
1744 #[test]
1745 fn set_degradation_same_level_preserves_cooldown() {
1746 let mut budget = RenderBudget::new(Duration::from_millis(16));
1747 budget.frames_since_change = 10;
1748
1749 budget.set_degradation(DegradationLevel::Full);
1751
1752 assert_eq!(budget.frames_since_change, 10);
1754 }
1755
1756 mod controller_tests {
1761 use super::super::*;
1762
1763 fn make_controller() -> BudgetController {
1764 BudgetController::new(BudgetControllerConfig::default())
1765 }
1766
1767 fn make_controller_with_config(
1768 target_ms: u64,
1769 warmup: u32,
1770 cooldown: u32,
1771 ) -> BudgetController {
1772 BudgetController::new(BudgetControllerConfig {
1773 target: Duration::from_millis(target_ms),
1774 eprocess: EProcessConfig {
1775 warmup_frames: warmup,
1776 ..Default::default()
1777 },
1778 cooldown_frames: cooldown,
1779 ..Default::default()
1780 })
1781 }
1782
1783 #[test]
1786 fn pid_step_input_yields_nonzero_output() {
1787 let mut state = PidState::default();
1788 let gains = PidGains::default();
1789
1790 let u = state.update(1.0, &gains);
1792 assert!(
1794 (u - 0.75).abs() < 1e-10,
1795 "First PID output should be 0.75, got {}",
1796 u
1797 );
1798 }
1799
1800 #[test]
1801 fn pid_zero_error_zero_output() {
1802 let mut state = PidState::default();
1803 let gains = PidGains::default();
1804
1805 let u = state.update(0.0, &gains);
1806 assert!(
1807 u.abs() < 1e-10,
1808 "Zero error should produce zero output, got {}",
1809 u
1810 );
1811 }
1812
1813 #[test]
1814 fn pid_integral_accumulates() {
1815 let mut state = PidState::default();
1816 let gains = PidGains::default();
1817
1818 state.update(1.0, &gains);
1820 state.update(1.0, &gains);
1821 state.update(1.0, &gains);
1822
1823 assert!(
1824 state.integral > 2.5,
1825 "Integral should accumulate: {}",
1826 state.integral
1827 );
1828 }
1829
1830 #[test]
1831 fn pid_integral_anti_windup() {
1832 let mut state = PidState::default();
1833 let gains = PidGains {
1834 integral_max: 2.0,
1835 ..Default::default()
1836 };
1837
1838 for _ in 0..100 {
1840 state.update(10.0, &gains);
1841 }
1842
1843 assert!(
1844 state.integral <= 2.0 + f64::EPSILON,
1845 "Integral should be clamped to max: {}",
1846 state.integral
1847 );
1848 assert!(
1849 state.integral >= -2.0 - f64::EPSILON,
1850 "Integral should be clamped to -max: {}",
1851 state.integral
1852 );
1853 }
1854
1855 #[test]
1856 fn pid_derivative_responds_to_change() {
1857 let mut state = PidState::default();
1858 let gains = PidGains::default();
1859
1860 let u1 = state.update(0.0, &gains);
1862 let u2 = state.update(1.0, &gains);
1864
1865 assert!(
1867 u2 > u1,
1868 "Step change should produce larger output: u1={}, u2={}",
1869 u1,
1870 u2
1871 );
1872 }
1873
1874 #[test]
1875 fn pid_settling_after_step() {
1876 let mut state = PidState::default();
1877 let gains = PidGains::default();
1878
1879 state.update(1.0, &gains);
1881 state.update(1.0, &gains);
1882 state.update(1.0, &gains);
1883
1884 let mut outputs = Vec::new();
1886 for _ in 0..20 {
1887 outputs.push(state.update(0.0, &gains));
1888 }
1889
1890 let last = *outputs.last().unwrap();
1892 assert!(
1893 last.abs() < 0.5,
1894 "PID should settle toward zero: last={}",
1895 last
1896 );
1897 }
1898
1899 #[test]
1900 fn pid_reset_clears_state() {
1901 let mut state = PidState::default();
1902 let gains = PidGains::default();
1903
1904 state.update(5.0, &gains);
1905 state.update(5.0, &gains);
1906 assert!(state.integral.abs() > 0.0);
1907
1908 state.reset();
1909 assert_eq!(state.integral, 0.0);
1910 assert_eq!(state.prev_error, 0.0);
1911 }
1912
1913 #[test]
1916 fn eprocess_starts_at_one() {
1917 let state = EProcessState::default();
1918 assert!(
1919 (state.e_value - 1.0).abs() < f64::EPSILON,
1920 "E-process should start at 1.0"
1921 );
1922 }
1923
1924 #[test]
1925 fn eprocess_grows_under_overload() {
1926 let mut state = EProcessState::default();
1927 let config = EProcessConfig {
1928 warmup_frames: 0,
1929 ..Default::default()
1930 };
1931
1932 for _ in 0..20 {
1934 state.update(30.0, 16.0, &config);
1935 }
1936
1937 assert!(
1938 state.e_value > 1.0,
1939 "E-value should grow under overload: {}",
1940 state.e_value
1941 );
1942 }
1943
1944 #[test]
1945 fn eprocess_shrinks_under_underload() {
1946 let mut state = EProcessState::default();
1947 let config = EProcessConfig {
1948 warmup_frames: 0,
1949 ..Default::default()
1950 };
1951
1952 for _ in 0..20 {
1954 state.update(8.0, 16.0, &config);
1955 }
1956
1957 assert!(
1958 state.e_value < 1.0,
1959 "E-value should shrink under underload: {}",
1960 state.e_value
1961 );
1962 }
1963
1964 #[test]
1965 fn eprocess_gate_blocks_during_warmup() {
1966 let mut state = EProcessState::default();
1967 let config = EProcessConfig {
1968 warmup_frames: 10,
1969 ..Default::default()
1970 };
1971
1972 for _ in 0..5 {
1974 state.update(50.0, 16.0, &config);
1975 }
1976
1977 assert!(
1978 !state.should_degrade(&config),
1979 "E-process should not permit degradation during warmup"
1980 );
1981 }
1982
1983 #[test]
1984 fn eprocess_gate_allows_after_warmup() {
1985 let mut state = EProcessState::default();
1986 let config = EProcessConfig {
1987 warmup_frames: 5,
1988 alpha: 0.05,
1989 ..Default::default()
1990 };
1991
1992 for _ in 0..50 {
1994 state.update(80.0, 16.0, &config);
1995 }
1996
1997 assert!(
1998 state.should_degrade(&config),
1999 "E-process should permit degradation after sustained overload: E={}",
2000 state.e_value
2001 );
2002 }
2003
2004 #[test]
2005 fn eprocess_recovery_after_overload() {
2006 let mut state = EProcessState::default();
2007 let config = EProcessConfig {
2008 warmup_frames: 0,
2009 ..Default::default()
2010 };
2011
2012 for _ in 0..30 {
2014 state.update(40.0, 16.0, &config);
2015 }
2016 let peak = state.e_value;
2017
2018 for _ in 0..100 {
2020 state.update(8.0, 16.0, &config);
2021 }
2022
2023 assert!(
2024 state.e_value < peak,
2025 "E-value should decrease after recovery: peak={}, now={}",
2026 peak,
2027 state.e_value
2028 );
2029 }
2030
2031 #[test]
2032 fn eprocess_sigma_floor_prevents_instability() {
2033 let mut state = EProcessState::default();
2034 let config = EProcessConfig {
2035 sigma_floor_ms: 1.0,
2036 warmup_frames: 0,
2037 ..Default::default()
2038 };
2039
2040 for _ in 0..20 {
2042 state.update(16.0, 16.0, &config);
2043 }
2044
2045 assert!(
2047 state.sigma_ema >= 0.0,
2048 "Sigma should be non-negative: {}",
2049 state.sigma_ema
2050 );
2051 assert!(
2053 state.e_value.is_finite(),
2054 "E-value should be finite: {}",
2055 state.e_value
2056 );
2057 }
2058
2059 #[test]
2060 fn eprocess_reset_returns_to_initial() {
2061 let mut state = EProcessState::default();
2062 let config = EProcessConfig::default();
2063
2064 state.update(50.0, 16.0, &config);
2065 state.update(50.0, 16.0, &config);
2066
2067 state.reset();
2068 assert!((state.e_value - 1.0).abs() < f64::EPSILON);
2069 assert_eq!(state.frames_observed, 0);
2070 }
2071
2072 #[test]
2075 fn controller_holds_under_normal_load() {
2076 let mut ctrl = make_controller_with_config(16, 0, 0);
2077
2078 for _ in 0..20 {
2080 let decision = ctrl.update(Duration::from_millis(16));
2081 assert_eq!(
2082 decision,
2083 BudgetDecision::Hold,
2084 "On-target frames should hold"
2085 );
2086 }
2087 assert_eq!(ctrl.level(), DegradationLevel::Full);
2088 }
2089
2090 #[test]
2091 fn controller_degrades_under_sustained_overload() {
2092 let mut ctrl = make_controller_with_config(16, 0, 0);
2093
2094 let mut degraded = false;
2095 for _ in 0..50 {
2097 let decision = ctrl.update(Duration::from_millis(40));
2098 if decision == BudgetDecision::Degrade {
2099 degraded = true;
2100 }
2101 }
2102
2103 assert!(
2104 degraded,
2105 "Controller should degrade under sustained overload"
2106 );
2107 assert!(
2108 ctrl.level() > DegradationLevel::Full,
2109 "Level should be degraded: {:?}",
2110 ctrl.level()
2111 );
2112 }
2113
2114 #[test]
2115 fn controller_upgrades_after_recovery() {
2116 let mut ctrl = make_controller_with_config(16, 0, 0);
2117
2118 for _ in 0..50 {
2120 ctrl.update(Duration::from_millis(40));
2121 }
2122 let degraded_level = ctrl.level();
2123 assert!(degraded_level > DegradationLevel::Full);
2124
2125 let mut upgraded = false;
2127 for _ in 0..200 {
2128 let decision = ctrl.update(Duration::from_millis(4));
2129 if decision == BudgetDecision::Upgrade {
2130 upgraded = true;
2131 }
2132 }
2133
2134 assert!(upgraded, "Controller should upgrade after recovery");
2135 assert!(
2136 ctrl.level() < degraded_level,
2137 "Level should improve: before={:?}, after={:?}",
2138 degraded_level,
2139 ctrl.level()
2140 );
2141 }
2142
2143 #[test]
2144 fn controller_cooldown_prevents_oscillation() {
2145 let mut ctrl = make_controller_with_config(16, 0, 5);
2146
2147 for _ in 0..50 {
2149 ctrl.update(Duration::from_millis(40));
2150 }
2151
2152 let mut decisions_during_cooldown = Vec::new();
2154 for _ in 0..4 {
2155 decisions_during_cooldown.push(ctrl.update(Duration::from_millis(4)));
2156 }
2157
2158 assert!(
2160 decisions_during_cooldown
2161 .iter()
2162 .all(|d| *d == BudgetDecision::Hold),
2163 "Cooldown should prevent changes: {:?}",
2164 decisions_during_cooldown
2165 );
2166 }
2167
2168 #[test]
2169 fn controller_no_oscillation_under_constant_load() {
2170 let mut ctrl = make_controller_with_config(16, 0, 3);
2171
2172 let mut transitions = 0u32;
2174 let mut prev_level = ctrl.level();
2175 for _ in 0..100 {
2176 ctrl.update(Duration::from_millis(20));
2177 if ctrl.level() != prev_level {
2178 transitions += 1;
2179 prev_level = ctrl.level();
2180 }
2181 }
2182
2183 assert!(
2186 transitions < 10,
2187 "Too many transitions under constant load: {}",
2188 transitions
2189 );
2190 }
2191
2192 #[test]
2193 fn controller_reset_restores_full_quality() {
2194 let mut ctrl = make_controller();
2195
2196 for _ in 0..50 {
2198 ctrl.update(Duration::from_millis(40));
2199 }
2200
2201 ctrl.reset();
2202
2203 assert_eq!(ctrl.level(), DegradationLevel::Full);
2204 assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
2205 assert_eq!(ctrl.pid_integral(), 0.0);
2206 }
2207
2208 #[test]
2209 fn controller_transient_spike_does_not_degrade() {
2210 let mut ctrl = make_controller_with_config(16, 5, 3);
2211
2212 for _ in 0..20 {
2214 ctrl.update(Duration::from_millis(16));
2215 }
2216
2217 ctrl.update(Duration::from_millis(100));
2219
2220 for _ in 0..5 {
2222 ctrl.update(Duration::from_millis(16));
2223 }
2224
2225 assert_eq!(
2227 ctrl.level(),
2228 DegradationLevel::Full,
2229 "Single spike should not cause degradation"
2230 );
2231 }
2232
2233 #[test]
2234 fn controller_never_exceeds_skip_frame() {
2235 let mut ctrl = make_controller_with_config(16, 0, 0);
2236
2237 for _ in 0..500 {
2239 ctrl.update(Duration::from_millis(200));
2240 }
2241
2242 assert!(
2243 ctrl.level() <= DegradationLevel::SkipFrame,
2244 "Level should not exceed SkipFrame: {:?}",
2245 ctrl.level()
2246 );
2247 }
2248
2249 #[test]
2250 fn controller_never_goes_below_full() {
2251 let mut ctrl = make_controller_with_config(16, 0, 0);
2252
2253 for _ in 0..200 {
2255 ctrl.update(Duration::from_millis(1));
2256 }
2257
2258 assert_eq!(
2259 ctrl.level(),
2260 DegradationLevel::Full,
2261 "Level should not go below Full"
2262 );
2263 }
2264
2265 #[test]
2268 fn pid_gains_default_valid() {
2269 let gains = PidGains::default();
2270 assert!(gains.kp > 0.0);
2271 assert!(gains.ki > 0.0);
2272 assert!(gains.kd > 0.0);
2273 assert!(gains.integral_max > 0.0);
2274 }
2275
2276 #[test]
2277 fn eprocess_config_default_valid() {
2278 let config = EProcessConfig::default();
2279 assert!(config.lambda > 0.0);
2280 assert!(config.alpha > 0.0 && config.alpha < 1.0);
2281 assert!(config.beta > 0.0 && config.beta < 1.0);
2282 assert!(config.sigma_floor_ms > 0.0);
2283 }
2284
2285 #[test]
2286 fn controller_config_default_valid() {
2287 let config = BudgetControllerConfig::default();
2288 assert!(config.degrade_threshold > 0.0);
2289 assert!(config.upgrade_threshold > 0.0);
2290 assert!(config.target > Duration::ZERO);
2291 }
2292
2293 #[test]
2294 fn budget_decision_equality() {
2295 assert_eq!(BudgetDecision::Hold, BudgetDecision::Hold);
2296 assert_ne!(BudgetDecision::Hold, BudgetDecision::Degrade);
2297 assert_ne!(BudgetDecision::Degrade, BudgetDecision::Upgrade);
2298 }
2299 }
2300
2301 mod integration_tests {
2306 use super::super::*;
2307
2308 #[test]
2309 fn render_budget_without_controller_returns_no_telemetry() {
2310 let budget = RenderBudget::new(Duration::from_millis(16));
2311 assert!(budget.telemetry().is_none());
2312 assert!(budget.controller().is_none());
2313 }
2314
2315 #[test]
2316 fn render_budget_with_controller_returns_telemetry() {
2317 let budget = RenderBudget::new(Duration::from_millis(16))
2318 .with_controller(BudgetControllerConfig::default());
2319 assert!(budget.controller().is_some());
2320
2321 let telem = budget.telemetry().unwrap();
2322 assert_eq!(telem.level, DegradationLevel::Full);
2323 assert_eq!(telem.last_decision, BudgetDecision::Hold);
2324 assert_eq!(telem.frames_observed, 0);
2325 assert!(telem.in_warmup);
2326 }
2327
2328 #[test]
2329 fn telemetry_fields_update_after_next_frame() {
2330 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2331 BudgetControllerConfig {
2332 eprocess: EProcessConfig {
2333 warmup_frames: 0,
2334 ..Default::default()
2335 },
2336 cooldown_frames: 0,
2337 ..Default::default()
2338 },
2339 );
2340
2341 for _ in 0..5 {
2343 budget.next_frame();
2344 }
2345
2346 let telem = budget.telemetry().unwrap();
2347 assert_eq!(telem.frames_observed, 5);
2348 assert!(!telem.in_warmup);
2349 assert!(telem.pid_output.is_finite());
2352 assert!(telem.e_value.is_finite());
2353 }
2354
2355 #[test]
2356 fn controller_next_frame_degrades_under_simulated_overload() {
2357 let config = BudgetControllerConfig {
2362 target: Duration::from_millis(16),
2363 eprocess: EProcessConfig {
2364 warmup_frames: 0,
2365 ..Default::default()
2366 },
2367 cooldown_frames: 0,
2368 ..Default::default()
2369 };
2370 let mut ctrl = BudgetController::new(config);
2371
2372 for _ in 0..50 {
2374 ctrl.update(Duration::from_millis(40));
2375 }
2376
2377 assert!(
2379 ctrl.level() > DegradationLevel::Full,
2380 "Controller should degrade: {:?}",
2381 ctrl.level()
2382 );
2383
2384 let telem = ctrl.telemetry();
2386 assert!(telem.level > DegradationLevel::Full);
2387 assert!(
2388 telem.pid_output > 0.0,
2389 "PID output should be positive under overload"
2390 );
2391 assert!(telem.e_value > 1.0, "E-value should grow under overload");
2392 }
2393
2394 #[test]
2395 fn next_frame_delegates_to_controller_when_attached() {
2396 let mut budget = RenderBudget::new(Duration::from_millis(1000))
2399 .with_controller(BudgetControllerConfig::default());
2400
2401 budget.degrade();
2403 assert_eq!(budget.degradation(), DegradationLevel::SimpleBorders);
2404
2405 budget.next_frame();
2409
2410 let telem = budget.telemetry().unwrap();
2415 assert_eq!(telem.frames_observed, 1);
2416 }
2417
2418 #[test]
2419 fn telemetry_is_copy_and_no_alloc() {
2420 let budget = RenderBudget::new(Duration::from_millis(16))
2421 .with_controller(BudgetControllerConfig::default());
2422
2423 let telem = budget.telemetry().unwrap();
2424 let telem2 = telem;
2426 assert_eq!(telem.level, telem2.level);
2427 assert_eq!(telem.e_value, telem2.e_value);
2428 }
2429
2430 #[test]
2431 fn telemetry_warmup_flag_transitions() {
2432 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
2433 BudgetControllerConfig {
2434 eprocess: EProcessConfig {
2435 warmup_frames: 3,
2436 ..Default::default()
2437 },
2438 ..Default::default()
2439 },
2440 );
2441
2442 budget.next_frame();
2444 budget.next_frame();
2445 let telem = budget.telemetry().unwrap();
2446 assert!(telem.in_warmup, "Should be in warmup at frame 2");
2447
2448 budget.next_frame();
2450 let telem = budget.telemetry().unwrap();
2451 assert!(!telem.in_warmup, "Should exit warmup at frame 3");
2452 }
2453
2454 #[test]
2455 fn phase_sub_budget_does_not_carry_controller() {
2456 let budget = RenderBudget::new(Duration::from_millis(100))
2457 .with_controller(BudgetControllerConfig::default());
2458
2459 let phase = budget.phase_budget(Phase::Render);
2460 assert!(
2461 phase.controller().is_none(),
2462 "Phase sub-budgets should not carry the controller"
2463 );
2464 }
2465
2466 #[test]
2467 fn controller_telemetry_tracks_frames_since_change() {
2468 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2469 eprocess: EProcessConfig {
2470 warmup_frames: 0,
2471 ..Default::default()
2472 },
2473 cooldown_frames: 0,
2474 ..Default::default()
2475 });
2476
2477 for i in 1..=5 {
2479 ctrl.update(Duration::from_millis(16));
2480 let telem = ctrl.telemetry();
2481 assert_eq!(
2482 telem.frames_since_change, i,
2483 "frames_since_change should be {} after {} frames",
2484 i, i
2485 );
2486 }
2487 }
2488
2489 #[test]
2490 fn telemetry_last_decision_reflects_controller_decision() {
2491 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2492 eprocess: EProcessConfig {
2493 warmup_frames: 0,
2494 ..Default::default()
2495 },
2496 cooldown_frames: 0,
2497 ..Default::default()
2498 });
2499
2500 ctrl.update(Duration::from_millis(16));
2502 assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Hold);
2503
2504 let mut saw_degrade = false;
2506 for _ in 0..50 {
2507 let d = ctrl.update(Duration::from_millis(50));
2508 if d == BudgetDecision::Degrade {
2509 saw_degrade = true;
2510 assert_eq!(ctrl.telemetry().last_decision, BudgetDecision::Degrade);
2511 break;
2512 }
2513 }
2514 assert!(saw_degrade, "Should have seen a Degrade decision");
2515 }
2516
2517 #[test]
2518 fn perf_overhead_controller_update_is_fast() {
2519 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2523
2524 let start = Instant::now();
2525 for _ in 0..10_000 {
2526 ctrl.update(Duration::from_millis(16));
2527 }
2528 let elapsed = start.elapsed();
2529
2530 assert!(
2534 elapsed < Duration::from_millis(50),
2535 "10k controller updates took {:?}, expected <50ms",
2536 elapsed
2537 );
2538 }
2539
2540 #[test]
2541 fn perf_overhead_telemetry_snapshot_is_fast() {
2542 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
2543 ctrl.update(Duration::from_millis(16));
2544
2545 let start = Instant::now();
2546 for _ in 0..10_000 {
2547 let _telem = ctrl.telemetry();
2548 }
2549 let elapsed = start.elapsed();
2550
2551 assert!(
2552 elapsed < Duration::from_millis(10),
2553 "10k telemetry snapshots took {:?}, expected <10ms",
2554 elapsed
2555 );
2556 }
2557 }
2558
2559 mod stability_tests {
2564 use super::super::*;
2565
2566 #[derive(Debug, Clone)]
2567 struct CampaignFrameLog {
2568 frame_idx: u64,
2569 phase: &'static str,
2570 frame_time_us: u64,
2571 telemetry: BudgetTelemetry,
2572 }
2573
2574 fn fast_controller(target_ms: u64) -> BudgetController {
2576 BudgetController::new(BudgetControllerConfig {
2577 target: Duration::from_millis(target_ms),
2578 eprocess: EProcessConfig {
2579 warmup_frames: 0,
2580 ..Default::default()
2581 },
2582 cooldown_frames: 0,
2583 ..Default::default()
2584 })
2585 }
2586
2587 fn run_trace(
2591 ctrl: &mut BudgetController,
2592 trace: &[Duration],
2593 ) -> Vec<(u64, u64, BudgetTelemetry)> {
2594 trace
2595 .iter()
2596 .enumerate()
2597 .map(|(i, &ft)| {
2598 ctrl.update(ft);
2599 let telem = ctrl.telemetry();
2600 (i as u64, ft.as_micros() as u64, telem)
2601 })
2602 .collect()
2603 }
2604
2605 fn run_campaign(
2607 ctrl: &mut BudgetController,
2608 phases: &[(&'static str, usize, Duration)],
2609 ) -> Vec<CampaignFrameLog> {
2610 let mut logs = Vec::new();
2611 let mut frame_idx: u64 = 0;
2612 for &(phase, count, frame_time) in phases {
2613 for _ in 0..count {
2614 ctrl.update(frame_time);
2615 logs.push(CampaignFrameLog {
2616 frame_idx,
2617 phase,
2618 frame_time_us: frame_time.as_micros() as u64,
2619 telemetry: ctrl.telemetry(),
2620 });
2621 frame_idx = frame_idx.saturating_add(1);
2622 }
2623 }
2624 logs
2625 }
2626
2627 fn count_transitions(log: &[(u64, u64, BudgetTelemetry)]) -> u32 {
2629 let mut transitions = 0u32;
2630 for pair in log.windows(2) {
2631 if pair[0].2.level != pair[1].2.level {
2632 transitions += 1;
2633 }
2634 }
2635 transitions
2636 }
2637
2638 #[test]
2641 fn e2e_burst_logs_no_oscillation() {
2642 let mut ctrl = fast_controller(16);
2645
2646 let mut trace = Vec::new();
2647 for _cycle in 0..5 {
2648 for _ in 0..10 {
2650 trace.push(Duration::from_millis(40));
2651 }
2652 for _ in 0..20 {
2654 trace.push(Duration::from_millis(16));
2655 }
2656 }
2657
2658 let log = run_trace(&mut ctrl, &trace);
2659
2660 let transitions = count_transitions(&log);
2665 assert!(
2666 transitions < 20,
2667 "Too many transitions under bursty load: {} (expected <20)",
2668 transitions
2669 );
2670
2671 for (frame, ft_us, telem) in &log {
2673 assert!(
2674 telem.pid_output.is_finite(),
2675 "frame {}: NaN pid_output",
2676 frame
2677 );
2678 assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
2679 assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
2680 assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
2681 assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
2682 assert!(*ft_us > 0, "frame {}: zero frame time", frame);
2683 }
2684 }
2685
2686 #[test]
2687 fn e2e_burst_recovers_after_moderate_overload() {
2688 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2692 target: Duration::from_millis(16),
2693 eprocess: EProcessConfig {
2694 warmup_frames: 5,
2695 ..Default::default()
2696 },
2697 cooldown_frames: 3,
2698 ..Default::default()
2699 });
2700
2701 let mut trace = Vec::new();
2702 for _cycle in 0..3 {
2703 for _ in 0..15 {
2705 trace.push(Duration::from_millis(30));
2706 }
2707 for _ in 0..50 {
2709 trace.push(Duration::from_millis(10));
2710 }
2711 }
2712
2713 let log = run_trace(&mut ctrl, &trace);
2714
2715 for cycle in 0..3 {
2718 let calm_end = (cycle + 1) * 65 - 1;
2719 if calm_end < log.len() {
2720 assert!(
2721 log[calm_end].2.level < DegradationLevel::SkipFrame,
2722 "cycle {}: should recover after calm period, got {:?} at frame {}",
2723 cycle,
2724 log[calm_end].2.level,
2725 calm_end
2726 );
2727 }
2728 }
2729
2730 let final_level = log.last().unwrap().2.level;
2732 assert!(
2733 final_level < DegradationLevel::Skeleton,
2734 "Final level should recover below Skeleton: {:?}",
2735 final_level
2736 );
2737 }
2738
2739 #[test]
2742 fn e2e_idle_to_burst_recovery() {
2743 let mut ctrl = fast_controller(16);
2746
2747 let mut trace = Vec::new();
2748 for _ in 0..50 {
2750 trace.push(Duration::from_millis(8));
2751 }
2752 for _ in 0..20 {
2754 trace.push(Duration::from_millis(50));
2755 }
2756 for _ in 0..100 {
2758 trace.push(Duration::from_millis(8));
2759 }
2760
2761 let log = run_trace(&mut ctrl, &trace);
2762
2763 assert_eq!(
2765 log[49].2.level,
2766 DegradationLevel::Full,
2767 "Should be Full during idle phase"
2768 );
2769
2770 let max_during_burst = log[50..70].iter().map(|(_, _, t)| t.level).max().unwrap();
2772 assert!(
2773 max_during_burst > DegradationLevel::Full,
2774 "Should degrade during burst"
2775 );
2776
2777 let final_level = log.last().unwrap().2.level;
2779 assert!(
2780 final_level < max_during_burst,
2781 "Should recover after burst: final={:?}, max_during_burst={:?}",
2782 final_level,
2783 max_during_burst
2784 );
2785 }
2786
2787 #[test]
2788 fn e2e_idle_to_burst_no_over_degrade() {
2789 let mut ctrl = fast_controller(16);
2792
2793 for _ in 0..30 {
2795 ctrl.update(Duration::from_millis(8));
2796 }
2797
2798 for _ in 0..5 {
2800 ctrl.update(Duration::from_millis(40));
2801 }
2802
2803 let level = ctrl.level();
2805 assert!(
2806 level <= DegradationLevel::NoStyling,
2807 "Brief burst should not over-degrade: {:?}",
2808 level
2809 );
2810 }
2811
2812 #[test]
2813 fn e2e_overload_campaign_burst_sustained_recovery_with_replay_logs() {
2814 let phases: [(&str, usize, Duration); 3] = [
2821 ("burst_overload", 24, Duration::from_millis(28)),
2822 ("sustained_overload", 80, Duration::from_millis(52)),
2823 ("recovery_underload", 140, Duration::from_millis(8)),
2824 ];
2825
2826 let mut ctrl = BudgetController::new(BudgetControllerConfig {
2827 target: Duration::from_millis(16),
2828 eprocess: EProcessConfig {
2829 warmup_frames: 0,
2830 ..Default::default()
2831 },
2832 cooldown_frames: 0,
2833 degradation_floor: DegradationLevel::SkipFrame,
2834 ..Default::default()
2835 });
2836 let logs = run_campaign(&mut ctrl, &phases);
2837 assert!(!logs.is_empty(), "campaign logs must be non-empty");
2838
2839 let mut burst_degrades = 0u32;
2840 let mut sustained_degrades = 0u32;
2841 let mut sustained_degraded_frames = 0u32;
2842 let mut recovery_upgrades = 0u32;
2843 let mut max_level = DegradationLevel::Full;
2844
2845 for log in &logs {
2846 let telem = &log.telemetry;
2847 if telem.level > max_level {
2848 max_level = telem.level;
2849 }
2850 if log.phase == "burst_overload" && telem.last_decision == BudgetDecision::Degrade {
2851 burst_degrades = burst_degrades.saturating_add(1);
2852 }
2853 if log.phase == "sustained_overload"
2854 && telem.last_decision == BudgetDecision::Degrade
2855 {
2856 sustained_degrades = sustained_degrades.saturating_add(1);
2857 }
2858 if log.phase == "sustained_overload" && telem.level > DegradationLevel::Full {
2859 sustained_degraded_frames = sustained_degraded_frames.saturating_add(1);
2860 }
2861 if log.phase == "recovery_underload"
2862 && telem.last_decision == BudgetDecision::Upgrade
2863 {
2864 recovery_upgrades = recovery_upgrades.saturating_add(1);
2865 }
2866
2867 assert!(
2869 telem.level <= DegradationLevel::SkipFrame,
2870 "frame {}: invalid degradation level {:?}",
2871 log.frame_idx,
2872 telem.level
2873 );
2874 assert!(
2875 telem.e_value.is_finite() && telem.e_value > 0.0,
2876 "frame {}: invalid e_value {}",
2877 log.frame_idx,
2878 telem.e_value
2879 );
2880 assert!(
2881 telem.pid_output.is_finite(),
2882 "frame {}: invalid pid_output {}",
2883 log.frame_idx,
2884 telem.pid_output
2885 );
2886 }
2887
2888 for pair in logs.windows(2) {
2890 let prev = pair[0].telemetry.level.level();
2891 let curr = pair[1].telemetry.level.level();
2892 let delta = (curr as i16 - prev as i16).unsigned_abs();
2893 assert!(
2894 delta <= 1,
2895 "frame {}->{} level jump {}: {:?} -> {:?}",
2896 pair[0].frame_idx,
2897 pair[1].frame_idx,
2898 delta,
2899 pair[0].telemetry.level,
2900 pair[1].telemetry.level
2901 );
2902 }
2903
2904 assert!(
2905 burst_degrades > 0,
2906 "burst phase should trigger degradation decisions"
2907 );
2908 assert!(
2909 sustained_degrades > 0 || sustained_degraded_frames > 0,
2910 "sustained overload phase should maintain degraded operation"
2911 );
2912 assert!(
2913 max_level >= DegradationLevel::Skeleton,
2914 "sustained overload should reach deep degradation (got {:?})",
2915 max_level
2916 );
2917 assert!(
2918 recovery_upgrades > 0,
2919 "recovery phase should trigger upgrade decisions"
2920 );
2921
2922 let final_level = logs
2923 .last()
2924 .map(|entry| entry.telemetry.level)
2925 .unwrap_or(DegradationLevel::SkipFrame);
2926 assert!(
2927 final_level < max_level,
2928 "final level should recover below peak degradation: final={:?} peak={:?}",
2929 final_level,
2930 max_level
2931 );
2932
2933 let mut ctrl_replay = BudgetController::new(BudgetControllerConfig {
2935 target: Duration::from_millis(16),
2936 eprocess: EProcessConfig {
2937 warmup_frames: 0,
2938 ..Default::default()
2939 },
2940 cooldown_frames: 0,
2941 degradation_floor: DegradationLevel::SkipFrame,
2942 ..Default::default()
2943 });
2944 let replay_logs = run_campaign(&mut ctrl_replay, &phases);
2945 assert_eq!(
2946 logs.len(),
2947 replay_logs.len(),
2948 "log length mismatch in replay"
2949 );
2950 for (lhs, rhs) in logs.iter().zip(replay_logs.iter()) {
2951 assert_eq!(lhs.frame_idx, rhs.frame_idx);
2952 assert_eq!(lhs.phase, rhs.phase);
2953 assert_eq!(lhs.frame_time_us, rhs.frame_time_us);
2954 assert_eq!(lhs.telemetry.schema_version, rhs.telemetry.schema_version);
2955 assert_eq!(lhs.telemetry.level, rhs.telemetry.level);
2956 assert_eq!(lhs.telemetry.last_decision, rhs.telemetry.last_decision);
2957 assert_eq!(
2958 lhs.telemetry.decision_reason, rhs.telemetry.decision_reason,
2959 "decision_reason mismatch at frame {}",
2960 lhs.frame_idx
2961 );
2962 assert_eq!(
2963 lhs.telemetry.transition_seq, rhs.telemetry.transition_seq,
2964 "transition_seq mismatch at frame {}",
2965 lhs.frame_idx
2966 );
2967 assert_eq!(
2968 lhs.telemetry.transition_correlation_id,
2969 rhs.telemetry.transition_correlation_id,
2970 "transition_correlation_id mismatch at frame {}",
2971 lhs.frame_idx
2972 );
2973 assert!(
2974 (lhs.telemetry.pid_output - rhs.telemetry.pid_output).abs() < 1e-12,
2975 "pid_output mismatch at frame {}",
2976 lhs.frame_idx
2977 );
2978 assert!(
2979 (lhs.telemetry.e_value - rhs.telemetry.e_value).abs() < 1e-12,
2980 "e_value mismatch at frame {}",
2981 lhs.frame_idx
2982 );
2983 }
2984
2985 for entry in &logs {
2987 let t = &entry.telemetry;
2988 eprintln!(
2989 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":{}}}"#,
2990 t.schema_version,
2991 entry.frame_idx,
2992 entry.phase,
2993 entry.frame_time_us,
2994 t.last_decision.as_str(),
2995 t.decision_reason.as_str(),
2996 t.transition_seq,
2997 t.transition_correlation_id,
2998 t.level.as_str(),
2999 t.pid_output,
3000 t.pid_p,
3001 t.pid_i,
3002 t.pid_d,
3003 t.e_value,
3004 t.frame_time_ms,
3005 t.target_ms,
3006 t.pid_gate_threshold,
3007 t.pid_gate_margin,
3008 t.evidence_threshold,
3009 t.evidence_margin,
3010 t.frames_observed,
3011 t.frames_since_change
3012 );
3013 }
3014 eprintln!(
3015 r#"{{"event":"control_campaign_summary","schema_version":{},"scenario":"bd-2vr05.15.4.5","frames":{},"burst_degrades":{},"sustained_degrades":{},"recovery_upgrades":{},"peak_level":"{}","final_level":"{}"}}"#,
3016 BUDGET_TELEMETRY_SCHEMA_VERSION,
3017 logs.len(),
3018 burst_degrades,
3019 sustained_degrades,
3020 recovery_upgrades,
3021 max_level.as_str(),
3022 final_level.as_str()
3023 );
3024 }
3025
3026 #[test]
3029 fn property_random_load_hysteresis_bounds() {
3030 let mut ctrl = fast_controller(16);
3033
3034 let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_BABE;
3037 let mut trace = Vec::new();
3038 for _ in 0..1000 {
3039 rng_state = rng_state
3041 .wrapping_mul(6_364_136_223_846_793_005)
3042 .wrapping_add(1_442_695_040_888_963_407);
3043 let frame_ms = 4 + ((rng_state >> 33) % 77);
3045 trace.push(Duration::from_millis(frame_ms));
3046 }
3047
3048 let log = run_trace(&mut ctrl, &trace);
3049
3050 for pair in log.windows(2) {
3052 let prev = pair[0].2.level.level();
3053 let curr = pair[1].2.level.level();
3054 let delta = (curr as i16 - prev as i16).unsigned_abs();
3055 assert!(
3056 delta <= 1,
3057 "Level jumped {} steps at frame {}: {:?} -> {:?}",
3058 delta,
3059 pair[1].0,
3060 pair[0].2.level,
3061 pair[1].2.level
3062 );
3063 }
3064
3065 for (frame, _, telem) in &log {
3067 assert!(
3068 telem.level <= DegradationLevel::SkipFrame,
3069 "frame {}: level out of range: {:?}",
3070 frame,
3071 telem.level
3072 );
3073 }
3074
3075 for (frame, _, telem) in &log {
3077 assert!(
3078 telem.pid_output.is_finite(),
3079 "frame {}: NaN pid_output",
3080 frame
3081 );
3082 assert!(telem.pid_p.is_finite(), "frame {}: NaN pid_p", frame);
3083 assert!(telem.pid_i.is_finite(), "frame {}: NaN pid_i", frame);
3084 assert!(telem.pid_d.is_finite(), "frame {}: NaN pid_d", frame);
3085 assert!(telem.e_value.is_finite(), "frame {}: NaN e_value", frame);
3086 assert!(
3087 telem.e_value > 0.0,
3088 "frame {}: e_value not positive: {}",
3089 frame,
3090 telem.e_value
3091 );
3092 }
3093 }
3094
3095 #[test]
3096 fn property_random_load_bounded_transitions() {
3097 let mut ctrl = BudgetController::new(BudgetControllerConfig {
3100 target: Duration::from_millis(16),
3101 eprocess: EProcessConfig {
3102 warmup_frames: 5,
3103 ..Default::default()
3104 },
3105 cooldown_frames: 3,
3106 ..Default::default()
3107 });
3108
3109 let mut rng_state: u64 = 0x1234_5678_9ABC_DEF0;
3111 let mut trace = Vec::new();
3112 for _ in 0..500 {
3113 rng_state = rng_state
3114 .wrapping_mul(6_364_136_223_846_793_005)
3115 .wrapping_add(1_442_695_040_888_963_407);
3116 let frame_ms = 8 + ((rng_state >> 33) % 40);
3117 trace.push(Duration::from_millis(frame_ms));
3118 }
3119
3120 let log = run_trace(&mut ctrl, &trace);
3121 let transitions = count_transitions(&log);
3122
3123 assert!(
3126 transitions < 80,
3127 "Too many transitions under random load: {} (expected <80 with cooldown=3)",
3128 transitions
3129 );
3130 }
3131
3132 #[test]
3133 fn property_deterministic_replay() {
3134 let trace: Vec<Duration> = (0..100)
3136 .map(|i| Duration::from_millis(10 + (i * 7 % 30)))
3137 .collect();
3138
3139 let mut ctrl1 = fast_controller(16);
3140 let log1 = run_trace(&mut ctrl1, &trace);
3141
3142 let mut ctrl2 = fast_controller(16);
3143 let log2 = run_trace(&mut ctrl2, &trace);
3144
3145 for (r1, r2) in log1.iter().zip(log2.iter()) {
3146 assert_eq!(r1.0, r2.0, "frame index mismatch");
3147 assert_eq!(r1.1, r2.1, "frame time mismatch");
3148 assert_eq!(r1.2.schema_version, r2.2.schema_version);
3149 assert_eq!(r1.2.level, r2.2.level, "level mismatch at frame {}", r1.0);
3150 assert_eq!(
3151 r1.2.last_decision, r2.2.last_decision,
3152 "decision mismatch at frame {}",
3153 r1.0
3154 );
3155 assert_eq!(
3156 r1.2.decision_reason, r2.2.decision_reason,
3157 "decision_reason mismatch at frame {}",
3158 r1.0
3159 );
3160 assert_eq!(
3161 r1.2.transition_seq, r2.2.transition_seq,
3162 "transition_seq mismatch at frame {}",
3163 r1.0
3164 );
3165 assert_eq!(
3166 r1.2.transition_correlation_id, r2.2.transition_correlation_id,
3167 "transition_correlation_id mismatch at frame {}",
3168 r1.0
3169 );
3170 assert!(
3171 (r1.2.pid_output - r2.2.pid_output).abs() < 1e-10,
3172 "pid_output mismatch at frame {}: {} vs {}",
3173 r1.0,
3174 r1.2.pid_output,
3175 r2.2.pid_output
3176 );
3177 assert!(
3178 (r1.2.e_value - r2.2.e_value).abs() < 1e-10,
3179 "e_value mismatch at frame {}: {} vs {}",
3180 r1.0,
3181 r1.2.e_value,
3182 r2.2.e_value
3183 );
3184 }
3185 }
3186
3187 #[test]
3190 fn telemetry_jsonl_fields_complete() {
3191 let mut ctrl = fast_controller(16);
3193 ctrl.update(Duration::from_millis(20));
3194
3195 let telem = ctrl.telemetry();
3196
3197 let _schema_version: u16 = telem.schema_version;
3199 let _degradation: &str = telem.level.as_str();
3200 let _pid_p: f64 = telem.pid_p;
3201 let _pid_i: f64 = telem.pid_i;
3202 let _pid_d: f64 = telem.pid_d;
3203 let _e_value: f64 = telem.e_value;
3204 let _decision: &str = telem.last_decision.as_str();
3205 let _reason: &str = telem.decision_reason.as_str();
3206 let _transition_seq: u64 = telem.transition_seq;
3207 let _transition_correlation_id: u64 = telem.transition_correlation_id;
3208 let _frame_time_ms: f64 = telem.frame_time_ms;
3209 let _target_ms: f64 = telem.target_ms;
3210 let _pid_gate_threshold: f64 = telem.pid_gate_threshold;
3211 let _pid_gate_margin: f64 = telem.pid_gate_margin;
3212 let _evidence_threshold: f64 = telem.evidence_threshold;
3213 let _evidence_margin: f64 = telem.evidence_margin;
3214 let _frames: u32 = telem.frames_observed;
3215
3216 assert_eq!(BudgetDecision::Hold.as_str(), "stay");
3218 assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
3219 assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
3220 assert_eq!(
3221 BUDGET_TELEMETRY_SCHEMA_VERSION, telem.schema_version,
3222 "schema version mismatch"
3223 );
3224 }
3225
3226 #[test]
3227 fn telemetry_transition_records_correlation_reason_and_evidence() {
3228 let mut ctrl = fast_controller(16);
3229
3230 let mut degrade_telem = None;
3232 for _ in 0..64 {
3233 ctrl.update(Duration::from_millis(48));
3234 let telem = ctrl.telemetry();
3235 if telem.last_decision == BudgetDecision::Degrade {
3236 degrade_telem = Some(telem);
3237 break;
3238 }
3239 }
3240 let degrade_telem =
3241 degrade_telem.expect("expected degrade transition with correlation metadata");
3242 assert_eq!(
3243 degrade_telem.decision_reason,
3244 BudgetDecisionReason::OverloadEvidencePassed
3245 );
3246 assert!(
3247 degrade_telem.transition_seq > 0,
3248 "transition_seq should increment on transitions"
3249 );
3250 assert!(
3251 degrade_telem.transition_correlation_id > 0,
3252 "transition correlation id should be populated on transitions"
3253 );
3254 assert!(
3255 degrade_telem.pid_gate_margin > 0.0,
3256 "degrade transition should have positive PID gate margin"
3257 );
3258 assert!(
3259 degrade_telem.evidence_margin > 0.0,
3260 "degrade transition should have positive evidence margin"
3261 );
3262
3263 let mut upgrade_telem = None;
3265 for _ in 0..160 {
3266 ctrl.update(Duration::from_millis(4));
3267 let telem = ctrl.telemetry();
3268 if telem.last_decision == BudgetDecision::Upgrade {
3269 upgrade_telem = Some(telem);
3270 break;
3271 }
3272 }
3273 let upgrade_telem =
3274 upgrade_telem.expect("expected upgrade transition with correlation metadata");
3275 assert_eq!(
3276 upgrade_telem.decision_reason,
3277 BudgetDecisionReason::UnderloadEvidencePassed
3278 );
3279 assert!(
3280 upgrade_telem.transition_seq >= degrade_telem.transition_seq,
3281 "transition sequence should be monotonic"
3282 );
3283 assert!(
3284 upgrade_telem.transition_correlation_id >= degrade_telem.transition_correlation_id,
3285 "transition correlation id should be monotonic"
3286 );
3287 assert!(
3288 upgrade_telem.pid_gate_margin > 0.0,
3289 "upgrade transition should have positive PID gate margin"
3290 );
3291 assert!(
3292 upgrade_telem.evidence_margin > 0.0,
3293 "upgrade transition should have positive evidence margin"
3294 );
3295 }
3296
3297 #[test]
3298 fn telemetry_pid_components_sum_to_output() {
3299 let mut ctrl = fast_controller(16);
3301
3302 for ms in [10u64, 16, 20, 30, 8, 50] {
3303 ctrl.update(Duration::from_millis(ms));
3304 let telem = ctrl.telemetry();
3305 let sum = telem.pid_p + telem.pid_i + telem.pid_d;
3306 assert!(
3307 (sum - telem.pid_output).abs() < 1e-10,
3308 "P+I+D != output at {}ms: {} + {} + {} = {} != {}",
3309 ms,
3310 telem.pid_p,
3311 telem.pid_i,
3312 telem.pid_d,
3313 sum,
3314 telem.pid_output
3315 );
3316 }
3317 }
3318 }
3319
3320 mod edge_case_tests {
3325 use super::super::*;
3326
3327 #[test]
3330 fn pid_negative_integral_windup() {
3331 let mut state = PidState::default();
3333 let gains = PidGains {
3334 integral_max: 3.0,
3335 ..Default::default()
3336 };
3337
3338 for _ in 0..200 {
3339 state.update(-10.0, &gains);
3340 }
3341
3342 assert!(
3343 state.integral >= -3.0 - f64::EPSILON,
3344 "Negative integral should be clamped to -max: {}",
3345 state.integral
3346 );
3347 assert!(
3348 state.integral <= -3.0 + f64::EPSILON,
3349 "Negative integral should saturate at -max: {}",
3350 state.integral
3351 );
3352 }
3353
3354 #[test]
3355 fn pid_zero_gains_zero_output() {
3356 let mut state = PidState::default();
3357 let gains = PidGains {
3358 kp: 0.0,
3359 ki: 0.0,
3360 kd: 0.0,
3361 integral_max: 5.0,
3362 };
3363
3364 let u = state.update(42.0, &gains);
3365 assert!(
3366 u.abs() < 1e-10,
3367 "Zero gains should yield zero output: {}",
3368 u
3369 );
3370 }
3371
3372 #[test]
3373 fn pid_large_error_stays_finite() {
3374 let mut state = PidState::default();
3375 let gains = PidGains::default();
3376
3377 let u = state.update(1e12, &gains);
3379 assert!(
3380 u.is_finite(),
3381 "PID output should be finite for large error: {}",
3382 u
3383 );
3384
3385 assert!(
3387 state.integral <= gains.integral_max + f64::EPSILON,
3388 "Integral should be clamped: {}",
3389 state.integral
3390 );
3391 }
3392
3393 #[test]
3394 fn pid_alternating_error_derivative_responds() {
3395 let mut state = PidState::default();
3396 let gains = PidGains::default();
3397
3398 let u1 = state.update(1.0, &gains);
3400 let u2 = state.update(-1.0, &gains);
3401
3402 assert!(
3405 u2 < u1,
3406 "Alternating error should reduce output: u1={}, u2={}",
3407 u1,
3408 u2
3409 );
3410 }
3411
3412 #[test]
3413 fn pid_telemetry_terms_match_after_update() {
3414 let mut state = PidState::default();
3415 let gains = PidGains::default();
3416
3417 state.update(2.0, &gains);
3418
3419 assert!(
3421 (state.last_p - 1.0).abs() < 1e-10,
3422 "P term: {}",
3423 state.last_p
3424 );
3425 assert!(
3427 (state.last_i - 0.1).abs() < 1e-10,
3428 "I term: {}",
3429 state.last_i
3430 );
3431 assert!(
3433 (state.last_d - 0.4).abs() < 1e-10,
3434 "D term: {}",
3435 state.last_d
3436 );
3437 }
3438
3439 #[test]
3440 fn pid_integral_clamping_symmetric() {
3441 let mut state = PidState::default();
3442 let gains = PidGains {
3443 integral_max: 1.0,
3444 ..Default::default()
3445 };
3446
3447 for _ in 0..50 {
3449 state.update(100.0, &gains);
3450 }
3451 let pos_integral = state.integral;
3452
3453 state.reset();
3454
3455 for _ in 0..50 {
3457 state.update(-100.0, &gains);
3458 }
3459 let neg_integral = state.integral;
3460
3461 assert!(
3462 (pos_integral + neg_integral).abs() < f64::EPSILON,
3463 "Clamping should be symmetric: pos={}, neg={}",
3464 pos_integral,
3465 neg_integral
3466 );
3467 }
3468
3469 #[test]
3472 fn eprocess_first_frame_initializes_mean() {
3473 let mut state = EProcessState::default();
3474 let config = EProcessConfig::default();
3475
3476 state.update(25.0, 16.0, &config);
3477
3478 assert!(
3479 (state.mean_ema - 25.0).abs() < f64::EPSILON,
3480 "First frame should set mean_ema directly: {}",
3481 state.mean_ema
3482 );
3483 assert!(
3484 (state.sigma_ema - config.sigma_floor_ms).abs() < f64::EPSILON,
3485 "First frame should set sigma_ema to floor: {}",
3486 state.sigma_ema
3487 );
3488 assert_eq!(state.frames_observed, 1);
3489 }
3490
3491 #[test]
3492 fn eprocess_e_value_clamped_at_upper_bound() {
3493 let mut state = EProcessState::default();
3494 let config = EProcessConfig {
3495 lambda: 2.0, warmup_frames: 0,
3497 sigma_floor_ms: 0.001, ..Default::default()
3499 };
3500
3501 for _ in 0..1000 {
3503 state.update(1e6, 16.0, &config);
3504 }
3505
3506 assert!(
3507 state.e_value <= 1e10,
3508 "E-value should be clamped at 1e10: {}",
3509 state.e_value
3510 );
3511 }
3512
3513 #[test]
3514 fn eprocess_e_value_clamped_at_lower_bound() {
3515 let mut state = EProcessState::default();
3516 let config = EProcessConfig {
3517 lambda: 2.0,
3518 warmup_frames: 0,
3519 sigma_floor_ms: 0.001,
3520 ..Default::default()
3521 };
3522
3523 for _ in 0..1000 {
3525 state.update(0.001, 1e6, &config);
3526 }
3527
3528 assert!(
3529 state.e_value >= 1e-10,
3530 "E-value should be clamped at 1e-10: {}",
3531 state.e_value
3532 );
3533 }
3534
3535 #[test]
3536 fn eprocess_should_upgrade_during_warmup() {
3537 let state = EProcessState::default();
3538 let config = EProcessConfig {
3539 warmup_frames: 10,
3540 ..Default::default()
3541 };
3542
3543 assert!(
3545 state.should_upgrade(&config),
3546 "should_upgrade should return true during warmup"
3547 );
3548 }
3549
3550 #[test]
3551 fn eprocess_frames_observed_saturates() {
3552 let mut state = EProcessState {
3553 frames_observed: u32::MAX,
3554 ..EProcessState::default()
3555 };
3556 let config = EProcessConfig::default();
3557
3558 state.update(16.0, 16.0, &config);
3560 assert_eq!(
3561 state.frames_observed,
3562 u32::MAX,
3563 "frames_observed should saturate at u32::MAX"
3564 );
3565 }
3566
3567 #[test]
3568 fn eprocess_sigma_ema_decay_boundary_zero() {
3569 let mut state = EProcessState::default();
3570 let config = EProcessConfig {
3571 sigma_ema_decay: 0.0,
3572 warmup_frames: 0,
3573 ..Default::default()
3574 };
3575
3576 state.update(20.0, 16.0, &config);
3578 state.update(30.0, 16.0, &config);
3579
3580 assert!(
3582 (state.mean_ema - 30.0).abs() < f64::EPSILON,
3583 "decay=0 should fully replace mean_ema: {}",
3584 state.mean_ema
3585 );
3586 }
3587
3588 #[test]
3589 fn eprocess_sigma_ema_decay_boundary_one() {
3590 let mut state = EProcessState::default();
3591 let config = EProcessConfig {
3592 sigma_ema_decay: 1.0,
3593 warmup_frames: 0,
3594 ..Default::default()
3595 };
3596
3597 state.update(20.0, 16.0, &config);
3599 let first_mean = state.mean_ema;
3600 state.update(100.0, 16.0, &config);
3601
3602 assert!(
3603 (state.mean_ema - first_mean).abs() < f64::EPSILON,
3604 "decay=1 should lock mean_ema at first value: got {}, expected {}",
3605 state.mean_ema,
3606 first_mean
3607 );
3608 }
3609
3610 #[test]
3611 fn eprocess_zero_target_no_panic() {
3612 let mut state = EProcessState::default();
3613 let config = EProcessConfig {
3614 warmup_frames: 0,
3615 ..Default::default()
3616 };
3617
3618 let e = state.update(16.0, 0.0, &config);
3620 assert!(
3621 e.is_finite(),
3622 "E-value should be finite with zero target: {}",
3623 e
3624 );
3625 }
3626
3627 #[test]
3630 fn degradation_level_default_is_full() {
3631 assert_eq!(DegradationLevel::default(), DegradationLevel::Full);
3632 }
3633
3634 #[test]
3635 fn degradation_level_hash_unique() {
3636 use std::collections::HashSet;
3637 let levels = [
3638 DegradationLevel::Full,
3639 DegradationLevel::SimpleBorders,
3640 DegradationLevel::NoStyling,
3641 DegradationLevel::EssentialOnly,
3642 DegradationLevel::Skeleton,
3643 DegradationLevel::SkipFrame,
3644 ];
3645 let set: HashSet<DegradationLevel> = levels.iter().copied().collect();
3646 assert_eq!(set.len(), 6, "All levels should hash uniquely");
3647 }
3648
3649 #[test]
3650 fn degradation_level_widget_queries_full() {
3651 let l = DegradationLevel::Full;
3652 assert!(l.use_unicode_borders());
3653 assert!(l.apply_styling());
3654 assert!(l.render_decorative());
3655 assert!(l.render_content());
3656 }
3657
3658 #[test]
3659 fn degradation_level_widget_queries_simple_borders() {
3660 let l = DegradationLevel::SimpleBorders;
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_no_styling() {
3669 let l = DegradationLevel::NoStyling;
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_essential_only() {
3678 let l = DegradationLevel::EssentialOnly;
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_skeleton() {
3687 let l = DegradationLevel::Skeleton;
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_skip_frame() {
3696 let l = DegradationLevel::SkipFrame;
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_partial_ord_consistent() {
3705 let levels = [
3707 DegradationLevel::Full,
3708 DegradationLevel::SimpleBorders,
3709 DegradationLevel::NoStyling,
3710 DegradationLevel::EssentialOnly,
3711 DegradationLevel::Skeleton,
3712 DegradationLevel::SkipFrame,
3713 ];
3714 for (i, a) in levels.iter().enumerate() {
3715 for (j, b) in levels.iter().enumerate() {
3716 let po = a.partial_cmp(b);
3717 let o = a.cmp(b);
3718 assert_eq!(po, Some(o), "PartialOrd != Ord for {:?} vs {:?}", a, b);
3719 if i < j {
3720 assert!(*a < *b, "{:?} should be < {:?}", a, b);
3721 }
3722 }
3723 }
3724 }
3725
3726 #[test]
3727 fn degradation_level_clone_eq() {
3728 let a = DegradationLevel::NoStyling;
3729 let b = a;
3730 assert_eq!(a, b);
3731 }
3732
3733 #[test]
3734 fn degradation_level_debug() {
3735 let s = format!("{:?}", DegradationLevel::EssentialOnly);
3736 assert!(s.contains("EssentialOnly"), "Debug output: {}", s);
3737 }
3738
3739 #[test]
3742 fn controller_eprocess_sigma_ms_uses_floor() {
3743 let ctrl = BudgetController::new(BudgetControllerConfig {
3744 eprocess: EProcessConfig {
3745 sigma_floor_ms: 2.5,
3746 ..Default::default()
3747 },
3748 ..Default::default()
3749 });
3750
3751 assert!(
3753 (ctrl.eprocess_sigma_ms() - 2.5).abs() < f64::EPSILON,
3754 "Should return sigma_floor_ms when sigma_ema < floor: {}",
3755 ctrl.eprocess_sigma_ms()
3756 );
3757 }
3758
3759 #[test]
3760 fn controller_config_accessor() {
3761 let config = BudgetControllerConfig {
3762 degrade_threshold: 0.42,
3763 ..Default::default()
3764 };
3765 let ctrl = BudgetController::new(config.clone());
3766
3767 assert_eq!(ctrl.config().degrade_threshold, 0.42);
3768 assert_eq!(ctrl.config().target, Duration::from_millis(16));
3769 }
3770
3771 #[test]
3772 fn controller_frames_observed_accessor() {
3773 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
3774
3775 assert_eq!(ctrl.frames_observed(), 0);
3776
3777 ctrl.update(Duration::from_millis(16));
3778 assert_eq!(ctrl.frames_observed(), 1);
3779
3780 ctrl.update(Duration::from_millis(16));
3781 assert_eq!(ctrl.frames_observed(), 2);
3782 }
3783
3784 #[test]
3787 fn render_budget_record_frame_time_used_by_next_frame() {
3788 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3789 budget.degrade();
3790
3791 for _ in 0..10 {
3793 budget.reset();
3794 }
3795
3796 budget.record_frame_time(Duration::from_millis(1));
3798 std::thread::sleep(Duration::from_millis(15));
3800
3801 let before = budget.degradation();
3802 budget.next_frame();
3803
3804 assert!(
3807 budget.degradation() < before,
3808 "Recorded frame time should enable upgrade: before={:?}, after={:?}",
3809 before,
3810 budget.degradation()
3811 );
3812 }
3813
3814 #[test]
3815 fn render_budget_phase_budget_clamped_by_remaining() {
3816 let budget = RenderBudget::new(Duration::from_millis(1));
3818 std::thread::sleep(Duration::from_millis(5));
3819
3820 let phase = budget.phase_budget(Phase::Render);
3822 assert!(
3823 phase.total() <= Duration::from_millis(1),
3824 "Phase budget should be clamped by remaining: {:?}",
3825 phase.total()
3826 );
3827 }
3828
3829 #[test]
3830 fn render_budget_exhausted_skipframe_with_no_frame_skip() {
3831 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3832 budget.allow_frame_skip = false;
3833 budget.set_degradation(DegradationLevel::SkipFrame);
3834
3835 assert!(
3838 !budget.exhausted(),
3839 "SkipFrame should not exhaust when frame skip disabled"
3840 );
3841 }
3842
3843 #[test]
3844 fn render_budget_remaining_fraction_zero_total() {
3845 let budget = RenderBudget::new(Duration::ZERO);
3846 assert_eq!(budget.remaining_fraction(), 0.0);
3847 }
3848
3849 #[test]
3850 fn render_budget_total_accessor() {
3851 let budget = RenderBudget::new(Duration::from_millis(42));
3852 assert_eq!(budget.total(), Duration::from_millis(42));
3853 }
3854
3855 #[test]
3856 fn render_budget_phase_budgets_accessor() {
3857 let budget = RenderBudget::new(Duration::from_millis(16));
3858 let pb = budget.phase_budgets();
3859 assert_eq!(pb.diff, Duration::from_millis(2));
3860 assert_eq!(pb.present, Duration::from_millis(4));
3861 assert_eq!(pb.render, Duration::from_millis(8));
3862 }
3863
3864 #[test]
3865 fn render_budget_set_degradation_no_op_preserves_cooldown() {
3866 let mut budget = RenderBudget::new(Duration::from_millis(16));
3867 budget.set_degradation(DegradationLevel::NoStyling);
3868 budget.frames_since_change = 7;
3869
3870 budget.set_degradation(DegradationLevel::NoStyling);
3872 assert_eq!(budget.frames_since_change, 7);
3873
3874 budget.set_degradation(DegradationLevel::Skeleton);
3876 assert_eq!(budget.frames_since_change, 0);
3877 }
3878
3879 #[test]
3880 fn render_budget_should_upgrade_false_at_full() {
3881 let budget = RenderBudget::new(Duration::from_millis(1000));
3882 assert!(!budget.should_upgrade(), "Full level should never upgrade");
3883 }
3884
3885 #[test]
3886 fn render_budget_should_upgrade_false_during_cooldown() {
3887 let mut budget = RenderBudget::new(Duration::from_millis(1000));
3888 budget.degrade();
3889 assert!(
3891 !budget.should_upgrade(),
3892 "Should not upgrade during cooldown"
3893 );
3894 }
3895
3896 #[test]
3897 fn render_budget_degrade_at_max_stays_at_max() {
3898 let mut budget = RenderBudget::new(Duration::from_millis(16));
3899 budget.set_degradation(DegradationLevel::SkipFrame);
3900 budget.degrade();
3901 assert_eq!(budget.degradation(), DegradationLevel::SkipFrame);
3902 }
3903
3904 #[test]
3905 fn render_budget_upgrade_at_full_stays_at_full() {
3906 let mut budget = RenderBudget::new(Duration::from_millis(16));
3907 budget.upgrade();
3908 assert_eq!(budget.degradation(), DegradationLevel::Full);
3909 }
3910
3911 #[test]
3914 fn frame_budget_config_partial_eq() {
3915 let a = FrameBudgetConfig::default();
3916 let b = FrameBudgetConfig::default();
3917 assert_eq!(a, b);
3918
3919 let c = FrameBudgetConfig::strict(Duration::from_millis(16));
3920 assert_ne!(a, c, "Different configs should not be equal");
3921 }
3922
3923 #[test]
3924 fn phase_budgets_eq_and_copy() {
3925 let a = PhaseBudgets::default();
3926 let b = a; assert_eq!(a, b);
3928
3929 let c = PhaseBudgets {
3930 diff: Duration::from_millis(1),
3931 ..Default::default()
3932 };
3933 assert_ne!(a, c);
3934 }
3935
3936 #[test]
3937 fn budget_controller_config_partial_eq() {
3938 let a = BudgetControllerConfig::default();
3939 let b = BudgetControllerConfig::default();
3940 assert_eq!(a, b);
3941 }
3942
3943 #[test]
3944 fn pid_gains_partial_eq() {
3945 let a = PidGains::default();
3946 let b = PidGains::default();
3947 assert_eq!(a, b);
3948 }
3949
3950 #[test]
3951 fn eprocess_config_partial_eq() {
3952 let a = EProcessConfig::default();
3953 let b = EProcessConfig::default();
3954 assert_eq!(a, b);
3955 }
3956
3957 #[test]
3960 fn budget_decision_debug_format() {
3961 assert!(format!("{:?}", BudgetDecision::Hold).contains("Hold"));
3962 assert!(format!("{:?}", BudgetDecision::Degrade).contains("Degrade"));
3963 assert!(format!("{:?}", BudgetDecision::Upgrade).contains("Upgrade"));
3964 }
3965
3966 #[test]
3967 fn budget_decision_clone_copy() {
3968 let d = BudgetDecision::Degrade;
3969 let d2 = d;
3970 assert_eq!(d, d2);
3971 }
3972
3973 #[test]
3974 fn budget_decision_as_str_coverage() {
3975 assert_eq!(BudgetDecision::Hold.as_str(), "stay");
3976 assert_eq!(BudgetDecision::Degrade.as_str(), "degrade");
3977 assert_eq!(BudgetDecision::Upgrade.as_str(), "upgrade");
3978 }
3979
3980 #[test]
3981 fn budget_decision_reason_debug_and_as_str() {
3982 assert!(
3983 format!("{:?}", BudgetDecisionReason::CooldownActive).contains("CooldownActive")
3984 );
3985 assert_eq!(
3986 BudgetDecisionReason::CooldownActive.as_str(),
3987 "cooldown_active"
3988 );
3989 assert_eq!(
3990 BudgetDecisionReason::OverloadEvidencePassed.as_str(),
3991 "overload_evidence_passed"
3992 );
3993 assert_eq!(
3994 BudgetDecisionReason::UnderloadEvidencePassed.as_str(),
3995 "underload_evidence_passed"
3996 );
3997 assert_eq!(
3998 BudgetDecisionReason::AtMaxDegradation.as_str(),
3999 "at_max_degradation"
4000 );
4001 assert_eq!(
4002 BudgetDecisionReason::AtDegradationFloor.as_str(),
4003 "at_degradation_floor"
4004 );
4005 assert_eq!(
4006 BudgetDecisionReason::AtFullQuality.as_str(),
4007 "at_full_quality"
4008 );
4009 assert_eq!(
4010 BudgetDecisionReason::WithinThresholdBand.as_str(),
4011 "within_threshold_band"
4012 );
4013 }
4014
4015 #[test]
4018 fn phase_eq_and_hash() {
4019 use std::collections::HashSet;
4020 let mut set = HashSet::new();
4021 set.insert(Phase::Diff);
4022 set.insert(Phase::Present);
4023 set.insert(Phase::Render);
4024 assert_eq!(set.len(), 3);
4025
4026 set.insert(Phase::Diff);
4028 assert_eq!(set.len(), 3);
4029 }
4030
4031 #[test]
4032 fn phase_debug() {
4033 assert!(format!("{:?}", Phase::Diff).contains("Diff"));
4034 assert!(format!("{:?}", Phase::Present).contains("Present"));
4035 assert!(format!("{:?}", Phase::Render).contains("Render"));
4036 }
4037
4038 #[test]
4039 fn phase_clone_copy() {
4040 let p = Phase::Present;
4041 let p2 = p;
4042 assert_eq!(p, p2);
4043 }
4044
4045 #[test]
4048 fn budget_telemetry_debug() {
4049 let telem = BudgetTelemetry {
4050 schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
4051 level: DegradationLevel::Full,
4052 pid_output: 0.0,
4053 pid_p: 0.0,
4054 pid_i: 0.0,
4055 pid_d: 0.0,
4056 e_value: 1.0,
4057 frames_observed: 0,
4058 frames_since_change: 0,
4059 last_decision: BudgetDecision::Hold,
4060 decision_reason: BudgetDecisionReason::WithinThresholdBand,
4061 transition_seq: 0,
4062 transition_correlation_id: 0,
4063 frame_time_ms: 0.0,
4064 target_ms: 16.0,
4065 pid_gate_threshold: 0.0,
4066 pid_gate_margin: 0.0,
4067 evidence_threshold: 0.0,
4068 evidence_margin: 0.0,
4069 in_warmup: true,
4070 };
4071 let s = format!("{:?}", telem);
4072 assert!(s.contains("BudgetTelemetry"), "Debug output: {}", s);
4073 }
4074
4075 #[test]
4076 fn budget_telemetry_partial_eq() {
4077 let a = BudgetTelemetry {
4078 schema_version: BUDGET_TELEMETRY_SCHEMA_VERSION,
4079 level: DegradationLevel::Full,
4080 pid_output: 0.5,
4081 pid_p: 0.3,
4082 pid_i: 0.1,
4083 pid_d: 0.1,
4084 e_value: 1.0,
4085 frames_observed: 5,
4086 frames_since_change: 2,
4087 last_decision: BudgetDecision::Hold,
4088 decision_reason: BudgetDecisionReason::WithinThresholdBand,
4089 transition_seq: 0,
4090 transition_correlation_id: 0,
4091 frame_time_ms: 16.0,
4092 target_ms: 16.0,
4093 pid_gate_threshold: 0.0,
4094 pid_gate_margin: 0.0,
4095 evidence_threshold: 0.0,
4096 evidence_margin: 0.0,
4097 in_warmup: false,
4098 };
4099 let b = a;
4100 assert_eq!(a, b);
4101
4102 let c = BudgetTelemetry {
4103 level: DegradationLevel::SimpleBorders,
4104 ..a
4105 };
4106 assert_ne!(a, c);
4107 }
4108
4109 #[test]
4112 fn next_frame_without_recorded_time_uses_elapsed() {
4113 let mut budget = RenderBudget::new(Duration::from_millis(1000));
4114
4115 budget.next_frame();
4117
4118 assert!(budget.remaining_fraction() > 0.9);
4120 }
4121
4122 #[test]
4123 fn controller_at_max_degradation_holds() {
4124 let mut ctrl = BudgetController::new(BudgetControllerConfig {
4125 eprocess: EProcessConfig {
4126 warmup_frames: 0,
4127 ..Default::default()
4128 },
4129 cooldown_frames: 0,
4130 degradation_floor: DegradationLevel::SkipFrame,
4132 ..Default::default()
4133 });
4134
4135 for _ in 0..500 {
4137 ctrl.update(Duration::from_millis(200));
4138 }
4139 assert_eq!(ctrl.level(), DegradationLevel::SkipFrame);
4140
4141 let d = ctrl.update(Duration::from_millis(200));
4143 assert_eq!(d, BudgetDecision::Hold, "At max level, should hold");
4144 }
4145
4146 #[test]
4147 fn controller_at_configured_degradation_floor_reports_floor_reason() {
4148 let mut ctrl = BudgetController::new(BudgetControllerConfig {
4149 eprocess: EProcessConfig {
4150 warmup_frames: 0,
4151 ..Default::default()
4152 },
4153 cooldown_frames: 0,
4154 degradation_floor: DegradationLevel::SimpleBorders,
4155 ..Default::default()
4156 });
4157
4158 ctrl.current_level = DegradationLevel::SimpleBorders;
4159
4160 let decision = ctrl.update(Duration::from_millis(200));
4161 let telemetry = ctrl.telemetry();
4162
4163 assert_eq!(decision, BudgetDecision::Hold);
4164 assert_eq!(telemetry.level, DegradationLevel::SimpleBorders);
4165 assert_eq!(
4166 telemetry.decision_reason,
4167 BudgetDecisionReason::AtDegradationFloor
4168 );
4169 }
4170
4171 #[test]
4172 fn controller_at_full_level_no_upgrade() {
4173 let mut ctrl = BudgetController::new(BudgetControllerConfig {
4174 eprocess: EProcessConfig {
4175 warmup_frames: 0,
4176 ..Default::default()
4177 },
4178 cooldown_frames: 0,
4179 ..Default::default()
4180 });
4181
4182 for _ in 0..50 {
4184 let d = ctrl.update(Duration::from_millis(1));
4185 assert_ne!(
4186 d,
4187 BudgetDecision::Upgrade,
4188 "Full level should never upgrade"
4189 );
4190 }
4191 }
4192
4193 #[test]
4194 fn render_budget_full_degrade_cycle_with_controller() {
4195 let mut budget = RenderBudget::new(Duration::from_millis(16)).with_controller(
4196 BudgetControllerConfig {
4197 eprocess: EProcessConfig {
4198 warmup_frames: 0,
4199 ..Default::default()
4200 },
4201 cooldown_frames: 0,
4202 ..Default::default()
4203 },
4204 );
4205
4206 for _ in 0..100 {
4208 budget.record_frame_time(Duration::from_millis(40));
4209 budget.next_frame();
4210 }
4211 let degraded = budget.degradation();
4212 assert!(
4213 degraded > DegradationLevel::Full,
4214 "Should degrade: {:?}",
4215 degraded
4216 );
4217
4218 for _ in 0..200 {
4220 budget.record_frame_time(Duration::from_millis(4));
4221 budget.next_frame();
4222 }
4223 let recovered = budget.degradation();
4224 assert!(
4225 recovered < degraded,
4226 "Should recover: {:?} -> {:?}",
4227 degraded,
4228 recovered
4229 );
4230 }
4231
4232 #[test]
4233 fn render_budget_phase_has_budget_exhausted() {
4234 let budget = RenderBudget::new(Duration::from_millis(1));
4235 std::thread::sleep(Duration::from_millis(10));
4236
4237 assert!(!budget.phase_has_budget(Phase::Diff));
4239 assert!(!budget.phase_has_budget(Phase::Present));
4240 assert!(!budget.phase_has_budget(Phase::Render));
4241 }
4242
4243 #[test]
4244 fn render_budget_elapsed_increases() {
4245 let budget = RenderBudget::new(Duration::from_millis(1000));
4246 let e1 = budget.elapsed();
4247 std::thread::sleep(Duration::from_millis(5));
4248 let e2 = budget.elapsed();
4249 assert!(e2 > e1, "Elapsed should increase: {:?} vs {:?}", e1, e2);
4250 }
4251
4252 #[test]
4253 fn controller_pid_integral_accessor() {
4254 let mut ctrl = BudgetController::new(BudgetControllerConfig::default());
4255
4256 assert_eq!(ctrl.pid_integral(), 0.0);
4257
4258 ctrl.update(Duration::from_millis(32)); assert!(
4261 ctrl.pid_integral() > 0.0,
4262 "Integral should grow: {}",
4263 ctrl.pid_integral()
4264 );
4265 }
4266
4267 #[test]
4268 fn controller_e_value_accessor() {
4269 let ctrl = BudgetController::new(BudgetControllerConfig::default());
4270 assert!((ctrl.e_value() - 1.0).abs() < f64::EPSILON);
4271 }
4272 }
4273}