1#![forbid(unsafe_code)]
2
3use 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#[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#[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#[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#[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#[cfg(test)]
89static TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
90
91pub 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#[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
110pub fn clear_diff_snapshot() {
112 set_diff_snapshot(None);
113}
114
115pub 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#[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
134pub fn clear_resize_snapshot() {
136 set_resize_snapshot(None);
137}
138
139pub 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#[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
158pub 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 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 #[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 #[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 #[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 #[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}