Skip to main content

lc_evaluation/
export.rs

1//! Offline report export (B9, v0.22.4): JSONL per-example lines and a plain-text table.
2//!
3//! The JSONL export is one [`crate::ExampleReport`] per line with the report's `run_id` stamped
4//! onto every row, so a batch file can be joined back to the trace of the evaluated system
5//! without carrying the separate envelope. The table export is a dependency-free human view:
6//! per-example score matrix, per-evaluator summary, failures, cost.
7
8use std::path::Path;
9
10use super::{EvalError, Report};
11
12/// Maximum width of the input column in the table view (chars).
13const TABLE_INPUT_WIDTH: usize = 40;
14/// Width of each score column.
15const SCORE_COL_WIDTH: usize = 9;
16
17impl Report {
18    /// Renders the report as JSONL: one example record per line, carrying the run id.
19    ///
20    /// ```json
21    /// {"run_id":"...","index":0,"input":"...","reference":"...","prediction":"...",
22    ///  "contexts":["..."],"scores":{"exact_match":{"value":1.0}}}
23    /// ```
24    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            // compact, single-line JSON; no embedded raw newlines in the record itself
37            out.push_str(&line.to_string());
38            out.push('\n');
39        }
40        out
41    }
42
43    /// Writes [`Self::to_jsonl`] to `path` (async, truncating/overwriting).
44    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    /// Renders a fixed-width, dependency-free plain-text view of the report.
51    pub fn to_table(&self) -> String {
52        // Deterministic evaluator-column order regardless of HashMap iteration order.
53        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        // --- per-example score matrix ----------------------------------------------------------
60        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        // --- summary ---------------------------------------------------------------------------
102        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        // --- failures --------------------------------------------------------------------------
112        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        // --- cost ------------------------------------------------------------------------------
125        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        // failure error is flattened to one line
250        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        // UUID v4 shape: 36 chars with hyphens at the usual positions
275        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        // begin_run fires exactly once for the whole dataset, not once per example
309        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}