Skip to main content

zeph_bench/
results.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Benchmark result types and writer.
5//!
6//! [`BenchRun`] is the top-level result record written to `results.json`.
7//! [`ResultWriter`] handles serialization to JSON and a human-readable Markdown summary,
8//! including partial flushing on SIGINT and resume support.
9
10use std::collections::HashSet;
11use std::fmt::Write as _;
12use std::path::PathBuf;
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::BenchError;
17use crate::utils::write_atomic;
18
19/// Status of a benchmark run serialized into `results.json`.
20///
21/// The `Running` variant is used in-memory during an active run and should never
22/// appear in a persisted file.
23///
24/// # Examples
25///
26/// ```
27/// use zeph_bench::RunStatus;
28///
29/// assert_ne!(RunStatus::Completed, RunStatus::Interrupted);
30/// ```
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33#[non_exhaustive]
34pub enum RunStatus {
35    /// All scenarios finished successfully.
36    Completed,
37    /// The run was cancelled (e.g. SIGINT) before all scenarios finished.
38    Interrupted,
39    /// The run is currently in progress; should not appear in a persisted file.
40    Running,
41}
42
43/// Per-scenario result record persisted inside [`BenchRun::results`].
44///
45/// # Examples
46///
47/// ```
48/// use zeph_bench::ScenarioResult;
49///
50/// let r = ScenarioResult {
51///     scenario_id: "gaia_t1".into(),
52///     score: 1.0,
53///     response_excerpt: "1945".into(),
54///     error: None,
55///     elapsed_ms: 820,
56/// };
57/// assert!(r.error.is_none());
58/// ```
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ScenarioResult {
61    /// Unique identifier for the scenario (matches [`crate::Scenario::id`]).
62    pub scenario_id: String,
63    /// Numeric score in `[0.0, 1.0]` produced by the evaluator.
64    pub score: f64,
65    /// First 200 characters of the agent response for quick review.
66    pub response_excerpt: String,
67    /// Error message if the scenario could not be completed, otherwise `None`.
68    pub error: Option<String>,
69    /// Wall-clock time in milliseconds for this scenario.
70    pub elapsed_ms: u64,
71}
72
73/// Aggregate statistics computed from all [`ScenarioResult`]s in a [`BenchRun`].
74///
75/// Recomputed after every scenario via [`BenchRun::recompute_aggregate`] and persisted
76/// into `results.json` so partial runs still contain meaningful statistics.
77///
78/// # Examples
79///
80/// ```
81/// use zeph_bench::Aggregate;
82///
83/// let agg = Aggregate {
84///     total: 100,
85///     mean_score: 0.72,
86///     median_score: 0.70,
87///     stddev: 0.15,
88///     exact_match: 55,
89///     error_count: 3,
90///     total_elapsed_ms: 240_000,
91/// };
92/// assert_eq!(agg.total, 100);
93/// assert_eq!(agg.error_count, 3);
94/// assert!((agg.median_score - 0.70).abs() < f64::EPSILON);
95/// ```
96#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97pub struct Aggregate {
98    /// Number of scenarios included in the statistics.
99    pub total: usize,
100    /// Arithmetic mean of all per-scenario scores.
101    pub mean_score: f64,
102    /// Median per-scenario score.
103    ///
104    /// For an even number of results, the median is the average of the two middle values.
105    /// Returns `0.0` when `total == 0`.
106    pub median_score: f64,
107    /// Population standard deviation of per-scenario scores (divide by N).
108    ///
109    /// The scenario set is treated as the full population of interest, not a sample.
110    /// Returns `0.0` when `total <= 1`.
111    pub stddev: f64,
112    /// Count of scenarios where `score >= 1.0` (exact match).
113    pub exact_match: usize,
114    /// Count of scenarios where `score == 0.0` and `error` is `Some(_)`.
115    ///
116    /// A non-zero value indicates the agent failed to produce a response (e.g. timeout,
117    /// LLM API error) rather than simply giving the wrong answer.
118    pub error_count: usize,
119    /// Sum of [`ScenarioResult::elapsed_ms`] across all scenarios.
120    pub total_elapsed_ms: u64,
121}
122
123/// Top-level benchmark run record written to `results.json`.
124///
125/// The schema is a superset of the `LongMemEval` leaderboard submission format (NFR-008),
126/// making it directly usable for leaderboard submission after a `longmemeval` run.
127///
128/// Create a default instance, then populate [`BenchRun::results`] incrementally and
129/// call [`BenchRun::recompute_aggregate`] before persisting with [`ResultWriter`].
130///
131/// # Examples
132///
133/// ```
134/// use zeph_bench::{BenchRun, RunStatus, Aggregate};
135///
136/// let run = BenchRun {
137///     dataset: "gaia".into(),
138///     model: "openai/gpt-4o".into(),
139///     run_id: "a1b2c3".into(),
140///     started_at: "2026-04-09T10:00:00Z".into(),
141///     finished_at: String::new(),
142///     status: RunStatus::Running,
143///     results: vec![],
144///     aggregate: Aggregate::default(),
145/// };
146/// assert_eq!(run.dataset, "gaia");
147/// assert!(run.results.is_empty());
148/// ```
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct BenchRun {
151    /// Dataset name (e.g. `"longmemeval"`).
152    pub dataset: String,
153    /// Provider/model identifier (e.g. `"openai/gpt-4o"`).
154    pub model: String,
155    /// UUID v4 uniquely identifying this run.
156    pub run_id: String,
157    /// RFC 3339 timestamp when the run started.
158    pub started_at: String,
159    /// RFC 3339 timestamp when the run ended (empty string if interrupted).
160    pub finished_at: String,
161    /// Run status.
162    pub status: RunStatus,
163    /// Per-scenario results.
164    pub results: Vec<ScenarioResult>,
165    /// Aggregate statistics.
166    pub aggregate: Aggregate,
167}
168
169impl BenchRun {
170    /// Recompute [`BenchRun::aggregate`] from the current [`BenchRun::results`] list.
171    ///
172    /// Call this after appending one or more [`ScenarioResult`]s to keep the
173    /// aggregate statistics in sync before writing to disk.
174    ///
175    /// # Examples
176    ///
177    /// ```
178    /// use zeph_bench::{BenchRun, RunStatus, ScenarioResult, Aggregate};
179    ///
180    /// let mut run = BenchRun {
181    ///     dataset: "frames".into(),
182    ///     model: "openai/gpt-4o-mini".into(),
183    ///     run_id: "r1".into(),
184    ///     started_at: "2026-01-01T00:00:00Z".into(),
185    ///     finished_at: String::new(),
186    ///     status: RunStatus::Running,
187    ///     results: vec![
188    ///         ScenarioResult {
189    ///             scenario_id: "frames_0".into(),
190    ///             score: 1.0,
191    ///             response_excerpt: "Paris".into(),
192    ///             error: None,
193    ///             elapsed_ms: 500,
194    ///         },
195    ///     ],
196    ///     aggregate: Aggregate::default(),
197    /// };
198    ///
199    /// run.recompute_aggregate();
200    /// assert_eq!(run.aggregate.total, 1);
201    /// assert!((run.aggregate.mean_score - 1.0).abs() < f64::EPSILON);
202    /// assert_eq!(run.aggregate.exact_match, 1);
203    /// assert_eq!(run.aggregate.error_count, 0);
204    /// ```
205    pub fn recompute_aggregate(&mut self) {
206        let total = self.results.len();
207
208        if total == 0 {
209            self.aggregate = Aggregate::default();
210            return;
211        }
212
213        #[allow(clippy::cast_precision_loss)]
214        let mean_score = self.results.iter().map(|r| r.score).sum::<f64>() / total as f64;
215
216        // Median: sort scores, average the two middle values for even N.
217        let mut sorted_scores: Vec<f64> = self.results.iter().map(|r| r.score).collect();
218        sorted_scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
219        #[allow(clippy::cast_precision_loss)]
220        let median_score = if total % 2 == 1 {
221            sorted_scores[total / 2]
222        } else {
223            f64::midpoint(sorted_scores[total / 2 - 1], sorted_scores[total / 2])
224        };
225
226        // Population standard deviation (divide by N).
227        #[allow(clippy::cast_precision_loss)]
228        let variance = self
229            .results
230            .iter()
231            .map(|r| (r.score - mean_score).powi(2))
232            .sum::<f64>()
233            / total as f64;
234        let stddev = variance.sqrt();
235
236        let exact_match = self.results.iter().filter(|r| r.score >= 1.0).count();
237        let error_count = self
238            .results
239            .iter()
240            .filter(|r| r.score == 0.0 && r.error.is_some())
241            .count();
242        let total_elapsed_ms = self.results.iter().map(|r| r.elapsed_ms).sum();
243
244        self.aggregate = Aggregate {
245            total,
246            mean_score,
247            median_score,
248            stddev,
249            exact_match,
250            error_count,
251            total_elapsed_ms,
252        };
253    }
254
255    /// Return the set of scenario IDs already present in [`BenchRun::results`].
256    ///
257    /// Used by the `--resume` logic to determine which scenarios can be skipped.
258    ///
259    /// # Examples
260    ///
261    /// ```
262    /// use zeph_bench::{BenchRun, RunStatus, ScenarioResult, Aggregate};
263    ///
264    /// let run = BenchRun {
265    ///     dataset: "gaia".into(),
266    ///     model: "openai/gpt-4o".into(),
267    ///     run_id: "r2".into(),
268    ///     started_at: "2026-01-01T00:00:00Z".into(),
269    ///     finished_at: String::new(),
270    ///     status: RunStatus::Interrupted,
271    ///     results: vec![
272    ///         ScenarioResult {
273    ///             scenario_id: "t1".into(),
274    ///             score: 1.0,
275    ///             response_excerpt: "1945".into(),
276    ///             error: None,
277    ///             elapsed_ms: 300,
278    ///         },
279    ///     ],
280    ///     aggregate: Aggregate::default(),
281    /// };
282    ///
283    /// let done = run.completed_ids();
284    /// assert!(done.contains("t1"));
285    /// assert!(!done.contains("t2"));
286    /// ```
287    #[must_use]
288    pub fn completed_ids(&self) -> HashSet<String> {
289        self.results.iter().map(|r| r.scenario_id.clone()).collect()
290    }
291}
292
293/// Writes `results.json` and `summary.md` to an output directory.
294///
295/// Files are written atomically by flushing to a `.tmp` sibling file and then
296/// renaming, so a concurrent SIGINT cannot leave a half-written JSON file.
297///
298/// # Examples
299///
300/// ```no_run
301/// use zeph_bench::{ResultWriter, BenchRun, RunStatus, Aggregate};
302///
303/// let writer = ResultWriter::new("/tmp/my-bench-run").unwrap();
304/// println!("results at {}", writer.results_path().display());
305/// ```
306pub struct ResultWriter {
307    output_dir: PathBuf,
308}
309
310impl ResultWriter {
311    /// Create a writer targeting `output_dir`.
312    ///
313    /// The directory is created automatically (single level) if it does not exist.
314    ///
315    /// # Errors
316    ///
317    /// Returns [`BenchError::Io`] if the directory cannot be created.
318    pub fn new(output_dir: impl Into<PathBuf>) -> Result<Self, BenchError> {
319        let output_dir = output_dir.into();
320        if !output_dir.exists() {
321            std::fs::create_dir_all(&output_dir)?;
322        }
323        Ok(Self { output_dir })
324    }
325
326    /// Absolute path of `results.json` inside the output directory.
327    ///
328    /// # Examples
329    ///
330    /// ```
331    /// use std::path::Path;
332    /// use zeph_bench::ResultWriter;
333    ///
334    /// let dir = tempfile::tempdir().unwrap();
335    /// let writer = ResultWriter::new(dir.path()).unwrap();
336    /// assert!(writer.results_path().ends_with("results.json"));
337    /// ```
338    #[must_use]
339    pub fn results_path(&self) -> PathBuf {
340        self.output_dir.join("results.json")
341    }
342
343    /// Absolute path of `summary.md` inside the output directory.
344    ///
345    /// # Examples
346    ///
347    /// ```
348    /// use zeph_bench::ResultWriter;
349    ///
350    /// let dir = tempfile::tempdir().unwrap();
351    /// let writer = ResultWriter::new(dir.path()).unwrap();
352    /// assert!(writer.summary_path().ends_with("summary.md"));
353    /// ```
354    #[must_use]
355    pub fn summary_path(&self) -> PathBuf {
356        self.output_dir.join("summary.md")
357    }
358
359    /// Load an existing `results.json` for resume.
360    ///
361    /// Returns `None` when the file does not exist (treat as fresh run).
362    ///
363    /// # Errors
364    ///
365    /// Returns [`BenchError::Io`] on read failure, or [`BenchError::InvalidFormat`] if
366    /// the file exists but cannot be deserialized.
367    pub fn load_existing(&self) -> Result<Option<BenchRun>, BenchError> {
368        let path = self.results_path();
369        if !path.exists() {
370            return Ok(None);
371        }
372        let data = std::fs::read_to_string(&path)?;
373        let run: BenchRun =
374            serde_json::from_str(&data).map_err(|e| BenchError::InvalidFormat(e.to_string()))?;
375        Ok(Some(run))
376    }
377
378    /// Write `run` to `results.json` and `summary.md` atomically (best-effort).
379    ///
380    /// # Errors
381    ///
382    /// Returns [`BenchError`] on serialization or I/O failure.
383    pub fn write(&self, run: &BenchRun) -> Result<(), BenchError> {
384        self.write_json(run)?;
385        self.write_markdown(run)?;
386        Ok(())
387    }
388
389    fn write_json(&self, run: &BenchRun) -> Result<(), BenchError> {
390        let json = serde_json::to_string_pretty(run)
391            .map_err(|e| BenchError::InvalidFormat(e.to_string()))?;
392        write_atomic(&self.results_path(), json.as_bytes())?;
393        Ok(())
394    }
395
396    fn write_markdown(&self, run: &BenchRun) -> Result<(), BenchError> {
397        let mut md = String::new();
398        let _ = writeln!(md, "# Benchmark Results: {}\n", run.dataset);
399        let _ = writeln!(md, "- **Model**: {}", run.model);
400        let _ = writeln!(md, "- **Run ID**: {}", run.run_id);
401        let _ = writeln!(md, "- **Status**: {:?}", run.status);
402        let _ = writeln!(md, "- **Started**: {}", run.started_at);
403        if !run.finished_at.is_empty() {
404            let _ = writeln!(md, "- **Finished**: {}", run.finished_at);
405        }
406        let _ = writeln!(
407            md,
408            "- **Mean score**: {:.4} (median: {:.4}, stddev: {:.4})\n",
409            run.aggregate.mean_score, run.aggregate.median_score, run.aggregate.stddev
410        );
411        let _ = writeln!(
412            md,
413            "- **Exact match**: {}/{} | **Errors**: {}\n",
414            run.aggregate.exact_match, run.aggregate.total, run.aggregate.error_count
415        );
416
417        md.push_str("| scenario_id | score | response_excerpt | error |\n");
418        md.push_str("|-------------|-------|------------------|-------|\n");
419        for r in &run.results {
420            let excerpt = r.response_excerpt.replace('|', "\\|");
421            let error = r.error.as_deref().unwrap_or("").replace('|', "\\|");
422            let _ = writeln!(
423                md,
424                "| {} | {:.4} | {} | {} |",
425                r.scenario_id, r.score, excerpt, error
426            );
427        }
428
429        write_atomic(&self.summary_path(), md.as_bytes())?;
430        Ok(())
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    fn make_run() -> BenchRun {
439        BenchRun {
440            dataset: "longmemeval".into(),
441            model: "openai/gpt-4o".into(),
442            run_id: "test-run-001".into(),
443            started_at: "2026-01-01T00:00:00Z".into(),
444            finished_at: "2026-01-01T00:01:00Z".into(),
445            status: RunStatus::Completed,
446            results: vec![
447                ScenarioResult {
448                    scenario_id: "s1".into(),
449                    score: 1.0,
450                    response_excerpt: "The answer is 42.".into(),
451                    error: None,
452                    elapsed_ms: 1000,
453                },
454                ScenarioResult {
455                    scenario_id: "s2".into(),
456                    score: 0.0,
457                    response_excerpt: String::new(),
458                    error: Some("timeout".into()),
459                    elapsed_ms: 5000,
460                },
461            ],
462            aggregate: Aggregate::default(),
463        }
464    }
465
466    #[test]
467    fn recompute_aggregate_correct() {
468        let mut run = make_run();
469        run.recompute_aggregate();
470        assert_eq!(run.aggregate.total, 2);
471        assert!((run.aggregate.mean_score - 0.5).abs() < f64::EPSILON);
472        // median for [0.0, 1.0] sorted = average of middle two = 0.5
473        assert!((run.aggregate.median_score - 0.5).abs() < f64::EPSILON);
474        // population stddev: mean=0.5, variance=((1.0-0.5)^2+(0.0-0.5)^2)/2 = 0.25, stddev=0.5
475        assert!((run.aggregate.stddev - 0.5).abs() < f64::EPSILON);
476        assert_eq!(run.aggregate.exact_match, 1);
477        // s2 has score=0.0 and error=Some("timeout")
478        assert_eq!(run.aggregate.error_count, 1);
479        assert_eq!(run.aggregate.total_elapsed_ms, 6000);
480    }
481
482    #[test]
483    fn recompute_aggregate_single_result() {
484        let mut run = make_run();
485        run.results.retain(|r| r.scenario_id == "s1");
486        run.recompute_aggregate();
487        assert_eq!(run.aggregate.total, 1);
488        assert!((run.aggregate.mean_score - 1.0).abs() < f64::EPSILON);
489        assert!((run.aggregate.median_score - 1.0).abs() < f64::EPSILON);
490        assert!(run.aggregate.stddev.abs() < f64::EPSILON);
491        assert_eq!(run.aggregate.error_count, 0);
492    }
493
494    #[test]
495    fn recompute_aggregate_empty_results() {
496        let mut run = make_run();
497        run.results.clear();
498        run.recompute_aggregate();
499        assert_eq!(run.aggregate.total, 0);
500        assert!(run.aggregate.mean_score.abs() < f64::EPSILON);
501        assert!(run.aggregate.median_score.abs() < f64::EPSILON);
502        assert!(run.aggregate.stddev.abs() < f64::EPSILON);
503        assert_eq!(run.aggregate.error_count, 0);
504    }
505
506    #[test]
507    fn recompute_aggregate_error_count_only_zero_score_with_error() {
508        let mut run = make_run();
509        // Add a scenario with score=0.0 but no error — should NOT count as error
510        run.results.push(ScenarioResult {
511            scenario_id: "s3".into(),
512            score: 0.0,
513            response_excerpt: "wrong answer".into(),
514            error: None,
515            elapsed_ms: 100,
516        });
517        run.recompute_aggregate();
518        // s2 has error, s3 does not — error_count should be 1
519        assert_eq!(run.aggregate.error_count, 1);
520    }
521
522    #[test]
523    fn completed_ids_returns_all_scenario_ids() {
524        let run = make_run();
525        let ids = run.completed_ids();
526        assert!(ids.contains("s1"));
527        assert!(ids.contains("s2"));
528        assert_eq!(ids.len(), 2);
529    }
530
531    #[test]
532    fn json_round_trip() {
533        let mut run = make_run();
534        run.recompute_aggregate();
535        let json = serde_json::to_string_pretty(&run).unwrap();
536        let decoded: BenchRun = serde_json::from_str(&json).unwrap();
537        assert_eq!(decoded.dataset, run.dataset);
538        assert_eq!(decoded.run_id, run.run_id);
539        assert_eq!(decoded.results.len(), 2);
540        assert_eq!(decoded.status, RunStatus::Completed);
541        assert_eq!(decoded.aggregate.exact_match, run.aggregate.exact_match);
542    }
543
544    #[test]
545    fn interrupted_status_serializes_correctly() {
546        let mut run = make_run();
547        run.status = RunStatus::Interrupted;
548        let json = serde_json::to_string(&run).unwrap();
549        assert!(json.contains("\"interrupted\""));
550    }
551
552    #[test]
553    fn write_and_load_round_trip() {
554        let dir = tempfile::tempdir().unwrap();
555        let writer = ResultWriter::new(dir.path()).unwrap();
556
557        assert!(writer.load_existing().unwrap().is_none());
558
559        let mut run = make_run();
560        run.recompute_aggregate();
561        writer.write(&run).unwrap();
562
563        let loaded = writer.load_existing().unwrap().unwrap();
564        assert_eq!(loaded.run_id, run.run_id);
565        assert_eq!(loaded.results.len(), 2);
566        assert_eq!(loaded.aggregate.exact_match, 1);
567    }
568
569    #[test]
570    fn summary_md_contains_table_header() {
571        let dir = tempfile::tempdir().unwrap();
572        let writer = ResultWriter::new(dir.path()).unwrap();
573        let mut run = make_run();
574        run.recompute_aggregate();
575        writer.write(&run).unwrap();
576
577        let md = std::fs::read_to_string(writer.summary_path()).unwrap();
578        assert!(md.contains("| scenario_id | score |"));
579        assert!(md.contains("s1"));
580        assert!(md.contains("s2"));
581    }
582
583    #[test]
584    fn write_creates_output_dir_if_absent() {
585        let tmp = tempfile::tempdir().unwrap();
586        let new_dir = tmp.path().join("new_subdir");
587        assert!(!new_dir.exists());
588        ResultWriter::new(&new_dir).unwrap();
589        assert!(new_dir.exists());
590    }
591
592    #[test]
593    fn resume_skips_completed_scenarios() {
594        let dir = tempfile::tempdir().unwrap();
595        let writer = ResultWriter::new(dir.path()).unwrap();
596
597        // Write partial results (only s1 done).
598        let mut partial = make_run();
599        partial.results.retain(|r| r.scenario_id == "s1");
600        partial.status = RunStatus::Interrupted;
601        partial.recompute_aggregate();
602        writer.write(&partial).unwrap();
603
604        let loaded = writer.load_existing().unwrap().unwrap();
605        let done = loaded.completed_ids();
606        assert!(done.contains("s1"));
607        assert!(!done.contains("s2"));
608    }
609}