Skip to main content

zeph_bench/
scenario.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::Path;
5
6use crate::error::BenchError;
7
8/// Role of a turn in a multi-turn scenario conversation.
9///
10/// # Examples
11///
12/// ```
13/// use zeph_bench::scenario::Role;
14///
15/// assert!(matches!(Role::User, Role::User));
16/// assert!(matches!(Role::Assistant, Role::Assistant));
17/// ```
18#[non_exhaustive]
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Role {
21    /// A message from the human user.
22    User,
23    /// A message from the AI assistant.
24    Assistant,
25}
26
27/// One turn in a multi-turn scenario conversation.
28///
29/// # Examples
30///
31/// ```
32/// use zeph_bench::scenario::{Role, Turn};
33///
34/// let turn = Turn { role: Role::User, content: "What is the capital of France?".into() };
35/// assert!(matches!(turn.role, Role::User));
36/// ```
37#[derive(Debug, Clone)]
38pub struct Turn {
39    /// Who authored this turn.
40    pub role: Role,
41    /// Text content of the turn.
42    pub content: String,
43}
44
45/// A single benchmark scenario loaded from a dataset file.
46///
47/// Each scenario represents one question/task that will be presented to the agent.
48/// The `id` field is used to correlate agent responses with ground-truth answers and
49/// to skip already-completed scenarios during a `--resume` run.
50///
51/// Construct via [`Scenario::single`] for single-turn scenarios (all built-in loaders),
52/// or push [`Turn`]s directly into [`Scenario::turns`] for multi-turn scenarios.
53///
54/// # Examples
55///
56/// ```
57/// use zeph_bench::Scenario;
58///
59/// let scenario = Scenario::single(
60///     "gaia_t42",
61///     "What is the boiling point of water in Celsius?",
62///     "100",
63///     serde_json::json!({"level": 1}),
64/// );
65/// assert_eq!(scenario.id, "gaia_t42");
66/// assert_eq!(scenario.primary_prompt().unwrap(), "What is the boiling point of water in Celsius?");
67/// ```
68#[derive(Debug, Clone)]
69pub struct Scenario {
70    /// Unique identifier within the dataset (e.g. `"frames_0"`, `"s1_2"`).
71    pub id: String,
72    /// Ordered turns in this scenario. Non-empty by contract of [`Scenario::single`].
73    ///
74    /// Direct construction is allowed for multi-turn scenarios; callers must ensure
75    /// at least one [`Role::User`] turn is present before calling [`Scenario::primary_prompt`].
76    pub turns: Vec<Turn>,
77    /// The gold-standard answer used for scoring.
78    pub expected: String,
79    /// Dataset-specific extras such as difficulty level or `reasoning_types`.
80    ///
81    /// Set to [`serde_json::Value::Null`] when the dataset has no extra metadata.
82    pub metadata: serde_json::Value,
83}
84
85impl Scenario {
86    /// Convenience constructor for single-turn scenarios.
87    ///
88    /// Wraps `prompt` in a one-element [`Vec<Turn>`] with [`Role::User`]. All built-in
89    /// dataset loaders use this constructor.
90    ///
91    /// # Examples
92    ///
93    /// ```
94    /// use zeph_bench::Scenario;
95    ///
96    /// let s = Scenario::single("id1", "What year?", "2026", serde_json::Value::Null);
97    /// assert_eq!(s.primary_prompt().unwrap(), "What year?");
98    /// ```
99    #[must_use]
100    pub fn single(
101        id: impl Into<String>,
102        prompt: impl Into<String>,
103        expected: impl Into<String>,
104        metadata: serde_json::Value,
105    ) -> Self {
106        Self {
107            id: id.into(),
108            turns: vec![Turn {
109                role: Role::User,
110                content: prompt.into(),
111            }],
112            expected: expected.into(),
113            metadata,
114        }
115    }
116
117    /// Returns the content of the first [`Role::User`] turn.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`BenchError::InvalidFormat`] when `turns` is empty or contains no
122    /// [`Role::User`] entry. Loaders must construct via [`Scenario::single`] or push
123    /// at least one user turn.
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use zeph_bench::Scenario;
129    ///
130    /// let s = Scenario::single("id1", "hello", "world", serde_json::Value::Null);
131    /// assert_eq!(s.primary_prompt().unwrap(), "hello");
132    /// ```
133    pub fn primary_prompt(&self) -> Result<&str, BenchError> {
134        self.turns
135            .iter()
136            .find(|t| matches!(t.role, Role::User))
137            .map(|t| t.content.as_str())
138            .ok_or_else(|| {
139                BenchError::InvalidFormat(format!("scenario '{}' has no user turn", self.id))
140            })
141    }
142}
143
144/// Result of evaluating one agent response against the expected answer.
145///
146/// Produced by [`Evaluator::evaluate`]. The `score` is always in `0.0..=1.0`:
147/// - `1.0` — perfect match (exact or token-level depending on the evaluator).
148/// - `0.0` — no match.
149/// - Intermediate values — partial token overlap (LOCOMO token-F1 evaluator).
150///
151/// # Examples
152///
153/// ```
154/// use zeph_bench::EvalResult;
155///
156/// let result = EvalResult {
157///     scenario_id: "s1".into(),
158///     score: 0.75,
159///     passed: true,
160///     details: "token_f1=0.7500".into(),
161/// };
162/// assert!(result.passed);
163/// ```
164#[derive(Debug, Clone)]
165pub struct EvalResult {
166    /// ID of the scenario that produced this result.
167    pub scenario_id: String,
168    /// Numeric score in `0.0..=1.0`.
169    pub score: f64,
170    /// `true` when `score >= threshold` (threshold is evaluator-specific).
171    pub passed: bool,
172    /// Human-readable details such as `"token_f1=0.7500"` or `"exact_match=true"`.
173    pub details: String,
174}
175
176/// Loads scenarios from a dataset file on disk.
177///
178/// Implement this trait to add support for a new dataset format. The harness
179/// calls [`DatasetLoader::load`] once per run to materialise the full scenario
180/// list before iterating.
181///
182/// Built-in implementations:
183/// - [`crate::loaders::LocomoLoader`] — JSON array of sessions
184/// - [`crate::loaders::FramesLoader`] — JSONL, one record per line
185/// - [`crate::loaders::GaiaLoader`] — JSONL with optional level filter
186pub trait DatasetLoader {
187    /// Short identifier matching the dataset name in [`crate::DatasetRegistry`].
188    fn name(&self) -> &'static str;
189
190    /// Load all matching scenarios from `path`.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`BenchError::Io`] when the file cannot be opened or read, and
195    /// [`BenchError::InvalidFormat`] when the file content cannot be parsed.
196    fn load(&self, path: &Path) -> Result<Vec<Scenario>, BenchError>;
197}
198
199/// Scores one agent response against a [`Scenario`].
200///
201/// Each dataset loader ships a paired evaluator:
202/// - [`crate::loaders::LocomoEvaluator`] — token F1 with threshold 0.5
203/// - [`crate::loaders::FramesEvaluator`] — exact match (case-insensitive, punctuation stripped)
204/// - [`crate::loaders::GaiaEvaluator`] — GAIA-normalized exact match (articles stripped)
205pub trait Evaluator {
206    /// Compute and return an [`EvalResult`] for the given `agent_response`.
207    fn evaluate(&self, scenario: &Scenario, agent_response: &str) -> EvalResult;
208}
209
210/// Token F1 score: overlap of whitespace-split tokens between prediction and reference.
211///
212/// Splits both strings on whitespace, computes precision and recall over the
213/// token-type intersection, then returns the harmonic mean (F1).
214/// Returns `0.0` when either string is empty.
215///
216/// This metric is tolerant of minor wording differences and is used by the
217/// LOCOMO evaluator.
218///
219/// # Examples
220///
221/// ```
222/// use zeph_bench::token_f1;
223///
224/// // Perfect match.
225/// assert!((token_f1("hello world", "hello world") - 1.0).abs() < f64::EPSILON);
226///
227/// // No overlap.
228/// assert!(token_f1("foo bar", "baz qux") < f64::EPSILON);
229///
230/// // Partial overlap gives a value between 0 and 1.
231/// let f1 = token_f1("the cat sat", "the cat ran");
232/// assert!(f1 > 0.0 && f1 < 1.0);
233///
234/// // Empty strings return 0.
235/// assert!(token_f1("", "hello") < f64::EPSILON);
236/// ```
237#[must_use]
238pub fn token_f1(prediction: &str, reference: &str) -> f64 {
239    let pred_tokens: std::collections::HashSet<&str> = prediction.split_whitespace().collect();
240    let ref_tokens: std::collections::HashSet<&str> = reference.split_whitespace().collect();
241
242    if pred_tokens.is_empty() || ref_tokens.is_empty() {
243        return 0.0;
244    }
245
246    #[allow(clippy::cast_precision_loss)]
247    let common = pred_tokens.intersection(&ref_tokens).count() as f64;
248    #[allow(clippy::cast_precision_loss)]
249    let precision = common / pred_tokens.len() as f64;
250    #[allow(clippy::cast_precision_loss)]
251    let recall = common / ref_tokens.len() as f64;
252
253    if precision + recall == 0.0 {
254        return 0.0;
255    }
256
257    2.0 * precision * recall / (precision + recall)
258}
259
260/// Exact match after lowercasing and stripping punctuation/whitespace.
261///
262/// Both strings are normalized by:
263/// 1. Keeping only alphanumeric characters and whitespace.
264/// 2. Converting to lowercase.
265/// 3. Collapsing runs of whitespace to a single space.
266///
267/// Used by the FRAMES evaluator.
268///
269/// # Examples
270///
271/// ```
272/// use zeph_bench::exact_match;
273///
274/// assert!(exact_match("Hello, World!", "hello world"));
275/// assert!(exact_match("answer: YES.", "answer yes"));
276/// assert!(!exact_match("foo", "bar"));
277/// ```
278#[must_use]
279pub fn exact_match(prediction: &str, reference: &str) -> bool {
280    normalize_basic(prediction) == normalize_basic(reference)
281}
282
283/// GAIA-normalized exact match: lowercase, strip articles, strip punctuation, collapse
284/// whitespace, then compare.
285///
286/// Normalization steps (in order):
287/// 1. Keep only alphanumeric characters and whitespace.
288/// 2. Convert to lowercase.
289/// 3. Remove the articles `a`, `an`, and `the`.
290/// 4. Collapse whitespace and compare.
291///
292/// This matches the official GAIA leaderboard scoring script.
293///
294/// # Examples
295///
296/// ```
297/// use zeph_bench::gaia_normalized_exact_match;
298///
299/// // Articles are stripped from both sides.
300/// assert!(gaia_normalized_exact_match("The Tokyo", "Tokyo"));
301/// assert!(gaia_normalized_exact_match("a cat sat on an apple", "cat sat on apple"));
302///
303/// // Different answers do not match.
304/// assert!(!gaia_normalized_exact_match("1944", "1945"));
305/// ```
306#[must_use]
307pub fn gaia_normalized_exact_match(prediction: &str, reference: &str) -> bool {
308    normalize_gaia(prediction) == normalize_gaia(reference)
309}
310
311fn normalize_basic(s: &str) -> String {
312    s.chars()
313        .filter(|c| c.is_alphanumeric() || c.is_whitespace())
314        .collect::<String>()
315        .to_lowercase()
316        .split_whitespace()
317        .collect::<Vec<_>>()
318        .join(" ")
319}
320
321fn normalize_gaia(s: &str) -> String {
322    const ARTICLES: &[&str] = &["a", "an", "the"];
323
324    // Map Unicode subscript/superscript digits to their ASCII equivalents before
325    // stripping — this ensures "H₂O" and "H2O" normalize identically.
326    let ascii_mapped: String = s.chars().map(ascii_fold_digit).collect();
327
328    let stripped = ascii_mapped
329        .chars()
330        .filter(|c| c.is_alphanumeric() || c.is_whitespace())
331        .collect::<String>()
332        .to_lowercase();
333
334    stripped
335        .split_whitespace()
336        .filter(|tok| !ARTICLES.contains(tok))
337        .collect::<Vec<_>>()
338        .join(" ")
339}
340
341/// Map Unicode subscript and superscript digit characters to their ASCII equivalents.
342///
343/// Returns the character unchanged if it is not a subscript/superscript digit.
344fn ascii_fold_digit(c: char) -> char {
345    match c {
346        '\u{2080}' | '\u{2070}' => '0',
347        '\u{2081}' | '\u{00B9}' => '1',
348        '\u{2082}' | '\u{00B2}' => '2',
349        '\u{2083}' | '\u{00B3}' => '3',
350        '\u{2084}' | '\u{2074}' => '4',
351        '\u{2085}' | '\u{2075}' => '5',
352        '\u{2086}' | '\u{2076}' => '6',
353        '\u{2087}' | '\u{2077}' => '7',
354        '\u{2088}' | '\u{2078}' => '8',
355        '\u{2089}' | '\u{2079}' => '9',
356        other => other,
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use std::assert_matches;
364
365    #[test]
366    fn token_f1_identical() {
367        assert!((token_f1("hello world", "hello world") - 1.0).abs() < f64::EPSILON);
368    }
369
370    #[test]
371    fn token_f1_no_overlap() {
372        assert!(token_f1("foo bar", "baz qux") < f64::EPSILON);
373    }
374
375    #[test]
376    fn token_f1_partial_overlap() {
377        let f1 = token_f1("hello world foo", "hello world bar");
378        assert!(f1 > 0.0 && f1 < 1.0);
379    }
380
381    #[test]
382    fn token_f1_empty_prediction() {
383        assert!(token_f1("", "hello") < f64::EPSILON);
384    }
385
386    #[test]
387    fn token_f1_empty_reference() {
388        assert!(token_f1("hello", "") < f64::EPSILON);
389    }
390
391    #[test]
392    fn exact_match_identical() {
393        assert!(exact_match("Hello, World!", "hello world"));
394    }
395
396    #[test]
397    fn exact_match_differs() {
398        assert!(!exact_match("foo", "bar"));
399    }
400
401    #[test]
402    fn exact_match_strips_punctuation() {
403        assert!(exact_match("answer: yes.", "answer yes"));
404    }
405
406    #[test]
407    fn gaia_normalized_strips_articles() {
408        assert!(gaia_normalized_exact_match(
409            "The quick brown fox",
410            "quick brown fox"
411        ));
412    }
413
414    #[test]
415    fn gaia_normalized_strips_a_an() {
416        assert!(gaia_normalized_exact_match(
417            "a cat sat on an apple",
418            "cat sat on apple"
419        ));
420    }
421
422    #[test]
423    fn gaia_normalized_differs() {
424        assert!(!gaia_normalized_exact_match("cat", "dog"));
425    }
426
427    #[test]
428    fn gaia_normalized_subscript_digits_match_ascii() {
429        // Model may respond with Unicode subscript "H₂O" — must match ASCII "H2O".
430        assert!(gaia_normalized_exact_match("H\u{2082}O", "H2O"));
431    }
432
433    #[test]
434    fn single_constructs_one_user_turn() {
435        let s = Scenario::single("id1", "hello", "world", serde_json::Value::Null);
436        assert_eq!(s.turns.len(), 1);
437        assert_matches!(s.turns[0].role, Role::User);
438        assert_eq!(s.turns[0].content, "hello");
439        assert_eq!(s.expected, "world");
440    }
441
442    #[test]
443    fn primary_prompt_returns_first_user_turn_content() {
444        let s = Scenario::single("id1", "What year?", "2026", serde_json::Value::Null);
445        assert_eq!(s.primary_prompt().unwrap(), "What year?");
446    }
447
448    #[test]
449    fn primary_prompt_skips_leading_assistant_turns() {
450        let s = Scenario {
451            id: "id2".into(),
452            turns: vec![
453                Turn {
454                    role: Role::Assistant,
455                    content: "I am ready.".into(),
456                },
457                Turn {
458                    role: Role::User,
459                    content: "What is Rust?".into(),
460                },
461            ],
462            expected: "A systems language".into(),
463            metadata: serde_json::Value::Null,
464        };
465        assert_eq!(s.primary_prompt().unwrap(), "What is Rust?");
466    }
467
468    #[test]
469    fn primary_prompt_errors_when_no_user_turn() {
470        let s = Scenario {
471            id: "id3".into(),
472            turns: vec![Turn {
473                role: Role::Assistant,
474                content: "assistant only".into(),
475            }],
476            expected: String::new(),
477            metadata: serde_json::Value::Null,
478        };
479        assert!(s.primary_prompt().is_err());
480
481        let empty = Scenario {
482            id: "id4".into(),
483            turns: vec![],
484            expected: String::new(),
485            metadata: serde_json::Value::Null,
486        };
487        assert!(empty.primary_prompt().is_err());
488    }
489}