1#![forbid(unsafe_code)]
2
3use 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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone)]
99pub struct VoiDebugOverlay {
100 data: VoiOverlayData,
101 style: VoiOverlayStyle,
102}
103
104impl VoiDebugOverlay {
105 pub fn new(data: VoiOverlayData) -> Self {
107 Self {
108 data,
109 style: VoiOverlayStyle::default(),
110 }
111 }
112
113 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 overlay.render(Rect::new(0, 0, 0, 10), &mut frame);
622 overlay.render(Rect::new(0, 0, 40, 0), &mut frame);
624 }
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 overlay.render(Rect::new(0, 0, 20, 6), &mut frame);
635 }
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 assert!(
707 lines.iter().any(|l| l.contains("VOI = Var[p] - E[Var|1]")),
708 "missing VOI equation label: {lines:?}"
709 );
710 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 #[test]
731 fn build_lines_width_zero() {
732 let overlay = VoiDebugOverlay::new(sample_data());
733 let lines = overlay.build_lines(0);
734 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 overlay.render(Rect::new(0, 0, 19, 10), &mut frame);
802 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 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 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 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 overlay.render(Rect::new(0, 0, 80, 32), &mut frame);
1018 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 assert_eq!(
1030 lines[1].len(),
1031 width,
1032 "divider should match requested width"
1033 );
1034 }
1035
1036 #[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}