use serde::Deserialize;
use serde_json::Value;
use crate::error::EvalError;
use crate::harness::{RunArtifacts, ToolCall};
#[derive(Debug, Clone, Deserialize)]
pub enum Expectation {
CalledTool {
tool: String,
},
DidNotCallTool {
tool: String,
},
CalledToolWith {
tool: String,
args: Value,
},
ToolCallCount {
#[serde(default)]
tool: Option<String>,
#[serde(default)]
min: Option<usize>,
#[serde(default)]
max: Option<usize>,
},
CalledToolsInOrder {
tools: Vec<String>,
},
NoToolCalls,
FinalTextContains {
text: String,
#[serde(default)]
case_insensitive: bool,
},
FinalTextEquals {
text: String,
},
FinalTextMatches {
regex: String,
},
FinalNumberEquals {
value: f64,
#[serde(default)]
tolerance: f64,
},
NoError,
}
impl Expectation {
pub fn evaluate(&self, artifacts: &RunArtifacts) -> Result<(String, bool), EvalError> {
let label = self.label();
let passed = match self {
Expectation::CalledTool { tool } => {
calls_to(&artifacts.tool_calls, tool).next().is_some()
}
Expectation::DidNotCallTool { tool } => {
calls_to(&artifacts.tool_calls, tool).next().is_none()
}
Expectation::CalledToolWith { tool, args } => calls_to(&artifacts.tool_calls, tool)
.any(|call| json_subset_matches(args, &call.args)),
Expectation::ToolCallCount { tool, min, max } => {
let count = match tool {
Some(name) => calls_to(&artifacts.tool_calls, name).count(),
None => artifacts.tool_calls.len(),
};
min.is_none_or(|lo| count >= lo) && max.is_none_or(|hi| count <= hi)
}
Expectation::CalledToolsInOrder { tools } => {
is_subsequence(tools, &artifacts.tool_calls)
}
Expectation::NoToolCalls => artifacts.tool_calls.is_empty(),
Expectation::FinalTextContains {
text,
case_insensitive,
} => match &artifacts.final_text {
Some(actual) if *case_insensitive => {
actual.to_lowercase().contains(&text.to_lowercase())
}
Some(actual) => actual.contains(text),
None => false,
},
Expectation::FinalTextEquals { text } => artifacts
.final_text
.as_deref()
.is_some_and(|actual| actual.trim() == text.trim()),
Expectation::FinalTextMatches { regex } => {
let re = regex::Regex::new(regex).map_err(|source| EvalError::Regex {
pattern: regex.clone(),
source,
})?;
artifacts
.final_text
.as_deref()
.is_some_and(|actual| re.is_match(actual))
}
Expectation::FinalNumberEquals { value, tolerance } => artifacts
.final_text
.as_deref()
.and_then(last_number)
.is_some_and(|n| (n - value).abs() <= *tolerance),
Expectation::NoError => artifacts.error.is_none(),
};
Ok((label, passed))
}
pub fn label(&self) -> String {
match self {
Expectation::CalledTool { tool } => format!("CalledTool({tool})"),
Expectation::DidNotCallTool { tool } => format!("DidNotCallTool({tool})"),
Expectation::CalledToolWith { tool, args } => format!("CalledToolWith({tool}, {args})"),
Expectation::ToolCallCount { tool, min, max } => format!(
"ToolCallCount({}, >= {min:?}, <= {max:?})",
tool.as_deref().unwrap_or("any")
),
Expectation::CalledToolsInOrder { tools } => {
format!("CalledToolsInOrder({})", tools.join(" -> "))
}
Expectation::NoToolCalls => "NoToolCalls".to_owned(),
Expectation::FinalTextContains {
text,
case_insensitive,
} => {
if *case_insensitive {
format!("FinalTextContains({text:?}, case-insensitive)")
} else {
format!("FinalTextContains({text:?})")
}
}
Expectation::FinalTextEquals { text } => format!("FinalTextEquals({text:?})"),
Expectation::FinalTextMatches { regex } => format!("FinalTextMatches({regex:?})"),
Expectation::FinalNumberEquals { value, tolerance } => {
if *tolerance == 0.0 {
format!("FinalNumberEquals({value})")
} else {
format!("FinalNumberEquals({value} ± {tolerance})")
}
}
Expectation::NoError => "NoError".to_owned(),
}
}
}
fn calls_to<'a>(calls: &'a [ToolCall], tool: &'a str) -> impl Iterator<Item = &'a ToolCall> {
calls.iter().filter(move |c| c.name == tool)
}
fn is_subsequence(tools: &[String], calls: &[ToolCall]) -> bool {
let mut wanted = tools.iter();
let mut current = wanted.next();
for call in calls {
if let Some(want) = current
&& call.name == *want
{
current = wanted.next();
}
}
current.is_none()
}
fn json_subset_matches(expected: &Value, actual: &Value) -> bool {
match (expected, actual) {
(Value::Object(exp), Value::Object(act)) => exp.iter().all(|(k, exp_v)| {
act.get(k)
.is_some_and(|act_v| json_subset_matches(exp_v, act_v))
}),
_ => expected == actual,
}
}
fn last_number(text: &str) -> Option<f64> {
let bytes = text.as_bytes();
let mut last: Option<f64> = None;
let mut i = 0usize;
while i < bytes.len() {
let start = i;
let mut j = i;
if j < bytes.len() && (bytes[j] == b'-' || bytes[j] == b'+') {
j += 1;
}
let digits_start = j;
let mut saw_digit = false;
let mut saw_dot = false;
while j < bytes.len() {
match bytes[j] {
b'0'..=b'9' => {
saw_digit = true;
j += 1;
}
b',' if !saw_dot
&& saw_digit
&& j + 1 < bytes.len()
&& bytes[j + 1].is_ascii_digit() =>
{
j += 1;
}
b'.' if !saw_dot => {
saw_dot = true;
j += 1;
}
_ => break,
}
}
if saw_digit && j > digits_start {
let token: String = text[start..j].chars().filter(|&c| c != ',').collect();
if let Ok(n) = token.parse::<f64>() {
last = Some(n);
}
i = j;
} else {
i += 1;
}
}
last
}
#[cfg(test)]
mod tests {
#![allow(clippy::approx_constant)] use super::*;
use serde_json::json;
fn artifacts(calls: Vec<ToolCall>, final_text: Option<&str>) -> RunArtifacts {
RunArtifacts {
tool_calls: calls,
final_text: final_text.map(str::to_owned),
..RunArtifacts::default()
}
}
fn pass(exp: &Expectation, art: &RunArtifacts) -> bool {
exp.evaluate(art).expect("infallible expectation").1
}
#[test]
fn called_tool_and_did_not_call_tool() {
let art = artifacts(
vec![ToolCall::new("calculator", json!({"op": "add"}))],
None,
);
assert!(pass(
&Expectation::CalledTool {
tool: "calculator".into()
},
&art
));
assert!(!pass(
&Expectation::CalledTool {
tool: "search".into()
},
&art
));
assert!(pass(
&Expectation::DidNotCallTool {
tool: "search".into()
},
&art
));
assert!(!pass(
&Expectation::DidNotCallTool {
tool: "calculator".into()
},
&art
));
}
#[test]
fn called_tool_with_subset_match() {
let art = artifacts(
vec![ToolCall::new(
"calculator",
json!({"op": "add", "a": 2, "b": 2}),
)],
None,
);
assert!(pass(
&Expectation::CalledToolWith {
tool: "calculator".into(),
args: json!({"op": "add"}),
},
&art
));
assert!(!pass(
&Expectation::CalledToolWith {
tool: "calculator".into(),
args: json!({"op": "sub"}),
},
&art
));
assert!(!pass(
&Expectation::CalledToolWith {
tool: "calculator".into(),
args: json!({"op": "add", "c": 9}),
},
&art
));
}
#[test]
fn nested_subset_and_whole_array_match() {
let art = artifacts(
vec![ToolCall::new(
"set_voxel",
json!({"at": [1, 2, 3], "block": {"type": "stone", "hardness": 5}}),
)],
None,
);
assert!(pass(
&Expectation::CalledToolWith {
tool: "set_voxel".into(),
args: json!({"block": {"type": "stone"}, "at": [1, 2, 3]}),
},
&art
));
assert!(!pass(
&Expectation::CalledToolWith {
tool: "set_voxel".into(),
args: json!({"at": [1, 2]}),
},
&art
));
}
#[test]
fn tool_call_count_bounds() {
let art = artifacts(
vec![
ToolCall::new("a", json!({})),
ToolCall::new("a", json!({})),
ToolCall::new("b", json!({})),
],
None,
);
assert!(pass(
&Expectation::ToolCallCount {
tool: None,
min: Some(3),
max: Some(3)
},
&art
));
assert!(pass(
&Expectation::ToolCallCount {
tool: Some("a".into()),
min: Some(2),
max: Some(2)
},
&art
));
assert!(!pass(
&Expectation::ToolCallCount {
tool: Some("b".into()),
min: Some(2),
max: None
},
&art
));
}
#[test]
fn called_tools_in_order_is_a_subsequence() {
let art = artifacts(
vec![
ToolCall::new("plan", json!({})),
ToolCall::new("search", json!({})),
ToolCall::new("write", json!({})),
],
None,
);
assert!(pass(
&Expectation::CalledToolsInOrder {
tools: vec!["plan".into(), "write".into()]
},
&art
));
assert!(!pass(
&Expectation::CalledToolsInOrder {
tools: vec!["write".into(), "plan".into()]
},
&art
));
assert!(pass(
&Expectation::CalledToolsInOrder { tools: vec![] },
&art
));
}
#[test]
fn no_tool_calls() {
assert!(pass(
&Expectation::NoToolCalls,
&artifacts(vec![], Some("hi"))
));
assert!(!pass(
&Expectation::NoToolCalls,
&artifacts(vec![ToolCall::new("x", json!({}))], None)
));
}
#[test]
fn final_text_contains_equals_matches() {
let art = artifacts(vec![], Some("The answer is Forty-Two."));
assert!(pass(
&Expectation::FinalTextContains {
text: "Forty-Two".into(),
case_insensitive: false
},
&art
));
assert!(!pass(
&Expectation::FinalTextContains {
text: "forty-two".into(),
case_insensitive: false
},
&art
));
assert!(pass(
&Expectation::FinalTextContains {
text: "forty-two".into(),
case_insensitive: true
},
&art
));
let trimmed = artifacts(vec![], Some(" done "));
assert!(pass(
&Expectation::FinalTextEquals {
text: "done".into()
},
&trimmed
));
assert!(pass(
&Expectation::FinalTextMatches {
regex: r"answer is \w+-\w+".into()
},
&art
));
}
#[test]
fn final_text_matches_bad_regex_is_an_error() {
let art = artifacts(vec![], Some("x"));
let err = Expectation::FinalTextMatches { regex: "(".into() }
.evaluate(&art)
.expect_err("a malformed regex is a hard error");
assert!(matches!(err, EvalError::Regex { .. }));
}
#[test]
fn final_number_extraction_takes_the_last_number() {
let art = artifacts(vec![], Some("Adding 2 and 2 gives 4"));
assert!(pass(
&Expectation::FinalNumberEquals {
value: 4.0,
tolerance: 0.0
},
&art
));
let art = artifacts(vec![], Some("approximately 3.1399"));
assert!(pass(
&Expectation::FinalNumberEquals {
value: 3.14159,
tolerance: 0.01
},
&art
));
assert!(!pass(
&Expectation::FinalNumberEquals {
value: 3.14159,
tolerance: 0.0001
},
&art
));
let art = artifacts(vec![], Some("the balance is -1,024.50"));
assert!(pass(
&Expectation::FinalNumberEquals {
value: -1024.5,
tolerance: 0.0
},
&art
));
let art = artifacts(vec![], Some("no digits here"));
assert!(!pass(
&Expectation::FinalNumberEquals {
value: 1.0,
tolerance: 0.0
},
&art
));
}
#[test]
fn no_error_reflects_artifacts_error() {
let mut art = artifacts(vec![], None);
assert!(pass(&Expectation::NoError, &art));
art.error = Some("boom".into());
assert!(!pass(&Expectation::NoError, &art));
}
#[test]
fn expectations_deserialize_from_ron() {
let exps: Vec<Expectation> = ron::from_str(
r#"[
CalledToolWith(tool: "calculator", args: { "op": "add", "a": 2, "b": 2 }),
FinalNumberEquals(value: 4.0),
ToolCallCount(tool: Some("calculator"), max: Some(1)),
FinalTextContains(text: "four", case_insensitive: true),
NoToolCalls,
]"#,
)
.expect("RON parses");
assert_eq!(exps.len(), 5);
assert!(matches!(exps[4], Expectation::NoToolCalls));
}
}