1use ratatui::buffer::Buffer;
48use ratatui::layout::Rect;
49use ratatui::style::{Modifier, Style};
50use ratatui::text::{Line, Span};
51use ratatui::widgets::Widget;
52
53use crate::theme::Theme;
54
55pub const MIN_SAMPLES: usize = 30;
58
59pub const BAR_CELLS: usize = 20;
62
63#[derive(Debug, Clone, Copy, Default)]
68pub struct CalibrationSample {
69 pub predicted: f64,
70 pub observed: f64,
71 pub n_samples: usize,
72}
73
74#[derive(Debug)]
75pub struct CalibrationBar {
76 pub sample: Option<CalibrationSample>,
77 pub theme: Theme,
78}
79
80impl Widget for CalibrationBar {
81 fn render(self, area: Rect, buf: &mut Buffer) {
82 if area.height == 0 || area.width == 0 {
83 return;
84 }
85 let row = Rect {
86 x: area.x,
87 y: area.y,
88 width: area.width,
89 height: 1,
90 };
91
92 let Some(sample) = self.sample else {
93 Line::from(vec![
94 Span::styled(" calib ", Style::default().fg(self.theme.primary)),
95 Span::styled(
96 "(no calibration data yet — engine has not reported a sample)",
97 Style::default().fg(self.theme.metadata),
98 ),
99 ])
100 .render(row, buf);
101 return;
102 };
103
104 if sample.n_samples < MIN_SAMPLES {
105 Line::from(vec![
106 Span::styled(" calib ", Style::default().fg(self.theme.primary)),
107 Span::styled(
108 format!(
109 "(insufficient data — need ≥{MIN_SAMPLES} graded decisions, have {})",
110 sample.n_samples
111 ),
112 Style::default().fg(self.theme.metadata),
113 ),
114 ])
115 .render(row, buf);
116 return;
117 }
118
119 let pred = clamp_unit(sample.predicted);
120 let obs = clamp_unit(sample.observed);
121 let gap = (pred - obs).abs();
122
123 let gap_style = if gap <= 0.05 {
124 Style::default().fg(self.theme.primary)
125 } else if gap <= 0.15 {
126 Style::default().fg(self.theme.caution)
127 } else {
128 Style::default()
129 .fg(self.theme.alert)
130 .add_modifier(Modifier::BOLD)
131 };
132
133 let mut spans = vec![Span::styled(
134 " calib ",
135 Style::default().fg(self.theme.primary),
136 )];
137
138 if usize::from(area.width) >= 30 {
139 let bar = render_bar(obs, pred);
140 spans.push(Span::styled("[", Style::default().fg(self.theme.metadata)));
141 spans.push(Span::styled(bar, gap_style));
142 spans.push(Span::styled("] ", Style::default().fg(self.theme.metadata)));
143 }
144
145 spans.push(Span::styled(
146 format!(" pred {}% / obs {}%", pct(pred), pct(obs)),
147 gap_style,
148 ));
149 spans.push(Span::styled(
150 format!(" n={}", sample.n_samples),
151 Style::default().fg(self.theme.metadata),
152 ));
153
154 Line::from(spans).render(row, buf);
155 }
156}
157
158fn clamp_unit(v: f64) -> f64 {
159 v.clamp(0.0, 1.0)
160}
161
162fn pct(v: f64) -> i32 {
163 #[allow(clippy::cast_possible_truncation)]
166 let p = (clamp_unit(v) * 100.0).round() as i32;
167 p
168}
169
170#[allow(
176 clippy::cast_possible_truncation,
177 clippy::cast_sign_loss,
178 clippy::cast_precision_loss
179)]
180fn cells_for(rate: f64) -> usize {
181 let scaled = (rate * BAR_CELLS as f64).round();
182 let as_usize = if scaled.is_finite() && scaled >= 0.0 {
183 scaled as usize
184 } else {
185 0
186 };
187 as_usize.min(BAR_CELLS)
188}
189
190fn render_bar(observed: f64, predicted: f64) -> String {
195 let obs_cells = cells_for(observed);
196 let pred_cells = cells_for(predicted);
197 let mut s = String::with_capacity(BAR_CELLS);
198 for i in 0..BAR_CELLS {
199 let filled = i < obs_cells;
200 let is_pred_marker = pred_cells > 0 && i + 1 == pred_cells && pred_cells != obs_cells;
201 s.push(match (filled, is_pred_marker) {
202 (true, _) => '■',
205 (false, true) => '│',
206 (false, false) => '□',
207 });
208 }
209 s
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use ratatui::Terminal;
216 use ratatui::backend::TestBackend;
217
218 fn render(sample: Option<CalibrationSample>, width: u16) -> String {
219 let backend = TestBackend::new(width, 1);
220 let mut term = Terminal::new(backend).expect("term");
221 term.draw(|f| {
222 let w = CalibrationBar {
223 sample,
224 theme: Theme::default(),
225 };
226 f.render_widget(w, f.area());
227 })
228 .expect("draw");
229 let buf = term.backend().buffer().clone();
230 (0..buf.area.width)
231 .map(|x| buf[(x, 0)].symbol().to_string())
232 .collect::<String>()
233 .trim_end()
234 .to_string()
235 }
236
237 #[test]
238 fn none_sample_renders_no_data_state() {
239 let line = render(None, 80);
240 assert!(line.contains("calib"));
241 assert!(line.contains("no calibration data"));
242 assert!(!line.contains("pred"), "must not show fake numbers");
243 assert!(!line.contains('■'), "must not draw fake bar");
244 }
245
246 #[test]
247 fn below_min_samples_renders_insufficient_data_state() {
248 let sample = CalibrationSample {
249 predicted: 0.7,
250 observed: 0.65,
251 n_samples: 12,
252 };
253 let line = render(Some(sample), 80);
254 assert!(line.contains("insufficient data"));
255 assert!(line.contains("have 12"));
256 assert!(!line.contains('■'), "bar must not render below MIN_SAMPLES");
257 }
258
259 #[test]
260 fn above_min_samples_renders_bar_and_numbers() {
261 let sample = CalibrationSample {
262 predicted: 0.72,
263 observed: 0.68,
264 n_samples: 134,
265 };
266 let line = render(Some(sample), 80);
267 assert!(line.contains('■'), "expected filled bar cells: {line:?}");
268 assert!(line.contains("pred 72%"), "pred missing: {line:?}");
269 assert!(line.contains("obs 68%"), "obs missing: {line:?}");
270 assert!(line.contains("n=134"), "n missing: {line:?}");
271 }
272
273 #[test]
274 fn narrow_width_drops_bar_but_keeps_numbers() {
275 let sample = CalibrationSample {
276 predicted: 0.5,
277 observed: 0.5,
278 n_samples: 100,
279 };
280 let line = render(Some(sample), 29);
281 assert!(
282 !line.contains('■'),
283 "bar should not render at width<30: {line:?}"
284 );
285 assert!(line.contains("pred 50%"), "pred still required: {line:?}");
286 }
287
288 #[test]
289 fn pct_clamps_out_of_range() {
290 assert_eq!(pct(-0.1), 0);
291 assert_eq!(pct(1.5), 100);
292 assert_eq!(pct(0.5), 50);
293 }
294
295 #[test]
296 fn bar_observed_cells_match_rounded_fraction() {
297 let s = render_bar(0.5, 0.5);
298 let filled = s.chars().filter(|c| *c == '■').count();
299 assert_eq!(filled, 10, "50% observed should fill 10/{BAR_CELLS} cells");
300 }
301
302 #[test]
303 fn bar_predicted_marker_visible_when_gap_nonzero() {
304 let s = render_bar(0.5, 0.8);
305 let filled = s.chars().filter(|c| *c == '■').count();
307 let markers = s.chars().filter(|c| *c == '│').count();
308 assert_eq!(filled, 10);
309 assert_eq!(markers, 1, "predicted marker must render when > observed");
310 }
311}