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::{Dataset, EvalError, Evaluator, PairwiseEvaluator, Predictor, Score};
13
14/// Complete evaluation record for one example (includes the original text, for tracing low scores).
15#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
16pub struct ExampleReport {
17    /// Example index in the dataset (0-based)
18    pub index: usize,
19    pub input: String,
20    pub reference: String,
21    pub prediction: String,
22    /// Scores each evaluator assigned to this example (failed or not-run evaluators are absent)
23    pub scores: HashMap<String, Score>,
24}
25
26/// Summary statistics for one evaluator (mean + population stddev + sample count).
27#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28pub struct ScoreSummary {
29    pub mean: f64,
30    pub std: f64,
31    pub count: usize,
32}
33
34/// Failure record: a predict or an evaluator score failed for the example at a given index.
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
36pub struct FailureRecord {
37    /// Example index in the dataset (0-based)
38    pub index: usize,
39    /// Failure stage: `"predict"` or an evaluator's `name()`
40    pub stage: String,
41    pub error: String,
42}
43
44/// Evaluation report (with original text, stddev, failure list; deserializable).
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct Report {
47    /// Per-example complete records (including input/reference/prediction originals)
48    pub per_example: Vec<ExampleReport>,
49    /// Per-evaluator summaries (mean + stddev + sample count)
50    pub summary: HashMap<String, ScoreSummary>,
51    /// Failure records collected by per-item tolerance (empty = all succeeded)
52    pub failures: Vec<FailureRecord>,
53    /// Cost ledger for the run (E1). Old reports without this field deserialize to the default.
54    #[serde(default)]
55    pub cost: crate::OverallCost,
56}
57
58/// Batch runner: holds both pointwise and pairwise evaluators.
59pub struct EvalRunner {
60    evaluators: Vec<Box<dyn Evaluator>>,
61    pairwise: Vec<Box<dyn PairwiseEvaluator>>,
62    /// USD price book used to turn reported token usage into cost (E1).
63    price_book: crate::PriceBook,
64}
65
66impl EvalRunner {
67    /// Creates a batch runner (pointwise evaluators only).
68    pub fn new(evaluators: Vec<Box<dyn Evaluator>>) -> Self {
69        Self {
70            evaluators,
71            pairwise: Vec::new(),
72            price_book: crate::PriceBook::default_set(),
73        }
74    }
75
76    /// Appends pairwise evaluators (P1-1, arena evaluation enters the unified report).
77    pub fn with_pairwise(mut self, pairwise: Vec<Box<dyn PairwiseEvaluator>>) -> Self {
78        self.pairwise.extend(pairwise);
79        self
80    }
81
82    /// Overrides the price book used for cost estimation (E1). Takes over the default set.
83    pub fn with_price_book(mut self, price_book: crate::PriceBook) -> Self {
84        self.price_book = price_book;
85        self
86    }
87
88    /// Runs all evaluators on the dataset, returning the report.
89    ///
90    /// P1-3: per-item tolerance — a failed predict records a `"predict"` failure and skips the example;
91    /// a failed evaluator score records only that evaluator's failure, others still score.
92    /// P1-1: pairwise evaluators participate too, using `(prediction, reference)` as the A/B candidates
93    /// (arena usage: put the answer under comparison in the reference slot).
94    pub async fn run(
95        &self,
96        dataset: &Dataset,
97        predictor: &dyn Predictor,
98    ) -> Result<Report, EvalError> {
99        Self::warn_duplicate_names(&self.evaluators, &self.pairwise);
100
101        let mut per_example = Vec::with_capacity(dataset.len());
102        let mut failures = Vec::new();
103        // accumulate each evaluator's successful sample scores per name, for mean/std computation
104        let mut per_name: HashMap<String, Vec<f64>> = HashMap::new();
105        // E1: accumulate reported predictor token usage into the report's cost ledger
106        let mut cost = crate::OverallCost::default();
107
108        for (i, ex) in dataset.examples.iter().enumerate() {
109            let prediction = match predictor.predict(&ex.input).await {
110                Ok(p) => p,
111                Err(e) => {
112                    failures.push(FailureRecord {
113                        index: i,
114                        stage: "predict".into(),
115                        error: e.to_string(),
116                    });
117                    continue;
118                }
119            };
120
121            // E1: meter whatever usage the predictor reports (None = no metering, ledger stays zero)
122            if let Some(usage) = predictor.report_token_usage().await {
123                cost.accumulate(&usage, &self.price_book);
124            }
125
126            let mut scores = HashMap::new();
127            for ev in &self.evaluators {
128                match ev.eval(&ex.input, &prediction, &ex.reference).await {
129                    Ok(s) => {
130                        per_name
131                            .entry(ev.name().to_string())
132                            .or_default()
133                            .push(s.value);
134                        scores.insert(ev.name().to_string(), s);
135                    }
136                    Err(e) => failures.push(FailureRecord {
137                        index: i,
138                        stage: ev.name().to_string(),
139                        error: e.to_string(),
140                    }),
141                }
142            }
143            for ev in &self.pairwise {
144                match ev.eval_pair(&ex.input, &prediction, &ex.reference).await {
145                    Ok(s) => {
146                        per_name
147                            .entry(ev.name().to_string())
148                            .or_default()
149                            .push(s.value);
150                        scores.insert(ev.name().to_string(), s);
151                    }
152                    Err(e) => failures.push(FailureRecord {
153                        index: i,
154                        stage: ev.name().to_string(),
155                        error: e.to_string(),
156                    }),
157                }
158            }
159
160            per_example.push(ExampleReport {
161                index: i,
162                input: ex.input.clone(),
163                reference: ex.reference.clone(),
164                prediction,
165                scores,
166            });
167        }
168
169        let mut summary = HashMap::new();
170        for (name, values) in per_name {
171            let count = values.len();
172            let mean = values.iter().sum::<f64>() / count as f64;
173            // population stddev: spread/variance reflects evaluator stability better than the mean alone
174            let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / count as f64;
175            summary.insert(
176                name,
177                ScoreSummary {
178                    mean,
179                    std: variance.sqrt(),
180                    count,
181                },
182            );
183        }
184
185        Ok(Report {
186            per_example,
187            summary,
188            failures,
189            cost,
190        })
191    }
192
193    /// P1-4: duplicate-named evaluators silently overwrite each other in the summary/report; at least `log::warn`.
194    fn warn_duplicate_names(
195        evaluators: &[Box<dyn Evaluator>],
196        pairwise: &[Box<dyn PairwiseEvaluator>],
197    ) {
198        let mut seen = HashSet::new();
199        for ev in evaluators {
200            if !seen.insert(ev.name()) {
201                log::warn!(
202                    "EvalRunner: duplicate evaluator name '{}', report data will be overwritten",
203                    ev.name()
204                );
205            }
206        }
207        for ev in pairwise {
208            if !seen.insert(ev.name()) {
209                log::warn!(
210                    "EvalRunner: duplicate evaluator name '{}', report data will be overwritten",
211                    ev.name()
212                );
213            }
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use async_trait::async_trait;
222
223    /// Dummy evaluator that always scores 1.0.
224    struct ConstantEvaluator;
225
226    #[async_trait]
227    impl Evaluator for ConstantEvaluator {
228        async fn eval(
229            &self,
230            _input: &str,
231            _prediction: &str,
232            _reference: &str,
233        ) -> Result<Score, EvalError> {
234            Ok(Score::new(1.0))
235        }
236        fn name(&self) -> &str {
237            "constant"
238        }
239    }
240
241    /// Predictor that hands back its input padding a "!" and reports a fixed token usage.
242    struct UsagePredictor;
243
244    #[async_trait]
245    impl Predictor for UsagePredictor {
246        async fn predict(&self, input: &str) -> Result<String, EvalError> {
247            Ok(format!("{input}!"))
248        }
249        async fn report_token_usage(&self) -> Option<crate::TokenUsage> {
250            Some(crate::TokenUsage {
251                prompt_tokens: 100,
252                completion_tokens: 50,
253                model: Some("gpt-4o-mini".into()),
254            })
255        }
256    }
257
258    /// Predictor that performs no token metering (the common case).
259    struct UnmeteredPredictor;
260
261    #[async_trait]
262    impl Predictor for UnmeteredPredictor {
263        async fn predict(&self, input: &str) -> Result<String, EvalError> {
264            Ok(input.to_string())
265        }
266        // report_token_usage defaults to None
267    }
268
269    async fn dataset2() -> Dataset {
270        Dataset::new(vec![
271            crate::Example::new("q1", "a1"),
272            crate::Example::new("q2", "a2"),
273        ])
274    }
275
276    #[tokio::test]
277    async fn old_report_without_cost_field_still_deserializes() {
278        // E1 compat: a report serialized before the `cost` field existed has no `cost` key.
279        let old_json = r#"{
280            "per_example": [],
281            "summary": {},
282            "failures": []
283        }"#;
284        let report: Report = serde_json::from_str(old_json).unwrap();
285        // default ledger: all-zero tokens, no USD
286        assert_eq!(report.cost.total_tokens, 0);
287        assert!(report.cost.cost_usd.is_none());
288    }
289
290    #[tokio::test]
291    async fn report_round_trips_with_cost_field() {
292        let runner = EvalRunner::new(vec![Box::new(ConstantEvaluator)])
293            .with_price_book(crate::PriceBook::default_set());
294        let report = runner
295            .run(&dataset2().await, &UsagePredictor)
296            .await
297            .unwrap();
298
299        let json = serde_json::to_string(&report).unwrap();
300        let back: Report = serde_json::from_str(&json).unwrap();
301        assert_eq!(back.cost.total_tokens, report.cost.total_tokens);
302        assert_eq!(back.cost.cost_usd, report.cost.cost_usd);
303    }
304
305    #[tokio::test]
306    async fn runner_accumulates_priced_usage_across_examples() {
307        let runner = EvalRunner::new(vec![Box::new(ConstantEvaluator)])
308            .with_price_book(crate::PriceBook::default_set());
309        let report = runner
310            .run(&dataset2().await, &UsagePredictor)
311            .await
312            .unwrap();
313
314        // 2 examples x (100 prompt + 50 completion); gpt-4o-mini at $0.15/$0.60 per 1M
315        assert_eq!(report.cost.prompt_tokens, 200);
316        assert_eq!(report.cost.completion_tokens, 100);
317        assert_eq!(report.cost.total_tokens, 300);
318        let expected = 200.0 / 1e6 * 0.15 + 100.0 / 1e6 * 0.60; // = 0.00009
319        let usd = report.cost.cost_usd.unwrap();
320        assert!(
321            (usd - expected).abs() < 1e-12,
322            "got {usd}, expected {expected}"
323        );
324    }
325
326    #[tokio::test]
327    async fn unmetered_predictor_leaves_cost_at_zero() {
328        let runner = EvalRunner::new(vec![Box::new(ConstantEvaluator)]);
329        let report = runner
330            .run(&dataset2().await, &UnmeteredPredictor)
331            .await
332            .unwrap();
333        // zero behavior change when no metering is wired up
334        assert_eq!(report.cost.total_tokens, 0);
335        assert!(report.cost.cost_usd.is_none());
336    }
337}