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