Skip to main content

ftui_runtime/
conformal_stages.rs

1//! Multi-stage conformal prediction for render pipeline timing.
2//!
3//! Extends conformal alerting to individual render stages (layout, diff,
4//! presenter) with Mondrian-style stage bucketing. Each stage maintains
5//! independent calibration and e-process tracking, so a regression in
6//! layout computation doesn't pollute diff detection and vice versa.
7//!
8//! # Render Pipeline Stages
9//!
10//! ```text
11//! view() → [Layout] → Buffer → [Diff] → Changes → [Present] → ANSI
12//!            ↑               ↑                ↑
13//!       stage monitor   stage monitor   stage monitor
14//! ```
15//!
16//! # Mondrian Conformal Prediction
17//!
18//! Mondrian conformal prediction partitions the input space into buckets
19//! and maintains separate calibration sets per bucket. Here, each render
20//! stage is a natural partition:
21//!
22//! ```text
23//! Bucket 0 (Layout):  calibrate on layout_time_us
24//! Bucket 1 (Diff):    calibrate on diff_time_us
25//! Bucket 2 (Present): calibrate on present_time_us
26//! ```
27//!
28//! Stage-level alerts feed into the unified degradation decision:
29//! if **any** stage exceeds its conformal bound, trigger degradation.
30//!
31//! # Usage
32//!
33//! ```rust
34//! use ftui_runtime::conformal_stages::{StagedConformalPredictor, RenderStage, StageObservation};
35//!
36//! let mut predictor = StagedConformalPredictor::default();
37//!
38//! // Calibration: feed baseline timings per stage
39//! for _ in 0..50 {
40//!     predictor.calibrate(RenderStage::Layout, 120.0);  // ~120μs
41//!     predictor.calibrate(RenderStage::Diff, 80.0);     // ~80μs
42//!     predictor.calibrate(RenderStage::Present, 200.0); // ~200μs
43//! }
44//!
45//! // Detection: observe new frame timings
46//! let result = predictor.observe_frame(StageObservation {
47//!     layout_us: 500.0,  // regression!
48//!     diff_us: 85.0,
49//!     present_us: 210.0,
50//! });
51//!
52//! if result.any_alert() {
53//!     // Trigger degradation
54//! }
55//! ```
56
57use std::collections::VecDeque;
58
59/// Render pipeline stages for Mondrian bucketing.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum RenderStage {
62    /// Widget layout computation.
63    Layout,
64    /// Buffer diff computation.
65    Diff,
66    /// ANSI presenter emission.
67    Present,
68}
69
70impl RenderStage {
71    /// All stages in pipeline order.
72    pub const ALL: [RenderStage; 3] = [Self::Layout, Self::Diff, Self::Present];
73
74    /// Human-readable name.
75    pub fn name(self) -> &'static str {
76        match self {
77            Self::Layout => "layout",
78            Self::Diff => "diff",
79            Self::Present => "present",
80        }
81    }
82}
83
84/// Per-stage timing observation for a single frame.
85#[derive(Debug, Clone, Copy)]
86pub struct StageObservation {
87    /// Layout computation time in microseconds.
88    pub layout_us: f64,
89    /// Diff computation time in microseconds.
90    pub diff_us: f64,
91    /// Presenter ANSI emission time in microseconds.
92    pub present_us: f64,
93}
94
95impl StageObservation {
96    /// Get timing for a specific stage.
97    pub fn get(&self, stage: RenderStage) -> f64 {
98        match stage {
99            RenderStage::Layout => self.layout_us,
100            RenderStage::Diff => self.diff_us,
101            RenderStage::Present => self.present_us,
102        }
103    }
104
105    /// Total frame time across all stages.
106    pub fn total_us(&self) -> f64 {
107        self.layout_us + self.diff_us + self.present_us
108    }
109}
110
111/// Alert decision for a single stage.
112#[derive(Debug, Clone)]
113pub struct StageAlert {
114    pub stage: RenderStage,
115    /// Whether this stage exceeded its conformal threshold.
116    pub is_alert: bool,
117    /// The observed value.
118    pub observed: f64,
119    /// The conformal threshold (quantile-based).
120    pub threshold: f64,
121    /// Current e-process value (anytime-valid evidence).
122    pub e_value: f64,
123    /// Number of calibration samples for this stage.
124    pub calibration_count: usize,
125}
126
127/// Combined result from observing all stages.
128#[derive(Debug, Clone)]
129pub struct FrameResult {
130    /// Per-stage alert decisions.
131    pub stages: [StageAlert; 3],
132}
133
134impl FrameResult {
135    /// Whether any stage triggered an alert.
136    pub fn any_alert(&self) -> bool {
137        self.stages.iter().any(|s| s.is_alert)
138    }
139
140    /// Which stages triggered alerts.
141    pub fn alerting_stages(&self) -> Vec<RenderStage> {
142        self.stages
143            .iter()
144            .filter(|s| s.is_alert)
145            .map(|s| s.stage)
146            .collect()
147    }
148
149    /// Get result for a specific stage.
150    pub fn stage(&self, stage: RenderStage) -> &StageAlert {
151        &self.stages[stage as usize]
152    }
153}
154
155/// Configuration for staged conformal prediction.
156#[derive(Debug, Clone)]
157pub struct StagedConfig {
158    /// Significance level alpha per stage. Default: 0.05.
159    pub alpha: f64,
160    /// Maximum calibration window per stage. Default: 500.
161    pub max_calibration: usize,
162    /// Minimum calibration samples before alerting. Default: 10.
163    pub min_calibration: usize,
164    /// E-process betting fraction. Default: 0.5.
165    pub lambda: f64,
166}
167
168impl Default for StagedConfig {
169    fn default() -> Self {
170        Self {
171            alpha: 0.05,
172            max_calibration: 500,
173            min_calibration: 10,
174            lambda: 0.5,
175        }
176    }
177}
178
179/// E-value floor to prevent permanent zero-lock.
180const E_MIN: f64 = 1e-12;
181/// E-value ceiling to prevent overflow.
182const E_MAX: f64 = 1e12;
183
184/// Per-stage calibration and detection state.
185#[derive(Debug, Clone)]
186struct StageState {
187    /// Calibration residuals (sorted ring buffer).
188    calibration: VecDeque<f64>,
189    /// Running mean for standardization.
190    mean: f64,
191    /// Running M2 (sum of squared deviations) for variance.
192    m2: f64,
193    /// Number of calibration samples seen.
194    n: u64,
195    /// Current e-process value.
196    e_value: f64,
197}
198
199impl StageState {
200    fn new() -> Self {
201        Self {
202            calibration: VecDeque::new(),
203            mean: 0.0,
204            m2: 0.0,
205            n: 0,
206            e_value: 1.0,
207        }
208    }
209
210    /// Add a calibration sample (Welford's online algorithm).
211    fn calibrate(&mut self, value: f64, max_samples: usize) {
212        self.n += 1;
213        let delta = value - self.mean;
214        self.mean += delta / self.n as f64;
215        let delta2 = value - self.mean;
216        self.m2 += delta * delta2;
217
218        self.calibration.push_back(value);
219        while self.calibration.len() > max_samples {
220            self.calibration.pop_front();
221        }
222    }
223
224    /// Variance of calibration data.
225    fn variance(&self) -> f64 {
226        if self.n < 2 {
227            return 1.0;
228        }
229        (self.m2 / (self.n - 1) as f64).max(1e-10)
230    }
231
232    /// Compute conformal threshold at level alpha using the (n+1) rule.
233    fn conformal_threshold(&self, alpha: f64) -> f64 {
234        if self.calibration.is_empty() {
235            return f64::MAX;
236        }
237        let n = self.calibration.len();
238        let mut sorted: Vec<f64> = self.calibration.iter().copied().collect();
239        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
240
241        // (n+1) rule: index = ceil((1 - alpha) * (n + 1) / n * n) - 1
242        let quantile_idx = (((1.0 - alpha) * (n + 1) as f64).ceil() as usize).min(n) - 1;
243        sorted[quantile_idx.min(n - 1)]
244    }
245
246    /// Update e-process with a new observation.
247    fn update_e_process(&mut self, value: f64, lambda: f64) {
248        let std = self.variance().sqrt();
249        let z = if std > 1e-10 {
250            (value - self.mean) / std
251        } else {
252            0.0
253        };
254        let log_e = lambda * z - lambda * lambda / 2.0;
255        self.e_value = (self.e_value * log_e.exp()).clamp(E_MIN, E_MAX);
256    }
257}
258
259/// Multi-stage conformal predictor with Mondrian bucketing.
260///
261/// Maintains independent conformal alerters for each render pipeline stage
262/// (layout, diff, present). Alerts are per-stage but the degradation
263/// decision is unified: any stage alert triggers overall degradation.
264#[derive(Debug, Clone)]
265pub struct StagedConformalPredictor {
266    config: StagedConfig,
267    states: [StageState; 3],
268}
269
270impl Default for StagedConformalPredictor {
271    fn default() -> Self {
272        Self::new(StagedConfig::default())
273    }
274}
275
276impl StagedConformalPredictor {
277    /// Create a new predictor with the given configuration.
278    pub fn new(config: StagedConfig) -> Self {
279        Self {
280            config,
281            states: [StageState::new(), StageState::new(), StageState::new()],
282        }
283    }
284
285    /// Add a calibration sample for a specific stage.
286    pub fn calibrate(&mut self, stage: RenderStage, value: f64) {
287        self.states[stage as usize].calibrate(value, self.config.max_calibration);
288    }
289
290    /// Add calibration samples for all stages from a frame observation.
291    pub fn calibrate_frame(&mut self, obs: &StageObservation) {
292        for stage in RenderStage::ALL {
293            self.calibrate(stage, obs.get(stage));
294        }
295    }
296
297    /// Observe a new frame and get per-stage alert decisions.
298    pub fn observe_frame(&mut self, obs: StageObservation) -> FrameResult {
299        let mut alerts = [
300            self.observe_stage(RenderStage::Layout, obs.layout_us),
301            self.observe_stage(RenderStage::Diff, obs.diff_us),
302            self.observe_stage(RenderStage::Present, obs.present_us),
303        ];
304        // Ensure stage field is correct (redundant but explicit).
305        alerts[0].stage = RenderStage::Layout;
306        alerts[1].stage = RenderStage::Diff;
307        alerts[2].stage = RenderStage::Present;
308        FrameResult { stages: alerts }
309    }
310
311    fn observe_stage(&mut self, stage: RenderStage, value: f64) -> StageAlert {
312        let state = &mut self.states[stage as usize];
313        let threshold = state.conformal_threshold(self.config.alpha);
314        let calibration_count = state.calibration.len();
315
316        // Update e-process.
317        state.update_e_process(value, self.config.lambda);
318
319        let is_alert = calibration_count >= self.config.min_calibration
320            && value > threshold
321            && state.e_value > 1.0 / self.config.alpha;
322
323        StageAlert {
324            stage,
325            is_alert,
326            observed: value,
327            threshold,
328            e_value: state.e_value,
329            calibration_count,
330        }
331    }
332
333    /// Number of calibration samples for a stage.
334    pub fn calibration_count(&self, stage: RenderStage) -> usize {
335        self.states[stage as usize].calibration.len()
336    }
337
338    /// Reset all stage states.
339    pub fn reset(&mut self) {
340        self.states = [StageState::new(), StageState::new(), StageState::new()];
341    }
342}
343
344// ---------------------------------------------------------------------------
345// Tests
346// ---------------------------------------------------------------------------
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn default_config() {
354        let cfg = StagedConfig::default();
355        assert_eq!(cfg.alpha, 0.05);
356        assert_eq!(cfg.max_calibration, 500);
357        assert_eq!(cfg.min_calibration, 10);
358    }
359
360    #[test]
361    fn render_stage_names() {
362        assert_eq!(RenderStage::Layout.name(), "layout");
363        assert_eq!(RenderStage::Diff.name(), "diff");
364        assert_eq!(RenderStage::Present.name(), "present");
365    }
366
367    #[test]
368    fn stage_observation_total() {
369        let obs = StageObservation {
370            layout_us: 100.0,
371            diff_us: 50.0,
372            present_us: 200.0,
373        };
374        assert!((obs.total_us() - 350.0).abs() < 1e-10);
375    }
376
377    #[test]
378    fn stage_observation_get() {
379        let obs = StageObservation {
380            layout_us: 100.0,
381            diff_us: 50.0,
382            present_us: 200.0,
383        };
384        assert!((obs.get(RenderStage::Layout) - 100.0).abs() < 1e-10);
385        assert!((obs.get(RenderStage::Diff) - 50.0).abs() < 1e-10);
386        assert!((obs.get(RenderStage::Present) - 200.0).abs() < 1e-10);
387    }
388
389    #[test]
390    fn no_alert_during_calibration() {
391        let mut pred = StagedConformalPredictor::default();
392        // Only 5 calibration samples (below min_calibration=10)
393        for _ in 0..5 {
394            pred.calibrate(RenderStage::Layout, 100.0);
395        }
396        let result = pred.observe_frame(StageObservation {
397            layout_us: 999.0, // extreme but shouldn't alert
398            diff_us: 0.0,
399            present_us: 0.0,
400        });
401        // Layout has insufficient calibration
402        assert!(!result.stage(RenderStage::Layout).is_alert);
403    }
404
405    #[test]
406    fn alert_on_regression() {
407        let mut pred = StagedConformalPredictor::default();
408        // Calibrate with stable baseline
409        for _ in 0..50 {
410            pred.calibrate_frame(&StageObservation {
411                layout_us: 100.0,
412                diff_us: 50.0,
413                present_us: 200.0,
414            });
415        }
416
417        // Observe many anomalous frames to build e-process evidence
418        let mut alerted = false;
419        for _ in 0..20 {
420            let result = pred.observe_frame(StageObservation {
421                layout_us: 500.0, // 5x regression
422                diff_us: 50.0,
423                present_us: 200.0,
424            });
425            if result.any_alert() {
426                alerted = true;
427                // Should be layout that alerts
428                assert!(result.stage(RenderStage::Layout).is_alert);
429                // Diff and present should be fine
430                assert!(!result.stage(RenderStage::Diff).is_alert);
431                assert!(!result.stage(RenderStage::Present).is_alert);
432                break;
433            }
434        }
435        assert!(alerted, "Should have alerted on 5x layout regression");
436    }
437
438    #[test]
439    fn no_alert_on_normal() {
440        let mut pred = StagedConformalPredictor::default();
441        // Calibrate
442        for _ in 0..50 {
443            pred.calibrate_frame(&StageObservation {
444                layout_us: 100.0,
445                diff_us: 50.0,
446                present_us: 200.0,
447            });
448        }
449        // Observe normal frames
450        for _ in 0..20 {
451            let result = pred.observe_frame(StageObservation {
452                layout_us: 100.0,
453                diff_us: 50.0,
454                present_us: 200.0,
455            });
456            assert!(!result.any_alert(), "Should not alert on normal frames");
457        }
458    }
459
460    #[test]
461    fn independent_stage_tracking() {
462        let mut pred = StagedConformalPredictor::default();
463        // Only calibrate layout
464        for _ in 0..50 {
465            pred.calibrate(RenderStage::Layout, 100.0);
466        }
467        assert_eq!(pred.calibration_count(RenderStage::Layout), 50);
468        assert_eq!(pred.calibration_count(RenderStage::Diff), 0);
469        assert_eq!(pred.calibration_count(RenderStage::Present), 0);
470    }
471
472    #[test]
473    fn reset_clears_state() {
474        let mut pred = StagedConformalPredictor::default();
475        for _ in 0..20 {
476            pred.calibrate(RenderStage::Layout, 100.0);
477        }
478        assert_eq!(pred.calibration_count(RenderStage::Layout), 20);
479        pred.reset();
480        assert_eq!(pred.calibration_count(RenderStage::Layout), 0);
481    }
482
483    #[test]
484    fn alerting_stages_list() {
485        // Create a manually constructed FrameResult for testing
486        let result = FrameResult {
487            stages: [
488                StageAlert {
489                    stage: RenderStage::Layout,
490                    is_alert: true,
491                    observed: 500.0,
492                    threshold: 120.0,
493                    e_value: 100.0,
494                    calibration_count: 50,
495                },
496                StageAlert {
497                    stage: RenderStage::Diff,
498                    is_alert: false,
499                    observed: 50.0,
500                    threshold: 80.0,
501                    e_value: 0.5,
502                    calibration_count: 50,
503                },
504                StageAlert {
505                    stage: RenderStage::Present,
506                    is_alert: true,
507                    observed: 800.0,
508                    threshold: 250.0,
509                    e_value: 200.0,
510                    calibration_count: 50,
511                },
512            ],
513        };
514        let alerting = result.alerting_stages();
515        assert_eq!(alerting.len(), 2);
516        assert!(alerting.contains(&RenderStage::Layout));
517        assert!(alerting.contains(&RenderStage::Present));
518    }
519
520    #[test]
521    fn calibration_window_bounded() {
522        let cfg = StagedConfig {
523            max_calibration: 20,
524            ..Default::default()
525        };
526        let mut pred = StagedConformalPredictor::new(cfg);
527        for i in 0..100 {
528            pred.calibrate(RenderStage::Layout, i as f64);
529        }
530        assert_eq!(pred.calibration_count(RenderStage::Layout), 20);
531    }
532}