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}