1use std::collections::BTreeMap;
19use std::time::Duration;
20
21use serde::{Deserialize, Serialize};
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub enum RunStatus {
27 Running,
29 Completed,
31 Failed,
34}
35
36impl RunStatus {
37 pub fn is_terminal(self) -> bool {
38 !matches!(self, RunStatus::Running)
39 }
40}
41
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
44pub enum CellStyle {
45 #[default]
46 Neutral,
47 Dim,
48 Accent,
51 Good,
52 Warn,
53 Bad,
54}
55
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub enum Align {
58 #[default]
59 Left,
60 Right,
61}
62
63#[derive(Clone, Debug, Serialize, Deserialize)]
64pub struct Column {
65 pub title: String,
66 pub align: Align,
67 pub width: u16,
69}
70
71impl Column {
72 pub fn left(title: impl Into<String>, width: u16) -> Self {
73 Self {
74 title: title.into(),
75 align: Align::Left,
76 width,
77 }
78 }
79 pub fn right(title: impl Into<String>, width: u16) -> Self {
81 Self {
82 title: title.into(),
83 align: Align::Right,
84 width,
85 }
86 }
87}
88
89#[derive(Clone, Debug, Default, Serialize, Deserialize)]
90pub struct Cell {
91 pub text: String,
92 pub style: CellStyle,
93}
94
95impl Cell {
96 pub fn new(text: impl Into<String>) -> Self {
97 Self {
98 text: text.into(),
99 style: CellStyle::Neutral,
100 }
101 }
102 pub fn styled(text: impl Into<String>, style: CellStyle) -> Self {
103 Self {
104 text: text.into(),
105 style,
106 }
107 }
108}
109
110#[derive(Clone, Debug, Serialize, Deserialize)]
111pub struct ResultTable {
112 pub title: String,
113 pub columns: Vec<Column>,
114 pub rows: Vec<Vec<Cell>>,
115}
116
117impl ResultTable {
118 pub fn new(title: impl Into<String>, columns: Vec<Column>) -> Self {
119 Self {
120 title: title.into(),
121 columns,
122 rows: Vec::new(),
123 }
124 }
125 pub fn push(&mut self, row: Vec<Cell>) {
126 self.rows.push(row);
127 }
128}
129
130#[derive(Clone, Debug, Serialize, Deserialize)]
132pub struct Stat {
133 pub label: String,
134 pub value: String,
135 pub unit: String,
136 pub style: CellStyle,
137}
138
139impl Stat {
140 pub fn new(
141 label: impl Into<String>,
142 value: impl Into<String>,
143 unit: impl Into<String>,
144 ) -> Self {
145 Self {
146 label: label.into(),
147 value: value.into(),
148 unit: unit.into(),
149 style: CellStyle::Neutral,
150 }
151 }
152 pub fn with_style(mut self, style: CellStyle) -> Self {
153 self.style = style;
154 self
155 }
156}
157
158#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
161pub enum VerdictKind {
162 Pass,
163 Fail,
164 Info,
165}
166
167#[derive(Clone, Debug, Serialize, Deserialize)]
168pub struct Verdict {
169 pub kind: VerdictKind,
170 pub reason: String,
173}
174
175impl Verdict {
176 pub fn pass(reason: impl Into<String>) -> Self {
177 Self {
178 kind: VerdictKind::Pass,
179 reason: reason.into(),
180 }
181 }
182 pub fn fail(reason: impl Into<String>) -> Self {
183 Self {
184 kind: VerdictKind::Fail,
185 reason: reason.into(),
186 }
187 }
188 pub fn info(reason: impl Into<String>) -> Self {
189 Self {
190 kind: VerdictKind::Info,
191 reason: reason.into(),
192 }
193 }
194}
195
196#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
197pub enum LogLevel {
198 Debug,
199 Info,
200 Warn,
201 Error,
202}
203
204#[derive(Clone, Debug, Serialize, Deserialize)]
205pub struct LogLine {
206 pub level: LogLevel,
207 pub text: String,
208}
209
210impl LogLine {
211 pub fn info(text: impl Into<String>) -> Self {
212 Self {
213 level: LogLevel::Info,
214 text: text.into(),
215 }
216 }
217 pub fn warn(text: impl Into<String>) -> Self {
218 Self {
219 level: LogLevel::Warn,
220 text: text.into(),
221 }
222 }
223 pub fn error(text: impl Into<String>) -> Self {
224 Self {
225 level: LogLevel::Error,
226 text: text.into(),
227 }
228 }
229}
230
231#[derive(Clone, Debug, Serialize, Deserialize)]
233pub struct BenchmarkResult {
234 pub status: RunStatus,
235 pub phase: String,
237 pub progress: Option<(u64, u64)>,
240 pub summary: Vec<Stat>,
241 pub table: Option<ResultTable>,
242 pub verdict: Option<Verdict>,
243 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
247 pub metrics: BTreeMap<String, f64>,
248 pub log: Vec<LogLine>,
251 pub elapsed: Duration,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub hardware_state: Option<crate::hardware::HardwareStateReport>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub dataset_fingerprint: Option<String>,
265}
266
267impl BenchmarkResult {
268 pub fn running(phase: impl Into<String>, elapsed: Duration) -> Self {
269 Self {
270 status: RunStatus::Running,
271 phase: phase.into(),
272 progress: None,
273 summary: Vec::new(),
274 table: None,
275 verdict: None,
276 metrics: BTreeMap::new(),
277 log: Vec::new(),
278 elapsed,
279 hardware_state: None,
280 dataset_fingerprint: None,
281 }
282 }
283
284 pub fn completed(phase: impl Into<String>, elapsed: Duration) -> Self {
285 Self {
286 status: RunStatus::Completed,
287 ..Self::running(phase, elapsed)
288 }
289 }
290
291 pub fn failed(phase: impl Into<String>, reason: impl Into<String>, elapsed: Duration) -> Self {
292 let reason = reason.into();
293 Self {
294 status: RunStatus::Failed,
295 verdict: Some(Verdict::fail(reason.clone())),
296 log: vec![LogLine::error(reason)],
297 ..Self::running(phase, elapsed)
298 }
299 }
300
301 pub fn with_progress(mut self, done: u64, total: u64) -> Self {
302 self.progress = Some((done, total));
303 self
304 }
305 pub fn with_summary(mut self, summary: Vec<Stat>) -> Self {
306 self.summary = summary;
307 self
308 }
309 pub fn with_metrics(mut self, metrics: BTreeMap<String, f64>) -> Self {
310 self.metrics = metrics;
311 self
312 }
313 pub fn with_table(mut self, table: ResultTable) -> Self {
314 self.table = Some(table);
315 self
316 }
317 pub fn with_verdict(mut self, verdict: Verdict) -> Self {
318 self.verdict = Some(verdict);
319 self
320 }
321 pub fn with_log(mut self, log: Vec<LogLine>) -> Self {
322 self.log = log;
323 self
324 }
325 pub fn log_line(mut self, line: LogLine) -> Self {
326 self.log.push(line);
327 self
328 }
329 pub fn with_hardware_state(mut self, report: crate::hardware::HardwareStateReport) -> Self {
330 self.hardware_state = Some(report);
331 self
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn only_running_is_non_terminal() {
341 assert!(!RunStatus::Running.is_terminal());
342 assert!(RunStatus::Completed.is_terminal());
343 assert!(RunStatus::Failed.is_terminal());
344 }
345
346 #[test]
347 fn failed_carries_the_reason_into_both_verdict_and_log() {
348 let r = BenchmarkResult::failed("scoring", "endpoint refused", Duration::from_secs(1));
349 assert_eq!(r.status, RunStatus::Failed);
350 assert_eq!(r.phase, "scoring");
351 assert_eq!(r.elapsed, Duration::from_secs(1));
352 let verdict = r.verdict.as_ref().expect("failure verdict");
353 assert_eq!(verdict.kind, VerdictKind::Fail);
354 assert_eq!(verdict.reason, "endpoint refused");
355 assert_eq!(r.log.len(), 1);
356 assert_eq!(r.log[0].level, LogLevel::Error);
357 assert_eq!(r.log[0].text, "endpoint refused");
358 }
359
360 #[test]
361 fn frames_round_trip_through_json_for_the_history_pane() {
362 let mut table = ResultTable::new("Samples", vec![Column::right("Rate", 8)]);
363 table.push(vec![Cell::styled("119.3", CellStyle::Good)]);
364 let mut r = BenchmarkResult::completed("done", Duration::from_millis(1500))
365 .with_progress(2, 3)
366 .with_summary(vec![
367 Stat::new("Throughput", "119.3", "tok/s").with_style(CellStyle::Accent),
368 ])
369 .with_table(table)
370 .with_metrics(BTreeMap::from([("median_tps".into(), 119.3)]))
371 .with_verdict(Verdict::pass("median +0.4% (limit 3%)"))
372 .with_log(vec![LogLine::warn("clock dipped")])
373 .with_hardware_state(crate::hardware::HardwareStateReport::opened(
374 crate::hardware::Sensitivity::Correctness,
375 crate::hardware::HardwareState::default(),
376 None,
377 ));
378 r.dataset_fingerprint = Some("file-sha256:abc".into());
379 let json = serde_json::to_string(&r).unwrap();
380 let back: BenchmarkResult = serde_json::from_str(&json).unwrap();
381 assert_eq!(back.status, RunStatus::Completed);
382 assert_eq!(back.phase, "done");
383 assert_eq!(back.progress, Some((2, 3)));
384 assert_eq!(back.elapsed, Duration::from_millis(1500));
385 assert_eq!(back.summary.len(), 1);
386 assert_eq!(back.summary[0].label, "Throughput");
387 assert_eq!(back.summary[0].value, "119.3");
388 assert_eq!(back.summary[0].unit, "tok/s");
389 assert_eq!(back.summary[0].style, CellStyle::Accent);
390 let table = back.table.as_ref().expect("table");
391 assert_eq!(table.title, "Samples");
392 assert_eq!(table.columns.len(), 1);
393 assert_eq!(table.columns[0].title, "Rate");
394 assert_eq!(table.columns[0].align, Align::Right);
395 assert_eq!(table.columns[0].width, 8);
396 assert_eq!(table.rows.len(), 1);
397 assert_eq!(table.rows[0][0].text, "119.3");
398 assert_eq!(table.rows[0][0].style, CellStyle::Good);
399 let verdict = back.verdict.as_ref().expect("verdict");
400 assert_eq!(verdict.kind, VerdictKind::Pass);
401 assert_eq!(verdict.reason, "median +0.4% (limit 3%)");
402 assert_eq!(back.metrics, BTreeMap::from([("median_tps".into(), 119.3)]));
403 assert_eq!(back.log.len(), 1);
404 assert_eq!(back.log[0].level, LogLevel::Warn);
405 assert_eq!(back.log[0].text, "clock dipped");
406 assert!(back.hardware_state.is_some());
407 assert_eq!(back.dataset_fingerprint.as_deref(), Some("file-sha256:abc"));
408 }
409}