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}