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};
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}