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