use crate::case::TraceExpects;
use crate::record::RunRecord;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct GradeResult {
pub check: String,
pub passed: bool,
pub detail: String,
}
impl GradeResult {
fn new(check: String, passed: bool, detail: impl Into<String>) -> Self {
Self {
check,
passed,
detail: detail.into(),
}
}
}
pub trait Grader: Send + Sync {
fn name(&self) -> &str;
fn grade(&self, run: &RunRecord) -> Vec<GradeResult>;
}
pub struct ExpectationsGrader {
pub expects: TraceExpects,
}
impl Grader for ExpectationsGrader {
fn name(&self) -> &str {
"expectations"
}
fn grade(&self, run: &RunRecord) -> Vec<GradeResult> {
evaluate_expects(&self.expects, run)
}
}
pub fn evaluate_expects(expects: &TraceExpects, run: &RunRecord) -> Vec<GradeResult> {
let mut out = Vec::new();
let resp = run.final_response.as_str();
for needle in &expects.response_contains {
let passed = resp.contains(needle);
out.push(GradeResult::new(
format!("response_contains({needle:?})"),
passed,
if passed {
"found".to_string()
} else {
format!("not found in response: {resp:?}")
},
));
}
for needle in &expects.response_not_contains {
let passed = !resp.contains(needle);
out.push(GradeResult::new(
format!("response_not_contains({needle:?})"),
passed,
if passed {
"absent".to_string()
} else {
format!("unexpectedly present in response: {resp:?}")
},
));
}
for tool in &expects.tools_used {
let passed = run.tools_called.iter().any(|t| t == tool);
out.push(GradeResult::new(
format!("tools_used({tool:?})"),
passed,
if passed {
"called".to_string()
} else {
format!("not called; tools called: {:?}", run.tools_called)
},
));
}
for tool in &expects.tools_not_used {
let passed = !run.tools_called.iter().any(|t| t == tool);
out.push(GradeResult::new(
format!("tools_not_used({tool:?})"),
passed,
if passed {
"not called".to_string()
} else {
"unexpectedly called".to_string()
},
));
}
if let Some(max) = expects.max_tool_calls {
let actual = run.tools_called.len();
let passed = actual <= max;
out.push(GradeResult::new(
format!("max_tool_calls({max})"),
passed,
format!("{actual} tool call(s)"),
));
}
if let Some(expected) = expects.all_tools_succeeded {
let passed = run.all_tools_succeeded == expected;
out.push(GradeResult::new(
format!("all_tools_succeeded({expected})"),
passed,
format!("actual all_tools_succeeded = {}", run.all_tools_succeeded),
));
}
for pattern in &expects.response_matches {
match regex::Regex::new(pattern) {
Ok(re) => {
let passed = re.is_match(resp);
out.push(GradeResult::new(
format!("response_matches({pattern:?})"),
passed,
if passed {
"matched".to_string()
} else {
format!("no match in response: {resp:?}")
},
));
}
Err(e) => out.push(GradeResult::new(
format!("response_matches({pattern:?})"),
false,
format!("invalid regex: {e}"),
)),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::case::TraceExpects;
use crate::record::RunRecord;
fn run(resp: &str, tools: &[&str], all_ok: bool) -> RunRecord {
RunRecord {
final_response: resp.to_string(),
history: Vec::new(),
tools_called: tools.iter().map(|s| s.to_string()).collect(),
all_tools_succeeded: all_ok,
input_tokens: 0,
output_tokens: 0,
}
}
#[test]
fn empty_expectations_produce_no_results() {
let out = evaluate_expects(&TraceExpects::default(), &run("hi", &[], true));
assert!(out.is_empty());
}
#[test]
fn response_contains_passes_and_fails() {
let expects = TraceExpects {
response_contains: vec!["hello".to_string(), "missing".to_string()],
..Default::default()
};
let out = evaluate_expects(&expects, &run("hello world", &[], true));
assert_eq!(out.len(), 2);
assert!(out[0].passed);
assert_eq!(out[0].check, r#"response_contains("hello")"#);
assert!(!out[1].passed);
}
#[test]
fn response_not_contains_inverts_the_check() {
let expects = TraceExpects {
response_not_contains: vec!["secret".to_string(), "world".to_string()],
..Default::default()
};
let out = evaluate_expects(&expects, &run("hello world", &[], true));
assert!(out[0].passed); assert!(!out[1].passed); }
#[test]
fn tools_used_and_not_used_are_evaluated_in_order() {
let expects = TraceExpects {
tools_used: vec!["search".to_string(), "absent".to_string()],
tools_not_used: vec!["danger".to_string(), "search".to_string()],
..Default::default()
};
let out = evaluate_expects(&expects, &run("", &["search", "read"], true));
assert!(out[0].passed); assert!(!out[1].passed); assert!(out[2].passed); assert!(!out[3].passed); }
#[test]
fn max_tool_calls_is_inclusive() {
let expects = TraceExpects {
max_tool_calls: Some(2),
..Default::default()
};
assert!(evaluate_expects(&expects, &run("", &["a", "b"], true))[0].passed);
assert!(!evaluate_expects(&expects, &run("", &["a", "b", "c"], true))[0].passed);
}
#[test]
fn all_tools_succeeded_matches_expected_value() {
let want_true = TraceExpects {
all_tools_succeeded: Some(true),
..Default::default()
};
assert!(evaluate_expects(&want_true, &run("", &[], true))[0].passed);
assert!(!evaluate_expects(&want_true, &run("", &[], false))[0].passed);
let want_false = TraceExpects {
all_tools_succeeded: Some(false),
..Default::default()
};
assert!(evaluate_expects(&want_false, &run("", &[], false))[0].passed);
}
#[test]
fn response_matches_regex_and_reports_invalid_pattern() {
let expects = TraceExpects {
response_matches: vec!["^h.*o$".to_string(), "(unclosed".to_string()],
..Default::default()
};
let out = evaluate_expects(&expects, &run("hello", &[], true));
assert!(out[0].passed); assert!(!out[1].passed); assert!(out[1].detail.contains("invalid regex"));
}
#[test]
fn invalid_response_regex_does_not_short_circuit_later_checks() {
let expects = TraceExpects {
response_matches: vec!["(unclosed".to_string(), "world$".to_string()],
..Default::default()
};
let out = evaluate_expects(&expects, &run("hello world", &[], true));
assert_eq!(out.len(), 2);
assert!(!out[0].passed);
assert!(out[0].detail.contains("invalid regex"));
assert!(out[1].passed);
assert_eq!(out[1].detail, "matched");
}
}