Skip to main content

ftui_runtime/
degradation_cascade.rs

1#![forbid(unsafe_code)]
2
3//! Degradation cascade: conformal guard → budget controller → widget priority.
4//!
5//! Orchestrates the flow from conformal frame guard risk detection through
6//! budget degradation to widget-level rendering decisions. Tracks recovery
7//! and emits structured evidence at each decision point.
8//!
9//! # Cascade Flow
10//!
11//! ```text
12//! ┌─────────────────────┐
13//! │ ConformalFrameGuard  │  p99 exceeds budget?
14//! └─────────┬───────────┘
15//!           │ yes
16//!           ▼
17//! ┌─────────────────────┐
18//! │   Budget Degrade     │  next degradation level
19//! └─────────┬───────────┘
20//!           │
21//!           ▼
22//! ┌─────────────────────┐
23//! │ Widget Filter        │  skip non-essential at EssentialOnly+
24//! └─────────────────────┘
25//!
26//! Recovery: N consecutive within-budget frames → upgrade one level
27//! ```
28//!
29//! # Evidence
30//!
31//! Every cascade decision emits a JSONL evidence entry with:
32//! - Guard state and prediction
33//! - Degradation level transition
34//! - Recovery progress
35//! - Nonconformity summary
36
37use ftui_render::budget::DegradationLevel;
38
39use crate::conformal_frame_guard::{
40    ConformalFrameGuard, ConformalFrameGuardConfig, GuardState, P99Prediction,
41};
42use crate::conformal_predictor::BucketKey;
43
44/// Configuration for the degradation cascade.
45#[derive(Debug, Clone)]
46pub struct CascadeConfig {
47    /// Conformal frame guard configuration.
48    pub guard: ConformalFrameGuardConfig,
49
50    /// Consecutive within-budget frames required before upgrading (recovery).
51    /// Default: 10.
52    pub recovery_threshold: u32,
53
54    /// Maximum degradation level the cascade is allowed to reach.
55    /// Default: `DegradationLevel::SkipFrame` (no limit).
56    pub max_degradation: DegradationLevel,
57
58    /// Minimum degradation level to use when the guard triggers.
59    /// If the current level is below this, jump directly to it.
60    /// Default: `DegradationLevel::SimpleBorders` (gradual).
61    pub min_trigger_level: DegradationLevel,
62
63    /// Minimum quality floor: the cascade will never degrade past this level.
64    ///
65    /// Default: `DegradationLevel::SimpleBorders` — preserves readable text
66    /// content, preventing escalation to `EssentialOnly`, `Skeleton`, or
67    /// `SkipFrame` after transient focus/resize spikes.
68    ///
69    /// This is distinct from `max_degradation` which caps the absolute worst
70    /// level. The floor is a safety net that prevents content-suppressing
71    /// degradation regardless of how bad the frame budget looks.
72    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/// Decision made by the cascade for a single frame.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum CascadeDecision {
90    /// No action needed; rendering proceeds at current level.
91    Hold,
92    /// Degrade: reduce visual fidelity one (or more) levels.
93    Degrade,
94    /// Recover: restore visual fidelity one level after sustained good frames.
95    Recover,
96}
97
98impl CascadeDecision {
99    /// Stable string for JSONL logging.
100    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/// Evidence record emitted for each cascade decision.
110#[derive(Debug, Clone)]
111pub struct CascadeEvidence {
112    /// Frame index within the run.
113    pub frame_idx: u64,
114    /// Decision taken.
115    pub decision: CascadeDecision,
116    /// Degradation level before this frame.
117    pub level_before: DegradationLevel,
118    /// Degradation level after this frame.
119    pub level_after: DegradationLevel,
120    /// Guard state.
121    pub guard_state: GuardState,
122    /// Consecutive within-budget frame count.
123    pub recovery_streak: u32,
124    /// Recovery threshold.
125    pub recovery_threshold: u32,
126    /// Frame time in µs (observed).
127    pub frame_time_us: f64,
128    /// Budget in µs.
129    pub budget_us: f64,
130    /// P99 prediction (if available).
131    pub prediction: Option<P99Prediction>,
132}
133
134impl CascadeEvidence {
135    /// Format as a JSONL line.
136    #[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/// Degradation cascade orchestrator.
170///
171/// Sits between the conformal frame guard and the render budget system.
172/// Call [`pre_render`] before each frame and [`post_render`] after.
173#[derive(Debug)]
174pub struct DegradationCascade {
175    config: CascadeConfig,
176    guard: ConformalFrameGuard,
177    /// Current degradation level managed by this cascade.
178    current_level: DegradationLevel,
179    /// Consecutive frames where p99 was within budget.
180    recovery_streak: u32,
181    /// Frame counter.
182    frame_idx: u64,
183    /// Total degrade events.
184    total_degrades: u64,
185    /// Total recovery events.
186    total_recoveries: u64,
187    /// Last cascade evidence (for external consumers).
188    last_evidence: Option<CascadeEvidence>,
189}
190
191impl DegradationCascade {
192    /// Create a new cascade with the given configuration.
193    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    /// Create a cascade with default configuration.
208    pub fn with_defaults() -> Self {
209        Self::new(CascadeConfig::default())
210    }
211
212    /// Pre-render check: predict p99 and decide whether to degrade.
213    ///
214    /// Returns the degradation level to use for this frame and the prediction.
215    /// The caller should apply the returned level to the render budget.
216    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            // Degrade (if not at max and not already at degradation floor)
224            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                // Jump to minimum trigger level if below it
230                if self.current_level < self.config.min_trigger_level {
231                    self.current_level = self.config.min_trigger_level;
232                }
233
234                // Clamp to degradation limits: never degrade past the configured
235                // minimum quality level or maximum degradation level.
236                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            // Within budget: track recovery streak
252            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    /// Post-render observation: feed actual frame time to the guard.
289    ///
290    /// Call this after the frame has been rendered with the measured time.
291    pub fn post_render(&mut self, frame_time_us: f64, key: BucketKey) {
292        self.guard.observe(frame_time_us, key);
293    }
294
295    /// Get the current degradation level.
296    #[inline]
297    pub fn level(&self) -> DegradationLevel {
298        self.current_level
299    }
300
301    /// Get the current recovery streak.
302    #[inline]
303    pub fn recovery_streak(&self) -> u32 {
304        self.recovery_streak
305    }
306
307    /// Get the frame counter.
308    #[inline]
309    pub fn frame_idx(&self) -> u64 {
310        self.frame_idx
311    }
312
313    /// Total degrade events.
314    #[inline]
315    pub fn total_degrades(&self) -> u64 {
316        self.total_degrades
317    }
318
319    /// Total recovery events.
320    #[inline]
321    pub fn total_recoveries(&self) -> u64 {
322        self.total_recoveries
323    }
324
325    /// Access the last cascade evidence.
326    pub fn last_evidence(&self) -> Option<&CascadeEvidence> {
327        self.last_evidence.as_ref()
328    }
329
330    /// Access the underlying guard.
331    pub fn guard(&self) -> &ConformalFrameGuard {
332        &self.guard
333    }
334
335    /// Access the configuration.
336    pub fn config(&self) -> &CascadeConfig {
337        &self.config
338    }
339
340    /// Reset the cascade to initial state.
341    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        // Preserve aggregate counts for audit trail
348    }
349
350    /// Whether widget should render given current degradation level and essentiality.
351    ///
352    /// At `EssentialOnly` or higher degradation, non-essential widgets are skipped.
353    #[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    /// Capture telemetry for the cascade.
363    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/// Result of a pre-render cascade check.
379#[derive(Debug, Clone)]
380pub struct PreRenderResult {
381    /// Degradation level to use for this frame.
382    pub level: DegradationLevel,
383    /// Decision taken.
384    pub decision: CascadeDecision,
385    /// P99 prediction from the guard.
386    pub prediction: P99Prediction,
387}
388
389/// Telemetry snapshot of the cascade.
390#[derive(Debug, Clone)]
391pub struct CascadeTelemetry {
392    /// Current degradation level.
393    pub level: DegradationLevel,
394    /// Recovery streak.
395    pub recovery_streak: u32,
396    /// Recovery threshold.
397    pub recovery_threshold: u32,
398    /// Frame counter.
399    pub frame_idx: u64,
400    /// Total degrade events.
401    pub total_degrades: u64,
402    /// Total recovery events.
403    pub total_recoveries: u64,
404    /// Guard state.
405    pub guard_state: GuardState,
406    /// Guard total observations.
407    pub guard_observations: u64,
408    /// Guard EMA estimate (µs).
409    pub guard_ema_us: f64,
410}
411
412impl CascadeTelemetry {
413    /// Format as JSONL.
414    #[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 // 16ms
446    }
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        // Calibrate with fast frames
462        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        // Calibrate with slow frames (20ms > 16ms budget)
477        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, // Low threshold for testing
490            ..Default::default()
491        };
492        let mut cascade = DegradationCascade::new(config);
493        let key = test_key();
494
495        // Calibrate with slow frames to trigger degradation
496        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        // Now feed fast frames to trigger recovery
505        for _ in 0..25 {
506            cascade.post_render(8_000.0, key);
507        }
508
509        // Run enough pre_render calls (with fast calibration) to hit recovery threshold
510        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        // Feed many slow frames
535        for _ in 0..25 {
536            cascade.post_render(30_000.0, key);
537        }
538
539        // Degrade multiple times
540        for _ in 0..10 {
541            cascade.pre_render(budget_us(), key);
542        }
543
544        // Should be capped at NoStyling
545        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        // At Full level, everything renders
553        assert!(cascade.should_render_widget(true));
554        assert!(cascade.should_render_widget(false));
555
556        // Force to EssentialOnly
557        cascade.current_level = DegradationLevel::EssentialOnly;
558        assert!(cascade.should_render_widget(true));
559        assert!(!cascade.should_render_widget(false));
560
561        // At Skeleton, still only essential
562        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        // Check JSONL is well-formed
585        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        // Build some recovery streak with fast frames in warmup
596        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        // Now send slow frames to trigger degradation
605        for _ in 0..25 {
606            cascade.post_render(25_000.0, key);
607        }
608        cascade.pre_render(budget_us(), key);
609
610        // After degradation, streak should be reset
611        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        // Aggregate counts preserved
631        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        // Only 5 observations (warmup, not calibrated)
658        for _ in 0..5 {
659            cascade.post_render(10_000.0, key);
660        }
661
662        let result = cascade.pre_render(budget_us(), key);
663        // During warmup with 10ms frames, should not degrade (10ms < 16ms fallback)
664        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        // Only 5 observations (warmup, not calibrated) but slow
674        for _ in 0..5 {
675            cascade.post_render(20_000.0, key);
676        }
677
678        let result = cascade.pre_render(budget_us(), key);
679        // During warmup with 20ms frames, EMA > 16ms fallback → degrade
680        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            // Floor must be >= min_trigger_level for the jump to work
688            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        // Should jump directly to NoStyling (skipping SimpleBorders)
700        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        // Keep feeding slow frames
710        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            // Feed more slow frames between checks
719            for _ in 0..5 {
720                cascade.post_render(25_000.0, key);
721            }
722        }
723
724        // Levels should be non-decreasing
725        for window in levels.windows(2) {
726            assert!(
727                window[1] >= window[0],
728                "levels should not decrease: {levels:?}"
729            );
730        }
731    }
732}