1use std::collections::{HashMap, HashSet};
11
12use super::criteria::{
13 Dataset, EvalError, Evaluator, PairwiseEvaluator, Predictor, RagEvaluator, Score,
14};
15
16#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18pub struct ExampleReport {
19 pub index: usize,
21 pub input: String,
23 pub reference: String,
25 pub prediction: String,
27 #[serde(default)]
30 pub contexts: Vec<String>,
31 pub scores: HashMap<String, Score>,
33}
34
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct ScoreSummary {
38 pub mean: f64,
40 pub std: f64,
42 pub count: usize,
44}
45
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
48pub struct FailureRecord {
49 pub index: usize,
51 pub stage: String,
53 pub error: String,
55}
56
57#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
59pub struct Report {
60 pub per_example: Vec<ExampleReport>,
62 pub summary: HashMap<String, ScoreSummary>,
64 pub failures: Vec<FailureRecord>,
66 #[serde(default)]
68 pub cost: crate::OverallCost,
69 #[serde(default)]
76 pub run_id: String,
77}
78
79pub struct EvalRunner {
81 evaluators: Vec<Box<dyn Evaluator>>,
82 pairwise: Vec<Box<dyn PairwiseEvaluator>>,
83 rag: Vec<Box<dyn RagEvaluator>>,
85 price_book: crate::PriceBook,
87 run_id: Option<String>,
89}
90
91impl EvalRunner {
92 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 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 pub fn with_pairwise(mut self, pairwise: Vec<Box<dyn PairwiseEvaluator>>) -> Self {
114 self.pairwise.extend(pairwise);
115 self
116 }
117
118 pub fn with_rag_evaluators(mut self, rag: Vec<Box<dyn RagEvaluator>>) -> Self {
121 self.rag.extend(rag);
122 self
123 }
124
125 pub fn with_price_book(mut self, price_book: crate::PriceBook) -> Self {
127 self.price_book = price_book;
128 self
129 }
130
131 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 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 let mut per_name: HashMap<String, Vec<f64>> = HashMap::new();
156 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 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 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 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 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 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 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 }
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 let old_json = r#"{
353 "per_example": [],
354 "summary": {},
355 "failures": []
356 }"#;
357 let report: Report = serde_json::from_str(old_json).unwrap();
358 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 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; let usd = report.cost.cost_usd.unwrap();
393 assert!(
394 (usd - expected).abs() < 1e-12,
395 "got {usd}, expected {expected}"
396 );
397 }
398
399 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 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 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 assert_eq!(report.cost.total_tokens, 0);
483 assert!(report.cost.cost_usd.is_none());
484 }
485}