1#![forbid(unsafe_code)]
2
3use std::collections::VecDeque;
25
26use ftui_render::budget::DegradationLevel;
27
28use crate::conformal_predictor::{
29 BucketKey, ConformalConfig, ConformalPrediction, ConformalPredictor,
30};
31
32const DEFAULT_FALLBACK_BUDGET_US: f64 = 16_000.0;
34
35#[derive(Debug, Clone)]
37pub struct ConformalFrameGuardConfig {
38 pub conformal: ConformalConfig,
40
41 pub fallback_budget_us: f64,
44
45 pub time_series_window: usize,
48
49 pub nonconformity_window: usize,
52}
53
54impl Default for ConformalFrameGuardConfig {
55 fn default() -> Self {
56 let conformal = ConformalConfig::default();
57 let nonconformity_window = conformal.window_size;
58 Self {
59 conformal,
60 fallback_budget_us: DEFAULT_FALLBACK_BUDGET_US,
61 time_series_window: 512,
62 nonconformity_window,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum GuardState {
70 Warmup,
72 Calibrated,
74 AtRisk,
76}
77
78impl GuardState {
79 pub fn as_str(self) -> &'static str {
81 match self {
82 Self::Warmup => "warmup",
83 Self::Calibrated => "calibrated",
84 Self::AtRisk => "at_risk",
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
91pub struct P99Prediction {
92 pub y_hat_us: f64,
94 pub upper_us: f64,
96 pub budget_us: f64,
98 pub exceeds_budget: bool,
100 pub calibration_size: usize,
102 pub fallback_level: u8,
105 pub state: GuardState,
107 pub interval_width_us: f64,
109 pub conformal: Option<ConformalPrediction>,
111}
112
113impl P99Prediction {
114 #[must_use]
116 pub fn to_jsonl(&self) -> String {
117 let conformal_fields = self
118 .conformal
119 .as_ref()
120 .map(|c| {
121 format!(
122 r#","conformal_quantile":{:.2},"conformal_bucket":"{}","conformal_confidence":{:.4}"#,
123 c.quantile, c.bucket, c.confidence,
124 )
125 })
126 .unwrap_or_default();
127
128 format!(
129 r#"{{"schema":"conformal-frame-guard-v1","y_hat_us":{:.1},"upper_us":{:.1},"budget_us":{:.1},"exceeds_budget":{},"calibration_size":{},"fallback_level":{},"state":"{}","interval_width_us":{:.1}{}}}"#,
130 self.y_hat_us,
131 self.upper_us,
132 self.budget_us,
133 self.exceeds_budget,
134 self.calibration_size,
135 self.fallback_level,
136 self.state.as_str(),
137 self.interval_width_us,
138 conformal_fields,
139 )
140 }
141}
142
143#[derive(Debug)]
150pub struct ConformalFrameGuard {
151 config: ConformalFrameGuardConfig,
152 predictor: ConformalPredictor,
153 frame_times: VecDeque<f64>,
155 nonconformity_scores: VecDeque<f64>,
157 ema_us: f64,
159 ema_decay: f64,
161 state: GuardState,
163 observations: u64,
165 degradation_triggers: u64,
167}
168
169impl ConformalFrameGuard {
170 pub fn new(config: ConformalFrameGuardConfig) -> Self {
172 let predictor = ConformalPredictor::new(config.conformal.clone());
173 Self {
174 config,
175 predictor,
176 frame_times: VecDeque::new(),
177 nonconformity_scores: VecDeque::new(),
178 ema_us: 0.0,
179 ema_decay: 0.95,
180 state: GuardState::Warmup,
181 observations: 0,
182 degradation_triggers: 0,
183 }
184 }
185
186 pub fn with_defaults() -> Self {
188 Self::new(ConformalFrameGuardConfig::default())
189 }
190
191 pub fn observe(&mut self, frame_time_us: f64, key: BucketKey) {
196 if !frame_time_us.is_finite() || frame_time_us < 0.0 {
197 return;
198 }
199
200 self.observations += 1;
201
202 if self.observations == 1 {
204 self.ema_us = frame_time_us;
205 } else {
206 self.ema_us = self.ema_decay * self.ema_us + (1.0 - self.ema_decay) * frame_time_us;
207 }
208
209 self.frame_times.push_back(frame_time_us);
211 while self.frame_times.len() > self.config.time_series_window {
212 self.frame_times.pop_front();
213 }
214
215 let y_hat = self.ema_us;
217 let residual = frame_time_us - y_hat;
218 self.nonconformity_scores.push_back(residual);
219 while self.nonconformity_scores.len() > self.config.nonconformity_window {
220 self.nonconformity_scores.pop_front();
221 }
222
223 self.predictor.observe(key, y_hat, frame_time_us);
225
226 let samples = self.predictor.bucket_samples(key);
228 if samples < self.config.conformal.min_samples && self.state == GuardState::Warmup {
229 } else if self.state == GuardState::Warmup {
231 self.state = GuardState::Calibrated;
232 }
233 }
234
235 pub fn predict_p99(&mut self, budget_us: f64, key: BucketKey) -> P99Prediction {
242 let y_hat = if self.observations > 0 {
243 self.ema_us
244 } else {
245 0.0
246 };
247
248 let samples = self.predictor.bucket_samples(key);
249 let is_calibrated = samples >= self.config.conformal.min_samples;
250
251 if is_calibrated {
252 let prediction = self.predictor.predict(key, y_hat, budget_us);
254 let exceeds = prediction.upper_us > budget_us;
255
256 self.state = if exceeds {
257 self.degradation_triggers += 1;
258 GuardState::AtRisk
259 } else {
260 GuardState::Calibrated
261 };
262
263 P99Prediction {
264 y_hat_us: y_hat,
265 upper_us: prediction.upper_us,
266 budget_us,
267 exceeds_budget: exceeds,
268 calibration_size: prediction.sample_count,
269 fallback_level: prediction.fallback_level,
270 state: self.state,
271 interval_width_us: (prediction.upper_us - y_hat).max(0.0),
272 conformal: Some(prediction),
273 }
274 } else {
275 let fallback = self.config.fallback_budget_us;
277 let exceeds = y_hat > fallback;
278
279 if exceeds && self.state != GuardState::Warmup {
280 self.degradation_triggers += 1;
281 }
282
283 let state = if exceeds {
285 GuardState::AtRisk
286 } else {
287 GuardState::Warmup
288 };
289 self.state = state;
290
291 P99Prediction {
292 y_hat_us: y_hat,
293 upper_us: y_hat, budget_us: fallback,
295 exceeds_budget: exceeds,
296 calibration_size: samples,
297 fallback_level: 4, state,
299 interval_width_us: 0.0,
300 conformal: None,
301 }
302 }
303 }
304
305 #[inline]
307 pub fn state(&self) -> GuardState {
308 self.state
309 }
310
311 #[inline]
313 pub fn is_calibrated(&self) -> bool {
314 matches!(self.state, GuardState::Calibrated | GuardState::AtRisk)
315 }
316
317 #[inline]
319 pub fn observations(&self) -> u64 {
320 self.observations
321 }
322
323 #[inline]
325 pub fn degradation_triggers(&self) -> u64 {
326 self.degradation_triggers
327 }
328
329 pub fn nonconformity_scores(&self) -> &VecDeque<f64> {
331 &self.nonconformity_scores
332 }
333
334 pub fn frame_times(&self) -> &VecDeque<f64> {
336 &self.frame_times
337 }
338
339 #[inline]
341 pub fn ema_us(&self) -> f64 {
342 self.ema_us
343 }
344
345 pub fn predictor(&self) -> &ConformalPredictor {
347 &self.predictor
348 }
349
350 pub fn config(&self) -> &ConformalFrameGuardConfig {
352 &self.config
353 }
354
355 pub fn nonconformity_summary(&self) -> Option<NonconformitySummary> {
359 if self.nonconformity_scores.is_empty() {
360 return None;
361 }
362
363 let mut sorted: Vec<f64> = self.nonconformity_scores.iter().copied().collect();
364 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
365
366 let n = sorted.len();
367 let mean = sorted.iter().sum::<f64>() / n as f64;
368 let p50 = sorted[n / 2];
369 let p90 = sorted[(n as f64 * 0.90).ceil() as usize - 1];
370 let p99 = sorted[(n as f64 * 0.99).ceil() as usize - 1];
371 let max = sorted[n - 1];
372
373 Some(NonconformitySummary {
374 count: n,
375 mean,
376 p50,
377 p90,
378 p99,
379 max,
380 })
381 }
382
383 pub fn reset(&mut self) {
385 self.predictor.reset_all();
386 self.frame_times.clear();
387 self.nonconformity_scores.clear();
388 self.ema_us = 0.0;
389 self.state = GuardState::Warmup;
390 self.observations = 0;
391 }
393
394 pub fn suggest_action(
399 &self,
400 prediction: &P99Prediction,
401 current_level: DegradationLevel,
402 ) -> Option<DegradationLevel> {
403 if prediction.exceeds_budget && !current_level.is_max() {
404 Some(current_level.next())
405 } else {
406 None
407 }
408 }
409
410 pub fn telemetry(&self) -> ConformalFrameGuardTelemetry {
412 ConformalFrameGuardTelemetry {
413 state: self.state,
414 observations: self.observations,
415 degradation_triggers: self.degradation_triggers,
416 ema_us: self.ema_us,
417 frame_times_len: self.frame_times.len(),
418 nonconformity_len: self.nonconformity_scores.len(),
419 summary: self.nonconformity_summary(),
420 }
421 }
422}
423
424#[derive(Debug, Clone, Copy)]
426pub struct NonconformitySummary {
427 pub count: usize,
429 pub mean: f64,
431 pub p50: f64,
433 pub p90: f64,
435 pub p99: f64,
437 pub max: f64,
439}
440
441impl NonconformitySummary {
442 #[must_use]
444 pub fn to_jsonl_fragment(&self) -> String {
445 format!(
446 r#""nc_count":{},"nc_mean":{:.2},"nc_p50":{:.2},"nc_p90":{:.2},"nc_p99":{:.2},"nc_max":{:.2}"#,
447 self.count, self.mean, self.p50, self.p90, self.p99, self.max,
448 )
449 }
450}
451
452#[derive(Debug, Clone)]
454pub struct ConformalFrameGuardTelemetry {
455 pub state: GuardState,
457 pub observations: u64,
459 pub degradation_triggers: u64,
461 pub ema_us: f64,
463 pub frame_times_len: usize,
465 pub nonconformity_len: usize,
467 pub summary: Option<NonconformitySummary>,
469}
470
471impl ConformalFrameGuardTelemetry {
472 #[must_use]
474 pub fn to_jsonl(&self) -> String {
475 let summary_fields = self
476 .summary
477 .as_ref()
478 .map(|s| format!(",{}", s.to_jsonl_fragment()))
479 .unwrap_or_default();
480
481 format!(
482 r#"{{"schema":"conformal-frame-guard-telemetry-v1","state":"{}","observations":{},"degradation_triggers":{},"ema_us":{:.1},"frame_times_len":{},"nonconformity_len":{}{}}}"#,
483 self.state.as_str(),
484 self.observations,
485 self.degradation_triggers,
486 self.ema_us,
487 self.frame_times_len,
488 self.nonconformity_len,
489 summary_fields,
490 )
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::conformal_predictor::{DiffBucket, ModeBucket};
498
499 fn test_key() -> BucketKey {
500 BucketKey {
501 mode: ModeBucket::AltScreen,
502 diff: DiffBucket::Full,
503 size_bucket: 2,
504 }
505 }
506
507 #[test]
508 fn warmup_uses_fixed_fallback() {
509 let mut guard = ConformalFrameGuard::with_defaults();
510 let key = test_key();
511
512 let pred = guard.predict_p99(16_000.0, key);
514 assert_eq!(pred.fallback_level, 4);
515 assert_eq!(pred.state, GuardState::Warmup);
516 assert!(!pred.exceeds_budget); assert!(pred.conformal.is_none());
518 }
519
520 #[test]
521 fn warmup_with_slow_frames_signals_risk() {
522 let mut guard = ConformalFrameGuard::with_defaults();
523 let key = test_key();
524
525 for _ in 0..5 {
527 guard.observe(30_000.0, key);
528 }
529
530 let pred = guard.predict_p99(16_000.0, key);
531 assert_eq!(pred.fallback_level, 4);
532 assert!(pred.exceeds_budget); assert_eq!(pred.state, GuardState::AtRisk);
534 }
535
536 #[test]
537 fn calibration_transitions_from_warmup() {
538 let mut guard = ConformalFrameGuard::with_defaults();
539 let key = test_key();
540
541 for _ in 0..20 {
543 guard.observe(8_000.0, key);
544 }
545
546 assert!(guard.is_calibrated());
547 assert_eq!(guard.state(), GuardState::Calibrated);
548 }
549
550 #[test]
551 fn calibrated_prediction_has_conformal_data() {
552 let mut guard = ConformalFrameGuard::with_defaults();
553 let key = test_key();
554
555 for _ in 0..25 {
557 guard.observe(10_000.0, key);
558 }
559
560 let pred = guard.predict_p99(16_000.0, key);
561 assert!(pred.conformal.is_some());
562 assert!(pred.fallback_level < 4);
563 assert!(!pred.exceeds_budget); assert_eq!(pred.state, GuardState::Calibrated);
565 }
566
567 #[test]
568 fn calibrated_slow_frames_trigger_at_risk() {
569 let mut guard = ConformalFrameGuard::with_defaults();
570 let key = test_key();
571
572 for _ in 0..25 {
574 guard.observe(20_000.0, key);
575 }
576
577 let pred = guard.predict_p99(16_000.0, key);
578 assert!(pred.exceeds_budget);
579 assert_eq!(pred.state, GuardState::AtRisk);
580 assert!(guard.degradation_triggers() > 0);
581 }
582
583 #[test]
584 fn nonconformity_scores_tracked() {
585 let mut guard = ConformalFrameGuard::with_defaults();
586 let key = test_key();
587
588 for i in 0..10 {
589 guard.observe(10_000.0 + (i as f64 * 100.0), key);
590 }
591
592 assert_eq!(guard.nonconformity_scores().len(), 10);
593 assert_eq!(guard.frame_times().len(), 10);
594 }
595
596 #[test]
597 fn nonconformity_summary_computes_percentiles() {
598 let mut guard = ConformalFrameGuard::with_defaults();
599 let key = test_key();
600
601 for i in 0..100 {
603 guard.observe(10_000.0 + (i as f64 * 100.0), key);
604 }
605
606 let summary = guard.nonconformity_summary();
607 assert!(summary.is_some());
608 let s = summary.unwrap();
609 assert_eq!(s.count, 100);
610 assert!(s.p99 >= s.p90);
611 assert!(s.p90 >= s.p50);
612 assert!(s.max >= s.p99);
613 }
614
615 #[test]
616 fn reset_clears_state_but_preserves_triggers() {
617 let mut guard = ConformalFrameGuard::with_defaults();
618 let key = test_key();
619
620 for _ in 0..25 {
622 guard.observe(20_000.0, key);
623 }
624 let _ = guard.predict_p99(16_000.0, key);
625 let triggers_before = guard.degradation_triggers();
626 assert!(triggers_before > 0);
627
628 guard.reset();
629
630 assert_eq!(guard.state(), GuardState::Warmup);
631 assert_eq!(guard.observations(), 0);
632 assert!(guard.frame_times().is_empty());
633 assert!(guard.nonconformity_scores().is_empty());
634 assert_eq!(guard.degradation_triggers(), triggers_before);
636 }
637
638 #[test]
639 fn suggest_action_degrades_when_at_risk() {
640 let guard = ConformalFrameGuard::with_defaults();
641
642 let pred = P99Prediction {
643 y_hat_us: 18_000.0,
644 upper_us: 20_000.0,
645 budget_us: 16_000.0,
646 exceeds_budget: true,
647 calibration_size: 25,
648 fallback_level: 0,
649 state: GuardState::AtRisk,
650 interval_width_us: 2_000.0,
651 conformal: None,
652 };
653
654 let action = guard.suggest_action(&pred, DegradationLevel::Full);
655 assert_eq!(action, Some(DegradationLevel::SimpleBorders));
656 }
657
658 #[test]
659 fn suggest_action_holds_at_max_degradation() {
660 let guard = ConformalFrameGuard::with_defaults();
661
662 let pred = P99Prediction {
663 y_hat_us: 30_000.0,
664 upper_us: 35_000.0,
665 budget_us: 16_000.0,
666 exceeds_budget: true,
667 calibration_size: 25,
668 fallback_level: 0,
669 state: GuardState::AtRisk,
670 interval_width_us: 5_000.0,
671 conformal: None,
672 };
673
674 let action = guard.suggest_action(&pred, DegradationLevel::SkipFrame);
675 assert!(action.is_none());
676 }
677
678 #[test]
679 fn suggest_action_holds_when_within_budget() {
680 let guard = ConformalFrameGuard::with_defaults();
681
682 let pred = P99Prediction {
683 y_hat_us: 10_000.0,
684 upper_us: 14_000.0,
685 budget_us: 16_000.0,
686 exceeds_budget: false,
687 calibration_size: 25,
688 fallback_level: 0,
689 state: GuardState::Calibrated,
690 interval_width_us: 4_000.0,
691 conformal: None,
692 };
693
694 let action = guard.suggest_action(&pred, DegradationLevel::Full);
695 assert!(action.is_none());
696 }
697
698 #[test]
699 fn ema_tracks_frame_times() {
700 let mut guard = ConformalFrameGuard::with_defaults();
701 let key = test_key();
702
703 for _ in 0..50 {
705 guard.observe(10_000.0, key);
706 }
707
708 let ema = guard.ema_us();
710 assert!(
711 (ema - 10_000.0).abs() < 500.0,
712 "EMA should be ~10000, got {ema}"
713 );
714 }
715
716 #[test]
717 fn invalid_frame_time_ignored() {
718 let mut guard = ConformalFrameGuard::with_defaults();
719 let key = test_key();
720
721 guard.observe(f64::NAN, key);
722 guard.observe(f64::INFINITY, key);
723 guard.observe(-1.0, key);
724
725 assert_eq!(guard.observations(), 0);
726 assert!(guard.frame_times().is_empty());
727 }
728
729 #[test]
730 fn jsonl_output_is_valid_json() {
731 let pred = P99Prediction {
732 y_hat_us: 10_000.0,
733 upper_us: 14_000.0,
734 budget_us: 16_000.0,
735 exceeds_budget: false,
736 calibration_size: 25,
737 fallback_level: 0,
738 state: GuardState::Calibrated,
739 interval_width_us: 4_000.0,
740 conformal: None,
741 };
742
743 let json_str = pred.to_jsonl();
744 assert!(json_str.starts_with('{'));
746 assert!(json_str.ends_with('}'));
747 assert!(json_str.contains("conformal-frame-guard-v1"));
748 }
749
750 #[test]
751 fn telemetry_snapshot_captures_state() {
752 let mut guard = ConformalFrameGuard::with_defaults();
753 let key = test_key();
754
755 for _ in 0..30 {
756 guard.observe(12_000.0, key);
757 }
758
759 let telem = guard.telemetry();
760 assert_eq!(telem.observations, 30);
761 assert_eq!(telem.frame_times_len, 30);
762 assert_eq!(telem.nonconformity_len, 30);
763 assert!(telem.summary.is_some());
764
765 let json_str = telem.to_jsonl();
766 assert!(json_str.contains("conformal-frame-guard-telemetry-v1"));
767 }
768
769 #[test]
770 fn window_limits_respected() {
771 let config = ConformalFrameGuardConfig {
772 time_series_window: 10,
773 nonconformity_window: 5,
774 ..Default::default()
775 };
776 let mut guard = ConformalFrameGuard::new(config);
777 let key = test_key();
778
779 for i in 0..100 {
780 guard.observe(10_000.0 + (i as f64), key);
781 }
782
783 assert_eq!(guard.frame_times().len(), 10);
784 assert_eq!(guard.nonconformity_scores().len(), 5);
785 }
786}