Skip to main content

lc_evaluation/
runner.rs

1//! Batch runner: `Report` and `EvalRunner`.
2//!
3//! `EvalRunner` calls the `Predictor` per example in the dataset, then scores with the pointwise
4//! `Evaluator`s and pairwise `PairwiseEvaluator`s, aggregating into a `Report`.
5//!
6//! P1-3: per-item tolerance — a failed predict or a failed evaluator score is recorded in
7//! `Report::failures`, computed results are kept, and the run does not abort. P1-4: `Report`
8//! carries the original text + stddev and implements `Serialize`/`Deserialize` for post-hoc analysis.
9
10use std::collections::{HashMap, HashSet};
11
12use super::criteria::{
13    Dataset, EvalError, Evaluator, PairwiseEvaluator, Predictor, RagEvaluator, Score,
14};
15
16/// Complete evaluation record for one example (includes the original text, for tracing low scores).
17#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18pub struct ExampleReport {
19    /// Example index in the dataset (0-based)
20    pub index: usize,
21    /// Original model input (question/prompt) of the example
22    pub input: String,
23    /// Ground-truth reference answer of the example
24    pub reference: String,
25    /// What the predictor actually produced
26    pub prediction: String,
27    /// Retrieved contexts this example was scored against (B9; empty for non-RAG datasets).
28    /// Old reports without this field deserialize to an empty vec.
29    #[serde(default)]
30    pub contexts: Vec<String>,
31    /// Scores each evaluator assigned to this example (failed or not-run evaluators are absent)
32    pub scores: HashMap<String, Score>,
33}
34
35/// Summary statistics for one evaluator (mean + population stddev + sample count).
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct ScoreSummary {
38    /// Arithmetic mean of the evaluator's scores across the dataset
39    pub mean: f64,
40    /// Population standard deviation across the dataset
41    pub std: f64,
42    /// Number of examples this evaluator successfully scored
43    pub count: usize,
44}
45
46/// Failure record: a predict or an evaluator score failed for the example at a given index.
47#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
48pub struct FailureRecord {
49    /// Example index in the dataset (0-based)
50    pub index: usize,
51    /// Failure stage: `"predict"` or an evaluator's `name()`
52    pub stage: String,
53    /// Human-readable error message recorded for the failure
54    pub error: String,
55}
56
57/// Evaluation report (with original text, stddev, failure list; deserializable).
58#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
59pub struct Report {
60    /// Per-example complete records (including input/reference/prediction originals)
61    pub per_example: Vec<ExampleReport>,
62    /// Per-evaluator summaries (mean + stddev + sample count)
63    pub summary: HashMap<String, ScoreSummary>,
64    /// Failure records collected by per-item tolerance (empty = all succeeded)
65    pub failures: Vec<FailureRecord>,
66    /// Cost ledger for the run (E1). Old reports without this field deserialize to the default.
67    #[serde(default)]
68    pub cost: crate::OverallCost,
69    /// Stable identifier of this evaluation run (B9): the join key against traces/spans.
70    ///
71    /// Set explicitly via [`EvalRunner::with_run_id`] or auto-generated as a UUID v4 when the
72    /// runner runs. The predictor receives it through [`Predictor::begin_run`] so a system
73    /// under test can stamp it (e.g. into `RunnableConfig.metadata["trace_id"]`, which the agent
74    /// executor propagates to callback/OTel spans). Old reports deserialize to an empty id.
75    #[serde(default)]
76    pub run_id: String,
77}
78
79/// Batch runner: holds pointwise, pairwise, and RAG evaluators.
80pub struct EvalRunner {
81    evaluators: Vec<Box<dyn Evaluator>>,
82    pairwise: Vec<Box<dyn PairwiseEvaluator>>,
83    /// RAGAS-style evaluators taking the example's retrieved contexts (B9).
84    rag: Vec<Box<dyn RagEvaluator>>,
85    /// USD price book used to turn reported token usage into cost (E1).
86    price_book: crate::PriceBook,
87    /// Explicit run id; [`None`] means generate a fresh UUID v4 per [`EvalRunner::run`].
88    run_id: Option<String>,
89}
90
91impl EvalRunner {
92    /// Creates a batch runner (pointwise evaluators only).
93    pub fn new(evaluators: Vec<Box<dyn Evaluator>>) -> Self {
94        Self {
95            evaluators,
96            pairwise: Vec::new(),
97            rag: Vec::new(),
98            price_book: crate::PriceBook::default_set(),
99            run_id: None,
100        }
101    }
102
103    /// Pins the run id stamped into the report and handed to [`Predictor::begin_run`].
104    ///
105    /// Use this to correlate an evaluation run with an external trace/CI record. Without it,
106    /// each `run()` generates a fresh UUID v4.
107    pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
108        self.run_id = Some(run_id.into());
109        self
110    }
111
112    /// Appends pairwise evaluators (P1-1, arena evaluation enters the unified report).
113    pub fn with_pairwise(mut self, pairwise: Vec<Box<dyn PairwiseEvaluator>>) -> Self {
114        self.pairwise.extend(pairwise);
115        self
116    }
117
118    /// Appends RAG evaluators (B9: context precision/recall, answer relevancy), scored with
119    /// each example's retrieved contexts in rank order.
120    pub fn with_rag_evaluators(mut self, rag: Vec<Box<dyn RagEvaluator>>) -> Self {
121        self.rag.extend(rag);
122        self
123    }
124
125    /// Overrides the price book used for cost estimation (E1). Takes over the default set.
126    pub fn with_price_book(mut self, price_book: crate::PriceBook) -> Self {
127        self.price_book = price_book;
128        self
129    }
130
131    /// Runs all evaluators on the dataset, returning the report.
132    ///
133    /// P1-3: per-item tolerance — a failed predict records a `"predict"` failure and skips the example;
134    /// a failed evaluator score records only that evaluator's failure, others still score.
135    /// P1-1: pairwise evaluators participate too, using `(prediction, reference)` as the A/B candidates
136    /// (arena usage: put the answer under comparison in the reference slot).
137    pub async fn run(
138        &self,
139        dataset: &Dataset,
140        predictor: &dyn Predictor,
141    ) -> Result<Report, EvalError> {
142        Self::warn_duplicate_names(&self.evaluators, &self.pairwise, &self.rag);
143
144        // B9: resolve the run id once, tell the predictor, and carry it on the report so
145        // evaluation output can be joined to traces/spans produced by the system under test.
146        let run_id = self
147            .run_id
148            .clone()
149            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
150        predictor.begin_run(&run_id).await;
151
152        let mut per_example = Vec::with_capacity(dataset.len());
153        let mut failures = Vec::new();
154        // accumulate each evaluator's successful sample scores per name, for mean/std computation
155        let mut per_name: HashMap<String, Vec<f64>> = HashMap::new();
156        // E1: accumulate reported predictor token usage into the report's cost ledger
157        let mut cost = crate::OverallCost::default();
158
159        for (i, ex) in dataset.examples.iter().enumerate() {
160            let prediction = match predictor.predict(&ex.input).await {
161                Ok(p) => p,
162                Err(e) => {
163                    failures.push(FailureRecord {
164                        index: i,
165                        stage: "predict".into(),
166                        error: e.to_string(),
167                    });
168                    continue;
169                }
170            };
171
172            // E1: meter whatever usage the predictor reports (None = no metering, ledger stays zero)
173            if let Some(usage) = predictor.report_token_usage().await {
174                cost.accumulate(&usage, &self.price_book);
175            }
176
177            let mut scores = HashMap::new();
178            for ev in &self.evaluators {
179                match ev.eval(&ex.input, &prediction, &ex.reference).await {
180                    Ok(s) => {
181                        per_name
182                            .entry(ev.name().to_string())
183                            .or_default()
184                            .push(s.value);
185                        scores.insert(ev.name().to_string(), s);
186                    }
187                    Err(e) => failures.push(FailureRecord {
188                        index: i,
189                        stage: ev.name().to_string(),
190                        error: e.to_string(),
191                    }),
192                }
193            }
194            for ev in &self.pairwise {
195                match ev.eval_pair(&ex.input, &prediction, &ex.reference).await {
196                    Ok(s) => {
197                        per_name
198                            .entry(ev.name().to_string())
199                            .or_default()
200                            .push(s.value);
201                        scores.insert(ev.name().to_string(), s);
202                    }
203                    Err(e) => failures.push(FailureRecord {
204                        index: i,
205                        stage: ev.name().to_string(),
206                        error: e.to_string(),
207                    }),
208                }
209            }
210            for ev in &self.rag {
211                match ev
212                    .eval_rag(&ex.input, &prediction, &ex.contexts, &ex.reference)
213                    .await
214                {
215                    Ok(s) => {
216                        per_name
217                            .entry(ev.name().to_string())
218                            .or_default()
219                            .push(s.value);
220                        scores.insert(ev.name().to_string(), s);
221                    }
222                    Err(e) => failures.push(FailureRecord {
223                        index: i,
224                        stage: ev.name().to_string(),
225                        error: e.to_string(),
226                    }),
227                }
228            }
229
230            per_example.push(ExampleReport {
231                index: i,
232                input: ex.input.clone(),
233                reference: ex.reference.clone(),
234                prediction,
235                contexts: ex.contexts.clone(),
236                scores,
237            });
238        }
239
240        let mut summary = HashMap::new();
241        for (name, values) in per_name {
242            let count = values.len();
243            let mean = values.iter().sum::<f64>() / count as f64;
244            // population stddev: spread/variance reflects evaluator stability better than the mean alone
245            let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / count as f64;
246            summary.insert(
247                name,
248                ScoreSummary {
249                    mean,
250                    std: variance.sqrt(),
251                    count,
252                },
253            );
254        }
255
256        Ok(Report {
257            per_example,
258            summary,
259            failures,
260            cost,
261            run_id,
262        })
263    }
264
265    /// P1-4: duplicate-named evaluators silently overwrite each other in the summary/report; at least `log::warn`.
266    fn warn_duplicate_names(
267        evaluators: &[Box<dyn Evaluator>],
268        pairwise: &[Box<dyn PairwiseEvaluator>],
269        rag: &[Box<dyn RagEvaluator>],
270    ) {
271        let mut seen: HashSet<String> = HashSet::new();
272        let mut push = |name: &str| {
273            if !seen.insert(name.to_string()) {
274                log::warn!(
275                    "EvalRunner: duplicate evaluator name '{name}', report data will be overwritten"
276                );
277            }
278        };
279        for ev in evaluators {
280            push(ev.name());
281        }
282        for ev in pairwise {
283            push(ev.name());
284        }
285        for ev in rag {
286            push(ev.name());
287        }
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use async_trait::async_trait;
295
296    /// Dummy evaluator that always scores 1.0.
297    struct ConstantEvaluator;
298
299    #[async_trait]
300    impl Evaluator for ConstantEvaluator {
301        async fn eval(
302            &self,
303            _input: &str,
304            _prediction: &str,
305            _reference: &str,
306        ) -> Result<Score, EvalError> {
307            Ok(Score::new(1.0))
308        }
309        fn name(&self) -> &str {
310            "constant"
311        }
312    }
313
314    /// Predictor that hands back its input padding a "!" and reports a fixed token usage.
315    struct UsagePredictor;
316
317    #[async_trait]
318    impl Predictor for UsagePredictor {
319        async fn predict(&self, input: &str) -> Result<String, EvalError> {
320            Ok(format!("{input}!"))
321        }
322        async fn report_token_usage(&self) -> Option<crate::TokenUsage> {
323            Some(crate::TokenUsage {
324                prompt_tokens: 100,
325                completion_tokens: 50,
326                model: Some("gpt-4o-mini".into()),
327            })
328        }
329    }
330
331    /// Predictor that performs no token metering (the common case).
332    struct UnmeteredPredictor;
333
334    #[async_trait]
335    impl Predictor for UnmeteredPredictor {
336        async fn predict(&self, input: &str) -> Result<String, EvalError> {
337            Ok(input.to_string())
338        }
339        // report_token_usage defaults to None
340    }
341
342    async fn dataset2() -> Dataset {
343        Dataset::new(vec![
344            crate::Example::new("q1", "a1"),
345            crate::Example::new("q2", "a2"),
346        ])
347    }
348
349    #[tokio::test]
350    async fn old_report_without_cost_field_still_deserializes() {
351        // E1 compat: a report serialized before the `cost` field existed has no `cost` key.
352        let old_json = r#"{
353            "per_example": [],
354            "summary": {},
355            "failures": []
356        }"#;
357        let report: Report = serde_json::from_str(old_json).unwrap();
358        // default ledger: all-zero tokens, no USD
359        assert_eq!(report.cost.total_tokens, 0);
360        assert!(report.cost.cost_usd.is_none());
361    }
362
363    #[tokio::test]
364    async fn report_round_trips_with_cost_field() {
365        let runner = EvalRunner::new(vec![Box::new(ConstantEvaluator)])
366            .with_price_book(crate::PriceBook::default_set());
367        let report = runner
368            .run(&dataset2().await, &UsagePredictor)
369            .await
370            .unwrap();
371
372        let json = serde_json::to_string(&report).unwrap();
373        let back: Report = serde_json::from_str(&json).unwrap();
374        assert_eq!(back.cost.total_tokens, report.cost.total_tokens);
375        assert_eq!(back.cost.cost_usd, report.cost.cost_usd);
376    }
377
378    #[tokio::test]
379    async fn runner_accumulates_priced_usage_across_examples() {
380        let runner = EvalRunner::new(vec![Box::new(ConstantEvaluator)])
381            .with_price_book(crate::PriceBook::default_set());
382        let report = runner
383            .run(&dataset2().await, &UsagePredictor)
384            .await
385            .unwrap();
386
387        // 2 examples x (100 prompt + 50 completion); gpt-4o-mini at $0.15/$0.60 per 1M
388        assert_eq!(report.cost.prompt_tokens, 200);
389        assert_eq!(report.cost.completion_tokens, 100);
390        assert_eq!(report.cost.total_tokens, 300);
391        let expected = 200.0 / 1e6 * 0.15 + 100.0 / 1e6 * 0.60; // = 0.00009
392        let usd = report.cost.cost_usd.unwrap();
393        assert!(
394            (usd - expected).abs() < 1e-12,
395            "got {usd}, expected {expected}"
396        );
397    }
398
399    /// RAG evaluator that records how many contexts it received and scores that count > 0.
400    struct ContextCountingRag;
401    #[async_trait]
402    impl RagEvaluator for ContextCountingRag {
403        async fn eval_rag(
404            &self,
405            _input: &str,
406            _prediction: &str,
407            contexts: &[String],
408            _reference: &str,
409        ) -> Result<Score, EvalError> {
410            Ok(Score::new(if contexts.is_empty() { 0.0 } else { 1.0 })
411                .with_label(format!("{} contexts", contexts.len())))
412        }
413        fn name(&self) -> &str {
414            "rag_contexts"
415        }
416    }
417
418    #[tokio::test]
419    async fn rag_evaluators_receive_example_contexts_and_enter_report() {
420        let dataset = Dataset::new(vec![
421            crate::Example::with_contexts("q1", "a1", vec!["c0".into(), "c1".into()]),
422            crate::Example::new("q2", "a2"),
423        ]);
424        let runner =
425            EvalRunner::new(vec![]).with_rag_evaluators(vec![Box::new(ContextCountingRag)]);
426        let report = runner.run(&dataset, &UnmeteredPredictor).await.unwrap();
427
428        assert_eq!(report.per_example[0].contexts.len(), 2);
429        assert_eq!(
430            report.per_example[0].scores["rag_contexts"].value, 1.0,
431            "first example carries contexts"
432        );
433        assert_eq!(
434            report.per_example[1].scores["rag_contexts"].value, 0.0,
435            "second example has none"
436        );
437        assert_eq!(report.summary["rag_contexts"].count, 2);
438        // report carries a generated run id even when none was pinned
439        assert!(!report.run_id.is_empty());
440    }
441
442    #[tokio::test]
443    async fn rag_evaluator_failure_is_isolated_per_item() {
444        struct FailingRag;
445        #[async_trait]
446        impl RagEvaluator for FailingRag {
447            async fn eval_rag(
448                &self,
449                _input: &str,
450                _prediction: &str,
451                _contexts: &[String],
452                _reference: &str,
453            ) -> Result<Score, EvalError> {
454                Err(EvalError::ParseError("rag judge broke".into()))
455            }
456            fn name(&self) -> &str {
457                "broken_rag"
458            }
459        }
460        let runner = EvalRunner::new(vec![Box::new(ConstantEvaluator)])
461            .with_rag_evaluators(vec![Box::new(FailingRag)]);
462        let report = runner
463            .run(&dataset2().await, &UnmeteredPredictor)
464            .await
465            .unwrap();
466        // pointwise evaluator still scored; the RAG failure is recorded, run not aborted
467        assert_eq!(report.per_example.len(), 2);
468        assert!(report.per_example[0].scores.contains_key("constant"));
469        assert!(!report.per_example[0].scores.contains_key("broken_rag"));
470        assert_eq!(report.failures.len(), 2);
471        assert!(report.failures.iter().all(|f| f.stage == "broken_rag"));
472    }
473
474    #[tokio::test]
475    async fn unmetered_predictor_leaves_cost_at_zero() {
476        let runner = EvalRunner::new(vec![Box::new(ConstantEvaluator)]);
477        let report = runner
478            .run(&dataset2().await, &UnmeteredPredictor)
479            .await
480            .unwrap();
481        // zero behavior change when no metering is wired up
482        assert_eq!(report.cost.total_tokens, 0);
483        assert!(report.cost.cost_usd.is_none());
484    }
485}