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    pub fn with_style(mut self, style: VoiOverlayStyle) -> Self {
115        self.style = style;
116        self
117    }
118
119    fn build_lines(&self, line_width: usize) -> Vec<String> {
120        let mut lines = Vec::with_capacity(20);
121        let divider = "-".repeat(line_width);
122
123        let mut header = self.data.title.clone();
124        if let Some(tick) = self.data.tick {
125            header.push_str(&format!(" (tick {})", tick));
126        }
127        if let Some(source) = &self.data.source {
128            header.push_str(&format!(" [{source}]"));
129        }
130
131        lines.push(header);
132        lines.push(divider.clone());
133
134        if let Some(decision) = &self.data.decision {
135            let verdict = if decision.should_sample {
136                "SAMPLE"
137            } else {
138                "SKIP"
139            };
140            lines.push(format!(
141                "Decision: {:<6}  reason: {}",
142                verdict, decision.reason
143            ));
144            lines.push(format!(
145                "log10 BF: {:+.3}  score/cost",
146                decision.log_bayes_factor
147            ));
148            lines.push(format!(
149                "E: {:.3} / {:.2}  boundary: {:.3}",
150                decision.e_value, decision.e_threshold, decision.boundary_score
151            ));
152        } else {
153            lines.push("Decision: —".to_string());
154        }
155
156        lines.push(String::new());
157        lines.push("Posterior Core".to_string());
158        lines.push(divider.clone());
159        lines.push(format!(
160            "p ~ Beta(a,b)  a={:.2}  b={:.2}",
161            self.data.posterior.alpha, self.data.posterior.beta
162        ));
163        lines.push(format!(
164            "mu={:.4}  Var={:.6}",
165            self.data.posterior.mean, self.data.posterior.variance
166        ));
167        lines.push("VOI = Var[p] - E[Var|1]".to_string());
168        lines.push(format!(
169            "VOI = {:.6} - {:.6} = {:.6}",
170            self.data.posterior.variance,
171            self.data.posterior.expected_variance_after,
172            self.data.posterior.voi_gain
173        ));
174
175        if let Some(decision) = &self.data.decision {
176            lines.push(String::new());
177            lines.push("Decision Equation".to_string());
178            lines.push(divider.clone());
179            lines.push(format!(
180                "score={:.6}  cost={:.6}",
181                decision.score, decision.cost
182            ));
183            lines.push(format!(
184                "log10 BF = log10({:.6}/{:.6}) = {:+.3}",
185                decision.score, decision.cost, decision.log_bayes_factor
186            ));
187        }
188
189        if let Some(obs) = &self.data.observation {
190            lines.push(String::new());
191            lines.push("Last Sample".to_string());
192            lines.push(divider.clone());
193            lines.push(format!(
194                "violated: {}  a={:.1}  b={:.1}  mu={:.3}",
195                obs.violated, obs.alpha, obs.beta, obs.posterior_mean
196            ));
197        }
198
199        if !self.data.ledger.is_empty() {
200            lines.push(String::new());
201            lines.push("Evidence Ledger (Recent)".to_string());
202            lines.push(divider);
203            for entry in &self.data.ledger {
204                match entry {
205                    VoiLedgerEntry::Decision {
206                        event_idx,
207                        should_sample,
208                        voi_gain,
209                        log_bayes_factor,
210                    } => {
211                        let verdict = if *should_sample { "S" } else { "-" };
212                        lines.push(format!(
213                            "D#{:>3} {verdict} VOI={:.5} logBF={:+.2}",
214                            event_idx, voi_gain, log_bayes_factor
215                        ));
216                    }
217                    VoiLedgerEntry::Observation {
218                        sample_idx,
219                        violated,
220                        posterior_mean,
221                    } => {
222                        lines.push(format!(
223                            "O#{:>3} viol={} mu={:.3}",
224                            sample_idx, violated, posterior_mean
225                        ));
226                    }
227                }
228            }
229        }
230
231        lines
232    }
233}
234
235impl Widget for VoiDebugOverlay {
236    fn render(&self, area: Rect, frame: &mut Frame) {
237        if area.is_empty() || area.width < 20 || area.height < 6 {
238            return;
239        }
240
241        if let Some(bg) = self.style.background {
242            let cell = Cell::default().with_bg(bg);
243            frame.buffer.fill(area, cell);
244        }
245
246        let block = Block::new()
247            .borders(Borders::ALL)
248            .border_type(self.style.border_type)
249            .border_style(self.style.border)
250            .title(&self.data.title)
251            .title_alignment(Alignment::Center)
252            .style(self.style.text);
253
254        let inner = block.inner(area);
255        block.render(area, frame);
256
257        if inner.is_empty() {
258            return;
259        }
260
261        let line_width = inner.width.saturating_sub(2) as usize;
262        let lines = self.build_lines(line_width.max(1));
263        let text = lines.join("\n");
264        Paragraph::new(text)
265            .style(self.style.text)
266            .render(inner, frame);
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use ftui_render::grapheme_pool::GraphemePool;
274
275    fn sample_posterior() -> VoiPosteriorSummary {
276        VoiPosteriorSummary {
277            alpha: 3.2,
278            beta: 7.4,
279            mean: 0.301,
280            variance: 0.0123,
281            expected_variance_after: 0.0101,
282            voi_gain: 0.0022,
283        }
284    }
285
286    fn sample_data() -> VoiOverlayData {
287        VoiOverlayData {
288            title: "VOI Overlay".to_string(),
289            tick: Some(42),
290            source: Some("budget".to_string()),
291            posterior: sample_posterior(),
292            decision: Some(VoiDecisionSummary {
293                event_idx: 7,
294                should_sample: true,
295                reason: "voi_gain > cost".to_string(),
296                score: 0.123456,
297                cost: 0.045,
298                log_bayes_factor: 0.437,
299                e_value: 1.23,
300                e_threshold: 0.95,
301                boundary_score: 0.77,
302            }),
303            observation: Some(VoiObservationSummary {
304                sample_idx: 4,
305                violated: false,
306                posterior_mean: 0.312,
307                alpha: 3.9,
308                beta: 8.2,
309            }),
310            ledger: vec![
311                VoiLedgerEntry::Decision {
312                    event_idx: 5,
313                    should_sample: true,
314                    voi_gain: 0.0042,
315                    log_bayes_factor: 0.31,
316                },
317                VoiLedgerEntry::Observation {
318                    sample_idx: 3,
319                    violated: true,
320                    posterior_mean: 0.4,
321                },
322            ],
323        }
324    }
325
326    #[test]
327    fn build_lines_without_decision_or_ledger() {
328        let data = VoiOverlayData {
329            title: "VOI".to_string(),
330            tick: None,
331            source: None,
332            posterior: sample_posterior(),
333            decision: None,
334            observation: None,
335            ledger: Vec::new(),
336        };
337        let overlay = VoiDebugOverlay::new(data);
338        let lines = overlay.build_lines(24);
339
340        assert!(lines[0].contains("VOI"), "header missing title: {lines:?}");
341        assert_eq!(lines[1].len(), 24, "divider width mismatch: {lines:?}");
342        assert!(
343            lines.iter().any(|line| line.contains("Decision: —")),
344            "missing default decision line: {lines:?}"
345        );
346        assert!(
347            lines.iter().any(|line| line.contains("Posterior Core")),
348            "missing posterior section: {lines:?}"
349        );
350        assert!(
351            !lines.iter().any(|line| line.contains("Evidence Ledger")),
352            "unexpected ledger section: {lines:?}"
353        );
354    }
355
356    #[test]
357    fn build_lines_with_decision_and_observation() {
358        let overlay = VoiDebugOverlay::new(sample_data());
359        let lines = overlay.build_lines(30);
360
361        assert!(
362            lines.iter().any(|line| line.contains("Decision: SAMPLE")),
363            "missing decision summary: {lines:?}"
364        );
365        assert!(
366            lines.iter().any(|line| line.contains("Last Sample")),
367            "missing observation summary: {lines:?}"
368        );
369        assert!(
370            lines.iter().any(|line| line.contains("Evidence Ledger")),
371            "missing ledger header: {lines:?}"
372        );
373        assert!(
374            lines.iter().any(|line| line.contains("D#  5")),
375            "missing decision ledger entry: {lines:?}"
376        );
377        assert!(
378            lines.iter().any(|line| line.contains("O#  3")),
379            "missing observation ledger entry: {lines:?}"
380        );
381    }
382
383    #[test]
384    fn render_applies_background_and_border() {
385        let bg = PackedRgba::rgb(12, 34, 56);
386        let style = VoiOverlayStyle {
387            background: Some(bg),
388            ..VoiOverlayStyle::default()
389        };
390        let overlay = VoiDebugOverlay::new(sample_data()).with_style(style);
391
392        let mut pool = GraphemePool::new();
393        let mut frame = Frame::new(80, 32, &mut pool);
394        let area = Rect::new(0, 0, 80, 32);
395
396        overlay.render(area, &mut frame);
397
398        let top_left = frame.buffer.get(0, 0).unwrap();
399        assert_eq!(
400            top_left.content.as_char(),
401            Some('╭'),
402            "border not rendered as rounded: cell={top_left:?}"
403        );
404
405        let inner = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2);
406        let lines = overlay.build_lines(inner.width.saturating_sub(2) as usize);
407        let extra_row = inner.y + (lines.len() as u16).saturating_add(1);
408        let bg_cell = frame.buffer.get(inner.x + 1, extra_row).unwrap();
409        assert_eq!(
410            bg_cell.bg,
411            bg,
412            "background not applied at ({}, {}): cell={bg_cell:?}",
413            inner.x + 1,
414            extra_row
415        );
416    }
417
418    #[test]
419    fn render_small_area_noop() {
420        let overlay = VoiDebugOverlay::new(sample_data());
421        let mut pool = GraphemePool::new();
422        let mut frame = Frame::new(10, 4, &mut pool);
423        let before = frame.buffer.get(0, 0).copied();
424
425        overlay.render(Rect::new(0, 0, 10, 4), &mut frame);
426
427        let after = frame.buffer.get(0, 0).copied();
428        assert_eq!(
429            before, after,
430            "small-area render should be no-op: before={before:?} after={after:?}"
431        );
432    }
433}