Skip to main content

ftui_widgets/
voi_debug_overlay.rs

1#![forbid(unsafe_code)]
2
3//! VOI debug overlay widget (Galaxy-Brain).
4
5use crate::Widget;
6use crate::block::{Alignment, Block};
7use crate::borders::{BorderType, Borders};
8use crate::paragraph::Paragraph;
9use ftui_core::geometry::Rect;
10use ftui_render::cell::{Cell, PackedRgba};
11use ftui_render::frame::Frame;
12use ftui_style::Style;
13
14/// Summary of the VOI posterior.
15#[derive(Debug, Clone)]
16pub struct VoiPosteriorSummary {
17    pub alpha: f64,
18    pub beta: f64,
19    pub mean: f64,
20    pub variance: f64,
21    pub expected_variance_after: f64,
22    pub voi_gain: f64,
23}
24
25/// Summary of the most recent VOI decision.
26#[derive(Debug, Clone)]
27pub struct VoiDecisionSummary {
28    pub event_idx: u64,
29    pub should_sample: bool,
30    pub reason: String,
31    pub score: f64,
32    pub cost: f64,
33    pub log_bayes_factor: f64,
34    pub e_value: f64,
35    pub e_threshold: f64,
36    pub boundary_score: f64,
37}
38
39/// Summary of the most recent VOI observation.
40#[derive(Debug, Clone)]
41pub struct VoiObservationSummary {
42    pub sample_idx: u64,
43    pub violated: bool,
44    pub posterior_mean: f64,
45    pub alpha: f64,
46    pub beta: f64,
47}
48
49/// Ledger entries for the VOI debug overlay.
50#[derive(Debug, Clone)]
51pub enum VoiLedgerEntry {
52    Decision {
53        event_idx: u64,
54        should_sample: bool,
55        voi_gain: f64,
56        log_bayes_factor: f64,
57    },
58    Observation {
59        sample_idx: u64,
60        violated: bool,
61        posterior_mean: f64,
62    },
63}
64
65/// Full overlay data payload.
66#[derive(Debug, Clone)]
67pub struct VoiOverlayData {
68    pub title: String,
69    pub tick: Option<u64>,
70    pub source: Option<String>,
71    pub posterior: VoiPosteriorSummary,
72    pub decision: Option<VoiDecisionSummary>,
73    pub observation: Option<VoiObservationSummary>,
74    pub ledger: Vec<VoiLedgerEntry>,
75}
76
77/// Styling options for the VOI overlay.
78#[derive(Debug, Clone)]
79pub struct VoiOverlayStyle {
80    pub border: Style,
81    pub text: Style,
82    pub background: Option<PackedRgba>,
83    pub border_type: BorderType,
84}
85
86impl Default for VoiOverlayStyle {
87    fn default() -> Self {
88        Self {
89            border: Style::new(),
90            text: Style::new(),
91            background: None,
92            border_type: BorderType::Rounded,
93        }
94    }
95}
96
97/// VOI debug overlay widget.
98#[derive(Debug, Clone)]
99pub struct VoiDebugOverlay {
100    data: VoiOverlayData,
101    style: VoiOverlayStyle,
102}
103
104impl VoiDebugOverlay {
105    /// Create a new VOI overlay widget.
106    pub fn new(data: VoiOverlayData) -> Self {
107        Self {
108            data,
109            style: VoiOverlayStyle::default(),
110        }
111    }
112
113    /// Override styling for the overlay.
114    #[must_use]
115    pub fn with_style(mut self, style: VoiOverlayStyle) -> Self {
116        self.style = style;
117        self
118    }
119
120    fn build_lines(&self, line_width: usize) -> Vec<String> {
121        let mut lines = Vec::with_capacity(20);
122        let divider = "-".repeat(line_width);
123
124        let mut header = self.data.title.clone();
125        if let Some(tick) = self.data.tick {
126            header.push_str(&format!(" (tick {})", tick));
127        }
128        if let Some(source) = &self.data.source {
129            header.push_str(&format!(" [{source}]"));
130        }
131
132        lines.push(header);
133        lines.push(divider.clone());
134
135        if let Some(decision) = &self.data.decision {
136            let verdict = if decision.should_sample {
137                "SAMPLE"
138            } else {
139                "SKIP"
140            };
141            lines.push(format!(
142                "Decision: {:<6}  reason: {}",
143                verdict, decision.reason
144            ));
145            lines.push(format!(
146                "log10 BF: {:+.3}  score/cost",
147                decision.log_bayes_factor
148            ));
149            lines.push(format!(
150                "E: {:.3} / {:.2}  boundary: {:.3}",
151                decision.e_value, decision.e_threshold, decision.boundary_score
152            ));
153        } else {
154            lines.push("Decision: —".to_string());
155        }
156
157        lines.push(String::new());
158        lines.push("Posterior Core".to_string());
159        lines.push(divider.clone());
160        lines.push(format!(
161            "p ~ Beta(a,b)  a={:.2}  b={:.2}",
162            self.data.posterior.alpha, self.data.posterior.beta
163        ));
164        lines.push(format!(
165            "mu={:.4}  Var={:.6}",
166            self.data.posterior.mean, self.data.posterior.variance
167        ));
168        lines.push("VOI = Var[p] - E[Var|1]".to_string());
169        lines.push(format!(
170            "VOI = {:.6} - {:.6} = {:.6}",
171            self.data.posterior.variance,
172            self.data.posterior.expected_variance_after,
173            self.data.posterior.voi_gain
174        ));
175
176        if let Some(decision) = &self.data.decision {
177            lines.push(String::new());
178            lines.push("Decision Equation".to_string());
179            lines.push(divider.clone());
180            lines.push(format!(
181                "score={:.6}  cost={:.6}",
182                decision.score, decision.cost
183            ));
184            lines.push(format!(
185                "log10 BF = log10({:.6}/{:.6}) = {:+.3}",
186                decision.score, decision.cost, decision.log_bayes_factor
187            ));
188        }
189
190        if let Some(obs) = &self.data.observation {
191            lines.push(String::new());
192            lines.push("Last Sample".to_string());
193            lines.push(divider.clone());
194            lines.push(format!(
195                "violated: {}  a={:.1}  b={:.1}  mu={:.3}",
196                obs.violated, obs.alpha, obs.beta, obs.posterior_mean
197            ));
198        }
199
200        if !self.data.ledger.is_empty() {
201            lines.push(String::new());
202            lines.push("Evidence Ledger (Recent)".to_string());
203            lines.push(divider);
204            for entry in &self.data.ledger {
205                match entry {
206                    VoiLedgerEntry::Decision {
207                        event_idx,
208                        should_sample,
209                        voi_gain,
210                        log_bayes_factor,
211                    } => {
212                        let verdict = if *should_sample { "S" } else { "-" };
213                        lines.push(format!(
214                            "D#{:>3} {verdict} VOI={:.5} logBF={:+.2}",
215                            event_idx, voi_gain, log_bayes_factor
216                        ));
217                    }
218                    VoiLedgerEntry::Observation {
219                        sample_idx,
220                        violated,
221                        posterior_mean,
222                    } => {
223                        lines.push(format!(
224                            "O#{:>3} viol={} mu={:.3}",
225                            sample_idx, violated, posterior_mean
226                        ));
227                    }
228                }
229            }
230        }
231
232        lines
233    }
234}
235
236impl Widget for VoiDebugOverlay {
237    fn render(&self, area: Rect, frame: &mut Frame) {
238        if area.is_empty() || area.width < 20 || area.height < 6 {
239            return;
240        }
241
242        if let Some(bg) = self.style.background {
243            let cell = Cell::default().with_bg(bg);
244            frame.buffer.fill(area, cell);
245        }
246
247        let block = Block::new()
248            .borders(Borders::ALL)
249            .border_type(self.style.border_type)
250            .border_style(self.style.border)
251            .title(&self.data.title)
252            .title_alignment(Alignment::Center)
253            .style(self.style.text);
254
255        let inner = block.inner(area);
256        block.render(area, frame);
257
258        if inner.is_empty() {
259            return;
260        }
261
262        let line_width = inner.width.saturating_sub(2) as usize;
263        let lines = self.build_lines(line_width.max(1));
264        let text = lines.join("\n");
265        Paragraph::new(text)
266            .style(self.style.text)
267            .render(inner, frame);
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use ftui_render::grapheme_pool::GraphemePool;
275
276    fn sample_posterior() -> VoiPosteriorSummary {
277        VoiPosteriorSummary {
278            alpha: 3.2,
279            beta: 7.4,
280            mean: 0.301,
281            variance: 0.0123,
282            expected_variance_after: 0.0101,
283            voi_gain: 0.0022,
284        }
285    }
286
287    fn sample_data() -> VoiOverlayData {
288        VoiOverlayData {
289            title: "VOI Overlay".to_string(),
290            tick: Some(42),
291            source: Some("budget".to_string()),
292            posterior: sample_posterior(),
293            decision: Some(VoiDecisionSummary {
294                event_idx: 7,
295                should_sample: true,
296                reason: "voi_gain > cost".to_string(),
297                score: 0.123456,
298                cost: 0.045,
299                log_bayes_factor: 0.437,
300                e_value: 1.23,
301                e_threshold: 0.95,
302                boundary_score: 0.77,
303            }),
304            observation: Some(VoiObservationSummary {
305                sample_idx: 4,
306                violated: false,
307                posterior_mean: 0.312,
308                alpha: 3.9,
309                beta: 8.2,
310            }),
311            ledger: vec![
312                VoiLedgerEntry::Decision {
313                    event_idx: 5,
314                    should_sample: true,
315                    voi_gain: 0.0042,
316                    log_bayes_factor: 0.31,
317                },
318                VoiLedgerEntry::Observation {
319                    sample_idx: 3,
320                    violated: true,
321                    posterior_mean: 0.4,
322                },
323            ],
324        }
325    }
326
327    #[test]
328    fn build_lines_without_decision_or_ledger() {
329        let data = VoiOverlayData {
330            title: "VOI".to_string(),
331            tick: None,
332            source: None,
333            posterior: sample_posterior(),
334            decision: None,
335            observation: None,
336            ledger: Vec::new(),
337        };
338        let overlay = VoiDebugOverlay::new(data);
339        let lines = overlay.build_lines(24);
340
341        assert!(lines[0].contains("VOI"), "header missing title: {lines:?}");
342        assert_eq!(lines[1].len(), 24, "divider width mismatch: {lines:?}");
343        assert!(
344            lines.iter().any(|line| line.contains("Decision: —")),
345            "missing default decision line: {lines:?}"
346        );
347        assert!(
348            lines.iter().any(|line| line.contains("Posterior Core")),
349            "missing posterior section: {lines:?}"
350        );
351        assert!(
352            !lines.iter().any(|line| line.contains("Evidence Ledger")),
353            "unexpected ledger section: {lines:?}"
354        );
355    }
356
357    #[test]
358    fn build_lines_with_decision_and_observation() {
359        let overlay = VoiDebugOverlay::new(sample_data());
360        let lines = overlay.build_lines(30);
361
362        assert!(
363            lines.iter().any(|line| line.contains("Decision: SAMPLE")),
364            "missing decision summary: {lines:?}"
365        );
366        assert!(
367            lines.iter().any(|line| line.contains("Last Sample")),
368            "missing observation summary: {lines:?}"
369        );
370        assert!(
371            lines.iter().any(|line| line.contains("Evidence Ledger")),
372            "missing ledger header: {lines:?}"
373        );
374        assert!(
375            lines.iter().any(|line| line.contains("D#  5")),
376            "missing decision ledger entry: {lines:?}"
377        );
378        assert!(
379            lines.iter().any(|line| line.contains("O#  3")),
380            "missing observation ledger entry: {lines:?}"
381        );
382    }
383
384    #[test]
385    fn render_applies_background_and_border() {
386        let bg = PackedRgba::rgb(12, 34, 56);
387        let style = VoiOverlayStyle {
388            background: Some(bg),
389            ..VoiOverlayStyle::default()
390        };
391        let overlay = VoiDebugOverlay::new(sample_data()).with_style(style);
392
393        let mut pool = GraphemePool::new();
394        let mut frame = Frame::new(80, 32, &mut pool);
395        let area = Rect::new(0, 0, 80, 32);
396
397        overlay.render(area, &mut frame);
398
399        let top_left = frame.buffer.get(0, 0).unwrap();
400        assert_eq!(
401            top_left.content.as_char(),
402            Some('╭'),
403            "border not rendered as rounded: cell={top_left:?}"
404        );
405
406        let inner = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2);
407        let lines = overlay.build_lines(inner.width.saturating_sub(2) as usize);
408        let extra_row = inner.y + (lines.len() as u16).saturating_add(1);
409        let bg_cell = frame.buffer.get(inner.x + 1, extra_row).unwrap();
410        assert_eq!(
411            bg_cell.bg,
412            bg,
413            "background not applied at ({}, {}): cell={bg_cell:?}",
414            inner.x + 1,
415            extra_row
416        );
417    }
418
419    #[test]
420    fn render_small_area_noop() {
421        let overlay = VoiDebugOverlay::new(sample_data());
422        let mut pool = GraphemePool::new();
423        let mut frame = Frame::new(10, 4, &mut pool);
424        let before = frame.buffer.get(0, 0).copied();
425
426        overlay.render(Rect::new(0, 0, 10, 4), &mut frame);
427
428        let after = frame.buffer.get(0, 0).copied();
429        assert_eq!(
430            before, after,
431            "small-area render should be no-op: before={before:?} after={after:?}"
432        );
433    }
434
435    // --- Style defaults ---
436
437    #[test]
438    fn overlay_style_default() {
439        let style = VoiOverlayStyle::default();
440        assert!(style.background.is_none());
441        assert!(matches!(style.border_type, BorderType::Rounded));
442    }
443
444    // --- Header formatting ---
445
446    #[test]
447    fn build_lines_header_with_tick_and_source() {
448        let data = VoiOverlayData {
449            title: "Test".to_string(),
450            tick: Some(100),
451            source: Some("resize".to_string()),
452            posterior: sample_posterior(),
453            decision: None,
454            observation: None,
455            ledger: Vec::new(),
456        };
457        let overlay = VoiDebugOverlay::new(data);
458        let lines = overlay.build_lines(40);
459        assert!(lines[0].contains("Test (tick 100) [resize]"));
460    }
461
462    #[test]
463    fn build_lines_header_no_tick_no_source() {
464        let data = VoiOverlayData {
465            title: "Plain".to_string(),
466            tick: None,
467            source: None,
468            posterior: sample_posterior(),
469            decision: None,
470            observation: None,
471            ledger: Vec::new(),
472        };
473        let overlay = VoiDebugOverlay::new(data);
474        let lines = overlay.build_lines(20);
475        assert_eq!(lines[0], "Plain");
476    }
477
478    // --- Decision verdict ---
479
480    #[test]
481    fn build_lines_skip_verdict() {
482        let data = VoiOverlayData {
483            title: "Test".to_string(),
484            tick: None,
485            source: None,
486            posterior: sample_posterior(),
487            decision: Some(VoiDecisionSummary {
488                event_idx: 1,
489                should_sample: false,
490                reason: "cost_too_high".to_string(),
491                score: 0.01,
492                cost: 0.1,
493                log_bayes_factor: -1.0,
494                e_value: 0.5,
495                e_threshold: 0.95,
496                boundary_score: 0.2,
497            }),
498            observation: None,
499            ledger: Vec::new(),
500        };
501        let overlay = VoiDebugOverlay::new(data);
502        let lines = overlay.build_lines(40);
503        assert!(
504            lines.iter().any(|l| l.contains("Decision: SKIP")),
505            "expected SKIP verdict: {lines:?}"
506        );
507    }
508
509    // --- Observation only (no decision) ---
510
511    #[test]
512    fn build_lines_observation_only() {
513        let data = VoiOverlayData {
514            title: "T".to_string(),
515            tick: None,
516            source: None,
517            posterior: sample_posterior(),
518            decision: None,
519            observation: Some(VoiObservationSummary {
520                sample_idx: 10,
521                violated: true,
522                posterior_mean: 0.456,
523                alpha: 5.0,
524                beta: 10.0,
525            }),
526            ledger: Vec::new(),
527        };
528        let overlay = VoiDebugOverlay::new(data);
529        let lines = overlay.build_lines(40);
530        assert!(
531            lines.iter().any(|l| l.contains("violated: true")),
532            "missing violated observation: {lines:?}"
533        );
534        assert!(
535            lines.iter().any(|l| l.contains("mu=0.456")),
536            "missing posterior mean: {lines:?}"
537        );
538    }
539
540    // --- Ledger formatting ---
541
542    #[test]
543    fn build_lines_ledger_skip_entry() {
544        let data = VoiOverlayData {
545            title: "T".to_string(),
546            tick: None,
547            source: None,
548            posterior: sample_posterior(),
549            decision: None,
550            observation: None,
551            ledger: vec![VoiLedgerEntry::Decision {
552                event_idx: 99,
553                should_sample: false,
554                voi_gain: 0.001,
555                log_bayes_factor: -0.5,
556            }],
557        };
558        let overlay = VoiDebugOverlay::new(data);
559        let lines = overlay.build_lines(40);
560        assert!(
561            lines.iter().any(|l| l.contains("D# 99 -")),
562            "expected skip marker: {lines:?}"
563        );
564    }
565
566    // --- Posterior formatting ---
567
568    #[test]
569    fn build_lines_posterior_values() {
570        let data = VoiOverlayData {
571            title: "T".to_string(),
572            tick: None,
573            source: None,
574            posterior: VoiPosteriorSummary {
575                alpha: 1.0,
576                beta: 1.0,
577                mean: 0.5,
578                variance: 0.0833,
579                expected_variance_after: 0.0500,
580                voi_gain: 0.0333,
581            },
582            decision: None,
583            observation: None,
584            ledger: Vec::new(),
585        };
586        let overlay = VoiDebugOverlay::new(data);
587        let lines = overlay.build_lines(40);
588        assert!(
589            lines
590                .iter()
591                .any(|l| l.contains("a=1.00") && l.contains("b=1.00")),
592            "missing alpha/beta: {lines:?}"
593        );
594        assert!(
595            lines.iter().any(|l| l.contains("mu=0.5000")),
596            "missing mean: {lines:?}"
597        );
598    }
599
600    // --- with_style builder ---
601
602    #[test]
603    fn with_style_replaces_style() {
604        let overlay = VoiDebugOverlay::new(sample_data());
605        let custom = VoiOverlayStyle {
606            background: Some(PackedRgba::rgb(255, 0, 0)),
607            border_type: BorderType::Square,
608            ..VoiOverlayStyle::default()
609        };
610        let styled = overlay.with_style(custom);
611        assert_eq!(styled.style.background, Some(PackedRgba::rgb(255, 0, 0)));
612    }
613
614    #[test]
615    fn render_empty_area_is_noop() {
616        let overlay = VoiDebugOverlay::new(sample_data());
617        let mut pool = GraphemePool::new();
618        let mut frame = Frame::new(40, 10, &mut pool);
619
620        // Zero-width area
621        overlay.render(Rect::new(0, 0, 0, 10), &mut frame);
622        // Zero-height area
623        overlay.render(Rect::new(0, 0, 40, 0), &mut frame);
624        // Both zero — should not panic
625    }
626
627    #[test]
628    fn render_narrow_area_where_inner_is_empty() {
629        let overlay = VoiDebugOverlay::new(sample_data());
630        let mut pool = GraphemePool::new();
631        let mut frame = Frame::new(80, 40, &mut pool);
632        // Area has borders consuming all space (width=20 passes threshold, height=6 passes)
633        // Inner is 18x4 which is non-empty, so use exactly at threshold
634        overlay.render(Rect::new(0, 0, 20, 6), &mut frame);
635        // Should render without panic
636    }
637
638    #[test]
639    fn build_lines_ledger_observation_entry() {
640        let data = VoiOverlayData {
641            title: "T".to_string(),
642            tick: None,
643            source: None,
644            posterior: sample_posterior(),
645            decision: None,
646            observation: None,
647            ledger: vec![VoiLedgerEntry::Observation {
648                sample_idx: 42,
649                violated: false,
650                posterior_mean: 0.789,
651            }],
652        };
653        let overlay = VoiDebugOverlay::new(data);
654        let lines = overlay.build_lines(40);
655        assert!(
656            lines.iter().any(|l| l.contains("O# 42")),
657            "missing observation ledger entry: {lines:?}"
658        );
659        assert!(
660            lines.iter().any(|l| l.contains("viol=false")),
661            "missing violated=false: {lines:?}"
662        );
663        assert!(
664            lines.iter().any(|l| l.contains("mu=0.789")),
665            "missing posterior mean in ledger: {lines:?}"
666        );
667    }
668
669    #[test]
670    fn build_lines_decision_equation_section() {
671        let overlay = VoiDebugOverlay::new(sample_data());
672        let lines = overlay.build_lines(50);
673        assert!(
674            lines.iter().any(|l| l.contains("Decision Equation")),
675            "missing decision equation header: {lines:?}"
676        );
677        assert!(
678            lines
679                .iter()
680                .any(|l| l.contains("score=") && l.contains("cost=")),
681            "missing score/cost line: {lines:?}"
682        );
683    }
684
685    #[test]
686    fn build_lines_voi_equation_format() {
687        let data = VoiOverlayData {
688            title: "T".to_string(),
689            tick: None,
690            source: None,
691            posterior: VoiPosteriorSummary {
692                alpha: 2.0,
693                beta: 3.0,
694                mean: 0.4,
695                variance: 0.04,
696                expected_variance_after: 0.03,
697                voi_gain: 0.01,
698            },
699            decision: None,
700            observation: None,
701            ledger: Vec::new(),
702        };
703        let overlay = VoiDebugOverlay::new(data);
704        let lines = overlay.build_lines(50);
705        // VOI = Var[p] - E[Var|1] header
706        assert!(
707            lines.iter().any(|l| l.contains("VOI = Var[p] - E[Var|1]")),
708            "missing VOI equation label: {lines:?}"
709        );
710        // VOI = 0.040000 - 0.030000 = 0.010000
711        assert!(
712            lines.iter().any(|l| l.contains("0.040000")
713                && l.contains("0.030000")
714                && l.contains("0.010000")),
715            "missing VOI computation line: {lines:?}"
716        );
717    }
718
719    #[test]
720    fn overlay_data_clone() {
721        let data = sample_data();
722        let cloned = data.clone();
723        assert_eq!(cloned.title, data.title);
724        assert_eq!(cloned.tick, data.tick);
725        assert_eq!(cloned.ledger.len(), data.ledger.len());
726    }
727
728    // ─── Edge-case tests (bd-3szd1) ────────────────────────────────────
729
730    #[test]
731    fn build_lines_width_zero() {
732        let overlay = VoiDebugOverlay::new(sample_data());
733        let lines = overlay.build_lines(0);
734        // Should not panic; divider is empty string
735        assert!(!lines.is_empty());
736    }
737
738    #[test]
739    fn build_lines_width_one() {
740        let overlay = VoiDebugOverlay::new(sample_data());
741        let lines = overlay.build_lines(1);
742        assert_eq!(lines[1], "-", "divider should be single dash");
743    }
744
745    #[test]
746    fn build_lines_empty_title() {
747        let data = VoiOverlayData {
748            title: String::new(),
749            tick: None,
750            source: None,
751            posterior: sample_posterior(),
752            decision: None,
753            observation: None,
754            ledger: Vec::new(),
755        };
756        let overlay = VoiDebugOverlay::new(data);
757        let lines = overlay.build_lines(20);
758        assert_eq!(lines[0], "", "empty title should produce empty header");
759    }
760
761    #[test]
762    fn build_lines_tick_only_no_source() {
763        let data = VoiOverlayData {
764            title: "T".to_string(),
765            tick: Some(0),
766            source: None,
767            posterior: sample_posterior(),
768            decision: None,
769            observation: None,
770            ledger: Vec::new(),
771        };
772        let overlay = VoiDebugOverlay::new(data);
773        let lines = overlay.build_lines(30);
774        assert!(lines[0].contains("(tick 0)"));
775        assert!(!lines[0].contains('['));
776    }
777
778    #[test]
779    fn build_lines_source_only_no_tick() {
780        let data = VoiOverlayData {
781            title: "T".to_string(),
782            tick: None,
783            source: Some("src".to_string()),
784            posterior: sample_posterior(),
785            decision: None,
786            observation: None,
787            ledger: Vec::new(),
788        };
789        let overlay = VoiDebugOverlay::new(data);
790        let lines = overlay.build_lines(30);
791        assert!(lines[0].contains("[src]"));
792        assert!(!lines[0].contains("tick"));
793    }
794
795    #[test]
796    fn render_width_below_threshold() {
797        let overlay = VoiDebugOverlay::new(sample_data());
798        let mut pool = GraphemePool::new();
799        let mut frame = Frame::new(80, 40, &mut pool);
800        // width=19 is below the 20 threshold
801        overlay.render(Rect::new(0, 0, 19, 10), &mut frame);
802        // Should be noop — verify no border rendered
803        let cell = frame.buffer.get(0, 0).unwrap();
804        assert_ne!(
805            cell.content.as_char(),
806            Some('╭'),
807            "should not render border at width=19"
808        );
809    }
810
811    #[test]
812    fn render_height_below_threshold() {
813        let overlay = VoiDebugOverlay::new(sample_data());
814        let mut pool = GraphemePool::new();
815        let mut frame = Frame::new(80, 40, &mut pool);
816        // height=5 is below the 6 threshold
817        overlay.render(Rect::new(0, 0, 40, 5), &mut frame);
818        let cell = frame.buffer.get(0, 0).unwrap();
819        assert_ne!(
820            cell.content.as_char(),
821            Some('╭'),
822            "should not render border at height=5"
823        );
824    }
825
826    #[test]
827    fn render_exact_minimum_size() {
828        let overlay = VoiDebugOverlay::new(sample_data());
829        let mut pool = GraphemePool::new();
830        let mut frame = Frame::new(80, 40, &mut pool);
831        // Exactly at threshold: width=20, height=6
832        overlay.render(Rect::new(0, 0, 20, 6), &mut frame);
833        let cell = frame.buffer.get(0, 0).unwrap();
834        assert_eq!(
835            cell.content.as_char(),
836            Some('╭'),
837            "should render border at exact minimum size"
838        );
839    }
840
841    #[test]
842    fn posterior_with_nan_values() {
843        let data = VoiOverlayData {
844            title: "T".to_string(),
845            tick: None,
846            source: None,
847            posterior: VoiPosteriorSummary {
848                alpha: f64::NAN,
849                beta: f64::INFINITY,
850                mean: f64::NEG_INFINITY,
851                variance: 0.0,
852                expected_variance_after: 0.0,
853                voi_gain: -0.0,
854            },
855            decision: None,
856            observation: None,
857            ledger: Vec::new(),
858        };
859        let overlay = VoiDebugOverlay::new(data);
860        let lines = overlay.build_lines(50);
861        // Should format without panic
862        assert!(
863            lines.iter().any(|l| l.contains("NaN") || l.contains("nan")),
864            "NaN alpha should appear in output: {lines:?}"
865        );
866    }
867
868    #[test]
869    fn large_event_idx_in_ledger() {
870        let data = VoiOverlayData {
871            title: "T".to_string(),
872            tick: None,
873            source: None,
874            posterior: sample_posterior(),
875            decision: None,
876            observation: None,
877            ledger: vec![VoiLedgerEntry::Decision {
878                event_idx: u64::MAX,
879                should_sample: true,
880                voi_gain: 0.0,
881                log_bayes_factor: 0.0,
882            }],
883        };
884        let overlay = VoiDebugOverlay::new(data);
885        let lines = overlay.build_lines(80);
886        assert!(
887            lines.iter().any(|l| l.contains(&u64::MAX.to_string())),
888            "large event_idx should appear: {lines:?}"
889        );
890    }
891
892    #[test]
893    fn multiple_ledger_entries_same_type() {
894        let data = VoiOverlayData {
895            title: "T".to_string(),
896            tick: None,
897            source: None,
898            posterior: sample_posterior(),
899            decision: None,
900            observation: None,
901            ledger: vec![
902                VoiLedgerEntry::Decision {
903                    event_idx: 1,
904                    should_sample: true,
905                    voi_gain: 0.01,
906                    log_bayes_factor: 0.5,
907                },
908                VoiLedgerEntry::Decision {
909                    event_idx: 2,
910                    should_sample: false,
911                    voi_gain: 0.001,
912                    log_bayes_factor: -0.3,
913                },
914                VoiLedgerEntry::Decision {
915                    event_idx: 3,
916                    should_sample: true,
917                    voi_gain: 0.02,
918                    log_bayes_factor: 1.0,
919                },
920            ],
921        };
922        let overlay = VoiDebugOverlay::new(data);
923        let lines = overlay.build_lines(50);
924        let decision_lines: Vec<_> = lines.iter().filter(|l| l.starts_with("D#")).collect();
925        assert_eq!(decision_lines.len(), 3, "expected 3 decision entries");
926    }
927
928    #[test]
929    fn negative_log_bayes_factor_format() {
930        let data = VoiOverlayData {
931            title: "T".to_string(),
932            tick: None,
933            source: None,
934            posterior: sample_posterior(),
935            decision: Some(VoiDecisionSummary {
936                event_idx: 1,
937                should_sample: false,
938                reason: "negative".to_string(),
939                score: 0.001,
940                cost: 0.1,
941                log_bayes_factor: -2.345,
942                e_value: 0.1,
943                e_threshold: 0.95,
944                boundary_score: 0.05,
945            }),
946            observation: None,
947            ledger: Vec::new(),
948        };
949        let overlay = VoiDebugOverlay::new(data);
950        let lines = overlay.build_lines(50);
951        assert!(
952            lines.iter().any(|l| l.contains("-2.345")),
953            "negative log BF should appear: {lines:?}"
954        );
955    }
956
957    #[test]
958    fn voi_ledger_entry_clone() {
959        let entry = VoiLedgerEntry::Decision {
960            event_idx: 5,
961            should_sample: true,
962            voi_gain: 0.01,
963            log_bayes_factor: 0.5,
964        };
965        let cloned = entry.clone();
966        assert!(format!("{cloned:?}").contains("Decision"));
967    }
968
969    #[test]
970    fn voi_decision_summary_clone() {
971        let d = VoiDecisionSummary {
972            event_idx: 1,
973            should_sample: true,
974            reason: "test".to_string(),
975            score: 1.0,
976            cost: 0.5,
977            log_bayes_factor: 0.3,
978            e_value: 1.0,
979            e_threshold: 0.95,
980            boundary_score: 0.5,
981        };
982        let cloned = d.clone();
983        assert_eq!(cloned.reason, "test");
984        assert_eq!(cloned.event_idx, 1);
985    }
986
987    #[test]
988    fn voi_observation_summary_clone() {
989        let o = VoiObservationSummary {
990            sample_idx: 42,
991            violated: true,
992            posterior_mean: 0.5,
993            alpha: 3.0,
994            beta: 7.0,
995        };
996        let cloned = o.clone();
997        assert!(cloned.violated);
998        assert_eq!(cloned.sample_idx, 42);
999    }
1000
1001    #[test]
1002    fn with_style_custom_border_type() {
1003        let overlay = VoiDebugOverlay::new(sample_data()).with_style(VoiOverlayStyle {
1004            border_type: BorderType::Double,
1005            ..VoiOverlayStyle::default()
1006        });
1007        assert!(matches!(overlay.style.border_type, BorderType::Double));
1008    }
1009
1010    #[test]
1011    fn render_no_background() {
1012        let data = sample_data();
1013        let overlay = VoiDebugOverlay::new(data);
1014        let mut pool = GraphemePool::new();
1015        let mut frame = Frame::new(80, 32, &mut pool);
1016        // Default style has no background
1017        overlay.render(Rect::new(0, 0, 80, 32), &mut frame);
1018        // Should render border without panic
1019        let cell = frame.buffer.get(0, 0).unwrap();
1020        assert_eq!(cell.content.as_char(), Some('╭'));
1021    }
1022
1023    #[test]
1024    fn build_lines_divider_matches_width() {
1025        let overlay = VoiDebugOverlay::new(sample_data());
1026        let width = 37;
1027        let lines = overlay.build_lines(width);
1028        // line[1] is the first divider
1029        assert_eq!(
1030            lines[1].len(),
1031            width,
1032            "divider should match requested width"
1033        );
1034    }
1035
1036    // ─── End edge-case tests (bd-3szd1) ──────────────────────────────
1037
1038    // --- Struct Debug impls ---
1039
1040    #[test]
1041    fn structs_implement_debug() {
1042        let posterior = sample_posterior();
1043        let _ = format!("{posterior:?}");
1044
1045        let data = sample_data();
1046        let _ = format!("{data:?}");
1047
1048        let overlay = VoiDebugOverlay::new(data);
1049        let _ = format!("{overlay:?}");
1050
1051        let style = VoiOverlayStyle::default();
1052        let _ = format!("{style:?}");
1053    }
1054}