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 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}