1use std::path::Path;
9
10use super::{EvalError, Report};
11
12const TABLE_INPUT_WIDTH: usize = 40;
14const SCORE_COL_WIDTH: usize = 9;
16
17impl Report {
18 pub fn to_jsonl(&self) -> String {
25 let mut out = String::new();
26 for ex in &self.per_example {
27 let line = serde_json::json!({
28 "run_id": self.run_id,
29 "index": ex.index,
30 "input": ex.input,
31 "reference": ex.reference,
32 "prediction": ex.prediction,
33 "contexts": ex.contexts,
34 "scores": ex.scores,
35 });
36 out.push_str(&line.to_string());
38 out.push('\n');
39 }
40 out
41 }
42
43 pub async fn write_jsonl(&self, path: impl AsRef<Path>) -> Result<(), EvalError> {
45 tokio::fs::write(path, self.to_jsonl())
46 .await
47 .map_err(|e| EvalError::IoError(e.to_string()))
48 }
49
50 pub fn to_table(&self) -> String {
52 let mut names: Vec<&str> = self.summary.keys().map(String::as_str).collect();
54 names.sort_unstable();
55
56 let mut out = String::new();
57 out.push_str(&format!("eval run: {}\n", empty_dash(&self.run_id)));
58
59 if !self.per_example.is_empty() {
61 let index_width = self
62 .per_example
63 .last()
64 .map(|r| r.index.to_string().len())
65 .unwrap_or(1)
66 .max(1);
67 out.push_str(&format!(
68 "{:>width$} | {:<input_width$}",
69 "#",
70 "input",
71 width = index_width,
72 input_width = TABLE_INPUT_WIDTH
73 ));
74 for name in &names {
75 out.push_str(&format!("| {:<width$}", name, width = SCORE_COL_WIDTH));
76 }
77 out.push('\n');
78
79 for row in &self.per_example {
80 let input = flatten(&row.input);
81 let input: String = input.chars().take(TABLE_INPUT_WIDTH).collect();
82 out.push_str(&format!(
83 "{:>width$} | {:<input_width$}",
84 row.index,
85 input,
86 width = index_width,
87 input_width = TABLE_INPUT_WIDTH
88 ));
89 for name in &names {
90 let cell = row
91 .scores
92 .get(*name)
93 .map(|s| format!("{:.3}", s.value))
94 .unwrap_or_else(|| "-".to_string());
95 out.push_str(&format!("| {:<width$}", cell, width = SCORE_COL_WIDTH));
96 }
97 out.push('\n');
98 }
99 }
100
101 out.push_str("\nsummary:\n");
103 for name in &names {
104 let s = &self.summary[*name];
105 out.push_str(&format!(
106 " {name}: mean={:.3} std={:.3} n={}\n",
107 s.mean, s.std, s.count
108 ));
109 }
110
111 if !self.failures.is_empty() {
113 out.push_str(&format!("\nfailures ({}):\n", self.failures.len()));
114 for f in &self.failures {
115 out.push_str(&format!(
116 " [#{} {}] {}\n",
117 f.index,
118 f.stage,
119 one_line(&f.error)
120 ));
121 }
122 }
123
124 if self.cost.total_tokens > 0 || self.cost.cost_usd.is_some() {
126 out.push_str(&format!(
127 "\ncost: {} tokens ({} prompt / {} completion)",
128 self.cost.total_tokens, self.cost.prompt_tokens, self.cost.completion_tokens
129 ));
130 if let Some(usd) = self.cost.cost_usd {
131 out.push_str(&format!(" ${usd:.6}"));
132 }
133 out.push('\n');
134 }
135
136 out
137 }
138}
139
140fn empty_dash(s: &str) -> &str {
141 if s.is_empty() {
142 "-"
143 } else {
144 s
145 }
146}
147
148fn one_line(s: &str) -> String {
149 flatten(s).chars().take(200).collect()
150}
151
152fn flatten(s: &str) -> String {
153 s.chars()
154 .map(|c| {
155 if c == '\n' || c == '\r' || c == '\t' {
156 ' '
157 } else {
158 c
159 }
160 })
161 .collect()
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::{
168 Dataset, EvalRunner, Example, ExampleReport, FailureRecord, Report, Score, ScoreSummary,
169 };
170 use async_trait::async_trait;
171 use std::collections::HashMap;
172
173 struct EchoPredictor;
174 #[async_trait]
175 impl crate::Predictor for EchoPredictor {
176 async fn predict(&self, input: &str) -> Result<String, EvalError> {
177 Ok(input.to_string())
178 }
179 }
180
181 struct Exactish;
182 #[async_trait]
183 impl crate::Evaluator for Exactish {
184 async fn eval(
185 &self,
186 _input: &str,
187 prediction: &str,
188 reference: &str,
189 ) -> Result<Score, EvalError> {
190 Ok(Score::new(if prediction == reference { 1.0 } else { 0.0 }))
191 }
192 fn name(&self) -> &str {
193 "exactish"
194 }
195 }
196
197 async fn sample_report() -> Report {
198 let runner = EvalRunner::new(vec![Box::new(Exactish)]).with_run_id("run-xyz");
199 let ds = Dataset::new(vec![
200 Example::with_contexts("a", "a", vec!["ctx-a".into()]),
201 Example::new("b", "different"),
202 ]);
203 runner.run(&ds, &EchoPredictor).await.unwrap()
204 }
205
206 #[tokio::test]
207 async fn jsonl_carries_run_id_contexts_and_scores_per_line() {
208 let report = sample_report().await;
209 let jsonl = report.to_jsonl();
210 let lines: Vec<&str> = jsonl.lines().collect();
211 assert_eq!(lines.len(), 2);
212 for line in &lines {
213 let v: serde_json::Value = serde_json::from_str(line).unwrap();
214 assert_eq!(v["run_id"], "run-xyz");
215 assert!(v["scores"]["exactish"]["value"].is_number());
216 }
217 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
218 assert_eq!(first["contexts"][0], "ctx-a");
219 assert_eq!(first["scores"]["exactish"]["value"], 1.0);
220 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
221 assert!(second["contexts"].as_array().unwrap().is_empty());
222 assert_eq!(second["scores"]["exactish"]["value"], 0.0);
223 }
224
225 #[tokio::test]
226 async fn write_jsonl_round_trips_through_file() {
227 let report = sample_report().await;
228 let dir = tempfile::tempdir().unwrap();
229 let path = dir.path().join("report.jsonl");
230 report.write_jsonl(&path).await.unwrap();
231 let content = tokio::fs::read_to_string(&path).await.unwrap();
232 assert_eq!(content.lines().count(), 2);
233 assert!(content.contains("run-xyz"));
234 }
235
236 #[tokio::test]
237 async fn table_lists_matrix_summary_and_failures() {
238 let mut report = sample_report().await;
239 report.failures.push(FailureRecord {
240 index: 7,
241 stage: "predict".into(),
242 error: "boom\nsecond line".into(),
243 });
244 let table = report.to_table();
245 assert!(table.contains("eval run: run-xyz"));
246 assert!(table.contains("exactish"));
247 assert!(table.contains("mean="));
248 assert!(table.starts_with("eval run:"));
249 assert!(table.contains("[#7 predict] boom second line"));
251 }
252
253 #[test]
254 fn old_report_without_run_id_or_contexts_still_deserializes() {
255 let old_json = r#"{
256 "per_example": [
257 {"index": 0, "input": "q", "reference": "r", "prediction": "p", "scores": {}}
258 ],
259 "summary": {},
260 "failures": []
261 }"#;
262 let report: Report = serde_json::from_str(old_json).unwrap();
263 assert_eq!(report.run_id, "");
264 assert!(report.per_example[0].contexts.is_empty());
265 }
266
267 #[tokio::test]
268 async fn run_id_autogenerates_when_not_pinned() {
269 let runner = EvalRunner::new(vec![Box::new(Exactish)]);
270 let report = runner
271 .run(&Dataset::new(vec![Example::new("a", "a")]), &EchoPredictor)
272 .await
273 .unwrap();
274 assert_eq!(report.run_id.len(), 36);
276 assert_eq!(report.run_id.as_bytes()[14], b'4');
277 }
278
279 #[tokio::test]
280 async fn begin_run_receives_the_pinned_run_id() {
281 use std::sync::Arc;
282 use std::sync::Mutex;
283
284 struct CapturingPredictor {
285 seen: Arc<Mutex<Vec<String>>>,
286 }
287 #[async_trait]
288 impl crate::Predictor for CapturingPredictor {
289 async fn predict(&self, input: &str) -> Result<String, EvalError> {
290 Ok(input.to_string())
291 }
292 async fn begin_run(&self, run_id: &str) {
293 self.seen.lock().unwrap().push(run_id.to_string());
294 }
295 }
296
297 let seen = Arc::new(Mutex::new(Vec::new()));
298 let predictor = CapturingPredictor { seen: seen.clone() };
299 let report = EvalRunner::new(vec![Box::new(Exactish)])
300 .with_run_id("trace-join-1")
301 .run(
302 &Dataset::new(vec![Example::new("a", "a"), Example::new("b", "b")]),
303 &predictor,
304 )
305 .await
306 .unwrap();
307 assert_eq!(report.run_id, "trace-join-1");
308 assert_eq!(seen.lock().unwrap().as_slice(), ["trace-join-1"]);
310 }
311
312 #[test]
313 fn empty_report_table_renders_without_panicking() {
314 let mut scores = HashMap::new();
315 scores.insert("x".to_string(), Score::new(1.0));
316 let report = Report {
317 per_example: vec![ExampleReport {
318 index: 0,
319 input: "q".into(),
320 reference: "r".into(),
321 prediction: "p".into(),
322 contexts: vec![],
323 scores,
324 }],
325 summary: HashMap::from([(
326 "x".to_string(),
327 ScoreSummary {
328 mean: 1.0,
329 std: 0.0,
330 count: 1,
331 },
332 )]),
333 failures: vec![],
334 cost: Default::default(),
335 run_id: String::new(),
336 };
337 let table = report.to_table();
338 assert!(table.contains("eval run: -"));
339 }
340}