Skip to main content

cairn_runtime/
eval.rs

1//! Evals by replay: re-run recorded runs on a candidate configuration (e.g.
2//! another model), score the results and compare cost.
3//!
4//! Replays never repeat real side effects. A tool call that matches one in
5//! the recorded run gets the recorded result. An unmatched `Read` call may
6//! run live ([`LiveTools::ReadOnly`], the default); unmatched `Write` and
7//! `Irreversible` calls are never executed and the model is told so.
8//! Replays use their own in-memory journal, never the production one.
9
10use 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/// What a run achieved: the recorded baseline or a replayed candidate.
28#[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/// One recorded run to replay.
78#[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    /// Build a case from a run's journal. `None` if the run never started.
88    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    /// Load cases for these run ids from a journal (skipping unknown ids).
122    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/// Whether unmatched tool calls may execute during a replay.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum LiveTools {
136    /// Only recorded results are served; every unmatched call errors.
137    Never,
138    /// Unmatched `Read` tools run live; writes never do.
139    #[default]
140    ReadOnly,
141}
142
143/// Serves recorded results for a tool during a replay.
144struct 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/// A 0–1 score with an explanation.
186#[derive(Debug, Clone, Serialize)]
187pub struct Score {
188    pub value: f64,
189    pub note: String,
190    /// What scoring itself cost (e.g. an LLM judge).
191    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
206/// 1 if the candidate run completed (including any finish schema).
207pub 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
219/// Fraction of these structured-output fields equal to the baseline's.
220pub 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
247/// An LLM grades the candidate's answer against the baseline's.
248pub 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    /// Mean of one scorer across cases (cases that errored count as 0).
317    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    /// Share of cases where every score is at least `threshold`.
327    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    /// Candidate run cost plus scoring cost.
344    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
382/// Replays cases against a candidate configuration derived from an agent.
383pub struct Evaluator {
384    agent: Agent,
385    scorers: Vec<Arc<dyn Scorer>>,
386    live: LiveTools,
387    concurrency: usize,
388}
389
390impl Evaluator {
391    /// `candidate` is the configuration to test, typically
392    /// `production.to_builder().model(k2).build()?`.
393    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            // Writes are already neutralised by ReplayTool, so approvals would only stall the replay.
435            .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}