use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use crate::theme::Theme;
pub const MIN_SAMPLES: usize = 30;
pub const BAR_CELLS: usize = 20;
#[derive(Debug, Clone, Copy, Default)]
pub struct CalibrationSample {
pub predicted: f64,
pub observed: f64,
pub n_samples: usize,
}
#[derive(Debug)]
pub struct CalibrationBar {
pub sample: Option<CalibrationSample>,
pub theme: Theme,
}
impl Widget for CalibrationBar {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let row = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
let Some(sample) = self.sample else {
Line::from(vec![
Span::styled(" calib ", Style::default().fg(self.theme.primary)),
Span::styled(
"(no calibration data yet — engine has not reported a sample)",
Style::default().fg(self.theme.metadata),
),
])
.render(row, buf);
return;
};
if sample.n_samples < MIN_SAMPLES {
Line::from(vec![
Span::styled(" calib ", Style::default().fg(self.theme.primary)),
Span::styled(
format!(
"(insufficient data — need ≥{MIN_SAMPLES} graded decisions, have {})",
sample.n_samples
),
Style::default().fg(self.theme.metadata),
),
])
.render(row, buf);
return;
}
let pred = clamp_unit(sample.predicted);
let obs = clamp_unit(sample.observed);
let gap = (pred - obs).abs();
let gap_style = if gap <= 0.05 {
Style::default().fg(self.theme.primary)
} else if gap <= 0.15 {
Style::default().fg(self.theme.caution)
} else {
Style::default()
.fg(self.theme.alert)
.add_modifier(Modifier::BOLD)
};
let mut spans = vec![Span::styled(
" calib ",
Style::default().fg(self.theme.primary),
)];
if usize::from(area.width) >= 30 {
let bar = render_bar(obs, pred);
spans.push(Span::styled("[", Style::default().fg(self.theme.metadata)));
spans.push(Span::styled(bar, gap_style));
spans.push(Span::styled("] ", Style::default().fg(self.theme.metadata)));
}
spans.push(Span::styled(
format!(" pred {}% / obs {}%", pct(pred), pct(obs)),
gap_style,
));
spans.push(Span::styled(
format!(" n={}", sample.n_samples),
Style::default().fg(self.theme.metadata),
));
Line::from(spans).render(row, buf);
}
}
fn clamp_unit(v: f64) -> f64 {
v.clamp(0.0, 1.0)
}
fn pct(v: f64) -> i32 {
#[allow(clippy::cast_possible_truncation)]
let p = (clamp_unit(v) * 100.0).round() as i32;
p
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
fn cells_for(rate: f64) -> usize {
let scaled = (rate * BAR_CELLS as f64).round();
let as_usize = if scaled.is_finite() && scaled >= 0.0 {
scaled as usize
} else {
0
};
as_usize.min(BAR_CELLS)
}
fn render_bar(observed: f64, predicted: f64) -> String {
let obs_cells = cells_for(observed);
let pred_cells = cells_for(predicted);
let mut s = String::with_capacity(BAR_CELLS);
for i in 0..BAR_CELLS {
let filled = i < obs_cells;
let is_pred_marker = pred_cells > 0 && i + 1 == pred_cells && pred_cells != obs_cells;
s.push(match (filled, is_pred_marker) {
(true, _) => '■',
(false, true) => '│',
(false, false) => '□',
});
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn render(sample: Option<CalibrationSample>, width: u16) -> String {
let backend = TestBackend::new(width, 1);
let mut term = Terminal::new(backend).expect("term");
term.draw(|f| {
let w = CalibrationBar {
sample,
theme: Theme::default(),
};
f.render_widget(w, f.area());
})
.expect("draw");
let buf = term.backend().buffer().clone();
(0..buf.area.width)
.map(|x| buf[(x, 0)].symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
}
#[test]
fn none_sample_renders_no_data_state() {
let line = render(None, 80);
assert!(line.contains("calib"));
assert!(line.contains("no calibration data"));
assert!(!line.contains("pred"), "must not show fake numbers");
assert!(!line.contains('■'), "must not draw fake bar");
}
#[test]
fn below_min_samples_renders_insufficient_data_state() {
let sample = CalibrationSample {
predicted: 0.7,
observed: 0.65,
n_samples: 12,
};
let line = render(Some(sample), 80);
assert!(line.contains("insufficient data"));
assert!(line.contains("have 12"));
assert!(!line.contains('■'), "bar must not render below MIN_SAMPLES");
}
#[test]
fn above_min_samples_renders_bar_and_numbers() {
let sample = CalibrationSample {
predicted: 0.72,
observed: 0.68,
n_samples: 134,
};
let line = render(Some(sample), 80);
assert!(line.contains('■'), "expected filled bar cells: {line:?}");
assert!(line.contains("pred 72%"), "pred missing: {line:?}");
assert!(line.contains("obs 68%"), "obs missing: {line:?}");
assert!(line.contains("n=134"), "n missing: {line:?}");
}
#[test]
fn narrow_width_drops_bar_but_keeps_numbers() {
let sample = CalibrationSample {
predicted: 0.5,
observed: 0.5,
n_samples: 100,
};
let line = render(Some(sample), 29);
assert!(
!line.contains('■'),
"bar should not render at width<30: {line:?}"
);
assert!(line.contains("pred 50%"), "pred still required: {line:?}");
}
#[test]
fn pct_clamps_out_of_range() {
assert_eq!(pct(-0.1), 0);
assert_eq!(pct(1.5), 100);
assert_eq!(pct(0.5), 50);
}
#[test]
fn bar_observed_cells_match_rounded_fraction() {
let s = render_bar(0.5, 0.5);
let filled = s.chars().filter(|c| *c == '■').count();
assert_eq!(filled, 10, "50% observed should fill 10/{BAR_CELLS} cells");
}
#[test]
fn bar_predicted_marker_visible_when_gap_nonzero() {
let s = render_bar(0.5, 0.8);
let filled = s.chars().filter(|c| *c == '■').count();
let markers = s.chars().filter(|c| *c == '│').count();
assert_eq!(filled, 10);
assert_eq!(markers, 1, "predicted marker must render when > observed");
}
}