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::block::{Alignment, Block};
6use crate::borders::{BorderType, Borders};
7use crate::paragraph::Paragraph;
8use crate::{Widget, clear_text_area};
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() {
239            return;
240        }
241
242        if area.width < 20 || area.height < 6 {
243            clear_text_area(frame, area, Style::default());
244            return;
245        }
246
247        let deg = frame.buffer.degradation;
248        if !deg.render_content() {
249            clear_text_area(frame, area, Style::default());
250            return;
251        }
252
253        if deg.apply_styling()
254            && let Some(bg) = self.style.background
255        {
256            let cell = Cell::default().with_bg(bg);
257            frame.buffer.fill(area, cell);
258        }
259
260        let block = Block::new()
261            .borders(Borders::ALL)
262            .border_type(self.style.border_type)
263            .border_style(self.style.border)
264            .title(&self.data.title)
265            .title_alignment(Alignment::Center)
266            .style(self.style.text);
267
268        let inner = block.inner(area);
269        block.render(area, frame);
270
271        if inner.is_empty() {
272            return;
273        }
274
275        let line_width = inner.width.saturating_sub(2) as usize;
276        let lines = self.build_lines(line_width.max(1));
277        let text = lines.join("\n");
278        Paragraph::new(text)
279            .style(self.style.text)
280            .render(inner, frame);
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use ftui_render::budget::DegradationLevel;
288    use ftui_render::grapheme_pool::GraphemePool;
289
290    fn sample_posterior() -> VoiPosteriorSummary {
291        VoiPosteriorSummary {
292            alpha: 3.2,
293            beta: 7.4,
294            mean: 0.301,
295            variance: 0.0123,
296            expected_variance_after: 0.0101,
297            voi_gain: 0.0022,
298        }
299    }
300
301    fn sample_data() -> VoiOverlayData {
302        VoiOverlayData {
303            title: "VOI Overlay".to_string(),
304            tick: Some(42),
305            source: Some("budget".to_string()),
306            posterior: sample_posterior(),
307            decision: Some(VoiDecisionSummary {
308                event_idx: 7,
309                should_sample: true,
310                reason: "voi_gain > cost".to_string(),
311                score: 0.123456,
312                cost: 0.045,
313                log_bayes_factor: 0.437,
314                e_value: 1.23,
315                e_threshold: 0.95,
316                boundary_score: 0.77,
317            }),
318            observation: Some(VoiObservationSummary {
319                sample_idx: 4,
320                violated: false,
321                posterior_mean: 0.312,
322                alpha: 3.9,
323                beta: 8.2,
324            }),
325            ledger: vec![
326                VoiLedgerEntry::Decision {
327                    event_idx: 5,
328                    should_sample: true,
329                    voi_gain: 0.0042,
330                    log_bayes_factor: 0.31,
331                },
332                VoiLedgerEntry::Observation {
333                    sample_idx: 3,
334                    violated: true,
335                    posterior_mean: 0.4,
336                },
337            ],
338        }
339    }
340
341    #[test]
342    fn build_lines_without_decision_or_ledger() {
343        let data = VoiOverlayData {
344            title: "VOI".to_string(),
345            tick: None,
346            source: None,
347            posterior: sample_posterior(),
348            decision: None,
349            observation: None,
350            ledger: Vec::new(),
351        };
352        let overlay = VoiDebugOverlay::new(data);
353        let lines = overlay.build_lines(24);
354
355        assert!(lines[0].contains("VOI"), "header missing title: {lines:?}");
356        assert_eq!(lines[1].len(), 24, "divider width mismatch: {lines:?}");
357        assert!(
358            lines.iter().any(|line| line.contains("Decision: —")),
359            "missing default decision line: {lines:?}"
360        );
361        assert!(
362            lines.iter().any(|line| line.contains("Posterior Core")),
363            "missing posterior section: {lines:?}"
364        );
365        assert!(
366            !lines.iter().any(|line| line.contains("Evidence Ledger")),
367            "unexpected ledger section: {lines:?}"
368        );
369    }
370
371    #[test]
372    fn build_lines_with_decision_and_observation() {
373        let overlay = VoiDebugOverlay::new(sample_data());
374        let lines = overlay.build_lines(30);
375
376        assert!(
377            lines.iter().any(|line| line.contains("Decision: SAMPLE")),
378            "missing decision summary: {lines:?}"
379        );
380        assert!(
381            lines.iter().any(|line| line.contains("Last Sample")),
382            "missing observation summary: {lines:?}"
383        );
384        assert!(
385            lines.iter().any(|line| line.contains("Evidence Ledger")),
386            "missing ledger header: {lines:?}"
387        );
388        assert!(
389            lines.iter().any(|line| line.contains("D#  5")),
390            "missing decision ledger entry: {lines:?}"
391        );
392        assert!(
393            lines.iter().any(|line| line.contains("O#  3")),
394            "missing observation ledger entry: {lines:?}"
395        );
396    }
397
398    #[test]
399    fn render_applies_background_and_border() {
400        let bg = PackedRgba::rgb(12, 34, 56);
401        let style = VoiOverlayStyle {
402            background: Some(bg),
403            ..VoiOverlayStyle::default()
404        };
405        let overlay = VoiDebugOverlay::new(sample_data()).with_style(style);
406
407        let mut pool = GraphemePool::new();
408        let mut frame = Frame::new(80, 32, &mut pool);
409        let area = Rect::new(0, 0, 80, 32);
410
411        overlay.render(area, &mut frame);
412
413        let top_left = frame.buffer.get(0, 0).unwrap();
414        assert_eq!(
415            top_left.content.as_char(),
416            Some('╭'),
417            "border not rendered as rounded: cell={top_left:?}"
418        );
419
420        let inner = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2);
421        let lines = overlay.build_lines(inner.width.saturating_sub(2) as usize);
422        let extra_row = inner.y + (lines.len() as u16).saturating_add(1);
423        let bg_cell = frame.buffer.get(inner.x + 1, extra_row).unwrap();
424        assert_eq!(
425            bg_cell.bg,
426            bg,
427            "background not applied at ({}, {}): cell={bg_cell:?}",
428            inner.x + 1,
429            extra_row
430        );
431    }
432
433    #[test]
434    fn render_small_area_clears_previous_content() {
435        let overlay = VoiDebugOverlay::new(sample_data());
436        let mut pool = GraphemePool::new();
437        let mut frame = Frame::new(10, 4, &mut pool);
438        let sentinel = Cell::from_char('X').with_bg(PackedRgba::rgb(1, 2, 3));
439        frame.buffer.fill(Rect::new(0, 0, 10, 4), sentinel);
440
441        overlay.render(Rect::new(0, 0, 10, 4), &mut frame);
442
443        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
444        assert_eq!(frame.buffer.get(9, 3).unwrap().content.as_char(), Some(' '));
445    }
446
447    #[test]
448    fn render_no_styling_drops_background_fill() {
449        let bg = PackedRgba::rgb(12, 34, 56);
450        let style = VoiOverlayStyle {
451            background: Some(bg),
452            ..VoiOverlayStyle::default()
453        };
454        let overlay = VoiDebugOverlay::new(sample_data()).with_style(style);
455
456        let mut pool = GraphemePool::new();
457        let mut frame = Frame::new(80, 32, &mut pool);
458        frame.buffer.degradation = DegradationLevel::NoStyling;
459        let area = Rect::new(0, 0, 80, 32);
460
461        overlay.render(area, &mut frame);
462
463        let bg_cell = frame.buffer.get(2, 2).unwrap();
464        let default_cell = Cell::default();
465        assert_eq!(bg_cell.bg, default_cell.bg);
466    }
467
468    #[test]
469    fn render_skeleton_clears_previous_overlay() {
470        let overlay = VoiDebugOverlay::new(sample_data());
471
472        let mut pool = GraphemePool::new();
473        let mut frame = Frame::new(80, 32, &mut pool);
474        overlay.render(Rect::new(0, 0, 80, 32), &mut frame);
475        frame.buffer.degradation = DegradationLevel::Skeleton;
476        let area = Rect::new(0, 0, 80, 32);
477
478        overlay.render(area, &mut frame);
479
480        let default_cell = Cell::default();
481        let corner = frame.buffer.get(0, 0).unwrap();
482        let inner = frame.buffer.get(10, 10).unwrap();
483        assert_eq!(corner.content.as_char(), Some(' '));
484        assert_eq!(corner.fg, default_cell.fg);
485        assert_eq!(corner.bg, default_cell.bg);
486        assert_eq!(inner.content.as_char(), Some(' '));
487        assert_eq!(inner.fg, default_cell.fg);
488        assert_eq!(inner.bg, default_cell.bg);
489    }
490
491    // --- Style defaults ---
492
493    #[test]
494    fn overlay_style_default() {
495        let style = VoiOverlayStyle::default();
496        assert!(style.background.is_none());
497        assert!(matches!(style.border_type, BorderType::Rounded));
498    }
499
500    // --- Header formatting ---
501
502    #[test]
503    fn build_lines_header_with_tick_and_source() {
504        let data = VoiOverlayData {
505            title: "Test".to_string(),
506            tick: Some(100),
507            source: Some("resize".to_string()),
508            posterior: sample_posterior(),
509            decision: None,
510            observation: None,
511            ledger: Vec::new(),
512        };
513        let overlay = VoiDebugOverlay::new(data);
514        let lines = overlay.build_lines(40);
515        assert!(lines[0].contains("Test (tick 100) [resize]"));
516    }
517
518    #[test]
519    fn build_lines_header_no_tick_no_source() {
520        let data = VoiOverlayData {
521            title: "Plain".to_string(),
522            tick: None,
523            source: None,
524            posterior: sample_posterior(),
525            decision: None,
526            observation: None,
527            ledger: Vec::new(),
528        };
529        let overlay = VoiDebugOverlay::new(data);
530        let lines = overlay.build_lines(20);
531        assert_eq!(lines[0], "Plain");
532    }
533
534    // --- Decision verdict ---
535
536    #[test]
537    fn build_lines_skip_verdict() {
538        let data = VoiOverlayData {
539            title: "Test".to_string(),
540            tick: None,
541            source: None,
542            posterior: sample_posterior(),
543            decision: Some(VoiDecisionSummary {
544                event_idx: 1,
545                should_sample: false,
546                reason: "cost_too_high".to_string(),
547                score: 0.01,
548                cost: 0.1,
549                log_bayes_factor: -1.0,
550                e_value: 0.5,
551                e_threshold: 0.95,
552                boundary_score: 0.2,
553            }),
554            observation: None,
555            ledger: Vec::new(),
556        };
557        let overlay = VoiDebugOverlay::new(data);
558        let lines = overlay.build_lines(40);
559        assert!(
560            lines.iter().any(|l| l.contains("Decision: SKIP")),
561            "expected SKIP verdict: {lines:?}"
562        );
563    }
564
565    // --- Observation only (no decision) ---
566
567    #[test]
568    fn build_lines_observation_only() {
569        let data = VoiOverlayData {
570            title: "T".to_string(),
571            tick: None,
572            source: None,
573            posterior: sample_posterior(),
574            decision: None,
575            observation: Some(VoiObservationSummary {
576                sample_idx: 10,
577                violated: true,
578                posterior_mean: 0.456,
579                alpha: 5.0,
580                beta: 10.0,
581            }),
582            ledger: Vec::new(),
583        };
584        let overlay = VoiDebugOverlay::new(data);
585        let lines = overlay.build_lines(40);
586        assert!(
587            lines.iter().any(|l| l.contains("violated: true")),
588            "missing violated observation: {lines:?}"
589        );
590        assert!(
591            lines.iter().any(|l| l.contains("mu=0.456")),
592            "missing posterior mean: {lines:?}"
593        );
594    }
595
596    // --- Ledger formatting ---
597
598    #[test]
599    fn build_lines_ledger_skip_entry() {
600        let data = VoiOverlayData {
601            title: "T".to_string(),
602            tick: None,
603            source: None,
604            posterior: sample_posterior(),
605            decision: None,
606            observation: None,
607            ledger: vec![VoiLedgerEntry::Decision {
608                event_idx: 99,
609                should_sample: false,
610                voi_gain: 0.001,
611                log_bayes_factor: -0.5,
612            }],
613        };
614        let overlay = VoiDebugOverlay::new(data);
615        let lines = overlay.build_lines(40);
616        assert!(
617            lines.iter().any(|l| l.contains("D# 99 -")),
618            "expected skip marker: {lines:?}"
619        );
620    }
621
622    // --- Posterior formatting ---
623
624    #[test]
625    fn build_lines_posterior_values() {
626        let data = VoiOverlayData {
627            title: "T".to_string(),
628            tick: None,
629            source: None,
630            posterior: VoiPosteriorSummary {
631                alpha: 1.0,
632                beta: 1.0,
633                mean: 0.5,
634                variance: 0.0833,
635                expected_variance_after: 0.0500,
636                voi_gain: 0.0333,
637            },
638            decision: None,
639            observation: None,
640            ledger: Vec::new(),
641        };
642        let overlay = VoiDebugOverlay::new(data);
643        let lines = overlay.build_lines(40);
644        assert!(
645            lines
646                .iter()
647                .any(|l| l.contains("a=1.00") && l.contains("b=1.00")),
648            "missing alpha/beta: {lines:?}"
649        );
650        assert!(
651            lines.iter().any(|l| l.contains("mu=0.5000")),
652            "missing mean: {lines:?}"
653        );
654    }
655
656    // --- with_style builder ---
657
658    #[test]
659    fn with_style_replaces_style() {
660        let overlay = VoiDebugOverlay::new(sample_data());
661        let custom = VoiOverlayStyle {
662            background: Some(PackedRgba::rgb(255, 0, 0)),
663            border_type: BorderType::Square,
664            ..VoiOverlayStyle::default()
665        };
666        let styled = overlay.with_style(custom);
667        assert_eq!(styled.style.background, Some(PackedRgba::rgb(255, 0, 0)));
668    }
669
670    #[test]
671    fn render_empty_area_is_noop() {
672        let overlay = VoiDebugOverlay::new(sample_data());
673        let mut pool = GraphemePool::new();
674        let mut frame = Frame::new(40, 10, &mut pool);
675
676        // Zero-width area
677        overlay.render(Rect::new(0, 0, 0, 10), &mut frame);
678        // Zero-height area
679        overlay.render(Rect::new(0, 0, 40, 0), &mut frame);
680        // Both zero — should not panic
681    }
682
683    #[test]
684    fn render_narrow_area_where_inner_is_empty() {
685        let overlay = VoiDebugOverlay::new(sample_data());
686        let mut pool = GraphemePool::new();
687        let mut frame = Frame::new(80, 40, &mut pool);
688        // Area has borders consuming all space (width=20 passes threshold, height=6 passes)
689        // Inner is 18x4 which is non-empty, so use exactly at threshold
690        overlay.render(Rect::new(0, 0, 20, 6), &mut frame);
691        // Should render without panic
692    }
693
694    #[test]
695    fn build_lines_ledger_observation_entry() {
696        let data = VoiOverlayData {
697            title: "T".to_string(),
698            tick: None,
699            source: None,
700            posterior: sample_posterior(),
701            decision: None,
702            observation: None,
703            ledger: vec![VoiLedgerEntry::Observation {
704                sample_idx: 42,
705                violated: false,
706                posterior_mean: 0.789,
707            }],
708        };
709        let overlay = VoiDebugOverlay::new(data);
710        let lines = overlay.build_lines(40);
711        assert!(
712            lines.iter().any(|l| l.contains("O# 42")),
713            "missing observation ledger entry: {lines:?}"
714        );
715        assert!(
716            lines.iter().any(|l| l.contains("viol=false")),
717            "missing violated=false: {lines:?}"
718        );
719        assert!(
720            lines.iter().any(|l| l.contains("mu=0.789")),
721            "missing posterior mean in ledger: {lines:?}"
722        );
723    }
724
725    #[test]
726    fn build_lines_decision_equation_section() {
727        let overlay = VoiDebugOverlay::new(sample_data());
728        let lines = overlay.build_lines(50);
729        assert!(
730            lines.iter().any(|l| l.contains("Decision Equation")),
731            "missing decision equation header: {lines:?}"
732        );
733        assert!(
734            lines
735                .iter()
736                .any(|l| l.contains("score=") && l.contains("cost=")),
737            "missing score/cost line: {lines:?}"
738        );
739    }
740
741    #[test]
742    fn build_lines_voi_equation_format() {
743        let data = VoiOverlayData {
744            title: "T".to_string(),
745            tick: None,
746            source: None,
747            posterior: VoiPosteriorSummary {
748                alpha: 2.0,
749                beta: 3.0,
750                mean: 0.4,
751                variance: 0.04,
752                expected_variance_after: 0.03,
753                voi_gain: 0.01,
754            },
755            decision: None,
756            observation: None,
757            ledger: Vec::new(),
758        };
759        let overlay = VoiDebugOverlay::new(data);
760        let lines = overlay.build_lines(50);
761        // VOI = Var[p] - E[Var|1] header
762        assert!(
763            lines.iter().any(|l| l.contains("VOI = Var[p] - E[Var|1]")),
764            "missing VOI equation label: {lines:?}"
765        );
766        // VOI = 0.040000 - 0.030000 = 0.010000
767        assert!(
768            lines.iter().any(|l| l.contains("0.040000")
769                && l.contains("0.030000")
770                && l.contains("0.010000")),
771            "missing VOI computation line: {lines:?}"
772        );
773    }
774
775    #[test]
776    fn overlay_data_clone() {
777        let data = sample_data();
778        let cloned = data.clone();
779        assert_eq!(cloned.title, data.title);
780        assert_eq!(cloned.tick, data.tick);
781        assert_eq!(cloned.ledger.len(), data.ledger.len());
782    }
783
784    // ─── Edge-case tests (bd-3szd1) ────────────────────────────────────
785
786    #[test]
787    fn build_lines_width_zero() {
788        let overlay = VoiDebugOverlay::new(sample_data());
789        let lines = overlay.build_lines(0);
790        // Should not panic; divider is empty string
791        assert!(!lines.is_empty());
792    }
793
794    #[test]
795    fn build_lines_width_one() {
796        let overlay = VoiDebugOverlay::new(sample_data());
797        let lines = overlay.build_lines(1);
798        assert_eq!(lines[1], "-", "divider should be single dash");
799    }
800
801    #[test]
802    fn build_lines_empty_title() {
803        let data = VoiOverlayData {
804            title: String::new(),
805            tick: None,
806            source: None,
807            posterior: sample_posterior(),
808            decision: None,
809            observation: None,
810            ledger: Vec::new(),
811        };
812        let overlay = VoiDebugOverlay::new(data);
813        let lines = overlay.build_lines(20);
814        assert_eq!(lines[0], "", "empty title should produce empty header");
815    }
816
817    #[test]
818    fn build_lines_tick_only_no_source() {
819        let data = VoiOverlayData {
820            title: "T".to_string(),
821            tick: Some(0),
822            source: None,
823            posterior: sample_posterior(),
824            decision: None,
825            observation: None,
826            ledger: Vec::new(),
827        };
828        let overlay = VoiDebugOverlay::new(data);
829        let lines = overlay.build_lines(30);
830        assert!(lines[0].contains("(tick 0)"));
831        assert!(!lines[0].contains('['));
832    }
833
834    #[test]
835    fn build_lines_source_only_no_tick() {
836        let data = VoiOverlayData {
837            title: "T".to_string(),
838            tick: None,
839            source: Some("src".to_string()),
840            posterior: sample_posterior(),
841            decision: None,
842            observation: None,
843            ledger: Vec::new(),
844        };
845        let overlay = VoiDebugOverlay::new(data);
846        let lines = overlay.build_lines(30);
847        assert!(lines[0].contains("[src]"));
848        assert!(!lines[0].contains("tick"));
849    }
850
851    #[test]
852    fn render_width_below_threshold() {
853        let overlay = VoiDebugOverlay::new(sample_data());
854        let mut pool = GraphemePool::new();
855        let mut frame = Frame::new(80, 40, &mut pool);
856        // width=19 is below the 20 threshold
857        overlay.render(Rect::new(0, 0, 19, 10), &mut frame);
858        // Should be noop — verify no border rendered
859        let cell = frame.buffer.get(0, 0).unwrap();
860        assert_ne!(
861            cell.content.as_char(),
862            Some('╭'),
863            "should not render border at width=19"
864        );
865    }
866
867    #[test]
868    fn render_height_below_threshold() {
869        let overlay = VoiDebugOverlay::new(sample_data());
870        let mut pool = GraphemePool::new();
871        let mut frame = Frame::new(80, 40, &mut pool);
872        // height=5 is below the 6 threshold
873        overlay.render(Rect::new(0, 0, 40, 5), &mut frame);
874        let cell = frame.buffer.get(0, 0).unwrap();
875        assert_ne!(
876            cell.content.as_char(),
877            Some('╭'),
878            "should not render border at height=5"
879        );
880    }
881
882    #[test]
883    fn render_exact_minimum_size() {
884        let overlay = VoiDebugOverlay::new(sample_data());
885        let mut pool = GraphemePool::new();
886        let mut frame = Frame::new(80, 40, &mut pool);
887        // Exactly at threshold: width=20, height=6
888        overlay.render(Rect::new(0, 0, 20, 6), &mut frame);
889        let cell = frame.buffer.get(0, 0).unwrap();
890        assert_eq!(
891            cell.content.as_char(),
892            Some('╭'),
893            "should render border at exact minimum size"
894        );
895    }
896
897    #[test]
898    fn posterior_with_nan_values() {
899        let data = VoiOverlayData {
900            title: "T".to_string(),
901            tick: None,
902            source: None,
903            posterior: VoiPosteriorSummary {
904                alpha: f64::NAN,
905                beta: f64::INFINITY,
906                mean: f64::NEG_INFINITY,
907                variance: 0.0,
908                expected_variance_after: 0.0,
909                voi_gain: -0.0,
910            },
911            decision: None,
912            observation: None,
913            ledger: Vec::new(),
914        };
915        let overlay = VoiDebugOverlay::new(data);
916        let lines = overlay.build_lines(50);
917        // Should format without panic
918        assert!(
919            lines.iter().any(|l| l.contains("NaN") || l.contains("nan")),
920            "NaN alpha should appear in output: {lines:?}"
921        );
922    }
923
924    #[test]
925    fn large_event_idx_in_ledger() {
926        let data = VoiOverlayData {
927            title: "T".to_string(),
928            tick: None,
929            source: None,
930            posterior: sample_posterior(),
931            decision: None,
932            observation: None,
933            ledger: vec![VoiLedgerEntry::Decision {
934                event_idx: u64::MAX,
935                should_sample: true,
936                voi_gain: 0.0,
937                log_bayes_factor: 0.0,
938            }],
939        };
940        let overlay = VoiDebugOverlay::new(data);
941        let lines = overlay.build_lines(80);
942        assert!(
943            lines.iter().any(|l| l.contains(&u64::MAX.to_string())),
944            "large event_idx should appear: {lines:?}"
945        );
946    }
947
948    #[test]
949    fn multiple_ledger_entries_same_type() {
950        let data = VoiOverlayData {
951            title: "T".to_string(),
952            tick: None,
953            source: None,
954            posterior: sample_posterior(),
955            decision: None,
956            observation: None,
957            ledger: vec![
958                VoiLedgerEntry::Decision {
959                    event_idx: 1,
960                    should_sample: true,
961                    voi_gain: 0.01,
962                    log_bayes_factor: 0.5,
963                },
964                VoiLedgerEntry::Decision {
965                    event_idx: 2,
966                    should_sample: false,
967                    voi_gain: 0.001,
968                    log_bayes_factor: -0.3,
969                },
970                VoiLedgerEntry::Decision {
971                    event_idx: 3,
972                    should_sample: true,
973                    voi_gain: 0.02,
974                    log_bayes_factor: 1.0,
975                },
976            ],
977        };
978        let overlay = VoiDebugOverlay::new(data);
979        let lines = overlay.build_lines(50);
980        let decision_lines: Vec<_> = lines.iter().filter(|l| l.starts_with("D#")).collect();
981        assert_eq!(decision_lines.len(), 3, "expected 3 decision entries");
982    }
983
984    #[test]
985    fn negative_log_bayes_factor_format() {
986        let data = VoiOverlayData {
987            title: "T".to_string(),
988            tick: None,
989            source: None,
990            posterior: sample_posterior(),
991            decision: Some(VoiDecisionSummary {
992                event_idx: 1,
993                should_sample: false,
994                reason: "negative".to_string(),
995                score: 0.001,
996                cost: 0.1,
997                log_bayes_factor: -2.345,
998                e_value: 0.1,
999                e_threshold: 0.95,
1000                boundary_score: 0.05,
1001            }),
1002            observation: None,
1003            ledger: Vec::new(),
1004        };
1005        let overlay = VoiDebugOverlay::new(data);
1006        let lines = overlay.build_lines(50);
1007        assert!(
1008            lines.iter().any(|l| l.contains("-2.345")),
1009            "negative log BF should appear: {lines:?}"
1010        );
1011    }
1012
1013    #[test]
1014    fn voi_ledger_entry_clone() {
1015        let entry = VoiLedgerEntry::Decision {
1016            event_idx: 5,
1017            should_sample: true,
1018            voi_gain: 0.01,
1019            log_bayes_factor: 0.5,
1020        };
1021        let cloned = entry.clone();
1022        assert!(format!("{cloned:?}").contains("Decision"));
1023    }
1024
1025    #[test]
1026    fn voi_decision_summary_clone() {
1027        let d = VoiDecisionSummary {
1028            event_idx: 1,
1029            should_sample: true,
1030            reason: "test".to_string(),
1031            score: 1.0,
1032            cost: 0.5,
1033            log_bayes_factor: 0.3,
1034            e_value: 1.0,
1035            e_threshold: 0.95,
1036            boundary_score: 0.5,
1037        };
1038        let cloned = d.clone();
1039        assert_eq!(cloned.reason, "test");
1040        assert_eq!(cloned.event_idx, 1);
1041    }
1042
1043    #[test]
1044    fn voi_observation_summary_clone() {
1045        let o = VoiObservationSummary {
1046            sample_idx: 42,
1047            violated: true,
1048            posterior_mean: 0.5,
1049            alpha: 3.0,
1050            beta: 7.0,
1051        };
1052        let cloned = o.clone();
1053        assert!(cloned.violated);
1054        assert_eq!(cloned.sample_idx, 42);
1055    }
1056
1057    #[test]
1058    fn with_style_custom_border_type() {
1059        let overlay = VoiDebugOverlay::new(sample_data()).with_style(VoiOverlayStyle {
1060            border_type: BorderType::Double,
1061            ..VoiOverlayStyle::default()
1062        });
1063        assert!(matches!(overlay.style.border_type, BorderType::Double));
1064    }
1065
1066    #[test]
1067    fn render_no_background() {
1068        let data = sample_data();
1069        let overlay = VoiDebugOverlay::new(data);
1070        let mut pool = GraphemePool::new();
1071        let mut frame = Frame::new(80, 32, &mut pool);
1072        // Default style has no background
1073        overlay.render(Rect::new(0, 0, 80, 32), &mut frame);
1074        // Should render border without panic
1075        let cell = frame.buffer.get(0, 0).unwrap();
1076        assert_eq!(cell.content.as_char(), Some('╭'));
1077    }
1078
1079    #[test]
1080    fn build_lines_divider_matches_width() {
1081        let overlay = VoiDebugOverlay::new(sample_data());
1082        let width = 37;
1083        let lines = overlay.build_lines(width);
1084        // line[1] is the first divider
1085        assert_eq!(
1086            lines[1].len(),
1087            width,
1088            "divider should match requested width"
1089        );
1090    }
1091
1092    // ─── End edge-case tests (bd-3szd1) ──────────────────────────────
1093
1094    // --- Struct Debug impls ---
1095
1096    #[test]
1097    fn structs_implement_debug() {
1098        let posterior = sample_posterior();
1099        let _ = format!("{posterior:?}");
1100
1101        let data = sample_data();
1102        let _ = format!("{data:?}");
1103
1104        let overlay = VoiDebugOverlay::new(data);
1105        let _ = format!("{overlay:?}");
1106
1107        let style = VoiOverlayStyle::default();
1108        let _ = format!("{style:?}");
1109    }
1110}