Skip to main content

eval_core/
scorer.rs

1//! Scoring one expectation against a run's result, behind the [`Scorer`] trait — plus the
2//! batteries-included [`BuiltinScorer`] that needs NO host scoring code at all.
3//!
4//! The host implements [`Scorer`] over the SAME `World` its [`Harness`](crate::Harness) produces. For
5//! each of a case's `expect` predicates the runner calls [`Scorer::score`], collecting the returned
6//! `(label, passed)` pairs into the case's [`CaseOutcome::predicates`](crate::report::CaseOutcome). A
7//! case passes iff the run succeeded AND every predicate's `passed` is `true`.
8//!
9//! `score` now also receives the run's [`RunArtifacts`], so a scorer can assert on
10//! what the agent DID (tool calls, params, final text) and not only on the post-run world. The common
11//! case — "score the built-in [`Expectation`]s over the artifacts, ignoring
12//! the world" — is [`BuiltinScorer`], which a host gets for free via [`run_suite`](crate::run_suite).
13
14use crate::expect::Expectation;
15use crate::harness::RunArtifacts;
16
17/// Scores one expectation against the world AND artifacts a case produced. The host implements this for a
18/// custom predicate/world; for the built-in assertions use [`BuiltinScorer`].
19///
20/// `score` returns BOTH a human-readable label and the pass/fail in one call (the existing AetherCore
21/// scorer kept these as a paired `score()`/`label()`; folding them avoids re-deriving the label and
22/// guarantees the label always matches the verdict). The label is shown in the report's per-predicate
23/// diagnostics, so make it identify WHICH predicate it is (e.g. `"SolidPlaced(>= 4)"`).
24pub trait Scorer {
25    /// The world type to score against — must be the same world the paired
26    /// [`Harness::World`](crate::Harness::World) produces (the runner enforces
27    /// `Scorer<World = Harness::World>`).
28    type World;
29    /// The host's predicate type — matches the element type of [`EvalCase::expect`](crate::EvalCase::expect).
30    type Expect;
31
32    /// Score one `expect` predicate against the post-run `world` and the run's `artifacts`.
33    ///
34    /// Returns `(label, passed)`: a human-readable label for per-predicate diagnostics, and whether the
35    /// predicate held. Must be side-effect-free with respect to `world` (it takes a shared reference);
36    /// the runner may call it for several predicates over the same world. A scorer that only inspects
37    /// the world can ignore `artifacts` (and vice versa).
38    fn score(
39        &self,
40        expect: &Self::Expect,
41        artifacts: &RunArtifacts,
42        world: &Self::World,
43    ) -> (String, bool);
44}
45
46/// The batteries-included scorer: evaluates the built-in [`Expectation`](crate::expect) assertions over
47/// a run's [`RunArtifacts`], ignoring the world entirely (`World = ()`).
48///
49/// This is what removes the "implement a `Scorer`" step for the common case. Paired with the
50/// [`Agent`](crate::Agent) trait + [`run_suite`](crate::run_suite), a host scores tool-call / parameter /
51/// final-text assertions with zero scoring code of its own.
52///
53/// A malformed regex in a [`FinalTextMatches`](crate::expect::Expectation::FinalTextMatches) expectation
54/// can't surface a `Result` through the infallible [`Scorer::score`] signature, so it is reported as a
55/// FAILED predicate whose label names the regex error (rather than panicking or being silently dropped).
56#[derive(Debug, Default, Clone, Copy)]
57pub struct BuiltinScorer;
58
59impl Scorer for BuiltinScorer {
60    type World = ();
61    type Expect = Expectation;
62
63    fn score(&self, expect: &Expectation, artifacts: &RunArtifacts, _world: &()) -> (String, bool) {
64        match expect.evaluate(artifacts) {
65            Ok(result) => result,
66            // A bad regex is an authoring error; surface it as a clearly-labeled failed predicate so the
67            // report shows WHICH expectation is malformed instead of hiding it behind the infallible API.
68            Err(err) => (format!("{} [invalid: {err}]", expect.label()), false),
69        }
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::harness::ToolCall;
77    use serde_json::json;
78
79    fn artifacts() -> RunArtifacts {
80        RunArtifacts {
81            tool_calls: vec![ToolCall::new(
82                "calculator",
83                json!({"op": "add", "a": 2, "b": 2}),
84            )],
85            final_text: Some("The answer is 4".to_owned()),
86            ..RunArtifacts::default()
87        }
88    }
89
90    #[test]
91    fn builtin_scorer_evaluates_over_artifacts() {
92        let art = artifacts();
93        let (label, passed) = BuiltinScorer.score(
94            &Expectation::CalledToolWith {
95                tool: "calculator".into(),
96                args: json!({"op": "add"}),
97            },
98            &art,
99            &(),
100        );
101        assert!(passed);
102        assert!(label.starts_with("CalledToolWith("));
103
104        let (_, passed) = BuiltinScorer.score(
105            &Expectation::FinalNumberEquals {
106                value: 4.0,
107                tolerance: 0.0,
108            },
109            &art,
110            &(),
111        );
112        assert!(passed);
113    }
114
115    #[test]
116    fn builtin_scorer_reports_bad_regex_as_failed_labeled_predicate() {
117        let (label, passed) = BuiltinScorer.score(
118            &Expectation::FinalTextMatches { regex: "(".into() },
119            &artifacts(),
120            &(),
121        );
122        assert!(!passed, "a malformed regex scores as a failed predicate");
123        assert!(
124            label.contains("invalid:"),
125            "label names the regex error: {label}"
126        );
127    }
128}