Skip to main content

eval_core/
runner.rs

1//! The generic benchmark **runner**: ties a [`Harness`] + [`Scorer`] over a shared `World`, runs every
2//! [`EvalCase`], times each, isolates panics, and assembles an [`EvalReport`].
3//!
4//! This is the domain-agnostic port of AetherCore's concrete eval runner: same per-case timing, same
5//! `catch_unwind` panic isolation (one bad case fails alone), same panic-hook suppression around the
6//! loop, and the same stderr progress lines — but with the game/LLM specifics (which backend, which
7//! world, which predicates) pushed out behind the [`Harness`]/[`Scorer`] traits.
8
9use std::time::Instant;
10
11use crate::case::EvalCase;
12use crate::expect::Expectation;
13use crate::harness::{Agent, Harness, RunArtifacts, ToolCall};
14use crate::report::{CaseOutcome, EvalReport};
15use crate::scorer::{BuiltinScorer, Scorer};
16
17/// Run-level metadata recorded on the [`EvalReport`] but NOT intrinsic to the generic runner.
18///
19/// [`EvalReport`] carries a few fields that are meaningful for LLM/agent runs (the sampling
20/// `temperature`, a `backend` label, the shared `system_prompt`) but have no generic meaning here. Rather
21/// than hardcode LLM assumptions into [`run_eval`], the host supplies them via this struct; the report's
22/// serialized shape (and therefore the HTML report + saved JSON) is unchanged. A host with no notion of
23/// these can use [`RunMeta::default`] (temperature `0.0`, empty `backend`/`system_prompt`).
24///
25/// `#[non_exhaustive]`: new run-level metadata can be added without a breaking change. Build it with
26/// `RunMeta::new(temperature, backend, system_prompt)`.
27#[derive(Debug, Clone, Default)]
28#[non_exhaustive]
29pub struct RunMeta {
30    /// Sampling temperature the run used, recorded in the report summary. Neutral default `0.0`.
31    pub temperature: f32,
32    /// A short description of what was benchmarked (e.g. the backend/model label). Neutral default `""`.
33    pub backend: String,
34    /// A run-level prompt/preamble shared across all cases, stored once on the report (shown at the top
35    /// of the HTML report's per-run expander). Neutral default `""`.
36    pub system_prompt: String,
37}
38
39impl RunMeta {
40    /// Create a new `RunMeta` with the given temperature, backend label, and system prompt.
41    pub fn new(
42        temperature: f32,
43        backend: impl Into<String>,
44        system_prompt: impl Into<String>,
45    ) -> Self {
46        Self {
47            temperature,
48            backend: backend.into(),
49            system_prompt: system_prompt.into(),
50        }
51    }
52}
53
54/// Run every `case` through `harness` + `scorer` and aggregate into an [`EvalReport`], using default
55/// [`RunMeta`] (temperature `0`, empty backend/system-prompt labels).
56///
57/// This is the simple entry point for hosts that don't track LLM run metadata. For LLM/agent runs that
58/// want the report to record a backend label, temperature, or shared system prompt, use
59/// [`run_eval_with_meta`].
60///
61/// Semantics per case: build a fresh world via [`Harness::setup`], time [`Harness::run`] (wall-clock for
62/// the whole run), then score every predicate via [`Scorer::score`]. A case PASSES iff `run` returned
63/// `Ok` AND every predicate passed. The whole build+run+score is isolated behind `catch_unwind`, so a
64/// panicking case fails only itself (with the panic message recorded in
65/// [`CaseOutcome::error`]). Progress is emitted to stderr.
66pub fn run_eval<H, S>(
67    harness: &H,
68    scorer: &S,
69    cases: &[EvalCase<H::Setup, S::Expect>],
70) -> EvalReport
71where
72    H: Harness,
73    S: Scorer<World = H::World>,
74{
75    run_eval_with_meta(harness, scorer, cases, RunMeta::default())
76}
77
78/// As [`run_eval`], but with explicit run [`RunMeta`] (backend label, temperature, shared system
79/// prompt) recorded on the resulting [`EvalReport`].
80///
81/// This is the single convergence point that owns the progress logging: a one-time startup banner, then
82/// a `[i/total]` line BEFORE and AFTER each case (the BEFORE line — the anti-hang signal — prints the
83/// instant the case starts). ALL progress goes to stderr; stdout is left clean for any report payload a
84/// host wants to print.
85pub fn run_eval_with_meta<H, S>(
86    harness: &H,
87    scorer: &S,
88    cases: &[EvalCase<H::Setup, S::Expect>],
89    meta: RunMeta,
90) -> EvalReport
91where
92    H: Harness,
93    S: Scorer<World = H::World>,
94{
95    let total = cases.len();
96    eprintln!("── eval run ──────────────────────────────────────────");
97    if !meta.backend.is_empty() {
98        eprintln!("backend:     {}", meta.backend);
99    }
100    eprintln!("cases:       {total}");
101    eprintln!("temperature: {}", meta.temperature);
102    eprintln!("──────────────────────────────────────────────────────");
103
104    // A caught panic still runs the DEFAULT hook FIRST (printing "thread '…' panicked at …" to stderr)
105    // before `catch_unwind` in `run_case` returns. Swap in a no-op hook for the duration of the run loop
106    // so that raw spam is suppressed — the panic message is surfaced cleanly via the per-case AFTER line
107    // and the recorded `CaseOutcome.error` instead. Scoped narrowly: the saved hook is restored right
108    // after the loop, before this fn returns (`.map(...).collect()` can't return early — it always yields
109    // one outcome per case — so the restore always runs).
110    let prev_hook = std::panic::take_hook();
111    std::panic::set_hook(Box::new(|_| {}));
112
113    let outcomes: Vec<CaseOutcome> = cases
114        .iter()
115        .enumerate()
116        .map(|(idx, case)| {
117            let i = idx + 1;
118            // BEFORE: which case is in flight, printed the instant the run starts (anti-hang signal).
119            eprintln!("[{i}/{total}] {} …", case.name);
120            let outcome = run_case(harness, scorer, case);
121            // AFTER: result + timing, so a slow case is visibly distinguishable from a hung one.
122            let status = if outcome.passed { "PASS" } else { "FAIL" };
123            let tokens = outcome
124                .tokens
125                .map_or_else(|| "?".to_owned(), |t| t.to_string());
126            let detail = match &outcome.error {
127                Some(err) => format!(", {}", truncate_one_line(err)),
128                None => String::new(),
129            };
130            eprintln!(
131                "[{i}/{total}] {} … {status} ({:.1}s, {tokens} tok{detail})",
132                case.name,
133                outcome.latency.as_secs_f64()
134            );
135            outcome
136        })
137        .collect();
138
139    // Restore the host's panic hook now the run loop is done.
140    std::panic::set_hook(prev_hook);
141
142    EvalReport::new(outcomes, meta.temperature, meta.backend, meta.system_prompt)
143}
144
145/// Build the world, run one case against it, and score it into a [`CaseOutcome`].
146///
147/// The whole build+run+score is wrapped in `catch_unwind`: the world is freshly built per case, so
148/// discarding a half-built world on a panic is safe, and `&H`/`&S` are shared references the closure only
149/// reads — hence the `AssertUnwindSafe` boundary. On a panic, the case is marked failed, every predicate
150/// is marked failed (with its scorer-derived label where we can still produce one — but a panic may have
151/// happened mid-scoring, so we fall back to a positional label), and the panic message is recorded.
152fn run_case<H, S>(harness: &H, scorer: &S, case: &EvalCase<H::Setup, S::Expect>) -> CaseOutcome
153where
154    H: Harness,
155    S: Scorer<World = H::World>,
156{
157    let started = Instant::now();
158
159    let scored = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
160        // Build a fresh world for this case, run the instruction (timing the whole run), then score.
161        let mut world = harness.setup(&case.setup);
162        let run = harness.run(&case.instruction, &mut world);
163        let latency = started.elapsed();
164
165        // The harness can report a run-level error two ways: an `Err` return (hard failure) or
166        // `RunArtifacts.error` (a soft, captured error). Prefer the `Err`; either fails the case.
167        // (`take` moves the soft error out rather than cloning it — the `error` field on the kept
168        // `artifacts` is then unused, since `run_error` is what flows onto the `CaseOutcome`.)
169        let (artifacts, run_error) = match run {
170            Ok(mut artifacts) => {
171                let err = artifacts.error.take();
172                (artifacts, err)
173            }
174            Err(e) => (RunArtifacts::default(), Some(e.to_string())),
175        };
176
177        // Score every predicate against the resulting world AND the run's artifacts, keeping each
178        // `(label, passed)`.
179        let predicates: Vec<(String, bool)> = case
180            .expect
181            .iter()
182            .map(|exp| scorer.score(exp, &artifacts, &world))
183            .collect();
184
185        (artifacts, run_error, latency, predicates)
186    }));
187
188    match scored {
189        Ok((artifacts, run_error, latency, predicates)) => {
190            // A case passes iff the run didn't error AND every predicate held. (A scored-but-error'd run
191            // can't pass — partial state is unreliable — but we still record which predicates held.)
192            let passed = run_error.is_none() && predicates.iter().all(|(_, p)| *p);
193            // The report keeps tool calls as DISPLAY strings (shape-compatible with the saved JSON /
194            // `--json` / HTML report). Derive them here from the structured `ToolCall`s the artifacts
195            // carry, so a host never formats them itself.
196            let tool_calls: Vec<String> =
197                artifacts.tool_calls.iter().map(ToolCall::display).collect();
198            CaseOutcome::new(
199                case.name.clone(),
200                passed,
201                predicates,
202                latency,
203                artifacts.tokens,
204                tool_calls,
205                artifacts.final_text,
206                run_error,
207                artifacts.transcript,
208            )
209        }
210        Err(payload) => {
211            // Recover a message from the panic payload (most panics carry a `&str` or `String`).
212            let msg = payload
213                .downcast_ref::<&str>()
214                .map(|s| (*s).to_owned())
215                .or_else(|| payload.downcast_ref::<String>().cloned())
216                .unwrap_or_else(|| "unknown panic".to_owned());
217            // A panic may have struck anywhere in build/run/score, so we can't trust any partial
218            // per-predicate labels. Record one failed predicate per `expect` with a positional label.
219            let predicates = (0..case.expect.len())
220                .map(|i| (format!("predicate #{i}"), false))
221                .collect();
222            CaseOutcome::new(
223                case.name.clone(),
224                false,
225                predicates,
226                started.elapsed(),
227                None,
228                Vec::new(),
229                None,
230                Some(format!("panic: {msg}")),
231                Vec::new(),
232            )
233        }
234    }
235}
236
237/// An internal adapter turning an [`Agent`] into a `Harness<World = (), Setup = ()>` so the easy
238/// [`run_suite`] path reuses the exact same panic-isolated runner as the full [`Harness`] path.
239///
240/// A deliberate explicit struct rather than a blanket `impl<T: Agent> Harness for T`: a blanket impl
241/// would conflict (coherence) with a host's own `Harness` impl (e.g. AetherCore's `AetherHarness`), so
242/// the adapter keeps the two trait families independent. The world is `()` (built once per case, inert)
243/// and the agent's run failure is mapped onto [`Harness::run`]'s `anyhow::Result`.
244#[derive(Debug)]
245pub struct AgentHarness<'a, A: Agent> {
246    agent: &'a A,
247}
248
249impl<'a, A: Agent> AgentHarness<'a, A> {
250    /// Wrap an agent reference as a `Harness`.
251    pub fn new(agent: &'a A) -> Self {
252        Self { agent }
253    }
254}
255
256impl<A: Agent> Harness for AgentHarness<'_, A> {
257    type World = ();
258    type Setup = ();
259
260    fn setup(&self, _setup: &()) {}
261
262    fn run(&self, instruction: &str, _world: &mut ()) -> anyhow::Result<RunArtifacts> {
263        // Map the public `EvalError` to the runner's internal `anyhow::Result` (an `Err` fails the
264        // case and records the message, just like a `Harness` returning `Err`).
265        self.agent
266            .run(instruction)
267            .map_err(|e| anyhow::anyhow!(e.to_string()))
268    }
269}
270
271/// The easy path: run a suite of [`Expectation`]-based cases against an [`Agent`], scoring with the
272/// built-in [`BuiltinScorer`] — no `World`, no `Setup`, no host `Scorer` impl.
273///
274/// Implement [`Agent::run`] for your harness, author `EvalCase<(), Expectation>` cases (in RON or
275/// inline), and call this; you get back the same [`EvalReport`] as the full path. Uses default
276/// [`RunMeta`]; for a backend/temperature label use [`run_suite_with_meta`].
277pub fn run_suite(agent: &impl Agent, cases: &[EvalCase<(), Expectation>]) -> EvalReport {
278    run_suite_with_meta(agent, cases, RunMeta::default())
279}
280
281/// As [`run_suite`], but records explicit [`RunMeta`] (backend label, temperature, shared system prompt)
282/// on the report.
283pub fn run_suite_with_meta(
284    agent: &impl Agent,
285    cases: &[EvalCase<(), Expectation>],
286    meta: RunMeta,
287) -> EvalReport {
288    let harness = AgentHarness::new(agent);
289    run_eval_with_meta(&harness, &BuiltinScorer, cases, meta)
290}
291
292/// Collapse a (possibly multi-line) error/panic message to a single, length-bounded line for the live
293/// per-case AFTER line: take only up to the first newline, then truncate to `MAX` chars (on a char
294/// boundary, since panic messages can contain non-ASCII) with an ellipsis. The full message is still
295/// preserved verbatim in [`CaseOutcome::error`].
296fn truncate_one_line(msg: &str) -> String {
297    const MAX: usize = 120;
298    let first_line = msg.lines().next().unwrap_or("");
299    if first_line.chars().count() <= MAX {
300        first_line.to_owned()
301    } else {
302        let truncated: String = first_line.chars().take(MAX).collect();
303        format!("{truncated}…")
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    /// A world that is just a flag set by the harness, scored by a closure-free predicate.
312    #[derive(Default)]
313    struct W {
314        ok: bool,
315    }
316
317    /// `Setup` placeholder. The per-case behavior is keyed off the instruction string, so the setup
318    /// itself is inert; it only needs to satisfy [`Harness::Setup`].
319    #[derive(Clone, Copy, Default)]
320    struct How;
321
322    struct H;
323
324    impl Harness for H {
325        type World = W;
326        type Setup = How;
327
328        fn setup(&self, _setup: &How) -> W {
329            W::default()
330        }
331
332        fn run(&self, instruction: &str, world: &mut W) -> anyhow::Result<RunArtifacts> {
333            // The behavior is keyed off the instruction string the test sets per case.
334            match instruction {
335                "pass" => {
336                    world.ok = true;
337                    Ok(RunArtifacts {
338                        tokens: Some(7),
339                        ..RunArtifacts::default()
340                    })
341                }
342                "soft" => {
343                    world.ok = true; // predicate would pass, but the soft error still fails the case.
344                    Ok(RunArtifacts {
345                        error: Some("soft boom".to_owned()),
346                        ..RunArtifacts::default()
347                    })
348                }
349                "hard" => anyhow::bail!("hard boom"),
350                "panic" => panic!("kaboom"),
351                other => panic!("unexpected instruction {other}"),
352            }
353        }
354    }
355
356    struct Sc;
357
358    impl Scorer for Sc {
359        type World = W;
360        type Expect = ();
361
362        fn score(&self, _expect: &(), _artifacts: &RunArtifacts, world: &W) -> (String, bool) {
363            ("world.ok".to_owned(), world.ok)
364        }
365    }
366
367    fn case(name: &str, instruction: &str) -> EvalCase<How, ()> {
368        EvalCase {
369            name: name.to_owned(),
370            instruction: instruction.to_owned(),
371            setup: How,
372            expect: vec![()],
373        }
374    }
375
376    #[test]
377    fn pass_soft_hard_and_panic_are_isolated() {
378        let cases = vec![
379            case("pass", "pass"),
380            case("soft", "soft"),
381            case("hard", "hard"),
382            case("panic", "panic"),
383        ];
384
385        let report = run_eval(&H, &Sc, &cases);
386
387        assert_eq!(report.total(), 4);
388        assert_eq!(report.passed(), 1, "only the clean run passes");
389
390        // Pass: ok, no error, token count forwarded, predicate held.
391        let pass = &report.outcomes[0];
392        assert!(pass.passed);
393        assert_eq!(pass.tokens, Some(7));
394        assert!(pass.error.is_none());
395        assert_eq!(pass.predicates, vec![("world.ok".to_owned(), true)]);
396
397        // Soft error: predicate held but the captured error still fails the case.
398        let soft = &report.outcomes[1];
399        assert!(!soft.passed);
400        assert_eq!(soft.error.as_deref(), Some("soft boom"));
401        assert!(soft.predicates[0].1, "predicate itself held");
402
403        // Hard error (`Err` return): failed, error recorded, world scored as built (not ok).
404        let hard = &report.outcomes[2];
405        assert!(!hard.passed);
406        assert_eq!(hard.error.as_deref(), Some("hard boom"));
407        assert!(!hard.predicates[0].1);
408
409        // Panic: isolated to this case, recorded as a `panic:`-prefixed error, predicate forced failed.
410        let panicked = &report.outcomes[3];
411        assert!(!panicked.passed);
412        assert!(
413            panicked
414                .error
415                .as_deref()
416                .is_some_and(|e| e.contains("kaboom")),
417            "panic message captured: {:?}",
418            panicked.error
419        );
420        assert_eq!(panicked.predicates.len(), 1);
421        assert!(!panicked.predicates[0].1);
422    }
423
424    /// The easy `Agent` + `run_suite` path: one `Agent::run`, built-in `Expectation`s, no `World`,
425    /// `Setup`, or `Scorer`. Exercises tool-call + final-text assertions and the structured-→display
426    /// derivation in the runner.
427    #[test]
428    fn run_suite_scores_builtin_expectations_over_agent_artifacts() {
429        use crate::expect::Expectation;
430        use crate::harness::ToolCall;
431        use serde_json::json;
432
433        // A fake agent: if asked to "add", it emits a calculator call and ends with the sum; otherwise
434        // it just echoes (no tool call), and "boom" reports a run failure.
435        struct FakeAgent;
436        impl Agent for FakeAgent {
437            fn run(&self, instruction: &str) -> Result<RunArtifacts, crate::error::EvalError> {
438                if instruction == "boom" {
439                    return Err(crate::error::EvalError::agent("backend down"));
440                }
441                if instruction.contains("add") {
442                    Ok(RunArtifacts {
443                        tool_calls: vec![ToolCall::new(
444                            "calculator",
445                            json!({"op": "add", "a": 2, "b": 2}),
446                        )],
447                        final_text: Some("The answer is 4".to_owned()),
448                        ..RunArtifacts::default()
449                    })
450                } else {
451                    Ok(RunArtifacts {
452                        final_text: Some(instruction.to_owned()),
453                        ..RunArtifacts::default()
454                    })
455                }
456            }
457        }
458
459        fn case(
460            name: &str,
461            instruction: &str,
462            expect: Vec<Expectation>,
463        ) -> EvalCase<(), Expectation> {
464            EvalCase {
465                name: name.to_owned(),
466                instruction: instruction.to_owned(),
467                setup: (),
468                expect,
469            }
470        }
471
472        let cases = vec![
473            case(
474                "adds",
475                "please add 2 and 2",
476                vec![
477                    Expectation::CalledToolWith {
478                        tool: "calculator".into(),
479                        args: json!({"op": "add"}),
480                    },
481                    Expectation::FinalNumberEquals {
482                        value: 4.0,
483                        tolerance: 0.0,
484                    },
485                ],
486            ),
487            case(
488                "no-tools",
489                "hello there",
490                vec![
491                    Expectation::NoToolCalls,
492                    Expectation::FinalTextContains {
493                        text: "hello".into(),
494                        case_insensitive: false,
495                    },
496                ],
497            ),
498            case("fails-run", "boom", vec![Expectation::NoError]),
499        ];
500
501        let report = run_suite(&FakeAgent, &cases);
502        assert_eq!(report.total(), 3);
503        assert_eq!(
504            report.passed(),
505            2,
506            "the two well-behaved cases pass; the run-error case fails"
507        );
508
509        // The structured calls were rendered to the report's display strings.
510        let adds = &report.outcomes[0];
511        assert!(adds.passed);
512        assert_eq!(adds.tool_calls.len(), 1);
513        assert!(
514            adds.tool_calls[0].starts_with("calculator("),
515            "display string derived from the structured ToolCall: {:?}",
516            adds.tool_calls
517        );
518
519        let boom = &report.outcomes[2];
520        assert!(!boom.passed);
521        assert!(
522            boom.error
523                .as_deref()
524                .is_some_and(|e| e.contains("backend down"))
525        );
526    }
527}