Skip to main content

ftui_runtime/
conformal_predictor.rs

1#![forbid(unsafe_code)]
2
3//! Conformal predictor for frame-time risk (bd-3e1t.3.2).
4//!
5//! This module provides a distribution-free upper bound on frame time using
6//! Mondrian (bucketed) conformal prediction. It is intentionally lightweight
7//! and explainable: each prediction returns the bucket key, quantile, and
8//! fallback level used to produce the bound.
9//!
10//! See docs/spec/state-machines.md section 3.13 for the governing spec.
11
12use std::collections::{HashMap, VecDeque};
13use std::fmt;
14
15use ftui_render::diff_strategy::DiffStrategy;
16
17use crate::terminal_writer::ScreenMode;
18
19/// Configuration for conformal frame-time prediction.
20#[derive(Debug, Clone)]
21pub struct ConformalConfig {
22    /// Significance level alpha. Coverage is >= 1 - alpha.
23    /// Default: 0.05.
24    pub alpha: f64,
25
26    /// Minimum samples required before a bucket is considered valid.
27    /// Default: 20.
28    pub min_samples: usize,
29
30    /// Maximum samples retained per bucket (rolling window).
31    /// Default: 256.
32    pub window_size: usize,
33
34    /// Conservative fallback residual (microseconds) when no calibration exists.
35    /// Default: 10_000.0 (10ms).
36    pub q_default: f64,
37}
38
39impl Default for ConformalConfig {
40    fn default() -> Self {
41        Self {
42            alpha: 0.05,
43            min_samples: 20,
44            window_size: 256,
45            q_default: 10_000.0,
46        }
47    }
48}
49
50/// Bucket identifier for conformal calibration.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub struct BucketKey {
53    pub mode: ModeBucket,
54    pub diff: DiffBucket,
55    pub size_bucket: u8,
56}
57
58impl BucketKey {
59    /// Create a bucket key from rendering context.
60    pub fn from_context(
61        screen_mode: ScreenMode,
62        diff_strategy: DiffStrategy,
63        cols: u16,
64        rows: u16,
65    ) -> Self {
66        Self {
67            mode: ModeBucket::from_screen_mode(screen_mode),
68            diff: DiffBucket::from(diff_strategy),
69            size_bucket: size_bucket(cols, rows),
70        }
71    }
72}
73
74/// Mode bucket for conformal calibration.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum ModeBucket {
77    Inline,
78    InlineAuto,
79    AltScreen,
80}
81
82impl ModeBucket {
83    pub fn as_str(self) -> &'static str {
84        match self {
85            Self::Inline => "inline",
86            Self::InlineAuto => "inline_auto",
87            Self::AltScreen => "altscreen",
88        }
89    }
90
91    pub fn from_screen_mode(mode: ScreenMode) -> Self {
92        match mode {
93            ScreenMode::Inline { .. } => Self::Inline,
94            ScreenMode::InlineAuto { .. } => Self::InlineAuto,
95            ScreenMode::AltScreen => Self::AltScreen,
96        }
97    }
98}
99
100/// Diff strategy bucket for conformal calibration.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub enum DiffBucket {
103    Full,
104    DirtyRows,
105    FullRedraw,
106}
107
108impl DiffBucket {
109    pub fn as_str(self) -> &'static str {
110        match self {
111            Self::Full => "full",
112            Self::DirtyRows => "dirty",
113            Self::FullRedraw => "redraw",
114        }
115    }
116}
117
118impl From<DiffStrategy> for DiffBucket {
119    fn from(strategy: DiffStrategy) -> Self {
120        match strategy {
121            DiffStrategy::Full => Self::Full,
122            DiffStrategy::DirtyRows => Self::DirtyRows,
123            DiffStrategy::FullRedraw => Self::FullRedraw,
124        }
125    }
126}
127
128impl fmt::Display for BucketKey {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        write!(
131            f,
132            "{}:{}:{}",
133            self.mode.as_str(),
134            self.diff.as_str(),
135            self.size_bucket
136        )
137    }
138}
139
140/// Prediction output with full explainability.
141#[derive(Debug, Clone)]
142pub struct ConformalPrediction {
143    /// Upper bound on frame time (microseconds).
144    pub upper_us: f64,
145    /// Whether the bound exceeds the current budget.
146    pub risk: bool,
147    /// Coverage confidence (1 - alpha).
148    pub confidence: f64,
149    /// Bucket key used for calibration (may be fallback aggregate).
150    pub bucket: BucketKey,
151    /// Calibration sample count used for the quantile.
152    pub sample_count: usize,
153    /// Conformal quantile q_b.
154    pub quantile: f64,
155    /// Fallback level (0 = exact, 1 = mode+diff, 2 = mode, 3 = global/default).
156    pub fallback_level: u8,
157    /// Rolling window size.
158    pub window_size: usize,
159    /// Total reset count for this predictor.
160    pub reset_count: u64,
161    /// Base prediction f(x_t).
162    pub y_hat: f64,
163    /// Frame budget in microseconds.
164    pub budget_us: f64,
165}
166
167impl ConformalPrediction {
168    /// Format this prediction as a JSONL line for structured logging.
169    #[must_use]
170    pub fn to_jsonl(&self) -> String {
171        format!(
172            r#"{{"schema":"conformal-v1","upper_us":{:.1},"risk":{},"confidence":{:.4},"bucket":"{}","samples":{},"quantile":{:.2},"fallback_level":{},"window":{},"resets":{},"y_hat":{:.1},"budget_us":{:.1}}}"#,
173            self.upper_us,
174            self.risk,
175            self.confidence,
176            self.bucket,
177            self.sample_count,
178            self.quantile,
179            self.fallback_level,
180            self.window_size,
181            self.reset_count,
182            self.y_hat,
183            self.budget_us,
184        )
185    }
186}
187
188/// Update metadata after observing a frame.
189#[derive(Debug, Clone)]
190pub struct ConformalUpdate {
191    /// Residual (y_t - f(x_t)).
192    pub residual: f64,
193    /// Bucket updated.
194    pub bucket: BucketKey,
195    /// New sample count in the bucket.
196    pub sample_count: usize,
197}
198
199#[derive(Debug, Default)]
200struct BucketState {
201    residuals: VecDeque<f64>,
202}
203
204impl BucketState {
205    fn push(&mut self, residual: f64, window_size: usize) {
206        self.residuals.push_back(residual);
207        while self.residuals.len() > window_size {
208            self.residuals.pop_front();
209        }
210    }
211}
212
213/// Conformal predictor with bucketed calibration.
214#[derive(Debug)]
215pub struct ConformalPredictor {
216    config: ConformalConfig,
217    buckets: HashMap<BucketKey, BucketState>,
218    reset_count: u64,
219}
220
221impl ConformalPredictor {
222    /// Create a new predictor with the given config.
223    pub fn new(config: ConformalConfig) -> Self {
224        Self {
225            config,
226            buckets: HashMap::new(),
227            reset_count: 0,
228        }
229    }
230
231    /// Access the configuration.
232    pub fn config(&self) -> &ConformalConfig {
233        &self.config
234    }
235
236    /// Number of samples currently stored for a bucket.
237    pub fn bucket_samples(&self, key: BucketKey) -> usize {
238        self.buckets
239            .get(&key)
240            .map(|state| state.residuals.len())
241            .unwrap_or(0)
242    }
243
244    /// Clear calibration for all buckets.
245    pub fn reset_all(&mut self) {
246        self.buckets.clear();
247        self.reset_count += 1;
248    }
249
250    /// Clear calibration for a single bucket.
251    pub fn reset_bucket(&mut self, key: BucketKey) {
252        if let Some(state) = self.buckets.get_mut(&key) {
253            state.residuals.clear();
254            self.reset_count += 1;
255        }
256    }
257
258    /// Observe a realized frame time and update calibration.
259    pub fn observe(&mut self, key: BucketKey, y_hat_us: f64, observed_us: f64) -> ConformalUpdate {
260        let residual = observed_us - y_hat_us;
261        if !residual.is_finite() {
262            return ConformalUpdate {
263                residual,
264                bucket: key,
265                sample_count: self.bucket_samples(key),
266            };
267        }
268
269        let window_size = self.config.window_size.max(1);
270        let state = self.buckets.entry(key).or_default();
271        state.push(residual, window_size);
272        ConformalUpdate {
273            residual,
274            bucket: key,
275            sample_count: state.residuals.len(),
276        }
277    }
278
279    /// Predict a conservative upper bound for frame time.
280    pub fn predict(&self, key: BucketKey, y_hat_us: f64, budget_us: f64) -> ConformalPrediction {
281        let span = tracing::info_span!(
282            "conformal.predict",
283            calibration_set_size = tracing::field::Empty,
284            predicted_upper_bound_us = tracing::field::Empty,
285            frame_budget_us = budget_us,
286            coverage_alpha = self.config.alpha,
287            gate_triggered = tracing::field::Empty,
288        );
289        let _guard = span.enter();
290
291        let QuantileDecision {
292            quantile,
293            sample_count,
294            fallback_level,
295        } = self.quantile_for(key);
296
297        let upper_us = y_hat_us + quantile.max(0.0);
298        let risk = upper_us > budget_us;
299
300        span.record("calibration_set_size", sample_count);
301        span.record("predicted_upper_bound_us", upper_us);
302        span.record("gate_triggered", risk);
303
304        tracing::debug!(
305            bucket = %key,
306            y_hat_us,
307            quantile,
308            interval_width_us = quantile.max(0.0),
309            fallback_level,
310            sample_count,
311            "prediction interval"
312        );
313
314        ConformalPrediction {
315            upper_us,
316            risk,
317            confidence: 1.0 - self.config.alpha,
318            bucket: key,
319            sample_count,
320            quantile,
321            fallback_level,
322            window_size: self.config.window_size,
323            reset_count: self.reset_count,
324            y_hat: y_hat_us,
325            budget_us,
326        }
327    }
328
329    fn quantile_for(&self, key: BucketKey) -> QuantileDecision {
330        let min_samples = self.config.min_samples.max(1);
331
332        let exact = self.collect_exact(key);
333        if exact.len() >= min_samples {
334            return QuantileDecision::new(self.config.alpha, exact, 0);
335        }
336
337        let mode_diff = self.collect_mode_diff(key.mode, key.diff);
338        if mode_diff.len() >= min_samples {
339            return QuantileDecision::new(self.config.alpha, mode_diff, 1);
340        }
341
342        let mode_only = self.collect_mode(key.mode);
343        if mode_only.len() >= min_samples {
344            return QuantileDecision::new(self.config.alpha, mode_only, 2);
345        }
346
347        let global = self.collect_all();
348        if !global.is_empty() {
349            return QuantileDecision::new(self.config.alpha, global, 3);
350        }
351
352        QuantileDecision {
353            quantile: self.config.q_default,
354            sample_count: 0,
355            fallback_level: 3,
356        }
357    }
358
359    fn collect_exact(&self, key: BucketKey) -> Vec<f64> {
360        self.buckets
361            .get(&key)
362            .map(|state| state.residuals.iter().copied().collect())
363            .unwrap_or_default()
364    }
365
366    fn collect_mode_diff(&self, mode: ModeBucket, diff: DiffBucket) -> Vec<f64> {
367        let mut values = Vec::new();
368        for (key, state) in &self.buckets {
369            if key.mode == mode && key.diff == diff {
370                values.extend(state.residuals.iter().copied());
371            }
372        }
373        values
374    }
375
376    fn collect_mode(&self, mode: ModeBucket) -> Vec<f64> {
377        let mut values = Vec::new();
378        for (key, state) in &self.buckets {
379            if key.mode == mode {
380                values.extend(state.residuals.iter().copied());
381            }
382        }
383        values
384    }
385
386    fn collect_all(&self) -> Vec<f64> {
387        let mut values = Vec::new();
388        for state in self.buckets.values() {
389            values.extend(state.residuals.iter().copied());
390        }
391        values
392    }
393}
394
395#[derive(Debug)]
396struct QuantileDecision {
397    quantile: f64,
398    sample_count: usize,
399    fallback_level: u8,
400}
401
402impl QuantileDecision {
403    fn new(alpha: f64, mut residuals: Vec<f64>, fallback_level: u8) -> Self {
404        let quantile = conformal_quantile(alpha, &mut residuals);
405        Self {
406            quantile,
407            sample_count: residuals.len(),
408            fallback_level,
409        }
410    }
411}
412
413fn conformal_quantile(alpha: f64, residuals: &mut [f64]) -> f64 {
414    if residuals.is_empty() {
415        return 0.0;
416    }
417    residuals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
418    let n = residuals.len();
419    let rank = ((n as f64 + 1.0) * (1.0 - alpha)).ceil() as usize;
420    let idx = rank.saturating_sub(1).min(n - 1);
421    residuals[idx]
422}
423
424fn size_bucket(cols: u16, rows: u16) -> u8 {
425    let area = cols as u32 * rows as u32;
426    if area == 0 {
427        return 0;
428    }
429    (31 - area.leading_zeros()) as u8
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    fn test_key(cols: u16, rows: u16) -> BucketKey {
437        BucketKey::from_context(
438            ScreenMode::Inline { ui_height: 4 },
439            DiffStrategy::Full,
440            cols,
441            rows,
442        )
443    }
444
445    #[test]
446    fn quantile_n_plus_1_rule() {
447        let mut predictor = ConformalPredictor::new(ConformalConfig {
448            alpha: 0.2,
449            min_samples: 1,
450            window_size: 10,
451            q_default: 0.0,
452        });
453
454        let key = test_key(80, 24);
455        predictor.observe(key, 0.0, 1.0);
456        predictor.observe(key, 0.0, 2.0);
457        predictor.observe(key, 0.0, 3.0);
458
459        let decision = predictor.predict(key, 0.0, 1_000.0);
460        assert_eq!(decision.quantile, 3.0);
461    }
462
463    #[test]
464    fn fallback_hierarchy_mode_diff() {
465        let mut predictor = ConformalPredictor::new(ConformalConfig {
466            alpha: 0.1,
467            min_samples: 4,
468            window_size: 16,
469            q_default: 0.0,
470        });
471
472        let key_a = test_key(80, 24);
473        for value in [1.0, 2.0, 3.0, 4.0] {
474            predictor.observe(key_a, 0.0, value);
475        }
476
477        let key_b = test_key(120, 40);
478        let decision = predictor.predict(key_b, 0.0, 1_000.0);
479        assert_eq!(decision.fallback_level, 1);
480        assert_eq!(decision.sample_count, 4);
481    }
482
483    #[test]
484    fn fallback_hierarchy_mode_only() {
485        let mut predictor = ConformalPredictor::new(ConformalConfig {
486            alpha: 0.1,
487            min_samples: 3,
488            window_size: 16,
489            q_default: 0.0,
490        });
491
492        let key_dirty = BucketKey::from_context(
493            ScreenMode::Inline { ui_height: 4 },
494            DiffStrategy::DirtyRows,
495            80,
496            24,
497        );
498        for value in [10.0, 20.0, 30.0] {
499            predictor.observe(key_dirty, 0.0, value);
500        }
501
502        let key_full = BucketKey::from_context(
503            ScreenMode::Inline { ui_height: 4 },
504            DiffStrategy::Full,
505            120,
506            40,
507        );
508        let decision = predictor.predict(key_full, 0.0, 1_000.0);
509        assert_eq!(decision.fallback_level, 2);
510        assert_eq!(decision.sample_count, 3);
511    }
512
513    #[test]
514    fn window_enforced() {
515        let mut predictor = ConformalPredictor::new(ConformalConfig {
516            alpha: 0.1,
517            min_samples: 1,
518            window_size: 3,
519            q_default: 0.0,
520        });
521        let key = test_key(80, 24);
522        for value in [1.0, 2.0, 3.0, 4.0, 5.0] {
523            predictor.observe(key, 0.0, value);
524        }
525        assert_eq!(predictor.bucket_samples(key), 3);
526    }
527
528    #[test]
529    fn predict_uses_default_when_empty() {
530        let predictor = ConformalPredictor::new(ConformalConfig {
531            alpha: 0.1,
532            min_samples: 2,
533            window_size: 4,
534            q_default: 42.0,
535        });
536        let key = test_key(120, 40);
537        let prediction = predictor.predict(key, 5.0, 10_000.0);
538        assert_eq!(prediction.quantile, 42.0);
539        assert_eq!(prediction.sample_count, 0);
540        assert_eq!(prediction.fallback_level, 3);
541    }
542
543    #[test]
544    fn bucket_isolation_by_size() {
545        let mut predictor = ConformalPredictor::new(ConformalConfig {
546            alpha: 0.2,
547            min_samples: 2,
548            window_size: 10,
549            q_default: 0.0,
550        });
551
552        let small = test_key(40, 10);
553        predictor.observe(small, 0.0, 1.0);
554        predictor.observe(small, 0.0, 2.0);
555
556        let large = test_key(200, 60);
557        predictor.observe(large, 0.0, 10.0);
558        predictor.observe(large, 0.0, 12.0);
559
560        let prediction = predictor.predict(large, 0.0, 1_000.0);
561        assert_eq!(prediction.fallback_level, 0);
562        assert_eq!(prediction.sample_count, 2);
563        assert_eq!(prediction.quantile, 12.0);
564    }
565
566    #[test]
567    fn reset_clears_bucket_and_raises_reset_count() {
568        let mut predictor = ConformalPredictor::new(ConformalConfig {
569            alpha: 0.1,
570            min_samples: 1,
571            window_size: 8,
572            q_default: 7.0,
573        });
574        let key = test_key(80, 24);
575        predictor.observe(key, 0.0, 3.0);
576        assert_eq!(predictor.bucket_samples(key), 1);
577
578        predictor.reset_bucket(key);
579        assert_eq!(predictor.bucket_samples(key), 0);
580
581        let prediction = predictor.predict(key, 0.0, 1_000.0);
582        assert_eq!(prediction.quantile, 7.0);
583        assert_eq!(prediction.reset_count, 1);
584    }
585
586    #[test]
587    fn reset_all_forces_conservative_fallback() {
588        let mut predictor = ConformalPredictor::new(ConformalConfig {
589            alpha: 0.1,
590            min_samples: 1,
591            window_size: 8,
592            q_default: 9.0,
593        });
594        let key = test_key(80, 24);
595        predictor.observe(key, 0.0, 2.0);
596
597        predictor.reset_all();
598        let prediction = predictor.predict(key, 0.0, 1_000.0);
599        assert_eq!(prediction.quantile, 9.0);
600        assert_eq!(prediction.sample_count, 0);
601        assert_eq!(prediction.fallback_level, 3);
602        assert_eq!(prediction.reset_count, 1);
603    }
604
605    #[test]
606    fn size_bucket_log2_area() {
607        let a = size_bucket(8, 8); // area 64 -> log2 = 6
608        let b = size_bucket(8, 16); // area 128 -> log2 = 7
609        assert_eq!(a, 6);
610        assert_eq!(b, 7);
611    }
612
613    // --- size_bucket edge cases ---
614
615    #[test]
616    fn size_bucket_zero_area() {
617        assert_eq!(size_bucket(0, 0), 0);
618        assert_eq!(size_bucket(0, 24), 0);
619        assert_eq!(size_bucket(80, 0), 0);
620    }
621
622    #[test]
623    fn size_bucket_one_by_one() {
624        assert_eq!(size_bucket(1, 1), 0); // area 1, log2(1) = 0
625    }
626
627    #[test]
628    fn size_bucket_typical_terminals() {
629        let b80 = size_bucket(80, 24); // 1920 -> log2 ~ 10
630        let b120 = size_bucket(120, 40); // 4800 -> log2 ~ 12
631        assert_eq!(b80, 10);
632        assert_eq!(b120, 12);
633    }
634
635    // --- conformal_quantile edge cases ---
636
637    #[test]
638    fn conformal_quantile_empty() {
639        let mut data: Vec<f64> = vec![];
640        assert_eq!(conformal_quantile(0.1, &mut data), 0.0);
641    }
642
643    #[test]
644    fn conformal_quantile_single_element() {
645        let mut data = vec![42.0];
646        assert_eq!(conformal_quantile(0.1, &mut data), 42.0);
647    }
648
649    #[test]
650    fn conformal_quantile_sorted_data() {
651        let mut data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
652        let q = conformal_quantile(0.5, &mut data);
653        // (5+1)*0.5 = 3.0 -> ceil = 3 -> idx = 2 -> data[2] = 3.0
654        assert_eq!(q, 3.0);
655    }
656
657    #[test]
658    fn conformal_quantile_alpha_half() {
659        let mut data = vec![10.0, 20.0, 30.0, 40.0];
660        let q = conformal_quantile(0.5, &mut data);
661        // (4+1)*0.5 = 2.5 -> ceil = 3 -> idx = 2 -> data[2] = 30.0
662        assert_eq!(q, 30.0);
663    }
664
665    // --- ModeBucket / DiffBucket ---
666
667    #[test]
668    fn mode_bucket_as_str_all_variants() {
669        assert_eq!(ModeBucket::Inline.as_str(), "inline");
670        assert_eq!(ModeBucket::InlineAuto.as_str(), "inline_auto");
671        assert_eq!(ModeBucket::AltScreen.as_str(), "altscreen");
672    }
673
674    #[test]
675    fn diff_bucket_as_str_all_variants() {
676        assert_eq!(DiffBucket::Full.as_str(), "full");
677        assert_eq!(DiffBucket::DirtyRows.as_str(), "dirty");
678        assert_eq!(DiffBucket::FullRedraw.as_str(), "redraw");
679    }
680
681    #[test]
682    fn diff_bucket_from_strategy() {
683        assert_eq!(DiffBucket::from(DiffStrategy::Full), DiffBucket::Full);
684        assert_eq!(
685            DiffBucket::from(DiffStrategy::DirtyRows),
686            DiffBucket::DirtyRows
687        );
688        assert_eq!(
689            DiffBucket::from(DiffStrategy::FullRedraw),
690            DiffBucket::FullRedraw
691        );
692    }
693
694    // --- BucketKey Display ---
695
696    #[test]
697    fn bucket_key_display_format() {
698        let key = BucketKey {
699            mode: ModeBucket::AltScreen,
700            diff: DiffBucket::DirtyRows,
701            size_bucket: 12,
702        };
703        assert_eq!(format!("{key}"), "altscreen:dirty:12");
704    }
705
706    // --- observe edge cases ---
707
708    #[test]
709    fn observe_nan_residual_not_stored() {
710        let mut predictor = ConformalPredictor::new(ConformalConfig {
711            alpha: 0.1,
712            min_samples: 1,
713            window_size: 8,
714            q_default: 5.0,
715        });
716        let key = test_key(80, 24);
717        let update = predictor.observe(key, 0.0, f64::NAN);
718        assert!(!update.residual.is_finite());
719        assert_eq!(predictor.bucket_samples(key), 0);
720    }
721
722    #[test]
723    fn observe_infinity_residual_not_stored() {
724        let mut predictor = ConformalPredictor::new(ConformalConfig {
725            alpha: 0.1,
726            min_samples: 1,
727            window_size: 8,
728            q_default: 5.0,
729        });
730        let key = test_key(80, 24);
731        predictor.observe(key, 0.0, f64::INFINITY);
732        assert_eq!(predictor.bucket_samples(key), 0);
733    }
734
735    // --- prediction fields ---
736
737    #[test]
738    fn prediction_risk_flag() {
739        let predictor = ConformalPredictor::new(ConformalConfig {
740            alpha: 0.1,
741            min_samples: 1,
742            window_size: 8,
743            q_default: 50.0,
744        });
745        let key = test_key(80, 24);
746        // No data -> q_default = 50.0, y_hat = 0 -> upper = 50
747        let p = predictor.predict(key, 0.0, 100.0);
748        assert!(!p.risk); // 50 <= 100
749        let p2 = predictor.predict(key, 0.0, 30.0);
750        assert!(p2.risk); // 50 > 30
751    }
752
753    #[test]
754    fn prediction_confidence() {
755        let predictor = ConformalPredictor::new(ConformalConfig {
756            alpha: 0.05,
757            min_samples: 1,
758            window_size: 8,
759            q_default: 0.0,
760        });
761        let key = test_key(80, 24);
762        let p = predictor.predict(key, 0.0, 100.0);
763        assert!((p.confidence - 0.95).abs() < 1e-10);
764    }
765
766    // --- global fallback with data ---
767
768    #[test]
769    fn global_fallback_with_data() {
770        let mut predictor = ConformalPredictor::new(ConformalConfig {
771            alpha: 0.1,
772            min_samples: 100, // impossibly high -> always fall through
773            window_size: 256,
774            q_default: 999.0,
775        });
776        // Use altscreen mode bucket, then query inline
777        let alt_key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
778        predictor.observe(alt_key, 0.0, 5.0);
779
780        let inline_key = test_key(80, 24);
781        let p = predictor.predict(inline_key, 0.0, 1000.0);
782        // Falls all the way to global (level 3), has 1 sample
783        assert_eq!(p.fallback_level, 3);
784        assert_eq!(p.sample_count, 1);
785        assert_eq!(p.quantile, 5.0);
786    }
787
788    // --- ModeBucket from_screen_mode ---
789
790    #[test]
791    fn mode_bucket_from_screen_modes() {
792        assert_eq!(
793            ModeBucket::from_screen_mode(ScreenMode::Inline { ui_height: 4 }),
794            ModeBucket::Inline
795        );
796        assert_eq!(
797            ModeBucket::from_screen_mode(ScreenMode::InlineAuto {
798                min_height: 4,
799                max_height: 24
800            }),
801            ModeBucket::InlineAuto
802        );
803        assert_eq!(
804            ModeBucket::from_screen_mode(ScreenMode::AltScreen),
805            ModeBucket::AltScreen
806        );
807    }
808
809    // --- Config defaults ---
810
811    #[test]
812    fn config_defaults() {
813        let config = ConformalConfig::default();
814        assert!((config.alpha - 0.05).abs() < 1e-10);
815        assert_eq!(config.min_samples, 20);
816        assert_eq!(config.window_size, 256);
817        assert!((config.q_default - 10_000.0).abs() < 1e-10);
818    }
819
820    #[test]
821    fn predictor_config_accessor() {
822        let config = ConformalConfig {
823            alpha: 0.2,
824            min_samples: 5,
825            window_size: 32,
826            q_default: 100.0,
827        };
828        let predictor = ConformalPredictor::new(config);
829        assert!((predictor.config().alpha - 0.2).abs() < 1e-10);
830        assert_eq!(predictor.config().min_samples, 5);
831    }
832
833    // --- negative residuals ---
834
835    #[test]
836    fn negative_residual_clamped_in_prediction() {
837        let mut predictor = ConformalPredictor::new(ConformalConfig {
838            alpha: 0.1,
839            min_samples: 1,
840            window_size: 8,
841            q_default: 0.0,
842        });
843        let key = test_key(80, 24);
844        // observed < y_hat -> negative residual
845        predictor.observe(key, 10.0, 5.0);
846        let p = predictor.predict(key, 10.0, 100.0);
847        // quantile is -5.0, but clamped to 0.0 via .max(0.0)
848        // so upper_us = 10.0 + 0.0 = 10.0
849        assert_eq!(p.upper_us, 10.0);
850    }
851
852    // --- ConformalUpdate fields ---
853
854    #[test]
855    fn observe_returns_correct_update() {
856        let mut predictor = ConformalPredictor::new(ConformalConfig {
857            alpha: 0.1,
858            min_samples: 1,
859            window_size: 8,
860            q_default: 0.0,
861        });
862        let key = test_key(80, 24);
863        let update = predictor.observe(key, 3.0, 10.0);
864        assert!((update.residual - 7.0).abs() < 1e-10);
865        assert_eq!(update.bucket, key);
866        assert_eq!(update.sample_count, 1);
867    }
868
869    // --- prediction y_hat and budget fields ---
870
871    #[test]
872    fn prediction_preserves_yhat_and_budget() {
873        let predictor = ConformalPredictor::new(ConformalConfig::default());
874        let key = test_key(80, 24);
875        let p = predictor.predict(key, 42.5, 16666.0);
876        assert!((p.y_hat - 42.5).abs() < 1e-10);
877        assert!((p.budget_us - 16666.0).abs() < 1e-10);
878    }
879
880    // --- tracing span verification ---
881
882    #[test]
883    fn predict_emits_conformal_predict_span() {
884        use std::sync::Arc;
885        use std::sync::atomic::{AtomicBool, Ordering};
886
887        struct SpanChecker {
888            saw_conformal_predict: Arc<AtomicBool>,
889        }
890
891        impl tracing::Subscriber for SpanChecker {
892            fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
893                true
894            }
895            fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
896                if span.metadata().name() == "conformal.predict" {
897                    self.saw_conformal_predict.store(true, Ordering::Relaxed);
898                }
899                tracing::span::Id::from_u64(1)
900            }
901            fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
902            fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {
903            }
904            fn event(&self, _event: &tracing::Event<'_>) {}
905            fn enter(&self, _span: &tracing::span::Id) {}
906            fn exit(&self, _span: &tracing::span::Id) {}
907        }
908
909        let saw_it = Arc::new(AtomicBool::new(false));
910        let subscriber = SpanChecker {
911            saw_conformal_predict: Arc::clone(&saw_it),
912        };
913        let _guard = tracing::subscriber::set_default(subscriber);
914
915        let predictor = ConformalPredictor::new(ConformalConfig::default());
916        let key = test_key(80, 24);
917        let _ = predictor.predict(key, 100.0, 16666.0);
918
919        assert!(
920            saw_it.load(Ordering::Relaxed),
921            "predict() must emit a 'conformal.predict' tracing span"
922        );
923    }
924
925    #[test]
926    fn predict_span_records_gate_triggered_true() {
927        use std::sync::Arc;
928        use std::sync::atomic::{AtomicBool, Ordering};
929
930        struct GateChecker {
931            saw_gate_true: Arc<AtomicBool>,
932        }
933
934        struct GateVisitor(Arc<AtomicBool>);
935
936        impl tracing::field::Visit for GateVisitor {
937            fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
938                if field.name() == "gate_triggered" && value {
939                    self.0.store(true, Ordering::Relaxed);
940                }
941            }
942            fn record_debug(&mut self, _field: &tracing::field::Field, _value: &dyn fmt::Debug) {}
943        }
944
945        impl tracing::Subscriber for GateChecker {
946            fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
947                true
948            }
949            fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
950                tracing::span::Id::from_u64(1)
951            }
952            fn record(&self, _span: &tracing::span::Id, values: &tracing::span::Record<'_>) {
953                let mut visitor = GateVisitor(Arc::clone(&self.saw_gate_true));
954                values.record(&mut visitor);
955            }
956            fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {
957            }
958            fn event(&self, _event: &tracing::Event<'_>) {}
959            fn enter(&self, _span: &tracing::span::Id) {}
960            fn exit(&self, _span: &tracing::span::Id) {}
961        }
962
963        let saw_gate = Arc::new(AtomicBool::new(false));
964        let subscriber = GateChecker {
965            saw_gate_true: Arc::clone(&saw_gate),
966        };
967        let _guard = tracing::subscriber::set_default(subscriber);
968
969        let predictor = ConformalPredictor::new(ConformalConfig {
970            alpha: 0.1,
971            min_samples: 1,
972            window_size: 8,
973            q_default: 50_000.0, // large default to guarantee risk
974        });
975        let key = test_key(80, 24);
976        // budget_us = 100 << q_default = 50_000 -> risk = true
977        let p = predictor.predict(key, 0.0, 100.0);
978        assert!(p.risk, "prediction should be risky");
979        assert!(
980            saw_gate.load(Ordering::Relaxed),
981            "predict() must record gate_triggered=true when risk"
982        );
983    }
984
985    // ========================================================================
986    // bd-1q5.12: Additional unit tests for conformal prediction frame-time gating
987    // ========================================================================
988
989    // --- Calibration with known distributions ---
990
991    #[test]
992    fn calibration_uniform_distribution_quantile() {
993        // Uniform residuals from 0 to 99. The (1-alpha) quantile should be near
994        // the top of the distribution.
995        let mut predictor = ConformalPredictor::new(ConformalConfig {
996            alpha: 0.05,
997            min_samples: 1,
998            window_size: 256,
999            q_default: 0.0,
1000        });
1001        let key = test_key(80, 24);
1002        for i in 0..100 {
1003            predictor.observe(key, 0.0, i as f64);
1004        }
1005        let p = predictor.predict(key, 0.0, 1_000.0);
1006        // (100+1)*0.95 = 95.95, ceil = 96, idx = 95 -> sorted[95] = 95.0
1007        assert_eq!(p.fallback_level, 0);
1008        assert_eq!(p.sample_count, 100);
1009        assert!((p.quantile - 95.0).abs() < 1e-10);
1010    }
1011
1012    #[test]
1013    fn calibration_gaussian_like_distribution() {
1014        // Simulate a roughly gaussian-shaped distribution of residuals.
1015        // Use a simple deterministic approximation: residuals centered around 0
1016        // with known spread.
1017        let mut predictor = ConformalPredictor::new(ConformalConfig {
1018            alpha: 0.1,
1019            min_samples: 1,
1020            window_size: 256,
1021            q_default: 0.0,
1022        });
1023        let key = test_key(120, 40);
1024
1025        // Generate 50 residuals that approximate a symmetric distribution
1026        // around 0: [-24.5, -23.5, ..., -0.5, 0.5, ..., 24.5]
1027        for i in 0..50 {
1028            let residual = (i as f64) - 24.5;
1029            predictor.observe(key, 100.0, 100.0 + residual);
1030        }
1031
1032        let p = predictor.predict(key, 100.0, 1_000.0);
1033        // (50+1)*0.9 = 45.9, ceil = 46, idx = 45 -> sorted residual at index 45
1034        // sorted: [-24.5, -23.5, ..., 24.5], index 45 = 20.5
1035        assert_eq!(p.fallback_level, 0);
1036        assert_eq!(p.sample_count, 50);
1037        assert!((p.quantile - 20.5).abs() < 1e-10);
1038        // upper_us = 100 + max(20.5, 0) = 120.5
1039        assert!((p.upper_us - 120.5).abs() < 1e-10);
1040    }
1041
1042    #[test]
1043    fn calibration_constant_residuals() {
1044        // All residuals identical -> quantile should be that constant.
1045        let mut predictor = ConformalPredictor::new(ConformalConfig {
1046            alpha: 0.05,
1047            min_samples: 1,
1048            window_size: 256,
1049            q_default: 0.0,
1050        });
1051        let key = test_key(80, 24);
1052        for _ in 0..30 {
1053            predictor.observe(key, 100.0, 105.0); // residual = 5.0
1054        }
1055        let p = predictor.predict(key, 100.0, 1_000.0);
1056        assert!((p.quantile - 5.0).abs() < 1e-10);
1057        assert!((p.upper_us - 105.0).abs() < 1e-10);
1058    }
1059
1060    // --- Prediction interval correctness (coverage property) ---
1061
1062    #[test]
1063    fn coverage_property_uniform_residuals() {
1064        // Calibrate with uniform [0..N), then test with new samples.
1065        // Empirical coverage should be >= 1 - alpha for a hold-out set.
1066        let alpha = 0.1;
1067        let n_calibrate = 100;
1068        let n_test = 200;
1069
1070        let mut predictor = ConformalPredictor::new(ConformalConfig {
1071            alpha,
1072            min_samples: 1,
1073            window_size: 256,
1074            q_default: 0.0,
1075        });
1076        let key = test_key(80, 24);
1077
1078        // Calibrate: residuals are 0, 1, 2, ..., 99
1079        for i in 0..n_calibrate {
1080            predictor.observe(key, 0.0, i as f64);
1081        }
1082
1083        // Test coverage: for each "new" sample, check if it falls within the
1084        // prediction interval [0, y_hat + q].
1085        let prediction = predictor.predict(key, 0.0, f64::MAX);
1086        let upper_bound = prediction.upper_us;
1087
1088        let mut covered = 0;
1089        // Test samples: same distribution range [0..200)
1090        for i in 0..n_test {
1091            let new_obs = (i as f64) * (n_calibrate as f64) / (n_test as f64);
1092            if new_obs <= upper_bound {
1093                covered += 1;
1094            }
1095        }
1096
1097        let empirical_coverage = covered as f64 / n_test as f64;
1098        // Coverage should be >= 1 - alpha - epsilon (epsilon accounts for
1099        // finite-sample effects)
1100        let target_coverage = 1.0 - alpha - 0.05; // generous epsilon
1101        assert!(
1102            empirical_coverage >= target_coverage,
1103            "Empirical coverage {empirical_coverage:.3} should be >= {target_coverage:.3}"
1104        );
1105    }
1106
1107    #[test]
1108    fn coverage_property_with_shifted_test_distribution() {
1109        // Calibrate, then test with samples from the same range.
1110        // Conformal prediction guarantees coverage for exchangeable data.
1111        let alpha = 0.05;
1112        let n = 200;
1113
1114        let mut predictor = ConformalPredictor::new(ConformalConfig {
1115            alpha,
1116            min_samples: 1,
1117            window_size: 512,
1118            q_default: 0.0,
1119        });
1120        let key = test_key(80, 24);
1121
1122        // Calibrate with known residuals: 1.0, 2.0, ..., 200.0
1123        for i in 1..=n {
1124            predictor.observe(key, 0.0, i as f64);
1125        }
1126
1127        let p = predictor.predict(key, 0.0, f64::MAX);
1128        // (200+1)*0.95 = 190.95, ceil = 191, idx = 190 -> sorted[190] = 191.0
1129        assert!((p.quantile - 191.0).abs() < 1e-10);
1130        // At least 95% of calibration samples should be <= upper bound
1131        let covered = (1..=n).filter(|&i| (i as f64) <= p.upper_us).count();
1132        let coverage = covered as f64 / n as f64;
1133        assert!(
1134            coverage >= 1.0 - alpha,
1135            "Coverage {coverage:.3} should be >= {:.3}",
1136            1.0 - alpha
1137        );
1138    }
1139
1140    // --- Gate trigger behavior at boundary conditions ---
1141
1142    #[test]
1143    fn gate_trigger_exact_boundary() {
1144        // When upper_us == budget_us, risk should be false (not strictly greater)
1145        let mut predictor = ConformalPredictor::new(ConformalConfig {
1146            alpha: 0.1,
1147            min_samples: 1,
1148            window_size: 8,
1149            q_default: 0.0,
1150        });
1151        let key = test_key(80, 24);
1152        predictor.observe(key, 0.0, 50.0);
1153        // quantile = 50.0, y_hat = 0.0, upper_us = 50.0
1154        let p = predictor.predict(key, 0.0, 50.0);
1155        assert!(
1156            !p.risk,
1157            "upper_us ({}) == budget_us ({}) should NOT trigger risk",
1158            p.upper_us, p.budget_us
1159        );
1160    }
1161
1162    #[test]
1163    fn gate_trigger_just_above_boundary() {
1164        // When upper_us is epsilon above budget, risk should be true
1165        let mut predictor = ConformalPredictor::new(ConformalConfig {
1166            alpha: 0.1,
1167            min_samples: 1,
1168            window_size: 8,
1169            q_default: 0.0,
1170        });
1171        let key = test_key(80, 24);
1172        predictor.observe(key, 0.0, 50.0);
1173        // upper_us = 50.0, budget = 49.999
1174        let p = predictor.predict(key, 0.0, 49.999);
1175        assert!(p.risk, "upper_us > budget should trigger risk");
1176    }
1177
1178    #[test]
1179    fn gate_trigger_just_below_boundary() {
1180        // When upper_us is epsilon below budget, risk should be false
1181        let mut predictor = ConformalPredictor::new(ConformalConfig {
1182            alpha: 0.1,
1183            min_samples: 1,
1184            window_size: 8,
1185            q_default: 0.0,
1186        });
1187        let key = test_key(80, 24);
1188        predictor.observe(key, 0.0, 50.0);
1189        // upper_us = 50.0, budget = 50.001
1190        let p = predictor.predict(key, 0.0, 50.001);
1191        assert!(!p.risk, "upper_us < budget should NOT trigger risk");
1192    }
1193
1194    #[test]
1195    fn gate_trigger_zero_budget() {
1196        // Zero budget: any positive prediction should trigger risk
1197        let predictor = ConformalPredictor::new(ConformalConfig {
1198            alpha: 0.1,
1199            min_samples: 1,
1200            window_size: 8,
1201            q_default: 1.0,
1202        });
1203        let key = test_key(80, 24);
1204        let p = predictor.predict(key, 0.0, 0.0);
1205        assert!(p.risk, "positive upper_us with zero budget should be risky");
1206    }
1207
1208    #[test]
1209    fn gate_trigger_very_large_budget() {
1210        // Extremely large budget: should never trigger risk
1211        let predictor = ConformalPredictor::new(ConformalConfig {
1212            alpha: 0.1,
1213            min_samples: 1,
1214            window_size: 8,
1215            q_default: 100_000.0,
1216        });
1217        let key = test_key(80, 24);
1218        let p = predictor.predict(key, 1_000.0, f64::MAX);
1219        assert!(!p.risk, "huge budget should never trigger risk");
1220    }
1221
1222    // --- Alpha parameter sensitivity ---
1223
1224    #[test]
1225    fn alpha_sensitivity_wider_interval_with_lower_alpha() {
1226        let key = test_key(80, 24);
1227
1228        // Calibrate two predictors with different alpha on same data
1229        let mut predictor_tight = ConformalPredictor::new(ConformalConfig {
1230            alpha: 0.5, // 50% coverage -> narrower interval
1231            min_samples: 1,
1232            window_size: 256,
1233            q_default: 0.0,
1234        });
1235
1236        let mut predictor_wide = ConformalPredictor::new(ConformalConfig {
1237            alpha: 0.01, // 99% coverage -> wider interval
1238            min_samples: 1,
1239            window_size: 256,
1240            q_default: 0.0,
1241        });
1242
1243        for i in 0..100 {
1244            let obs = i as f64;
1245            predictor_tight.observe(key, 0.0, obs);
1246            predictor_wide.observe(key, 0.0, obs);
1247        }
1248
1249        let p_tight = predictor_tight.predict(key, 0.0, 10_000.0);
1250        let p_wide = predictor_wide.predict(key, 0.0, 10_000.0);
1251
1252        assert!(
1253            p_wide.quantile > p_tight.quantile,
1254            "Lower alpha ({}) should produce wider interval: quantile {} vs {}",
1255            0.01,
1256            p_wide.quantile,
1257            p_tight.quantile
1258        );
1259        assert!(
1260            p_wide.upper_us > p_tight.upper_us,
1261            "Lower alpha should produce higher upper bound"
1262        );
1263    }
1264
1265    #[test]
1266    fn alpha_sensitivity_confidence_reflects_alpha() {
1267        for &alpha in &[0.01, 0.05, 0.1, 0.2, 0.5] {
1268            let predictor = ConformalPredictor::new(ConformalConfig {
1269                alpha,
1270                min_samples: 1,
1271                window_size: 8,
1272                q_default: 0.0,
1273            });
1274            let key = test_key(80, 24);
1275            let p = predictor.predict(key, 0.0, 1_000.0);
1276            let expected_confidence = 1.0 - alpha;
1277            assert!(
1278                (p.confidence - expected_confidence).abs() < 1e-10,
1279                "confidence should be 1-alpha for alpha={alpha}"
1280            );
1281        }
1282    }
1283
1284    #[test]
1285    fn alpha_sensitivity_extreme_alpha_zero() {
1286        // alpha near 0 -> coverage near 100% -> picks the max residual
1287        let mut predictor = ConformalPredictor::new(ConformalConfig {
1288            alpha: 0.001,
1289            min_samples: 1,
1290            window_size: 256,
1291            q_default: 0.0,
1292        });
1293        let key = test_key(80, 24);
1294        for i in 0..100 {
1295            predictor.observe(key, 0.0, i as f64);
1296        }
1297        let p = predictor.predict(key, 0.0, 10_000.0);
1298        // (100+1)*0.999 = 100.899, ceil=101, idx=min(100,99)=99 -> sorted[99]=99
1299        assert!((p.quantile - 99.0).abs() < 1e-10);
1300    }
1301
1302    #[test]
1303    fn alpha_sensitivity_extreme_alpha_one() {
1304        // alpha near 1 -> coverage near 0% -> picks the smallest residual
1305        let mut predictor = ConformalPredictor::new(ConformalConfig {
1306            alpha: 0.99,
1307            min_samples: 1,
1308            window_size: 256,
1309            q_default: 0.0,
1310        });
1311        let key = test_key(80, 24);
1312        for i in 0..100 {
1313            predictor.observe(key, 0.0, i as f64);
1314        }
1315        let p = predictor.predict(key, 0.0, 10_000.0);
1316        // (100+1)*0.01 = 1.01, ceil=2, idx=1 -> sorted[1]=1
1317        assert!((p.quantile - 1.0).abs() < 1e-10);
1318    }
1319
1320    // --- Empty/small calibration set handling ---
1321
1322    #[test]
1323    fn empty_calibration_uses_default() {
1324        let predictor = ConformalPredictor::new(ConformalConfig {
1325            alpha: 0.05,
1326            min_samples: 20,
1327            window_size: 256,
1328            q_default: 10_000.0,
1329        });
1330        let key = test_key(80, 24);
1331        let p = predictor.predict(key, 100.0, 16_666.0);
1332        assert_eq!(p.sample_count, 0);
1333        assert_eq!(p.fallback_level, 3);
1334        assert!((p.quantile - 10_000.0).abs() < 1e-10);
1335        assert!((p.upper_us - 10_100.0).abs() < 1e-10);
1336    }
1337
1338    #[test]
1339    fn one_sample_below_min_samples_uses_fallback() {
1340        // With min_samples=20, 1 sample should fall through to global (level 3)
1341        let mut predictor = ConformalPredictor::new(ConformalConfig {
1342            alpha: 0.05,
1343            min_samples: 20,
1344            window_size: 256,
1345            q_default: 999.0,
1346        });
1347        let key = test_key(80, 24);
1348        predictor.observe(key, 0.0, 5.0);
1349        let p = predictor.predict(key, 0.0, 1_000.0);
1350        // Only 1 sample in exact bucket, 1 in mode+diff, 1 in mode, 1 in global
1351        // Global has data, so uses global fallback (level 3)
1352        assert_eq!(p.fallback_level, 3);
1353        assert_eq!(p.sample_count, 1);
1354    }
1355
1356    #[test]
1357    fn exactly_min_samples_minus_one_uses_fallback() {
1358        let min_samples = 5;
1359        let mut predictor = ConformalPredictor::new(ConformalConfig {
1360            alpha: 0.1,
1361            min_samples,
1362            window_size: 256,
1363            q_default: 999.0,
1364        });
1365        let key = test_key(80, 24);
1366        for i in 0..(min_samples - 1) {
1367            predictor.observe(key, 0.0, (i as f64) * 10.0);
1368        }
1369        let p = predictor.predict(key, 0.0, 1_000.0);
1370        // 4 samples < min_samples=5, so exact bucket not used
1371        // Falls through to mode+diff (4 samples < 5), mode (4 < 5), global (4 > 0)
1372        assert_eq!(p.fallback_level, 3);
1373        assert_eq!(p.sample_count, min_samples - 1);
1374    }
1375
1376    #[test]
1377    fn exactly_min_samples_uses_exact_bucket() {
1378        let min_samples = 5;
1379        let mut predictor = ConformalPredictor::new(ConformalConfig {
1380            alpha: 0.1,
1381            min_samples,
1382            window_size: 256,
1383            q_default: 999.0,
1384        });
1385        let key = test_key(80, 24);
1386        for i in 0..min_samples {
1387            predictor.observe(key, 0.0, (i as f64) * 10.0);
1388        }
1389        let p = predictor.predict(key, 0.0, 1_000.0);
1390        // 5 samples == min_samples=5, exact bucket should be used
1391        assert_eq!(p.fallback_level, 0);
1392        assert_eq!(p.sample_count, min_samples);
1393    }
1394
1395    #[test]
1396    fn min_samples_plus_one_uses_exact_bucket() {
1397        let min_samples = 5;
1398        let mut predictor = ConformalPredictor::new(ConformalConfig {
1399            alpha: 0.1,
1400            min_samples,
1401            window_size: 256,
1402            q_default: 999.0,
1403        });
1404        let key = test_key(80, 24);
1405        for i in 0..=min_samples {
1406            predictor.observe(key, 0.0, (i as f64) * 10.0);
1407        }
1408        let p = predictor.predict(key, 0.0, 1_000.0);
1409        assert_eq!(p.fallback_level, 0);
1410        assert_eq!(p.sample_count, min_samples + 1);
1411    }
1412
1413    #[test]
1414    fn min_samples_one_allows_single_observation() {
1415        // Edge case: min_samples = 1
1416        let mut predictor = ConformalPredictor::new(ConformalConfig {
1417            alpha: 0.1,
1418            min_samples: 1,
1419            window_size: 256,
1420            q_default: 999.0,
1421        });
1422        let key = test_key(80, 24);
1423        predictor.observe(key, 0.0, 42.0);
1424        let p = predictor.predict(key, 0.0, 1_000.0);
1425        assert_eq!(p.fallback_level, 0);
1426        assert_eq!(p.sample_count, 1);
1427        assert!((p.quantile - 42.0).abs() < 1e-10);
1428    }
1429
1430    // --- Tracing span field assertions ---
1431
1432    #[test]
1433    fn predict_span_records_calibration_set_size() {
1434        use std::sync::Arc;
1435        use std::sync::Mutex;
1436
1437        struct FieldRecorder {
1438            calibration_size: Arc<Mutex<Option<u64>>>,
1439        }
1440
1441        struct SizeVisitor(Arc<Mutex<Option<u64>>>);
1442
1443        impl tracing::field::Visit for SizeVisitor {
1444            fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
1445                if field.name() == "calibration_set_size" {
1446                    *self.0.lock().unwrap() = Some(value);
1447                }
1448            }
1449            fn record_debug(&mut self, _field: &tracing::field::Field, _value: &dyn fmt::Debug) {}
1450        }
1451
1452        impl tracing::Subscriber for FieldRecorder {
1453            fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
1454                true
1455            }
1456            fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1457                tracing::span::Id::from_u64(1)
1458            }
1459            fn record(&self, _span: &tracing::span::Id, values: &tracing::span::Record<'_>) {
1460                let mut v = SizeVisitor(Arc::clone(&self.calibration_size));
1461                values.record(&mut v);
1462            }
1463            fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
1464            fn event(&self, _: &tracing::Event<'_>) {}
1465            fn enter(&self, _: &tracing::span::Id) {}
1466            fn exit(&self, _: &tracing::span::Id) {}
1467        }
1468
1469        let size = Arc::new(Mutex::new(None));
1470        let subscriber = FieldRecorder {
1471            calibration_size: Arc::clone(&size),
1472        };
1473        let _guard = tracing::subscriber::set_default(subscriber);
1474
1475        let mut predictor = ConformalPredictor::new(ConformalConfig {
1476            alpha: 0.1,
1477            min_samples: 1,
1478            window_size: 8,
1479            q_default: 0.0,
1480        });
1481        let key = test_key(80, 24);
1482        for i in 0..5 {
1483            predictor.observe(key, 0.0, i as f64);
1484        }
1485        let _ = predictor.predict(key, 0.0, 1_000.0);
1486
1487        let recorded = size.lock().unwrap();
1488        assert_eq!(*recorded, Some(5), "calibration_set_size should be 5");
1489    }
1490
1491    #[test]
1492    fn predict_span_records_predicted_upper_bound() {
1493        use std::sync::Arc;
1494        use std::sync::Mutex;
1495
1496        struct UpperBoundRecorder {
1497            upper_bound: Arc<Mutex<Option<f64>>>,
1498        }
1499
1500        struct UpperVisitor(Arc<Mutex<Option<f64>>>);
1501
1502        impl tracing::field::Visit for UpperVisitor {
1503            fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
1504                if field.name() == "predicted_upper_bound_us" {
1505                    *self.0.lock().unwrap() = Some(value);
1506                }
1507            }
1508            fn record_debug(&mut self, _: &tracing::field::Field, _: &dyn fmt::Debug) {}
1509        }
1510
1511        impl tracing::Subscriber for UpperBoundRecorder {
1512            fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
1513                true
1514            }
1515            fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1516                tracing::span::Id::from_u64(1)
1517            }
1518            fn record(&self, _: &tracing::span::Id, values: &tracing::span::Record<'_>) {
1519                let mut v = UpperVisitor(Arc::clone(&self.upper_bound));
1520                values.record(&mut v);
1521            }
1522            fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
1523            fn event(&self, _: &tracing::Event<'_>) {}
1524            fn enter(&self, _: &tracing::span::Id) {}
1525            fn exit(&self, _: &tracing::span::Id) {}
1526        }
1527
1528        let upper = Arc::new(Mutex::new(None));
1529        let subscriber = UpperBoundRecorder {
1530            upper_bound: Arc::clone(&upper),
1531        };
1532        let _guard = tracing::subscriber::set_default(subscriber);
1533
1534        let predictor = ConformalPredictor::new(ConformalConfig {
1535            alpha: 0.1,
1536            min_samples: 1,
1537            window_size: 8,
1538            q_default: 42.0,
1539        });
1540        let key = test_key(80, 24);
1541        let p = predictor.predict(key, 10.0, 1_000.0);
1542
1543        let recorded = upper.lock().unwrap();
1544        assert!(
1545            recorded.is_some(),
1546            "predicted_upper_bound_us should be recorded"
1547        );
1548        assert!(
1549            (recorded.unwrap() - p.upper_us).abs() < 1e-10,
1550            "recorded upper bound should match prediction"
1551        );
1552    }
1553
1554    #[test]
1555    fn predict_span_records_gate_triggered_false() {
1556        use std::sync::Arc;
1557        use std::sync::Mutex;
1558
1559        struct GateFalseChecker {
1560            gate_value: Arc<Mutex<Option<bool>>>,
1561        }
1562
1563        struct GateFalseVisitor(Arc<Mutex<Option<bool>>>);
1564
1565        impl tracing::field::Visit for GateFalseVisitor {
1566            fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
1567                if field.name() == "gate_triggered" {
1568                    *self.0.lock().unwrap() = Some(value);
1569                }
1570            }
1571            fn record_debug(&mut self, _: &tracing::field::Field, _: &dyn fmt::Debug) {}
1572        }
1573
1574        impl tracing::Subscriber for GateFalseChecker {
1575            fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
1576                true
1577            }
1578            fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1579                tracing::span::Id::from_u64(1)
1580            }
1581            fn record(&self, _: &tracing::span::Id, values: &tracing::span::Record<'_>) {
1582                let mut v = GateFalseVisitor(Arc::clone(&self.gate_value));
1583                values.record(&mut v);
1584            }
1585            fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
1586            fn event(&self, _: &tracing::Event<'_>) {}
1587            fn enter(&self, _: &tracing::span::Id) {}
1588            fn exit(&self, _: &tracing::span::Id) {}
1589        }
1590
1591        let gate = Arc::new(Mutex::new(None));
1592        let subscriber = GateFalseChecker {
1593            gate_value: Arc::clone(&gate),
1594        };
1595        let _guard = tracing::subscriber::set_default(subscriber);
1596
1597        let predictor = ConformalPredictor::new(ConformalConfig {
1598            alpha: 0.1,
1599            min_samples: 1,
1600            window_size: 8,
1601            q_default: 1.0,
1602        });
1603        let key = test_key(80, 24);
1604        let p = predictor.predict(key, 0.0, 1_000_000.0);
1605        assert!(!p.risk);
1606
1607        let recorded = gate.lock().unwrap();
1608        assert_eq!(
1609            *recorded,
1610            Some(false),
1611            "gate_triggered should be recorded as false"
1612        );
1613    }
1614
1615    // --- JSONL output ---
1616
1617    #[test]
1618    fn jsonl_output_contains_required_fields() {
1619        let prediction = ConformalPrediction {
1620            upper_us: 150.5,
1621            risk: true,
1622            confidence: 0.95,
1623            bucket: BucketKey {
1624                mode: ModeBucket::Inline,
1625                diff: DiffBucket::Full,
1626                size_bucket: 10,
1627            },
1628            sample_count: 42,
1629            quantile: 50.5,
1630            fallback_level: 0,
1631            window_size: 256,
1632            reset_count: 1,
1633            y_hat: 100.0,
1634            budget_us: 140.0,
1635        };
1636        let jsonl = prediction.to_jsonl();
1637        assert!(jsonl.contains("\"schema\":\"conformal-v1\""));
1638        assert!(jsonl.contains("\"upper_us\":150.5"));
1639        assert!(jsonl.contains("\"risk\":true"));
1640        assert!(jsonl.contains("\"confidence\":0.9500"));
1641        assert!(jsonl.contains("\"bucket\":\"inline:full:10\""));
1642        assert!(jsonl.contains("\"samples\":42"));
1643        assert!(jsonl.contains("\"quantile\":50.50"));
1644        assert!(jsonl.contains("\"fallback_level\":0"));
1645        assert!(jsonl.contains("\"window\":256"));
1646        assert!(jsonl.contains("\"resets\":1"));
1647        assert!(jsonl.contains("\"y_hat\":100.0"));
1648        assert!(jsonl.contains("\"budget_us\":140.0"));
1649    }
1650
1651    // --- Property-based: coverage verification ---
1652
1653    #[test]
1654    fn property_empirical_coverage_deterministic_sequences() {
1655        // For multiple deterministic sequences, verify that the conformal
1656        // prediction interval achieves its stated coverage guarantee.
1657        for alpha in [0.05, 0.1, 0.2] {
1658            let n_calibrate = 100;
1659            let n_test = 100;
1660
1661            let mut predictor = ConformalPredictor::new(ConformalConfig {
1662                alpha,
1663                min_samples: 1,
1664                window_size: 256,
1665                q_default: 0.0,
1666            });
1667            let key = test_key(80, 24);
1668
1669            // Calibrate with residuals 1.0, 2.0, ..., 100.0
1670            for i in 1..=n_calibrate {
1671                predictor.observe(key, 0.0, i as f64);
1672            }
1673
1674            let p = predictor.predict(key, 0.0, f64::MAX);
1675
1676            // Count how many calibration-like points are covered
1677            let covered = (1..=n_test).filter(|&i| (i as f64) <= p.upper_us).count();
1678            let coverage = covered as f64 / n_test as f64;
1679
1680            assert!(
1681                coverage >= 1.0 - alpha - 0.02,
1682                "alpha={alpha}: coverage {coverage:.3} should be >= {:.3}",
1683                1.0 - alpha - 0.02
1684            );
1685        }
1686    }
1687
1688    #[test]
1689    fn property_monotone_quantile_with_more_extreme_data() {
1690        // Adding more extreme residuals should increase the quantile
1691        let key = test_key(80, 24);
1692
1693        let mut predictor = ConformalPredictor::new(ConformalConfig {
1694            alpha: 0.1,
1695            min_samples: 1,
1696            window_size: 256,
1697            q_default: 0.0,
1698        });
1699
1700        // First: moderate residuals
1701        for i in 0..50 {
1702            predictor.observe(key, 0.0, i as f64);
1703        }
1704        let q_moderate = predictor.predict(key, 0.0, f64::MAX).quantile;
1705
1706        // Add extreme residuals
1707        for _ in 0..50 {
1708            predictor.observe(key, 0.0, 1000.0);
1709        }
1710        let q_extreme = predictor.predict(key, 0.0, f64::MAX).quantile;
1711
1712        assert!(
1713            q_extreme >= q_moderate,
1714            "Adding extreme data should not decrease quantile: {q_extreme} vs {q_moderate}"
1715        );
1716    }
1717
1718    #[test]
1719    fn property_quantile_bounded_by_max_residual() {
1720        // The conformal quantile should never exceed the maximum residual
1721        let key = test_key(80, 24);
1722        let mut predictor = ConformalPredictor::new(ConformalConfig {
1723            alpha: 0.001, // very high coverage
1724            min_samples: 1,
1725            window_size: 256,
1726            q_default: 0.0,
1727        });
1728
1729        let max_residual = 100.0;
1730        for i in 0..50 {
1731            predictor.observe(key, 0.0, (i as f64) * 2.0); // 0, 2, 4, ..., 98
1732        }
1733
1734        let p = predictor.predict(key, 0.0, f64::MAX);
1735        assert!(
1736            p.quantile <= max_residual,
1737            "quantile {} should be <= max residual {max_residual}",
1738            p.quantile
1739        );
1740    }
1741
1742    #[test]
1743    fn property_window_eviction_changes_quantile() {
1744        // After filling and evicting the window, old extreme values should
1745        // no longer affect the quantile
1746        let key = test_key(80, 24);
1747        let window_size = 10;
1748
1749        let mut predictor = ConformalPredictor::new(ConformalConfig {
1750            alpha: 0.1,
1751            min_samples: 1,
1752            window_size,
1753            q_default: 0.0,
1754        });
1755
1756        // Fill window with large residuals
1757        for _ in 0..window_size {
1758            predictor.observe(key, 0.0, 1000.0);
1759        }
1760        let q_large = predictor.predict(key, 0.0, f64::MAX).quantile;
1761
1762        // Evict all large residuals with small ones
1763        for _ in 0..window_size {
1764            predictor.observe(key, 0.0, 1.0);
1765        }
1766        let q_small = predictor.predict(key, 0.0, f64::MAX).quantile;
1767
1768        assert!(
1769            q_small < q_large,
1770            "After eviction, quantile should decrease: {q_small} vs {q_large}"
1771        );
1772    }
1773
1774    // --- Multiple bucket interaction ---
1775
1776    #[test]
1777    fn cross_mode_fallback_does_not_mix_modes() {
1778        // mode_diff fallback only aggregates same mode+diff, not across modes
1779        let mut predictor = ConformalPredictor::new(ConformalConfig {
1780            alpha: 0.1,
1781            min_samples: 5,
1782            window_size: 256,
1783            q_default: 999.0,
1784        });
1785
1786        // Add data to AltScreen mode
1787        let alt_key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
1788        for i in 0..10 {
1789            predictor.observe(alt_key, 0.0, (i as f64) * 100.0);
1790        }
1791
1792        // Query Inline mode with different size bucket (no exact match)
1793        let inline_key = BucketKey::from_context(
1794            ScreenMode::Inline { ui_height: 4 },
1795            DiffStrategy::Full,
1796            120,
1797            40,
1798        );
1799        let p = predictor.predict(inline_key, 0.0, 1_000_000.0);
1800
1801        // Mode fallback should NOT find inline data, so falls to global
1802        assert_eq!(
1803            p.fallback_level, 3,
1804            "Cross-mode query should fall to global"
1805        );
1806    }
1807
1808    #[test]
1809    fn reset_count_accumulates_across_resets() {
1810        let mut predictor = ConformalPredictor::new(ConformalConfig::default());
1811        let key = test_key(80, 24);
1812
1813        predictor.observe(key, 0.0, 1.0);
1814        predictor.reset_bucket(key);
1815        predictor.observe(key, 0.0, 2.0);
1816        predictor.reset_all();
1817        predictor.observe(key, 0.0, 3.0);
1818        predictor.reset_bucket(key);
1819
1820        let p = predictor.predict(key, 0.0, 1_000.0);
1821        assert_eq!(p.reset_count, 3, "reset_count should accumulate");
1822    }
1823}