Skip to main content

ftui_runtime/
evidence_telemetry.rs

1#![forbid(unsafe_code)]
2
3//! Evidence telemetry snapshots for runtime explainability overlays.
4//!
5//! These snapshots provide a low-overhead, in-memory view of the most recent
6//! diff, resize, and budget decisions so demo screens can render cockpit
7//! views without parsing JSONL logs.
8
9use std::sync::{LazyLock, RwLock};
10
11#[cfg(test)]
12use std::sync::Mutex;
13
14use ftui_render::budget::{BudgetDecision, DegradationLevel};
15use ftui_render::diff_strategy::{DiffStrategy, StrategyEvidence};
16
17use crate::bocpd::BocpdEvidence;
18use crate::resize_coalescer::Regime;
19
20/// Snapshot of the most recent diff-strategy decision.
21#[derive(Debug, Clone)]
22pub struct DiffDecisionSnapshot {
23    pub event_idx: u64,
24    pub screen_mode: String,
25    pub cols: u16,
26    pub rows: u16,
27    pub evidence: StrategyEvidence,
28    pub span_count: usize,
29    pub span_coverage_pct: f64,
30    pub max_span_len: usize,
31    pub scan_cost_estimate: usize,
32    pub fallback_reason: String,
33    pub tile_used: bool,
34    pub tile_fallback: String,
35    pub strategy_used: DiffStrategy,
36}
37
38/// Snapshot of the most recent resize/coalescer decision.
39#[derive(Debug, Clone)]
40pub struct ResizeDecisionSnapshot {
41    pub event_idx: u64,
42    pub action: &'static str,
43    pub dt_ms: f64,
44    pub event_rate: f64,
45    pub regime: Regime,
46    pub pending_size: Option<(u16, u16)>,
47    pub applied_size: Option<(u16, u16)>,
48    pub time_since_render_ms: f64,
49    pub bocpd: Option<BocpdEvidence>,
50}
51
52/// Conformal evidence snapshot for budget decisions.
53#[derive(Debug, Clone)]
54pub struct ConformalSnapshot {
55    pub bucket_key: String,
56    pub sample_count: usize,
57    pub upper_us: f64,
58    pub risk: bool,
59}
60
61/// Snapshot of the most recent budget decision.
62#[derive(Debug, Clone)]
63pub struct BudgetDecisionSnapshot {
64    pub frame_idx: u64,
65    pub decision: BudgetDecision,
66    pub controller_decision: BudgetDecision,
67    pub degradation_before: DegradationLevel,
68    pub degradation_after: DegradationLevel,
69    pub frame_time_us: f64,
70    pub budget_us: f64,
71    pub pid_output: f64,
72    pub e_value: f64,
73    pub frames_observed: u32,
74    pub frames_since_change: u32,
75    pub in_warmup: bool,
76    pub conformal: Option<ConformalSnapshot>,
77}
78
79static DIFF_SNAPSHOT: LazyLock<RwLock<Option<DiffDecisionSnapshot>>> =
80    LazyLock::new(|| RwLock::new(None));
81static RESIZE_SNAPSHOT: LazyLock<RwLock<Option<ResizeDecisionSnapshot>>> =
82    LazyLock::new(|| RwLock::new(None));
83static BUDGET_SNAPSHOT: LazyLock<RwLock<Option<BudgetDecisionSnapshot>>> =
84    LazyLock::new(|| RwLock::new(None));
85
86// Global snapshot telemetry is shared state. In tests, we serialize snapshot
87// access to avoid flakiness under parallel test execution.
88#[cfg(test)]
89static TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
90
91/// Store the latest diff decision snapshot.
92pub fn set_diff_snapshot(snapshot: Option<DiffDecisionSnapshot>) {
93    #[cfg(test)]
94    let _lock = TEST_LOCK.lock().expect("test lock poisoned");
95
96    if let Ok(mut guard) = DIFF_SNAPSHOT.write() {
97        *guard = snapshot;
98    }
99}
100
101/// Fetch the latest diff decision snapshot.
102#[must_use]
103pub fn diff_snapshot() -> Option<DiffDecisionSnapshot> {
104    #[cfg(test)]
105    let _lock = TEST_LOCK.lock().expect("test lock poisoned");
106
107    DIFF_SNAPSHOT.read().ok().and_then(|guard| guard.clone())
108}
109
110/// Clear any stored diff snapshot.
111pub fn clear_diff_snapshot() {
112    set_diff_snapshot(None);
113}
114
115/// Store the latest resize decision snapshot.
116pub fn set_resize_snapshot(snapshot: Option<ResizeDecisionSnapshot>) {
117    #[cfg(test)]
118    let _lock = TEST_LOCK.lock().expect("test lock poisoned");
119
120    if let Ok(mut guard) = RESIZE_SNAPSHOT.write() {
121        *guard = snapshot;
122    }
123}
124
125/// Fetch the latest resize decision snapshot.
126#[must_use]
127pub fn resize_snapshot() -> Option<ResizeDecisionSnapshot> {
128    #[cfg(test)]
129    let _lock = TEST_LOCK.lock().expect("test lock poisoned");
130
131    RESIZE_SNAPSHOT.read().ok().and_then(|guard| guard.clone())
132}
133
134/// Clear any stored resize snapshot.
135pub fn clear_resize_snapshot() {
136    set_resize_snapshot(None);
137}
138
139/// Store the latest budget decision snapshot.
140pub fn set_budget_snapshot(snapshot: Option<BudgetDecisionSnapshot>) {
141    #[cfg(test)]
142    let _lock = TEST_LOCK.lock().expect("test lock poisoned");
143
144    if let Ok(mut guard) = BUDGET_SNAPSHOT.write() {
145        *guard = snapshot;
146    }
147}
148
149/// Fetch the latest budget decision snapshot.
150#[must_use]
151pub fn budget_snapshot() -> Option<BudgetDecisionSnapshot> {
152    #[cfg(test)]
153    let _lock = TEST_LOCK.lock().expect("test lock poisoned");
154
155    BUDGET_SNAPSHOT.read().ok().and_then(|guard| guard.clone())
156}
157
158/// Clear any stored budget snapshot.
159pub fn clear_budget_snapshot() {
160    set_budget_snapshot(None);
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use ftui_render::budget::{BudgetDecision, DegradationLevel};
167    use ftui_render::diff_strategy::{DiffStrategy, StrategyEvidence};
168
169    use crate::bocpd::{BocpdEvidence, BocpdRegime};
170
171    // ── helpers ──────────────────────────────────────────────────────
172
173    fn make_diff_snapshot(event_idx: u64) -> DiffDecisionSnapshot {
174        DiffDecisionSnapshot {
175            event_idx,
176            screen_mode: "alt".into(),
177            cols: 80,
178            rows: 24,
179            evidence: StrategyEvidence {
180                strategy: DiffStrategy::DirtyRows,
181                cost_full: 1.0,
182                cost_dirty: 0.5,
183                cost_redraw: 2.0,
184                posterior_mean: 0.05,
185                posterior_variance: 0.001,
186                alpha: 2.0,
187                beta: 38.0,
188                dirty_rows: 3,
189                total_rows: 24,
190                total_cells: 1920,
191                guard_reason: "none",
192                hysteresis_applied: false,
193                hysteresis_ratio: 0.05,
194            },
195            span_count: 2,
196            span_coverage_pct: 6.25,
197            max_span_len: 12,
198            scan_cost_estimate: 200,
199            fallback_reason: "none".into(),
200            tile_used: false,
201            tile_fallback: String::new(),
202            strategy_used: DiffStrategy::DirtyRows,
203        }
204    }
205
206    fn make_resize_snapshot(event_idx: u64) -> ResizeDecisionSnapshot {
207        ResizeDecisionSnapshot {
208            event_idx,
209            action: "apply",
210            dt_ms: 150.0,
211            event_rate: 5.0,
212            regime: Regime::Steady,
213            pending_size: None,
214            applied_size: Some((120, 40)),
215            time_since_render_ms: 100.0,
216            bocpd: None,
217        }
218    }
219
220    fn make_budget_snapshot(frame_idx: u64) -> BudgetDecisionSnapshot {
221        BudgetDecisionSnapshot {
222            frame_idx,
223            decision: BudgetDecision::Hold,
224            controller_decision: BudgetDecision::Hold,
225            degradation_before: DegradationLevel::Full,
226            degradation_after: DegradationLevel::Full,
227            frame_time_us: 8000.0,
228            budget_us: 16000.0,
229            pid_output: 0.1,
230            e_value: 0.5,
231            frames_observed: 100,
232            frames_since_change: 50,
233            in_warmup: false,
234            conformal: None,
235        }
236    }
237
238    // ── diff snapshot tests ─────────────────────────────────────────
239
240    #[test]
241    fn diff_snapshot_initially_none() {
242        let mut guard = DIFF_SNAPSHOT.write().expect("diff snapshot lock poisoned");
243        *guard = None;
244        assert!(guard.is_none());
245    }
246
247    #[test]
248    fn diff_snapshot_store_and_retrieve() {
249        let snap = make_diff_snapshot(42);
250        let mut guard = DIFF_SNAPSHOT.write().expect("diff snapshot lock poisoned");
251        *guard = Some(snap);
252        let retrieved = guard.clone().expect("should be Some");
253        assert_eq!(retrieved.event_idx, 42);
254        assert_eq!(retrieved.cols, 80);
255        assert_eq!(retrieved.rows, 24);
256        *guard = None;
257    }
258
259    #[test]
260    fn diff_snapshot_overwrite() {
261        let mut guard = DIFF_SNAPSHOT.write().expect("diff snapshot lock poisoned");
262        *guard = Some(make_diff_snapshot(1));
263        *guard = Some(make_diff_snapshot(2));
264        let snap = guard.clone().expect("should be Some");
265        assert_eq!(snap.event_idx, 2);
266        *guard = None;
267    }
268
269    #[test]
270    fn diff_snapshot_clear() {
271        let mut guard = DIFF_SNAPSHOT.write().expect("diff snapshot lock poisoned");
272        *guard = Some(make_diff_snapshot(10));
273        *guard = None;
274        assert!(guard.is_none());
275    }
276
277    #[test]
278    fn diff_snapshot_preserves_evidence_fields() {
279        let snap = make_diff_snapshot(7);
280        let mut guard = DIFF_SNAPSHOT.write().expect("diff snapshot lock poisoned");
281        *guard = Some(snap);
282        let retrieved = guard.clone().unwrap();
283        assert_eq!(retrieved.evidence.strategy, DiffStrategy::DirtyRows);
284        assert!((retrieved.evidence.cost_full - 1.0).abs() < f64::EPSILON);
285        assert!((retrieved.evidence.posterior_mean - 0.05).abs() < f64::EPSILON);
286        assert_eq!(retrieved.span_count, 2);
287        assert_eq!(retrieved.strategy_used, DiffStrategy::DirtyRows);
288        *guard = None;
289    }
290
291    // ── resize snapshot tests ───────────────────────────────────────
292
293    #[test]
294    fn resize_snapshot_initially_none() {
295        let mut guard = RESIZE_SNAPSHOT
296            .write()
297            .expect("resize snapshot lock poisoned");
298        *guard = None;
299        assert!(guard.is_none());
300    }
301
302    #[test]
303    fn resize_snapshot_store_and_retrieve() {
304        let snap = make_resize_snapshot(5);
305        let mut guard = RESIZE_SNAPSHOT
306            .write()
307            .expect("resize snapshot lock poisoned");
308        *guard = Some(snap);
309        let retrieved = guard.clone().expect("should be Some");
310        assert_eq!(retrieved.event_idx, 5);
311        assert_eq!(retrieved.action, "apply");
312        assert_eq!(retrieved.regime, Regime::Steady);
313        assert_eq!(retrieved.applied_size, Some((120, 40)));
314        *guard = None;
315    }
316
317    #[test]
318    fn resize_snapshot_overwrite() {
319        let mut guard = RESIZE_SNAPSHOT
320            .write()
321            .expect("resize snapshot lock poisoned");
322        *guard = Some(make_resize_snapshot(1));
323        *guard = Some(make_resize_snapshot(2));
324        let snap = guard.clone().unwrap();
325        assert_eq!(snap.event_idx, 2);
326        *guard = None;
327    }
328
329    #[test]
330    fn resize_snapshot_clear() {
331        let mut guard = RESIZE_SNAPSHOT
332            .write()
333            .expect("resize snapshot lock poisoned");
334        *guard = Some(make_resize_snapshot(10));
335        *guard = None;
336        assert!(guard.is_none());
337    }
338
339    #[test]
340    fn resize_snapshot_with_bocpd_evidence() {
341        let mut snap = make_resize_snapshot(3);
342        snap.regime = Regime::Burst;
343        snap.bocpd = Some(BocpdEvidence {
344            p_burst: 0.85,
345            log_bayes_factor: 1.5,
346            observation_ms: 15.0,
347            regime: BocpdRegime::Burst,
348            likelihood_steady: 0.001,
349            likelihood_burst: 0.05,
350            expected_run_length: 3.0,
351            run_length_variance: 2.0,
352            run_length_mode: 2,
353            run_length_p95: 8,
354            run_length_tail_mass: 0.01,
355            recommended_delay_ms: Some(20),
356            hard_deadline_forced: None,
357            observation_count: 50,
358            timestamp: web_time::Instant::now(),
359        });
360        let mut guard = RESIZE_SNAPSHOT
361            .write()
362            .expect("resize snapshot lock poisoned");
363        *guard = Some(snap);
364        let retrieved = guard.clone().unwrap();
365        assert_eq!(retrieved.regime, Regime::Burst);
366        let bocpd = retrieved.bocpd.as_ref().unwrap();
367        assert!((bocpd.p_burst - 0.85).abs() < f64::EPSILON);
368        assert_eq!(bocpd.regime, BocpdRegime::Burst);
369        *guard = None;
370    }
371
372    // ── budget snapshot tests ───────────────────────────────────────
373
374    #[test]
375    fn budget_snapshot_clear_then_none() {
376        let mut guard = BUDGET_SNAPSHOT
377            .write()
378            .expect("budget snapshot lock poisoned");
379        *guard = None;
380        assert!(guard.is_none());
381    }
382
383    #[test]
384    fn budget_snapshot_store_and_retrieve() {
385        let snap = make_budget_snapshot(100);
386        let mut guard = BUDGET_SNAPSHOT
387            .write()
388            .expect("budget snapshot lock poisoned");
389        *guard = Some(snap);
390        let retrieved = guard.clone().expect("should be Some");
391        assert_eq!(retrieved.frame_idx, 100);
392        assert_eq!(retrieved.decision, BudgetDecision::Hold);
393        assert_eq!(retrieved.degradation_before, DegradationLevel::Full);
394        assert_eq!(retrieved.frames_observed, 100);
395        *guard = None;
396    }
397
398    #[test]
399    fn budget_snapshot_overwrite() {
400        let mut guard = BUDGET_SNAPSHOT
401            .write()
402            .expect("budget snapshot lock poisoned");
403        *guard = Some(make_budget_snapshot(1));
404        *guard = Some(make_budget_snapshot(2));
405        let snap = guard.clone().unwrap();
406        assert_eq!(snap.frame_idx, 2);
407        *guard = None;
408    }
409
410    #[test]
411    fn budget_snapshot_clear() {
412        let mut guard = BUDGET_SNAPSHOT
413            .write()
414            .expect("budget snapshot lock poisoned");
415        *guard = Some(make_budget_snapshot(10));
416        *guard = None;
417        assert!(guard.is_none());
418    }
419
420    #[test]
421    fn budget_snapshot_with_conformal() {
422        let mut snap = make_budget_snapshot(50);
423        snap.decision = BudgetDecision::Degrade;
424        snap.conformal = Some(ConformalSnapshot {
425            bucket_key: "alt:DirtyRows:medium".into(),
426            sample_count: 30,
427            upper_us: 20000.0,
428            risk: true,
429        });
430        let mut guard = BUDGET_SNAPSHOT
431            .write()
432            .expect("budget snapshot lock poisoned");
433        *guard = Some(snap);
434        let retrieved = guard.clone().unwrap();
435        assert_eq!(retrieved.decision, BudgetDecision::Degrade);
436        let conformal = retrieved.conformal.as_ref().unwrap();
437        assert_eq!(conformal.bucket_key, "alt:DirtyRows:medium");
438        assert_eq!(conformal.sample_count, 30);
439        assert!(conformal.risk);
440        *guard = None;
441    }
442
443    #[test]
444    fn budget_snapshot_degradation_levels() {
445        let mut snap = make_budget_snapshot(1);
446        snap.degradation_before = DegradationLevel::Full;
447        snap.degradation_after = DegradationLevel::SimpleBorders;
448        snap.decision = BudgetDecision::Degrade;
449        let mut guard = BUDGET_SNAPSHOT
450            .write()
451            .expect("budget snapshot lock poisoned");
452        *guard = Some(snap);
453        let retrieved = guard.clone().unwrap();
454        assert!(retrieved.degradation_after > retrieved.degradation_before);
455        *guard = None;
456    }
457
458    #[test]
459    fn budget_snapshot_warmup_flag() {
460        let mut snap = make_budget_snapshot(1);
461        snap.in_warmup = true;
462        snap.frames_observed = 5;
463        let mut guard = BUDGET_SNAPSHOT
464            .write()
465            .expect("budget snapshot lock poisoned");
466        *guard = Some(snap);
467        let retrieved = guard.clone().unwrap();
468        assert!(retrieved.in_warmup);
469        assert_eq!(retrieved.frames_observed, 5);
470        *guard = None;
471    }
472
473    // ── set_*_snapshot(None) tests ──────────────────────────────────
474
475    #[test]
476    fn set_diff_none_clears() {
477        let mut guard = DIFF_SNAPSHOT.write().expect("diff snapshot lock poisoned");
478        *guard = Some(make_diff_snapshot(1));
479        *guard = None;
480        assert!(guard.is_none());
481    }
482
483    #[test]
484    fn set_resize_none_clears() {
485        let mut guard = RESIZE_SNAPSHOT
486            .write()
487            .expect("resize snapshot lock poisoned");
488        *guard = Some(make_resize_snapshot(1));
489        *guard = None;
490        assert!(guard.is_none());
491    }
492
493    #[test]
494    fn set_budget_none_clears() {
495        let mut guard = BUDGET_SNAPSHOT
496            .write()
497            .expect("budget snapshot lock poisoned");
498        *guard = Some(make_budget_snapshot(1));
499        *guard = None;
500        assert!(guard.is_none());
501    }
502}