eval_core/expect.rs
1//! The built-in **assertion library**: [`Expectation`], a serde/RON-authored predicate over a run's
2//! universal [`RunArtifacts`](crate::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`](crate::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}