Skip to main content

ftui_runtime/
conformal_frame_guard.rs

1#![forbid(unsafe_code)]
2
3//! Conformal frame guard: coverage-guaranteed prediction intervals for frame timing.
4//!
5//! Wraps [`ConformalPredictor`] with a frame-time time series, nonconformity
6//! score tracking, and p99 prediction intervals. When the predicted p99
7//! exceeds the frame budget, degradation is triggered.
8//!
9//! **Fallback:** before calibration reaches `min_samples`, a fixed 16 ms
10//! budget threshold is used (no conformal interval).
11//!
12//! # Integration
13//!
14//! The guard sits between frame measurement and [`BudgetController`]:
15//!
16//! ```text
17//! frame_time ──► ConformalFrameGuard ──► P99Prediction
18//!                        │                      │
19//!                        ▼                      ▼
20//!                   observe()              exceeds_budget?
21//!                   (calibrate)           → trigger degrade
22//! ```
23
24use std::collections::VecDeque;
25
26use ftui_render::budget::DegradationLevel;
27
28use crate::conformal_predictor::{
29    BucketKey, ConformalConfig, ConformalPrediction, ConformalPredictor,
30};
31
32/// Default fallback budget threshold in microseconds (16 ms = 60 fps target).
33const DEFAULT_FALLBACK_BUDGET_US: f64 = 16_000.0;
34
35/// Configuration for the conformal frame guard.
36#[derive(Debug, Clone)]
37pub struct ConformalFrameGuardConfig {
38    /// Underlying conformal predictor configuration.
39    pub conformal: ConformalConfig,
40
41    /// Fixed fallback budget threshold (µs) used before calibration.
42    /// Default: 16 000.0 (16 ms).
43    pub fallback_budget_us: f64,
44
45    /// Maximum frame time samples retained for time-series tracking.
46    /// Default: 512.
47    pub time_series_window: usize,
48
49    /// Maximum nonconformity scores retained.
50    /// Default: 256 (matches conformal window).
51    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/// State of the conformal frame guard.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum GuardState {
70    /// Insufficient calibration data; using fixed fallback threshold.
71    Warmup,
72    /// Calibrated with enough samples; conformal intervals active.
73    Calibrated,
74    /// Last prediction indicated p99 exceeds budget.
75    AtRisk,
76}
77
78impl GuardState {
79    /// Stable string for JSONL logging.
80    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/// Result of a p99 prediction from the guard.
90#[derive(Debug, Clone)]
91pub struct P99Prediction {
92    /// Base prediction (most recent frame time or EMA estimate) in µs.
93    pub y_hat_us: f64,
94    /// Upper bound of the p99 prediction interval in µs.
95    pub upper_us: f64,
96    /// Frame budget in µs.
97    pub budget_us: f64,
98    /// Whether the p99 upper bound exceeds the budget.
99    pub exceeds_budget: bool,
100    /// Calibration sample count used.
101    pub calibration_size: usize,
102    /// Fallback level from the underlying conformal predictor (0..=4).
103    /// Level 4 means frame-guard fixed fallback was used.
104    pub fallback_level: u8,
105    /// Current guard state.
106    pub state: GuardState,
107    /// Width of the prediction interval (upper - y_hat) in µs.
108    pub interval_width_us: f64,
109    /// Underlying conformal prediction (if calibrated).
110    pub conformal: Option<ConformalPrediction>,
111}
112
113impl P99Prediction {
114    /// Format as a JSONL line for structured evidence logging.
115    #[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/// Conformal frame guard: wraps [`ConformalPredictor`] with p99 intervals.
144///
145/// Tracks frame render times as a time series, computes nonconformity scores,
146/// and emits coverage-guaranteed prediction intervals for the next frame.
147/// When the predicted p99 exceeds the frame budget, the guard signals
148/// degradation.
149#[derive(Debug)]
150pub struct ConformalFrameGuard {
151    config: ConformalFrameGuardConfig,
152    predictor: ConformalPredictor,
153    /// Rolling frame time measurements (µs).
154    frame_times: VecDeque<f64>,
155    /// Rolling nonconformity scores (residual = observed - predicted).
156    nonconformity_scores: VecDeque<f64>,
157    /// EMA of frame times (µs) for base prediction.
158    ema_us: f64,
159    /// EMA decay factor. Closer to 1.0 = slower adaptation.
160    ema_decay: f64,
161    /// Current guard state.
162    state: GuardState,
163    /// Total observations processed.
164    observations: u64,
165    /// Count of degradation triggers.
166    degradation_triggers: u64,
167}
168
169impl ConformalFrameGuard {
170    /// Create a new guard with the given configuration.
171    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    /// Create a guard with default configuration.
187    pub fn with_defaults() -> Self {
188        Self::new(ConformalFrameGuardConfig::default())
189    }
190
191    /// Observe a realized frame time and update calibration.
192    ///
193    /// `frame_time_us`: measured frame render time in microseconds.
194    /// `key`: bucket key from the rendering context.
195    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        // Update EMA
203        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        // Track frame time in rolling window
210        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        // Compute and track nonconformity score (residual)
216        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        // Feed the underlying conformal predictor
224        self.predictor.observe(key, y_hat, frame_time_us);
225
226        // Update state based on calibration
227        let samples = self.predictor.bucket_samples(key);
228        if samples < self.config.conformal.min_samples && self.state == GuardState::Warmup {
229            // Stay in warmup
230        } else if self.state == GuardState::Warmup {
231            self.state = GuardState::Calibrated;
232        }
233    }
234
235    /// Predict the p99 upper bound for the next frame.
236    ///
237    /// `budget_us`: current frame budget in microseconds.
238    /// `key`: bucket key for the upcoming rendering context.
239    ///
240    /// Returns a [`P99Prediction`] with the interval and risk assessment.
241    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            // Use conformal prediction for coverage-guaranteed bound
253            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            // Fallback: fixed budget threshold (16ms default)
276            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            // In warmup, signal risk only if EMA clearly exceeds fallback
284            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, // No interval in fallback mode
294                budget_us: fallback,
295                exceeds_budget: exceeds,
296                calibration_size: samples,
297                fallback_level: 4, // Frame-guard fixed fallback
298                state,
299                interval_width_us: 0.0,
300                conformal: None,
301            }
302        }
303    }
304
305    /// Get the current guard state.
306    #[inline]
307    pub fn state(&self) -> GuardState {
308        self.state
309    }
310
311    /// Whether the guard has enough calibration data for conformal intervals.
312    #[inline]
313    pub fn is_calibrated(&self) -> bool {
314        matches!(self.state, GuardState::Calibrated | GuardState::AtRisk)
315    }
316
317    /// Total frame observations processed.
318    #[inline]
319    pub fn observations(&self) -> u64 {
320        self.observations
321    }
322
323    /// Total degradation triggers.
324    #[inline]
325    pub fn degradation_triggers(&self) -> u64 {
326        self.degradation_triggers
327    }
328
329    /// Access the rolling nonconformity scores.
330    pub fn nonconformity_scores(&self) -> &VecDeque<f64> {
331        &self.nonconformity_scores
332    }
333
334    /// Access the rolling frame time series.
335    pub fn frame_times(&self) -> &VecDeque<f64> {
336        &self.frame_times
337    }
338
339    /// Current EMA of frame times (µs).
340    #[inline]
341    pub fn ema_us(&self) -> f64 {
342        self.ema_us
343    }
344
345    /// Access the underlying conformal predictor.
346    pub fn predictor(&self) -> &ConformalPredictor {
347        &self.predictor
348    }
349
350    /// Access the configuration.
351    pub fn config(&self) -> &ConformalFrameGuardConfig {
352        &self.config
353    }
354
355    /// Compute summary statistics for the nonconformity score distribution.
356    ///
357    /// Returns `(mean, p50, p90, p99, max)` or `None` if no scores exist.
358    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    /// Reset all calibration state (e.g., after a mode change).
384    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        // Preserve degradation_triggers count across resets for audit trail
392    }
393
394    /// Suggest what degradation action to take based on the prediction.
395    ///
396    /// Returns `Some(DegradationLevel::next())` if the p99 exceeds budget
397    /// and the guard is calibrated, `None` otherwise (hold current level).
398    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    /// Capture a telemetry snapshot for structured logging.
411    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/// Summary statistics for nonconformity score distribution.
425#[derive(Debug, Clone, Copy)]
426pub struct NonconformitySummary {
427    /// Number of scores in the window.
428    pub count: usize,
429    /// Mean nonconformity score.
430    pub mean: f64,
431    /// Median (p50).
432    pub p50: f64,
433    /// 90th percentile.
434    pub p90: f64,
435    /// 99th percentile.
436    pub p99: f64,
437    /// Maximum.
438    pub max: f64,
439}
440
441impl NonconformitySummary {
442    /// Format as a JSONL fragment (no outer braces).
443    #[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/// Telemetry snapshot of the conformal frame guard.
453#[derive(Debug, Clone)]
454pub struct ConformalFrameGuardTelemetry {
455    /// Current guard state.
456    pub state: GuardState,
457    /// Total observations.
458    pub observations: u64,
459    /// Total degradation triggers.
460    pub degradation_triggers: u64,
461    /// Current EMA estimate (µs).
462    pub ema_us: f64,
463    /// Frame time window length.
464    pub frame_times_len: usize,
465    /// Nonconformity window length.
466    pub nonconformity_len: usize,
467    /// Nonconformity summary (if any scores exist).
468    pub summary: Option<NonconformitySummary>,
469}
470
471impl ConformalFrameGuardTelemetry {
472    /// Format as a JSONL line.
473    #[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        // No observations yet
513        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); // y_hat=0 < 16ms
517        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        // Feed 5 slow frames (30ms each) — not enough for calibration
526        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); // EMA ~30ms > 16ms fallback
533        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        // Feed min_samples (20) fast frames
542        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        // Calibrate with 25 samples of ~10ms
556        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); // 10ms well under 16ms budget
564        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        // Calibrate with slow frames (20ms)
573        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        // Feed 100 samples with known distribution
602        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        // Feed slow frames to trigger degradation
621        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        // Triggers preserved for audit trail
635        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        // All 10ms frames
704        for _ in 0..50 {
705            guard.observe(10_000.0, key);
706        }
707
708        // EMA should converge close to 10_000
709        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        // Verify it parses as JSON (basic check: starts/ends with braces, has schema)
745        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}