1#![forbid(unsafe_code)]
2
3use std::collections::{HashMap, VecDeque};
13use std::fmt;
14
15use ftui_render::diff_strategy::DiffStrategy;
16
17use crate::terminal_writer::ScreenMode;
18
19#[derive(Debug, Clone)]
21pub struct ConformalConfig {
22 pub alpha: f64,
25
26 pub min_samples: usize,
29
30 pub window_size: usize,
33
34 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#[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 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#[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#[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#[derive(Debug, Clone)]
142pub struct ConformalPrediction {
143 pub upper_us: f64,
145 pub risk: bool,
147 pub confidence: f64,
149 pub bucket: BucketKey,
151 pub sample_count: usize,
153 pub quantile: f64,
155 pub fallback_level: u8,
157 pub window_size: usize,
159 pub reset_count: u64,
161 pub y_hat: f64,
163 pub budget_us: f64,
165}
166
167impl ConformalPrediction {
168 #[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#[derive(Debug, Clone)]
190pub struct ConformalUpdate {
191 pub residual: f64,
193 pub bucket: BucketKey,
195 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#[derive(Debug)]
215pub struct ConformalPredictor {
216 config: ConformalConfig,
217 buckets: HashMap<BucketKey, BucketState>,
218 reset_count: u64,
219}
220
221impl ConformalPredictor {
222 pub fn new(config: ConformalConfig) -> Self {
224 Self {
225 config,
226 buckets: HashMap::new(),
227 reset_count: 0,
228 }
229 }
230
231 pub fn config(&self) -> &ConformalConfig {
233 &self.config
234 }
235
236 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 pub fn reset_all(&mut self) {
246 self.buckets.clear();
247 self.reset_count += 1;
248 }
249
250 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 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 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); let b = size_bucket(8, 16); assert_eq!(a, 6);
610 assert_eq!(b, 7);
611 }
612
613 #[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); }
626
627 #[test]
628 fn size_bucket_typical_terminals() {
629 let b80 = size_bucket(80, 24); let b120 = size_bucket(120, 40); assert_eq!(b80, 10);
632 assert_eq!(b120, 12);
633 }
634
635 #[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 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 assert_eq!(q, 30.0);
663 }
664
665 #[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 #[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 #[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 #[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 let p = predictor.predict(key, 0.0, 100.0);
748 assert!(!p.risk); let p2 = predictor.predict(key, 0.0, 30.0);
750 assert!(p2.risk); }
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 #[test]
769 fn global_fallback_with_data() {
770 let mut predictor = ConformalPredictor::new(ConformalConfig {
771 alpha: 0.1,
772 min_samples: 100, window_size: 256,
774 q_default: 999.0,
775 });
776 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 assert_eq!(p.fallback_level, 3);
784 assert_eq!(p.sample_count, 1);
785 assert_eq!(p.quantile, 5.0);
786 }
787
788 #[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 #[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 #[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 predictor.observe(key, 10.0, 5.0);
846 let p = predictor.predict(key, 10.0, 100.0);
847 assert_eq!(p.upper_us, 10.0);
850 }
851
852 #[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 #[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 #[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, });
975 let key = test_key(80, 24);
976 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 #[test]
992 fn calibration_uniform_distribution_quantile() {
993 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 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 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 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 assert_eq!(p.fallback_level, 0);
1036 assert_eq!(p.sample_count, 50);
1037 assert!((p.quantile - 20.5).abs() < 1e-10);
1038 assert!((p.upper_us - 120.5).abs() < 1e-10);
1040 }
1041
1042 #[test]
1043 fn calibration_constant_residuals() {
1044 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); }
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 #[test]
1063 fn coverage_property_uniform_residuals() {
1064 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 for i in 0..n_calibrate {
1080 predictor.observe(key, 0.0, i as f64);
1081 }
1082
1083 let prediction = predictor.predict(key, 0.0, f64::MAX);
1086 let upper_bound = prediction.upper_us;
1087
1088 let mut covered = 0;
1089 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 let target_coverage = 1.0 - alpha - 0.05; 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 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 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 assert!((p.quantile - 191.0).abs() < 1e-10);
1130 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 #[test]
1143 fn gate_trigger_exact_boundary() {
1144 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 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 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 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 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 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 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 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 #[test]
1225 fn alpha_sensitivity_wider_interval_with_lower_alpha() {
1226 let key = test_key(80, 24);
1227
1228 let mut predictor_tight = ConformalPredictor::new(ConformalConfig {
1230 alpha: 0.5, 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, 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 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 assert!((p.quantile - 99.0).abs() < 1e-10);
1300 }
1301
1302 #[test]
1303 fn alpha_sensitivity_extreme_alpha_one() {
1304 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 assert!((p.quantile - 1.0).abs() < 1e-10);
1318 }
1319
1320 #[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 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 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 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 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 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 #[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 #[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 #[test]
1654 fn property_empirical_coverage_deterministic_sequences() {
1655 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 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 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 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 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 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 let key = test_key(80, 24);
1722 let mut predictor = ConformalPredictor::new(ConformalConfig {
1723 alpha: 0.001, 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); }
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 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 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 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 #[test]
1777 fn cross_mode_fallback_does_not_mix_modes() {
1778 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 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 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 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}