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}