1#![forbid(unsafe_code)]
2
3use ftui_render::budget::DegradationLevel;
38
39use crate::conformal_frame_guard::{
40 ConformalFrameGuard, ConformalFrameGuardConfig, GuardState, P99Prediction,
41};
42use crate::conformal_predictor::BucketKey;
43
44#[derive(Debug, Clone)]
46pub struct CascadeConfig {
47 pub guard: ConformalFrameGuardConfig,
49
50 pub recovery_threshold: u32,
53
54 pub max_degradation: DegradationLevel,
57
58 pub min_trigger_level: DegradationLevel,
62
63 pub degradation_floor: DegradationLevel,
73}
74
75impl Default for CascadeConfig {
76 fn default() -> Self {
77 Self {
78 guard: ConformalFrameGuardConfig::default(),
79 recovery_threshold: 10,
80 max_degradation: DegradationLevel::SkipFrame,
81 min_trigger_level: DegradationLevel::SimpleBorders,
82 degradation_floor: DegradationLevel::SimpleBorders,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum CascadeDecision {
90 Hold,
92 Degrade,
94 Recover,
96}
97
98impl CascadeDecision {
99 pub fn as_str(self) -> &'static str {
101 match self {
102 Self::Hold => "hold",
103 Self::Degrade => "degrade",
104 Self::Recover => "recover",
105 }
106 }
107}
108
109#[derive(Debug, Clone)]
111pub struct CascadeEvidence {
112 pub frame_idx: u64,
114 pub decision: CascadeDecision,
116 pub level_before: DegradationLevel,
118 pub level_after: DegradationLevel,
120 pub guard_state: GuardState,
122 pub recovery_streak: u32,
124 pub recovery_threshold: u32,
126 pub frame_time_us: f64,
128 pub budget_us: f64,
130 pub prediction: Option<P99Prediction>,
132}
133
134impl CascadeEvidence {
135 #[must_use]
137 pub fn to_jsonl(&self) -> String {
138 let pred_fields = self
139 .prediction
140 .as_ref()
141 .map(|p| {
142 format!(
143 r#","p99_upper_us":{:.1},"p99_exceeds":{},"p99_fallback_level":{},"p99_calibration_size":{},"p99_interval_width_us":{:.1}"#,
144 p.upper_us,
145 p.exceeds_budget,
146 p.fallback_level,
147 p.calibration_size,
148 p.interval_width_us,
149 )
150 })
151 .unwrap_or_default();
152
153 format!(
154 r#"{{"schema":"degradation-cascade-v1","frame_idx":{},"decision":"{}","level_before":"{}","level_after":"{}","guard_state":"{}","recovery_streak":{},"recovery_threshold":{},"frame_time_us":{:.1},"budget_us":{:.1}{}}}"#,
155 self.frame_idx,
156 self.decision.as_str(),
157 self.level_before.as_str(),
158 self.level_after.as_str(),
159 self.guard_state.as_str(),
160 self.recovery_streak,
161 self.recovery_threshold,
162 self.frame_time_us,
163 self.budget_us,
164 pred_fields,
165 )
166 }
167}
168
169#[derive(Debug)]
174pub struct DegradationCascade {
175 config: CascadeConfig,
176 guard: ConformalFrameGuard,
177 current_level: DegradationLevel,
179 recovery_streak: u32,
181 frame_idx: u64,
183 total_degrades: u64,
185 total_recoveries: u64,
187 last_evidence: Option<CascadeEvidence>,
189}
190
191impl DegradationCascade {
192 pub fn new(config: CascadeConfig) -> Self {
194 let guard = ConformalFrameGuard::new(config.guard.clone());
195 Self {
196 config,
197 guard,
198 current_level: DegradationLevel::Full,
199 recovery_streak: 0,
200 frame_idx: 0,
201 total_degrades: 0,
202 total_recoveries: 0,
203 last_evidence: None,
204 }
205 }
206
207 pub fn with_defaults() -> Self {
209 Self::new(CascadeConfig::default())
210 }
211
212 pub fn pre_render(&mut self, budget_us: f64, key: BucketKey) -> PreRenderResult {
217 self.frame_idx += 1;
218 let level_before = self.current_level;
219
220 let prediction = self.guard.predict_p99(budget_us, key);
221
222 let decision = if prediction.exceeds_budget {
223 if self.current_level < self.config.max_degradation
225 && self.current_level < self.config.degradation_floor
226 {
227 self.current_level = self.current_level.next();
228
229 if self.current_level < self.config.min_trigger_level {
231 self.current_level = self.config.min_trigger_level;
232 }
233
234 if self.current_level > self.config.degradation_floor {
237 self.current_level = self.config.degradation_floor;
238 }
239 if self.current_level > self.config.max_degradation {
240 self.current_level = self.config.max_degradation;
241 }
242
243 self.recovery_streak = 0;
244 self.total_degrades += 1;
245 CascadeDecision::Degrade
246 } else {
247 self.recovery_streak = 0;
248 CascadeDecision::Hold
249 }
250 } else {
251 self.recovery_streak += 1;
253
254 if self.recovery_streak >= self.config.recovery_threshold
255 && !self.current_level.is_full()
256 {
257 self.current_level = self.current_level.prev();
258 self.recovery_streak = 0;
259 self.total_recoveries += 1;
260 CascadeDecision::Recover
261 } else {
262 CascadeDecision::Hold
263 }
264 };
265
266 let evidence = CascadeEvidence {
267 frame_idx: self.frame_idx,
268 decision,
269 level_before,
270 level_after: self.current_level,
271 guard_state: self.guard.state(),
272 recovery_streak: self.recovery_streak,
273 recovery_threshold: self.config.recovery_threshold,
274 frame_time_us: self.guard.ema_us(),
275 budget_us,
276 prediction: Some(prediction.clone()),
277 };
278
279 self.last_evidence = Some(evidence);
280
281 PreRenderResult {
282 level: self.current_level,
283 decision,
284 prediction,
285 }
286 }
287
288 pub fn post_render(&mut self, frame_time_us: f64, key: BucketKey) {
292 self.guard.observe(frame_time_us, key);
293 }
294
295 #[inline]
297 pub fn level(&self) -> DegradationLevel {
298 self.current_level
299 }
300
301 #[inline]
303 pub fn recovery_streak(&self) -> u32 {
304 self.recovery_streak
305 }
306
307 #[inline]
309 pub fn frame_idx(&self) -> u64 {
310 self.frame_idx
311 }
312
313 #[inline]
315 pub fn total_degrades(&self) -> u64 {
316 self.total_degrades
317 }
318
319 #[inline]
321 pub fn total_recoveries(&self) -> u64 {
322 self.total_recoveries
323 }
324
325 pub fn last_evidence(&self) -> Option<&CascadeEvidence> {
327 self.last_evidence.as_ref()
328 }
329
330 pub fn guard(&self) -> &ConformalFrameGuard {
332 &self.guard
333 }
334
335 pub fn config(&self) -> &CascadeConfig {
337 &self.config
338 }
339
340 pub fn reset(&mut self) {
342 self.guard.reset();
343 self.current_level = DegradationLevel::Full;
344 self.recovery_streak = 0;
345 self.frame_idx = 0;
346 self.last_evidence = None;
347 }
349
350 #[inline]
354 pub fn should_render_widget(&self, is_essential: bool) -> bool {
355 if self.current_level >= DegradationLevel::EssentialOnly {
356 is_essential
357 } else {
358 true
359 }
360 }
361
362 pub fn telemetry(&self) -> CascadeTelemetry {
364 CascadeTelemetry {
365 level: self.current_level,
366 recovery_streak: self.recovery_streak,
367 recovery_threshold: self.config.recovery_threshold,
368 frame_idx: self.frame_idx,
369 total_degrades: self.total_degrades,
370 total_recoveries: self.total_recoveries,
371 guard_state: self.guard.state(),
372 guard_observations: self.guard.observations(),
373 guard_ema_us: self.guard.ema_us(),
374 }
375 }
376}
377
378#[derive(Debug, Clone)]
380pub struct PreRenderResult {
381 pub level: DegradationLevel,
383 pub decision: CascadeDecision,
385 pub prediction: P99Prediction,
387}
388
389#[derive(Debug, Clone)]
391pub struct CascadeTelemetry {
392 pub level: DegradationLevel,
394 pub recovery_streak: u32,
396 pub recovery_threshold: u32,
398 pub frame_idx: u64,
400 pub total_degrades: u64,
402 pub total_recoveries: u64,
404 pub guard_state: GuardState,
406 pub guard_observations: u64,
408 pub guard_ema_us: f64,
410}
411
412impl CascadeTelemetry {
413 #[must_use]
415 pub fn to_jsonl(&self) -> String {
416 format!(
417 r#"{{"schema":"cascade-telemetry-v1","level":"{}","recovery_streak":{},"recovery_threshold":{},"frame_idx":{},"total_degrades":{},"total_recoveries":{},"guard_state":"{}","guard_observations":{},"guard_ema_us":{:.1}}}"#,
418 self.level.as_str(),
419 self.recovery_streak,
420 self.recovery_threshold,
421 self.frame_idx,
422 self.total_degrades,
423 self.total_recoveries,
424 self.guard_state.as_str(),
425 self.guard_observations,
426 self.guard_ema_us,
427 )
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::conformal_predictor::{DiffBucket, ModeBucket};
435
436 fn test_key() -> BucketKey {
437 BucketKey {
438 mode: ModeBucket::AltScreen,
439 diff: DiffBucket::Full,
440 size_bucket: 2,
441 }
442 }
443
444 fn budget_us() -> f64 {
445 16_000.0 }
447
448 #[test]
449 fn initial_state_is_full_quality() {
450 let cascade = DegradationCascade::with_defaults();
451 assert_eq!(cascade.level(), DegradationLevel::Full);
452 assert_eq!(cascade.recovery_streak(), 0);
453 assert_eq!(cascade.frame_idx(), 0);
454 }
455
456 #[test]
457 fn fast_frames_stay_at_full() {
458 let mut cascade = DegradationCascade::with_defaults();
459 let key = test_key();
460
461 for _ in 0..30 {
463 cascade.post_render(8_000.0, key);
464 }
465
466 let result = cascade.pre_render(budget_us(), key);
467 assert_eq!(result.level, DegradationLevel::Full);
468 assert_eq!(result.decision, CascadeDecision::Hold);
469 }
470
471 #[test]
472 fn slow_frames_trigger_degradation() {
473 let mut cascade = DegradationCascade::with_defaults();
474 let key = test_key();
475
476 for _ in 0..25 {
478 cascade.post_render(20_000.0, key);
479 }
480
481 let result = cascade.pre_render(budget_us(), key);
482 assert_eq!(result.decision, CascadeDecision::Degrade);
483 assert!(result.level > DegradationLevel::Full);
484 }
485
486 #[test]
487 fn recovery_after_sustained_good_frames() {
488 let config = CascadeConfig {
489 recovery_threshold: 5, ..Default::default()
491 };
492 let mut cascade = DegradationCascade::new(config);
493 let key = test_key();
494
495 for _ in 0..25 {
497 cascade.post_render(20_000.0, key);
498 }
499 let result = cascade.pre_render(budget_us(), key);
500 assert_eq!(result.decision, CascadeDecision::Degrade);
501 let degraded_level = cascade.level();
502 assert!(degraded_level > DegradationLevel::Full);
503
504 for _ in 0..25 {
506 cascade.post_render(8_000.0, key);
507 }
508
509 let mut recovered = false;
511 for _ in 0..10 {
512 let result = cascade.pre_render(budget_us(), key);
513 if result.decision == CascadeDecision::Recover {
514 recovered = true;
515 break;
516 }
517 }
518 assert!(
519 recovered,
520 "Should have recovered after sustained good frames"
521 );
522 assert!(cascade.level() < degraded_level);
523 }
524
525 #[test]
526 fn max_degradation_capped() {
527 let config = CascadeConfig {
528 max_degradation: DegradationLevel::NoStyling,
529 ..Default::default()
530 };
531 let mut cascade = DegradationCascade::new(config);
532 let key = test_key();
533
534 for _ in 0..25 {
536 cascade.post_render(30_000.0, key);
537 }
538
539 for _ in 0..10 {
541 cascade.pre_render(budget_us(), key);
542 }
543
544 assert!(cascade.level() <= DegradationLevel::NoStyling);
546 }
547
548 #[test]
549 fn widget_filtering_at_essential_only() {
550 let mut cascade = DegradationCascade::with_defaults();
551
552 assert!(cascade.should_render_widget(true));
554 assert!(cascade.should_render_widget(false));
555
556 cascade.current_level = DegradationLevel::EssentialOnly;
558 assert!(cascade.should_render_widget(true));
559 assert!(!cascade.should_render_widget(false));
560
561 cascade.current_level = DegradationLevel::Skeleton;
563 assert!(cascade.should_render_widget(true));
564 assert!(!cascade.should_render_widget(false));
565 }
566
567 #[test]
568 fn evidence_emitted_on_degrade() {
569 let mut cascade = DegradationCascade::with_defaults();
570 let key = test_key();
571
572 for _ in 0..25 {
573 cascade.post_render(20_000.0, key);
574 }
575
576 cascade.pre_render(budget_us(), key);
577
578 let evidence = cascade.last_evidence().expect("evidence should exist");
579 assert_eq!(evidence.decision, CascadeDecision::Degrade);
580 assert_eq!(evidence.level_before, DegradationLevel::Full);
581 assert!(evidence.level_after > DegradationLevel::Full);
582 assert!(evidence.prediction.is_some());
583
584 let json_str = evidence.to_jsonl();
586 assert!(json_str.contains("degradation-cascade-v1"));
587 assert!(json_str.contains("\"decision\":\"degrade\""));
588 }
589
590 #[test]
591 fn recovery_streak_resets_on_degrade() {
592 let mut cascade = DegradationCascade::with_defaults();
593 let key = test_key();
594
595 for _ in 0..5 {
597 cascade.post_render(8_000.0, key);
598 cascade.pre_render(budget_us(), key);
599 }
600
601 let streak_before = cascade.recovery_streak();
602 assert!(streak_before > 0);
603
604 for _ in 0..25 {
606 cascade.post_render(25_000.0, key);
607 }
608 cascade.pre_render(budget_us(), key);
609
610 assert_eq!(cascade.recovery_streak(), 0);
612 }
613
614 #[test]
615 fn reset_preserves_aggregate_counts() {
616 let mut cascade = DegradationCascade::with_defaults();
617 let key = test_key();
618
619 for _ in 0..25 {
620 cascade.post_render(20_000.0, key);
621 }
622 cascade.pre_render(budget_us(), key);
623 assert!(cascade.total_degrades() > 0);
624
625 cascade.reset();
626
627 assert_eq!(cascade.level(), DegradationLevel::Full);
628 assert_eq!(cascade.frame_idx(), 0);
629 assert_eq!(cascade.recovery_streak(), 0);
630 assert!(cascade.total_degrades() > 0);
632 }
633
634 #[test]
635 fn telemetry_captures_state() {
636 let mut cascade = DegradationCascade::with_defaults();
637 let key = test_key();
638
639 for _ in 0..10 {
640 cascade.post_render(12_000.0, key);
641 cascade.pre_render(budget_us(), key);
642 }
643
644 let telem = cascade.telemetry();
645 assert_eq!(telem.frame_idx, 10);
646 assert_eq!(telem.level, DegradationLevel::Full);
647
648 let json_str = telem.to_jsonl();
649 assert!(json_str.contains("cascade-telemetry-v1"));
650 }
651
652 #[test]
653 fn warmup_fallback_does_not_degrade_for_fast_frames() {
654 let mut cascade = DegradationCascade::with_defaults();
655 let key = test_key();
656
657 for _ in 0..5 {
659 cascade.post_render(10_000.0, key);
660 }
661
662 let result = cascade.pre_render(budget_us(), key);
663 assert_eq!(result.decision, CascadeDecision::Hold);
665 assert_eq!(result.level, DegradationLevel::Full);
666 }
667
668 #[test]
669 fn warmup_fallback_degrades_for_slow_frames() {
670 let mut cascade = DegradationCascade::with_defaults();
671 let key = test_key();
672
673 for _ in 0..5 {
675 cascade.post_render(20_000.0, key);
676 }
677
678 let result = cascade.pre_render(budget_us(), key);
679 assert_eq!(result.decision, CascadeDecision::Degrade);
681 }
682
683 #[test]
684 fn min_trigger_level_enforced() {
685 let config = CascadeConfig {
686 min_trigger_level: DegradationLevel::NoStyling,
687 degradation_floor: DegradationLevel::NoStyling,
689 ..Default::default()
690 };
691 let mut cascade = DegradationCascade::new(config);
692 let key = test_key();
693
694 for _ in 0..25 {
695 cascade.post_render(20_000.0, key);
696 }
697
698 let result = cascade.pre_render(budget_us(), key);
699 assert_eq!(result.decision, CascadeDecision::Degrade);
701 assert!(cascade.level() >= DegradationLevel::NoStyling);
702 }
703
704 #[test]
705 fn consecutive_degrades_increase_level() {
706 let mut cascade = DegradationCascade::with_defaults();
707 let key = test_key();
708
709 for _ in 0..25 {
711 cascade.post_render(25_000.0, key);
712 }
713
714 let mut levels = vec![];
715 for _ in 0..5 {
716 let result = cascade.pre_render(budget_us(), key);
717 levels.push(result.level);
718 for _ in 0..5 {
720 cascade.post_render(25_000.0, key);
721 }
722 }
723
724 for window in levels.windows(2) {
726 assert!(
727 window[1] >= window[0],
728 "levels should not decrease: {levels:?}"
729 );
730 }
731 }
732}