Skip to main content

eval_core/
expect.rs

1//! The built-in **assertion library**: [`Expectation`], a serde/RON-authored predicate over a run's
2//! universal [`RunArtifacts`].
3//!
4//! This is what makes `eval-core` "pytest for agents": the common case needs NO user-implemented
5//! [`Scorer`](crate::Scorer). A test case is a prompt plus a list of `Expectation`s asserting what the
6//! agent DID — which tools it called, with which parameters, and what it finally said or computed —
7//! scored by [`BuiltinScorer`](crate::BuiltinScorer) over the artifacts every harness already returns.
8//!
9//! ## RON authoring
10//!
11//! Variants read cleanly as RON (the enum is `#[derive(Deserialize)]` with struct-style variants):
12//!
13//! ```ron
14//! (
15//!   name: "adds two numbers",
16//!   instruction: "what is 2 + 2?",
17//!   expect: [
18//!     CalledToolWith(tool: "calculator", args: { "op": "add", "a": 2, "b": 2 }),
19//!     FinalNumberEquals(value: 4.0),
20//!   ],
21//! )
22//! ```
23//!
24//! Note the case has NO `setup` field — it defaults to `()` for the easy [`Agent`](crate::Agent) path.
25//!
26//! ## Two matching rules to know
27//!
28//! - **Args subset match** ([`CalledToolWith`](Expectation::CalledToolWith)): the expected `args` JSON
29//!   must be a SUBSET of the actual call's args — objects recurse key-by-key, every other JSON value
30//!   (string/number/bool/null/array) must match exactly. So `{ "op": "add" }` matches a call made with
31//!   `{ "op": "add", "a": 2, "b": 2 }`, but `{ "op": "sub" }` does not. (Arrays are compared whole, not
32//!   element-subset.)
33//! - **Number extraction** ([`FinalNumberEquals`](Expectation::FinalNumberEquals)): the *last* number in
34//!   `final_text` is taken as the agent's answer (models typically end with the answer), then compared
35//!   to `value` within `tolerance` (default `0.0` = exact). "Number" = an optionally-signed integer or
36//!   decimal, with optional thousands separators stripped.
37
38use serde::Deserialize;
39use serde_json::Value;
40
41use crate::error::EvalError;
42use crate::harness::{RunArtifacts, ToolCall};
43
44/// A single built-in assertion over a run's [`RunArtifacts`].
45///
46/// A case PASSES iff the run did not error AND every `Expectation` holds. Evaluate one against a run via
47/// [`Expectation::evaluate`] (or let [`BuiltinScorer`](crate::BuiltinScorer) +
48/// [`run_suite`](crate::run_suite) do it). Each variant produces a clear, report-ready label via
49/// [`Expectation::label`].
50#[derive(Debug, Clone, Deserialize)]
51pub enum Expectation {
52    // --- Tool use -------------------------------------------------------------------------------
53    /// The agent called `tool` at least once (any args).
54    CalledTool {
55        /// The tool/function name that must appear among the calls.
56        tool: String,
57    },
58    /// The agent did NOT call `tool` at all.
59    DidNotCallTool {
60        /// The tool/function name that must be absent from the calls.
61        tool: String,
62    },
63    /// The agent called `tool` at least once with args that SUPERSET the given `args` (subset match —
64    /// see the module docs). The canonical "called X with these parameters" assertion.
65    CalledToolWith {
66        /// The tool/function name that must have been called.
67        tool: String,
68        /// The expected argument subset (objects recurse; other values match exactly).
69        args: Value,
70    },
71    /// The number of tool calls is within `[min, max]` (each optional). When `tool` is `Some`, only
72    /// calls to that tool are counted; when `None`, ALL calls are counted.
73    ToolCallCount {
74        /// Restrict the count to this tool; `None` counts every call.
75        #[serde(default)]
76        tool: Option<String>,
77        /// Inclusive lower bound on the count (optional).
78        #[serde(default)]
79        min: Option<usize>,
80        /// Inclusive upper bound on the count (optional).
81        #[serde(default)]
82        max: Option<usize>,
83    },
84    /// The named tools appear as a SUBSEQUENCE of the call order (in order, but not necessarily
85    /// contiguous — other calls may be interleaved). Empty `tools` trivially holds.
86    CalledToolsInOrder {
87        /// The tools that must appear in this relative order among the calls.
88        tools: Vec<String>,
89    },
90    /// The agent made NO tool calls at all (a pure-reasoning / refusal assertion).
91    NoToolCalls,
92
93    // --- Text -----------------------------------------------------------------------------------
94    /// `final_text` contains `text` (optionally case-insensitively). Fails when there is no final text.
95    FinalTextContains {
96        /// The substring that must be present in the final reply.
97        text: String,
98        /// Compare case-insensitively when `true` (default `false`, an exact-case substring match).
99        #[serde(default)]
100        case_insensitive: bool,
101    },
102    /// `final_text` equals `text` exactly (after trimming surrounding whitespace on both sides). Fails
103    /// when there is no final text.
104    FinalTextEquals {
105        /// The exact (trimmed) final reply expected.
106        text: String,
107    },
108    /// `final_text` matches the `regex` (anywhere, via [`regex::Regex::is_match`]). A malformed regex
109    /// is a hard [`EvalError::Regex`] from [`Expectation::evaluate`] (NOT a silent failure). Fails when
110    /// there is no final text.
111    FinalTextMatches {
112        /// The regular expression to match against the final reply.
113        regex: String,
114    },
115
116    // --- Math -----------------------------------------------------------------------------------
117    /// The LAST number in `final_text` equals `value` within `tolerance` (see the module docs for the
118    /// extraction rule). Fails when there is no final text or it contains no number.
119    FinalNumberEquals {
120        /// The expected numeric answer.
121        value: f64,
122        /// Allowed absolute difference (default `0.0` = exact match).
123        #[serde(default)]
124        tolerance: f64,
125    },
126
127    // --- Health ---------------------------------------------------------------------------------
128    /// The run reported no error ([`RunArtifacts::error`] is `None`). The runner already fails a case on
129    /// any run error, so this is mostly for an explicit, labeled "the run was clean" predicate.
130    NoError,
131}
132
133impl Expectation {
134    /// Evaluate this expectation against a run's `artifacts`, returning `(label, passed)`.
135    ///
136    /// The label identifies WHICH predicate it is (for the report's per-predicate diagnostics), matching
137    /// the labeling style of a hand-rolled scorer. The ONLY fallible case is
138    /// [`FinalTextMatches`](Expectation::FinalTextMatches) with a malformed regex, which returns
139    /// [`EvalError::Regex`]; every other variant is infallible.
140    pub fn evaluate(&self, artifacts: &RunArtifacts) -> Result<(String, bool), EvalError> {
141        let label = self.label();
142        let passed = match self {
143            Expectation::CalledTool { tool } => {
144                calls_to(&artifacts.tool_calls, tool).next().is_some()
145            }
146            Expectation::DidNotCallTool { tool } => {
147                calls_to(&artifacts.tool_calls, tool).next().is_none()
148            }
149            Expectation::CalledToolWith { tool, args } => calls_to(&artifacts.tool_calls, tool)
150                .any(|call| json_subset_matches(args, &call.args)),
151            Expectation::ToolCallCount { tool, min, max } => {
152                let count = match tool {
153                    Some(name) => calls_to(&artifacts.tool_calls, name).count(),
154                    None => artifacts.tool_calls.len(),
155                };
156                min.is_none_or(|lo| count >= lo) && max.is_none_or(|hi| count <= hi)
157            }
158            Expectation::CalledToolsInOrder { tools } => {
159                is_subsequence(tools, &artifacts.tool_calls)
160            }
161            Expectation::NoToolCalls => artifacts.tool_calls.is_empty(),
162
163            Expectation::FinalTextContains {
164                text,
165                case_insensitive,
166            } => match &artifacts.final_text {
167                Some(actual) if *case_insensitive => {
168                    actual.to_lowercase().contains(&text.to_lowercase())
169                }
170                Some(actual) => actual.contains(text),
171                None => false,
172            },
173            Expectation::FinalTextEquals { text } => artifacts
174                .final_text
175                .as_deref()
176                .is_some_and(|actual| actual.trim() == text.trim()),
177            Expectation::FinalTextMatches { regex } => {
178                // A bad regex is a hard error (a malformed assertion, not a failed one).
179                let re = regex::Regex::new(regex).map_err(|source| EvalError::Regex {
180                    pattern: regex.clone(),
181                    source,
182                })?;
183                artifacts
184                    .final_text
185                    .as_deref()
186                    .is_some_and(|actual| re.is_match(actual))
187            }
188
189            Expectation::FinalNumberEquals { value, tolerance } => artifacts
190                .final_text
191                .as_deref()
192                .and_then(last_number)
193                .is_some_and(|n| (n - value).abs() <= *tolerance),
194
195            Expectation::NoError => artifacts.error.is_none(),
196        };
197        Ok((label, passed))
198    }
199
200    /// A short, human-readable label identifying this expectation, for the report's per-predicate lines.
201    pub fn label(&self) -> String {
202        match self {
203            Expectation::CalledTool { tool } => format!("CalledTool({tool})"),
204            Expectation::DidNotCallTool { tool } => format!("DidNotCallTool({tool})"),
205            Expectation::CalledToolWith { tool, args } => format!("CalledToolWith({tool}, {args})"),
206            Expectation::ToolCallCount { tool, min, max } => format!(
207                "ToolCallCount({}, >= {min:?}, <= {max:?})",
208                tool.as_deref().unwrap_or("any")
209            ),
210            Expectation::CalledToolsInOrder { tools } => {
211                format!("CalledToolsInOrder({})", tools.join(" -> "))
212            }
213            Expectation::NoToolCalls => "NoToolCalls".to_owned(),
214            Expectation::FinalTextContains {
215                text,
216                case_insensitive,
217            } => {
218                if *case_insensitive {
219                    format!("FinalTextContains({text:?}, case-insensitive)")
220                } else {
221                    format!("FinalTextContains({text:?})")
222                }
223            }
224            Expectation::FinalTextEquals { text } => format!("FinalTextEquals({text:?})"),
225            Expectation::FinalTextMatches { regex } => format!("FinalTextMatches({regex:?})"),
226            Expectation::FinalNumberEquals { value, tolerance } => {
227                if *tolerance == 0.0 {
228                    format!("FinalNumberEquals({value})")
229                } else {
230                    format!("FinalNumberEquals({value} ± {tolerance})")
231                }
232            }
233            Expectation::NoError => "NoError".to_owned(),
234        }
235    }
236}
237
238/// Iterator over the calls made to `tool` (by name), in call order.
239fn calls_to<'a>(calls: &'a [ToolCall], tool: &'a str) -> impl Iterator<Item = &'a ToolCall> {
240    calls.iter().filter(move |c| c.name == tool)
241}
242
243/// Are `tools` a subsequence of the call names in `calls` — present in this relative order, though not
244/// necessarily contiguous? An empty `tools` trivially holds.
245fn is_subsequence(tools: &[String], calls: &[ToolCall]) -> bool {
246    let mut wanted = tools.iter();
247    let mut current = wanted.next();
248    for call in calls {
249        if let Some(want) = current
250            && call.name == *want
251        {
252            current = wanted.next();
253        }
254    }
255    current.is_none()
256}
257
258/// Is `expected` a SUBSET of `actual`? Objects recurse key-by-key (every key in `expected` must be
259/// present in `actual` with a subset-matching value); every other JSON value must equal exactly.
260///
261/// Arrays are compared WHOLE (not element-subset): `[1, 2]` matches only `[1, 2]`. This keeps the rule
262/// predictable for positional args like a `[x, y, z]` coordinate, where partial match would be
263/// surprising.
264fn json_subset_matches(expected: &Value, actual: &Value) -> bool {
265    match (expected, actual) {
266        (Value::Object(exp), Value::Object(act)) => exp.iter().all(|(k, exp_v)| {
267            act.get(k)
268                .is_some_and(|act_v| json_subset_matches(exp_v, act_v))
269        }),
270        // Exact match for scalars and arrays alike.
271        _ => expected == actual,
272    }
273}
274
275/// Extract the LAST number from `text` and return it as `f64`, or `None` if there is none.
276///
277/// "Number" = an optionally-signed run of digits with an optional single decimal point, with ASCII
278/// thousands-separator commas tolerated inside the integer part (`1,024` → `1024`). The LAST such token
279/// is returned because models typically end with the answer. A lone `-`/`.` (no digits) is not a number.
280fn last_number(text: &str) -> Option<f64> {
281    let bytes = text.as_bytes();
282    let mut last: Option<f64> = None;
283    let mut i = 0usize;
284    while i < bytes.len() {
285        // A number token may start with a sign, a digit, or a leading decimal point.
286        let start = i;
287        let mut j = i;
288        // Optional leading sign.
289        if j < bytes.len() && (bytes[j] == b'-' || bytes[j] == b'+') {
290            j += 1;
291        }
292        let digits_start = j;
293        let mut saw_digit = false;
294        let mut saw_dot = false;
295        while j < bytes.len() {
296            match bytes[j] {
297                b'0'..=b'9' => {
298                    saw_digit = true;
299                    j += 1;
300                }
301                // A comma is a thousands separator only between digits; otherwise it ends the token.
302                b',' if !saw_dot
303                    && saw_digit
304                    && j + 1 < bytes.len()
305                    && bytes[j + 1].is_ascii_digit() =>
306                {
307                    j += 1;
308                }
309                b'.' if !saw_dot => {
310                    saw_dot = true;
311                    j += 1;
312                }
313                _ => break,
314            }
315        }
316        if saw_digit && j > digits_start {
317            // Parse the token, stripping thousands-separator commas first.
318            let token: String = text[start..j].chars().filter(|&c| c != ',').collect();
319            if let Ok(n) = token.parse::<f64>() {
320                last = Some(n);
321            }
322            i = j;
323        } else {
324            // Not the start of a number; advance one byte (ASCII-safe; multibyte prose just advances).
325            i += 1;
326        }
327    }
328    last
329}
330
331#[cfg(test)]
332mod tests {
333    #![allow(clippy::approx_constant)] // number-extraction fixtures use realistic decimals (e.g. 3.14159)
334    use super::*;
335    use serde_json::json;
336
337    fn artifacts(calls: Vec<ToolCall>, final_text: Option<&str>) -> RunArtifacts {
338        RunArtifacts {
339            tool_calls: calls,
340            final_text: final_text.map(str::to_owned),
341            ..RunArtifacts::default()
342        }
343    }
344
345    fn pass(exp: &Expectation, art: &RunArtifacts) -> bool {
346        exp.evaluate(art).expect("infallible expectation").1
347    }
348
349    #[test]
350    fn called_tool_and_did_not_call_tool() {
351        let art = artifacts(
352            vec![ToolCall::new("calculator", json!({"op": "add"}))],
353            None,
354        );
355        assert!(pass(
356            &Expectation::CalledTool {
357                tool: "calculator".into()
358            },
359            &art
360        ));
361        assert!(!pass(
362            &Expectation::CalledTool {
363                tool: "search".into()
364            },
365            &art
366        ));
367        assert!(pass(
368            &Expectation::DidNotCallTool {
369                tool: "search".into()
370            },
371            &art
372        ));
373        assert!(!pass(
374            &Expectation::DidNotCallTool {
375                tool: "calculator".into()
376            },
377            &art
378        ));
379    }
380
381    #[test]
382    fn called_tool_with_subset_match() {
383        let art = artifacts(
384            vec![ToolCall::new(
385                "calculator",
386                json!({"op": "add", "a": 2, "b": 2}),
387            )],
388            None,
389        );
390        // Subset of the args → matches.
391        assert!(pass(
392            &Expectation::CalledToolWith {
393                tool: "calculator".into(),
394                args: json!({"op": "add"}),
395            },
396            &art
397        ));
398        // Wrong leaf value → no match.
399        assert!(!pass(
400            &Expectation::CalledToolWith {
401                tool: "calculator".into(),
402                args: json!({"op": "sub"}),
403            },
404            &art
405        ));
406        // Extra key the call didn't have → no match.
407        assert!(!pass(
408            &Expectation::CalledToolWith {
409                tool: "calculator".into(),
410                args: json!({"op": "add", "c": 9}),
411            },
412            &art
413        ));
414    }
415
416    #[test]
417    fn nested_subset_and_whole_array_match() {
418        let art = artifacts(
419            vec![ToolCall::new(
420                "set_voxel",
421                json!({"at": [1, 2, 3], "block": {"type": "stone", "hardness": 5}}),
422            )],
423            None,
424        );
425        // Nested object subset matches; array must match whole.
426        assert!(pass(
427            &Expectation::CalledToolWith {
428                tool: "set_voxel".into(),
429                args: json!({"block": {"type": "stone"}, "at": [1, 2, 3]}),
430            },
431            &art
432        ));
433        // A partial array does NOT match (arrays compared whole).
434        assert!(!pass(
435            &Expectation::CalledToolWith {
436                tool: "set_voxel".into(),
437                args: json!({"at": [1, 2]}),
438            },
439            &art
440        ));
441    }
442
443    #[test]
444    fn tool_call_count_bounds() {
445        let art = artifacts(
446            vec![
447                ToolCall::new("a", json!({})),
448                ToolCall::new("a", json!({})),
449                ToolCall::new("b", json!({})),
450            ],
451            None,
452        );
453        // Total count in [3,3].
454        assert!(pass(
455            &Expectation::ToolCallCount {
456                tool: None,
457                min: Some(3),
458                max: Some(3)
459            },
460            &art
461        ));
462        // Per-tool count for "a" is 2.
463        assert!(pass(
464            &Expectation::ToolCallCount {
465                tool: Some("a".into()),
466                min: Some(2),
467                max: Some(2)
468            },
469            &art
470        ));
471        // Too few of "b".
472        assert!(!pass(
473            &Expectation::ToolCallCount {
474                tool: Some("b".into()),
475                min: Some(2),
476                max: None
477            },
478            &art
479        ));
480    }
481
482    #[test]
483    fn called_tools_in_order_is_a_subsequence() {
484        let art = artifacts(
485            vec![
486                ToolCall::new("plan", json!({})),
487                ToolCall::new("search", json!({})),
488                ToolCall::new("write", json!({})),
489            ],
490            None,
491        );
492        // Subsequence (non-contiguous) holds.
493        assert!(pass(
494            &Expectation::CalledToolsInOrder {
495                tools: vec!["plan".into(), "write".into()]
496            },
497            &art
498        ));
499        // Wrong order fails.
500        assert!(!pass(
501            &Expectation::CalledToolsInOrder {
502                tools: vec!["write".into(), "plan".into()]
503            },
504            &art
505        ));
506        // Empty trivially holds.
507        assert!(pass(
508            &Expectation::CalledToolsInOrder { tools: vec![] },
509            &art
510        ));
511    }
512
513    #[test]
514    fn no_tool_calls() {
515        assert!(pass(
516            &Expectation::NoToolCalls,
517            &artifacts(vec![], Some("hi"))
518        ));
519        assert!(!pass(
520            &Expectation::NoToolCalls,
521            &artifacts(vec![ToolCall::new("x", json!({}))], None)
522        ));
523    }
524
525    #[test]
526    fn final_text_contains_equals_matches() {
527        let art = artifacts(vec![], Some("The answer is Forty-Two."));
528        assert!(pass(
529            &Expectation::FinalTextContains {
530                text: "Forty-Two".into(),
531                case_insensitive: false
532            },
533            &art
534        ));
535        // Case-sensitive miss, then case-insensitive hit.
536        assert!(!pass(
537            &Expectation::FinalTextContains {
538                text: "forty-two".into(),
539                case_insensitive: false
540            },
541            &art
542        ));
543        assert!(pass(
544            &Expectation::FinalTextContains {
545                text: "forty-two".into(),
546                case_insensitive: true
547            },
548            &art
549        ));
550        // Equals trims surrounding whitespace.
551        let trimmed = artifacts(vec![], Some("  done  "));
552        assert!(pass(
553            &Expectation::FinalTextEquals {
554                text: "done".into()
555            },
556            &trimmed
557        ));
558        // Regex.
559        assert!(pass(
560            &Expectation::FinalTextMatches {
561                regex: r"answer is \w+-\w+".into()
562            },
563            &art
564        ));
565    }
566
567    #[test]
568    fn final_text_matches_bad_regex_is_an_error() {
569        let art = artifacts(vec![], Some("x"));
570        let err = Expectation::FinalTextMatches { regex: "(".into() }
571            .evaluate(&art)
572            .expect_err("a malformed regex is a hard error");
573        assert!(matches!(err, EvalError::Regex { .. }));
574    }
575
576    #[test]
577    fn final_number_extraction_takes_the_last_number() {
578        // The last number is the answer even with earlier numbers in the text.
579        let art = artifacts(vec![], Some("Adding 2 and 2 gives 4"));
580        assert!(pass(
581            &Expectation::FinalNumberEquals {
582                value: 4.0,
583                tolerance: 0.0
584            },
585            &art
586        ));
587        // Tolerance.
588        let art = artifacts(vec![], Some("approximately 3.1399"));
589        assert!(pass(
590            &Expectation::FinalNumberEquals {
591                value: 3.14159,
592                tolerance: 0.01
593            },
594            &art
595        ));
596        assert!(!pass(
597            &Expectation::FinalNumberEquals {
598                value: 3.14159,
599                tolerance: 0.0001
600            },
601            &art
602        ));
603        // Negative + thousands separators.
604        let art = artifacts(vec![], Some("the balance is -1,024.50"));
605        assert!(pass(
606            &Expectation::FinalNumberEquals {
607                value: -1024.5,
608                tolerance: 0.0
609            },
610            &art
611        ));
612        // No number → fails (rather than panicking).
613        let art = artifacts(vec![], Some("no digits here"));
614        assert!(!pass(
615            &Expectation::FinalNumberEquals {
616                value: 1.0,
617                tolerance: 0.0
618            },
619            &art
620        ));
621    }
622
623    #[test]
624    fn no_error_reflects_artifacts_error() {
625        let mut art = artifacts(vec![], None);
626        assert!(pass(&Expectation::NoError, &art));
627        art.error = Some("boom".into());
628        assert!(!pass(&Expectation::NoError, &art));
629    }
630
631    /// Expectations deserialize from the RON authoring form shown in the module docs.
632    #[test]
633    fn expectations_deserialize_from_ron() {
634        let exps: Vec<Expectation> = ron::from_str(
635            r#"[
636                CalledToolWith(tool: "calculator", args: { "op": "add", "a": 2, "b": 2 }),
637                FinalNumberEquals(value: 4.0),
638                ToolCallCount(tool: Some("calculator"), max: Some(1)),
639                FinalTextContains(text: "four", case_insensitive: true),
640                NoToolCalls,
641            ]"#,
642        )
643        .expect("RON parses");
644        assert_eq!(exps.len(), 5);
645        assert!(matches!(exps[4], Expectation::NoToolCalls));
646    }
647}