1use std::collections::{HashMap, VecDeque};
11use std::sync::{Arc, Mutex};
12
13use async_trait::async_trait;
14use futures::StreamExt;
15use serde::Serialize;
16use serde_json::Value;
17
18use crate::agent::{Agent, RunOutcome};
19use crate::event::{Event, EventKind};
20use crate::journal::{InMemoryJournal, Journal, JournalError};
21use crate::message::{Content, Message};
22use crate::model::{Model, Usage};
23use crate::policy::AllowAll;
24use crate::state::{RunState, Status};
25use crate::tool::{Effect, Tool, ToolCtx, ToolError, ToolSpec};
26
27#[derive(Debug, Clone, Serialize)]
29pub struct RunSummary {
30 pub status: Status,
31 pub output: Option<String>,
32 pub structured: Option<Value>,
33 pub steps: u32,
34 pub usage: Usage,
35 pub cost_usd: f64,
36}
37
38impl RunSummary {
39 fn from_state(s: &RunState) -> Self {
40 Self {
41 output: match &s.status {
42 Status::Completed { output } => Some(output.clone()),
43 _ => None,
44 },
45 status: s.status.clone(),
46 structured: s.structured_output.clone(),
47 steps: s.steps,
48 usage: s.usage,
49 cost_usd: s.cost_usd,
50 }
51 }
52
53 fn from_outcome(o: &RunOutcome) -> Self {
54 Self {
55 status: o.status.clone(),
56 output: o.output().map(str::to_owned),
57 structured: o.structured.clone(),
58 steps: o.steps,
59 usage: o.usage,
60 cost_usd: o.cost_usd,
61 }
62 }
63
64 pub fn completed(&self) -> bool {
65 matches!(self.status, Status::Completed { .. })
66 }
67}
68
69#[derive(Debug, Clone)]
70struct Recording {
71 tool: String,
72 input: Value,
73 output: Value,
74 is_error: bool,
75}
76
77#[derive(Debug, Clone)]
79pub struct EvalCase {
80 pub run_id: String,
81 pub input: Message,
82 pub baseline: RunSummary,
83 recordings: Vec<Recording>,
84}
85
86impl EvalCase {
87 pub fn from_events(events: &[Event]) -> Option<Self> {
89 let state = RunState::from_events(events)?;
90 let input = events.iter().find_map(|e| match &e.kind {
91 EventKind::RunStarted { input, .. } => Some(input.clone()),
92 _ => None,
93 })?;
94 let mut calls: HashMap<String, (String, Value)> = HashMap::new();
95 let mut recordings = Vec::new();
96 for e in events {
97 match &e.kind {
98 EventKind::ModelCalled { response, .. } => {
99 for c in &response.content {
100 if let Content::ToolUse { id, name, input } = c {
101 calls.insert(id.clone(), (name.clone(), input.clone()));
102 }
103 }
104 }
105 EventKind::ToolCompleted { call_id, output, is_error, .. } => {
106 if let Some((tool, input)) = calls.get(call_id) {
107 recordings.push(Recording {
108 tool: tool.clone(),
109 input: input.clone(),
110 output: output.clone(),
111 is_error: *is_error,
112 });
113 }
114 }
115 _ => {}
116 }
117 }
118 Some(Self { run_id: state.run_id.clone(), input, baseline: RunSummary::from_state(&state), recordings })
119 }
120
121 pub async fn load(journal: &dyn Journal, run_ids: &[String]) -> Result<Vec<Self>, JournalError> {
123 let mut cases = Vec::new();
124 for id in run_ids {
125 if let Some(case) = Self::from_events(&journal.load(id).await?) {
126 cases.push(case);
127 }
128 }
129 Ok(cases)
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum LiveTools {
136 Never,
138 #[default]
140 ReadOnly,
141}
142
143struct ReplayTool {
145 inner: Arc<dyn Tool>,
146 recorded: Mutex<VecDeque<Recording>>,
147 live: LiveTools,
148}
149
150#[async_trait]
151impl Tool for ReplayTool {
152 fn spec(&self) -> &ToolSpec {
153 self.inner.spec()
154 }
155
156 fn timeout(&self) -> Option<std::time::Duration> {
157 self.inner.timeout()
158 }
159
160 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<Value, ToolError> {
161 let hit = {
162 let mut recorded = self.recorded.lock().unwrap();
163 recorded.iter().position(|r| r.input == input).and_then(|i| recorded.remove(i))
164 };
165 if let Some(r) = hit {
166 return if r.is_error {
167 Err(ToolError::new(
168 r.output["error"].as_str().map(str::to_owned).unwrap_or_else(|| r.output.to_string()),
169 ))
170 } else {
171 Ok(r.output)
172 };
173 }
174 let spec = self.inner.spec();
175 if self.live == LiveTools::ReadOnly && spec.effect == Effect::Read {
176 return self.inner.call(input, ctx).await;
177 }
178 Err(ToolError::new(format!(
179 "`{}` was not executed: this is an evaluation replay and no recorded result matches these arguments",
180 spec.name
181 )))
182 }
183}
184
185#[derive(Debug, Clone, Serialize)]
187pub struct Score {
188 pub value: f64,
189 pub note: String,
190 pub cost_usd: f64,
192}
193
194impl Score {
195 pub fn new(value: f64, note: impl Into<String>) -> Self {
196 Self { value: value.clamp(0.0, 1.0), note: note.into(), cost_usd: 0.0 }
197 }
198}
199
200#[async_trait]
201pub trait Scorer: Send + Sync {
202 fn name(&self) -> String;
203 async fn score(&self, case: &EvalCase, candidate: &RunSummary) -> Score;
204}
205
206pub struct Completed;
208
209#[async_trait]
210impl Scorer for Completed {
211 fn name(&self) -> String {
212 "completed".into()
213 }
214 async fn score(&self, _: &EvalCase, c: &RunSummary) -> Score {
215 if c.completed() { Score::new(1.0, "completed") } else { Score::new(0.0, format!("{:?}", c.status)) }
216 }
217}
218
219pub struct FieldsMatch(pub Vec<String>);
221
222impl FieldsMatch {
223 pub fn new(fields: &[&str]) -> Self {
224 Self(fields.iter().map(|f| (*f).to_owned()).collect())
225 }
226}
227
228#[async_trait]
229impl Scorer for FieldsMatch {
230 fn name(&self) -> String {
231 format!("fields_match({})", self.0.join(","))
232 }
233 async fn score(&self, case: &EvalCase, c: &RunSummary) -> Score {
234 let (Some(b), Some(s)) = (&case.baseline.structured, &c.structured) else {
235 return Score::new(0.0, "missing structured output");
236 };
237 let differing: Vec<&str> =
238 self.0.iter().filter(|f| b[f.as_str()] != s[f.as_str()]).map(String::as_str).collect();
239 let value = 1.0 - differing.len() as f64 / self.0.len().max(1) as f64;
240 Score::new(
241 value,
242 if differing.is_empty() { "all match".into() } else { format!("differs: {}", differing.join(", ")) },
243 )
244 }
245}
246
247pub struct Judge {
249 model: Arc<dyn Model>,
250 rubric: String,
251}
252
253impl Judge {
254 pub fn new(model: impl Model + 'static, rubric: impl Into<String>) -> Self {
255 Self { model: Arc::new(model), rubric: rubric.into() }
256 }
257}
258
259#[async_trait]
260impl Scorer for Judge {
261 fn name(&self) -> String {
262 "judge".into()
263 }
264
265 async fn score(&self, case: &EvalCase, c: &RunSummary) -> Score {
266 let answer = |s: &RunSummary| {
267 s.structured
268 .as_ref()
269 .map(Value::to_string)
270 .or_else(|| s.output.clone())
271 .unwrap_or_else(|| "(no answer)".into())
272 };
273 let prompt = format!(
274 "Grade a candidate answer against a reference answer to the same task.\n\nRubric: {}\n\n\
275 Task:\n{}\n\nReference answer:\n{}\n\nCandidate answer:\n{}\n\n\
276 Reply with only JSON: {{\"score\": <0.0 to 1.0>, \"reason\": \"<one sentence>\"}}",
277 self.rubric,
278 case.input.text(),
279 answer(&case.baseline),
280 answer(c),
281 );
282 let request =
283 crate::model::ModelRequest { messages: vec![Message::user(prompt)], max_tokens: 512, ..Default::default() };
284 match self.model.complete(&request).await {
285 Ok(resp) => {
286 let cost = self.model.cost_usd(&resp).unwrap_or(0.0);
287 let text = crate::message::content_text(&resp.content);
288 let parsed = crate::output::extract_json(&text);
289 let mut score = match parsed.as_ref().and_then(|v| v["score"].as_f64()) {
290 Some(v) => Score::new(v, parsed.as_ref().and_then(|p| p["reason"].as_str()).unwrap_or_default()),
291 None => Score::new(0.0, format!("judge reply unreadable: {text}")),
292 };
293 score.cost_usd = cost;
294 score
295 }
296 Err(e) => Score::new(0.0, format!("judge failed: {e}")),
297 }
298 }
299}
300
301#[derive(Debug, Clone, Serialize)]
302pub struct CaseResult {
303 pub run_id: String,
304 pub baseline: RunSummary,
305 pub candidate: Option<RunSummary>,
306 pub scores: Vec<(String, Score)>,
307 pub error: Option<String>,
308}
309
310#[derive(Debug, Clone, Serialize)]
311pub struct EvalReport {
312 pub cases: Vec<CaseResult>,
313}
314
315impl EvalReport {
316 pub fn mean(&self, scorer: &str) -> f64 {
318 if self.cases.is_empty() {
319 return 0.0;
320 }
321 let total: f64 =
322 self.cases.iter().map(|c| c.scores.iter().find(|(n, _)| n == scorer).map_or(0.0, |(_, s)| s.value)).sum();
323 total / self.cases.len() as f64
324 }
325
326 pub fn pass_rate(&self, threshold: f64) -> f64 {
328 if self.cases.is_empty() {
329 return 0.0;
330 }
331 let passed = self
332 .cases
333 .iter()
334 .filter(|c| c.error.is_none() && !c.scores.is_empty() && c.scores.iter().all(|(_, s)| s.value >= threshold))
335 .count();
336 passed as f64 / self.cases.len() as f64
337 }
338
339 pub fn baseline_cost(&self) -> f64 {
340 self.cases.iter().map(|c| c.baseline.cost_usd).sum()
341 }
342
343 pub fn candidate_cost(&self) -> f64 {
345 self.cases
346 .iter()
347 .map(|c| {
348 c.candidate.as_ref().map_or(0.0, |r| r.cost_usd) + c.scores.iter().map(|(_, s)| s.cost_usd).sum::<f64>()
349 })
350 .sum()
351 }
352
353 pub fn to_markdown(&self) -> String {
354 let names: Vec<String> =
355 self.cases.first().map(|c| c.scores.iter().map(|(n, _)| n.clone()).collect()).unwrap_or_default();
356 let mut out = format!("| run | baseline $ | candidate $ | steps (b→c) | {} |\n", names.join(" | "));
357 out += &format!("|---|---|---|---|{}\n", "---|".repeat(names.len()));
358 for c in &self.cases {
359 let cand = c.candidate.as_ref();
360 let scores: Vec<String> = c.scores.iter().map(|(_, s)| format!("{:.2}", s.value)).collect();
361 out += &format!(
362 "| {} | {:.6} | {} | {}→{} | {} |\n",
363 &c.run_id[..c.run_id.len().min(13)],
364 c.baseline.cost_usd,
365 cand.map_or("error".into(), |r| format!("{:.6}", r.cost_usd)),
366 c.baseline.steps,
367 cand.map_or("-".into(), |r| r.steps.to_string()),
368 if c.error.is_some() { c.error.clone().unwrap_or_default() } else { scores.join(" | ") }
369 );
370 }
371 let means: Vec<String> = names.iter().map(|n| format!("{n} {:.2}", self.mean(n))).collect();
372 out += &format!(
373 "\n**Mean:** {} · **Cost:** baseline ${:.6} vs candidate ${:.6}\n",
374 means.join(", "),
375 self.baseline_cost(),
376 self.candidate_cost()
377 );
378 out
379 }
380}
381
382pub struct Evaluator {
384 agent: Agent,
385 scorers: Vec<Arc<dyn Scorer>>,
386 live: LiveTools,
387 concurrency: usize,
388}
389
390impl Evaluator {
391 pub fn new(candidate: Agent) -> Self {
394 Self { agent: candidate, scorers: Vec::new(), live: LiveTools::default(), concurrency: 4 }
395 }
396
397 pub fn scorer(mut self, s: impl Scorer + 'static) -> Self {
398 self.scorers.push(Arc::new(s));
399 self
400 }
401
402 pub fn live_tools(mut self, live: LiveTools) -> Self {
403 self.live = live;
404 self
405 }
406
407 pub fn concurrency(mut self, n: usize) -> Self {
408 self.concurrency = n.max(1);
409 self
410 }
411
412 pub async fn run(&self, cases: &[EvalCase]) -> EvalReport {
413 let results = futures::stream::iter(cases.iter().map(|case| self.run_case(case)))
414 .buffered(self.concurrency)
415 .collect::<Vec<_>>()
416 .await;
417 EvalReport { cases: results }
418 }
419
420 async fn run_case(&self, case: &EvalCase) -> CaseResult {
421 let builder = self.agent.to_builder();
422 let tools: Vec<Arc<dyn Tool>> = builder
423 .tool_list()
424 .iter()
425 .map(|t| {
426 let recorded = case.recordings.iter().filter(|r| r.tool == t.spec().name).cloned().collect();
427 Arc::new(ReplayTool { inner: t.clone(), recorded: Mutex::new(recorded), live: self.live })
428 as Arc<dyn Tool>
429 })
430 .collect();
431 let agent = builder
432 .replace_tools(tools)
433 .journal(InMemoryJournal::new())
434 .policy(AllowAll)
436 .without_on_delta()
437 .build();
438 let result = match agent {
439 Ok(agent) => agent.run_message(case.input.clone()).await.map_err(|e| e.to_string()),
440 Err(e) => Err(e.to_string()),
441 };
442 match result {
443 Ok(outcome) => {
444 let candidate = RunSummary::from_outcome(&outcome);
445 let mut scores = Vec::new();
446 for s in &self.scorers {
447 scores.push((s.name(), s.score(case, &candidate).await));
448 }
449 CaseResult {
450 run_id: case.run_id.clone(),
451 baseline: case.baseline.clone(),
452 candidate: Some(candidate),
453 scores,
454 error: None,
455 }
456 }
457 Err(error) => CaseResult {
458 run_id: case.run_id.clone(),
459 baseline: case.baseline.clone(),
460 candidate: None,
461 scores: Vec::new(),
462 error: Some(error),
463 },
464 }
465 }
466}