Skip to main content

jig/
report.rs

1//! Report emitter.
2//!
3//! Aggregates a collection of (TrialResult, JudgeResult) pairs into a
4//! Report split by tuning vs holdout. Primary metric is mean judge
5//! score; secondaries are completion rate, tokens, turns, invented
6//! commands, and fallback-to-sql count. Emits JSON and Markdown.
7
8use crate::judge::JudgeResult;
9use crate::runner::TrialResult;
10use crate::schema::AgentShape;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::fmt::Write as _;
14
15/// Which battery a task belongs to.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Section {
19    Tuning,
20    Holdout,
21}
22
23/// One (trial, verdict) pair with its section assignment.
24pub struct ScoredTrial<'a> {
25    pub section: Section,
26    pub trial: &'a TrialResult,
27    pub verdict: &'a JudgeResult,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Report {
32    pub subject: String,
33    pub version_pin: Option<String>,
34    pub run_timestamp: String,
35    pub judge_model: String,
36    pub tuning: BatteryReport,
37    pub holdout: BatteryReport,
38    /// Per-(task, model) breakdown, flattened for easy table rendering.
39    pub cells: Vec<CellReport>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct BatteryReport {
44    pub n_trials: usize,
45    pub mean_score: Option<f64>,
46    pub completion_rate: Option<f64>,
47    pub mean_tokens: Option<f64>,
48    pub mean_turns: Option<f64>,
49    pub total_invented_commands: usize,
50    pub total_fallback_to_sql: usize,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CellReport {
55    pub section: Section,
56    pub task_id: String,
57    pub model: String,
58    pub n: usize,
59    pub mean_score: f64,
60    pub score_stddev: f64,
61    pub mean_tokens: f64,
62    pub mean_turns: f64,
63    pub invented_commands: Vec<String>,
64    pub fallback_count: usize,
65    pub mean_irr_delta: Option<f64>,
66}
67
68/// Aggregate a slice of scored trials into a Report. Pure function.
69pub fn build_report(
70    config: &AgentShape,
71    scored: &[ScoredTrial<'_>],
72    run_timestamp: String,
73    judge_model: String,
74) -> Report {
75    let tuning: Vec<&ScoredTrial> = scored
76        .iter()
77        .filter(|s| s.section == Section::Tuning)
78        .collect();
79    let holdout: Vec<&ScoredTrial> = scored
80        .iter()
81        .filter(|s| s.section == Section::Holdout)
82        .collect();
83
84    Report {
85        subject: config.subject.name.clone(),
86        version_pin: config.subject.version_pin.clone(),
87        run_timestamp,
88        judge_model,
89        tuning: battery_summary(&tuning),
90        holdout: battery_summary(&holdout),
91        cells: build_cells(scored),
92    }
93}
94
95fn battery_summary(scored: &[&ScoredTrial]) -> BatteryReport {
96    let n = scored.len();
97    if n == 0 {
98        return BatteryReport::default();
99    }
100    let scores: Vec<f64> = scored.iter().map(|s| s.verdict.first.score).collect();
101    let completions: Vec<bool> = scored.iter().map(|s| s.verdict.first.completed).collect();
102    let tokens: Vec<f64> = scored
103        .iter()
104        .map(|s| (s.trial.input_tokens + s.trial.output_tokens) as f64)
105        .collect();
106    let turns: Vec<f64> = scored.iter().map(|s| s.trial.num_turns as f64).collect();
107    let invented: usize = scored
108        .iter()
109        .map(|s| s.verdict.first.invented_commands.len())
110        .sum();
111    let fallback: usize = scored
112        .iter()
113        .filter(|s| s.verdict.first.fallback_to_sql)
114        .count();
115
116    BatteryReport {
117        n_trials: n,
118        mean_score: Some(mean(&scores)),
119        completion_rate: Some(completions.iter().filter(|c| **c).count() as f64 / n as f64),
120        mean_tokens: Some(mean(&tokens)),
121        mean_turns: Some(mean(&turns)),
122        total_invented_commands: invented,
123        total_fallback_to_sql: fallback,
124    }
125}
126
127fn build_cells(scored: &[ScoredTrial<'_>]) -> Vec<CellReport> {
128    // Group by (section, task_id, model).
129    let mut groups: BTreeMap<(Section, String, String), Vec<&ScoredTrial>> = BTreeMap::new();
130    for s in scored {
131        groups
132            .entry((s.section, s.trial.task_id.clone(), s.trial.model.clone()))
133            .or_default()
134            .push(s);
135    }
136
137    let mut out = Vec::with_capacity(groups.len());
138    for ((section, task_id, model), items) in groups {
139        let scores: Vec<f64> = items.iter().map(|s| s.verdict.first.score).collect();
140        let tokens: Vec<f64> = items
141            .iter()
142            .map(|s| (s.trial.input_tokens + s.trial.output_tokens) as f64)
143            .collect();
144        let turns: Vec<f64> = items.iter().map(|s| s.trial.num_turns as f64).collect();
145        let mut invented_acc: Vec<String> = Vec::new();
146        let mut fallback_count = 0usize;
147        let mut irr_deltas: Vec<f64> = Vec::new();
148        for s in &items {
149            invented_acc.extend(s.verdict.first.invented_commands.clone());
150            if s.verdict.first.fallback_to_sql {
151                fallback_count += 1;
152            }
153            if let Some(d) = s.verdict.irr_delta {
154                irr_deltas.push(d);
155            }
156        }
157        invented_acc.sort();
158        invented_acc.dedup();
159
160        out.push(CellReport {
161            section,
162            task_id,
163            model,
164            n: items.len(),
165            mean_score: mean(&scores),
166            score_stddev: stddev(&scores),
167            mean_tokens: mean(&tokens),
168            mean_turns: mean(&turns),
169            invented_commands: invented_acc,
170            fallback_count,
171            mean_irr_delta: if irr_deltas.is_empty() {
172                None
173            } else {
174                Some(mean(&irr_deltas))
175            },
176        });
177    }
178    out
179}
180
181fn mean(xs: &[f64]) -> f64 {
182    if xs.is_empty() {
183        0.0
184    } else {
185        xs.iter().sum::<f64>() / xs.len() as f64
186    }
187}
188
189fn stddev(xs: &[f64]) -> f64 {
190    let n = xs.len();
191    if n < 2 {
192        return 0.0;
193    }
194    let m = mean(xs);
195    let var = xs.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (n as f64 - 1.0);
196    var.sqrt()
197}
198
199pub fn emit_json(report: &Report) -> String {
200    serde_json::to_string_pretty(report).expect("report always serializes")
201}
202
203pub fn emit_markdown(report: &Report) -> String {
204    let mut s = String::new();
205    let _ = writeln!(s, "# agent-shape report: {}", report.subject);
206    if let Some(pin) = &report.version_pin {
207        let _ = writeln!(s, "\nversion_pin: `{pin}`");
208    }
209    let _ = writeln!(s, "\nrun_timestamp: `{}`", report.run_timestamp);
210    let _ = writeln!(s, "judge_model: `{}`", report.judge_model);
211
212    let _ = writeln!(s, "\n## Tuning battery\n");
213    write_battery(&mut s, &report.tuning);
214
215    let _ = writeln!(s, "\n## Holdout battery\n");
216    if report.holdout.n_trials == 0 {
217        let _ = writeln!(s, "_empty in v1 (schema supports it; corpus deferred)_");
218    } else {
219        write_battery(&mut s, &report.holdout);
220    }
221
222    let _ = writeln!(s, "\n## Per-cell breakdown\n");
223    let _ = writeln!(
224        s,
225        "| section | task | model | n | score | stddev | tokens | turns | invented | fallback | irr_delta |"
226    );
227    let _ = writeln!(
228        s,
229        "|---------|------|-------|---|-------|--------|--------|-------|----------|----------|-----------|"
230    );
231    for c in &report.cells {
232        let sec = match c.section {
233            Section::Tuning => "tuning",
234            Section::Holdout => "holdout",
235        };
236        let irr = c
237            .mean_irr_delta
238            .map(|d| format!("{d:.3}"))
239            .unwrap_or_else(|| "n/a".into());
240        let invented_preview = if c.invented_commands.is_empty() {
241            "-".into()
242        } else {
243            c.invented_commands.join("; ")
244        };
245        let _ = writeln!(
246            s,
247            "| {sec} | {task} | {model} | {n} | {score:.3} | {sd:.3} | {tok:.0} | {tu:.2} | {inv} | {fb} | {irr} |",
248            task = c.task_id,
249            model = c.model,
250            n = c.n,
251            score = c.mean_score,
252            sd = c.score_stddev,
253            tok = c.mean_tokens,
254            tu = c.mean_turns,
255            inv = invented_preview,
256            fb = c.fallback_count,
257        );
258    }
259    s
260}
261
262fn write_battery(s: &mut String, b: &BatteryReport) {
263    let _ = writeln!(s, "- n_trials: {}", b.n_trials);
264    if let Some(x) = b.mean_score {
265        let _ = writeln!(s, "- mean_score: {x:.3}");
266    }
267    if let Some(x) = b.completion_rate {
268        let _ = writeln!(s, "- completion_rate: {:.1}%", x * 100.0);
269    }
270    if let Some(x) = b.mean_tokens {
271        let _ = writeln!(s, "- mean_tokens: {x:.0}");
272    }
273    if let Some(x) = b.mean_turns {
274        let _ = writeln!(s, "- mean_turns: {x:.2}");
275    }
276    let _ = writeln!(
277        s,
278        "- total_invented_commands: {}",
279        b.total_invented_commands
280    );
281    let _ = writeln!(s, "- total_fallback_to_sql: {}", b.total_fallback_to_sql);
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::judge::{JudgeResult, JudgeScore};
288    use crate::runner::TrialResult;
289
290    fn trial(task_id: &str, model: &str, tokens: u64, turns: u32) -> TrialResult {
291        TrialResult {
292            task_id: task_id.into(),
293            model: model.into(),
294            bash_commands: vec![],
295            assistant_texts: vec![],
296            num_turns: turns,
297            input_tokens: tokens / 2,
298            output_tokens: tokens / 2,
299            cost_usd: 0.01,
300            duration_ms: 1000,
301            terminal_reason: "completed".into(),
302            is_error: false,
303            completed_under_turn_cap: true,
304            final_text: String::new(),
305            setup_failed: false,
306            timed_out: false,
307        }
308    }
309
310    fn verdict(
311        task_id: &str,
312        model: &str,
313        score: f64,
314        completed: bool,
315        invented: Vec<&str>,
316        fallback: bool,
317        irr: Option<f64>,
318    ) -> JudgeResult {
319        let s = JudgeScore {
320            score,
321            first_command: Some("x".into()),
322            first_command_existed: true,
323            completed,
324            invented_commands: invented.into_iter().map(String::from).collect(),
325            fallback_to_sql: fallback,
326            reasoning: "r".into(),
327        };
328        JudgeResult {
329            task_id: task_id.into(),
330            model_under_test: model.into(),
331            judge_model: "claude-haiku-4-5".into(),
332            first: s,
333            second: None,
334            irr_delta: irr,
335        }
336    }
337
338    fn sample_config() -> AgentShape {
339        use crate::schema::{
340            ExpectedCommands, Fixture, JudgeConfig, RunConfig, Subject, Task, Tasks,
341        };
342        AgentShape {
343            subject: Subject {
344                name: "demo".into(),
345                binary: "demo".into(),
346                description: "demo".into(),
347                version_pin: None,
348            },
349            fixture: Fixture {
350                setup: "true".into(),
351                cleanup: None,
352                workdir: "/tmp".into(),
353                strip_env: vec![],
354            },
355            run: RunConfig {
356                n: 1,
357                models: vec!["m".into()],
358                turn_cap: 3,
359                timeout_seconds: 60,
360            },
361            judge: JudgeConfig {
362                model: "claude-haiku-4-5".into(),
363                double_score: false,
364                required_fields: vec!["score".into()],
365                rubric: "rubric".into(),
366            },
367            tasks: Tasks {
368                tuning: vec![Task {
369                    id: "t1".into(),
370                    summary: "demo task".into(),
371                    prompt: "do the thing".into(),
372                    success_criteria: vec![],
373                    author: "test".into(),
374                    created_at: "2026-01-01".into(),
375                    sealed_against_tag: "demo-v0".into(),
376                }],
377                holdout: vec![],
378            },
379            commands: Some(ExpectedCommands { top_level: vec![] }),
380        }
381    }
382
383    #[test]
384    fn empty_inputs_produce_empty_batteries() {
385        let cfg = sample_config();
386        let report = build_report(&cfg, &[], "t".into(), "claude-haiku-4-5".into());
387        assert_eq!(report.tuning.n_trials, 0);
388        assert_eq!(report.holdout.n_trials, 0);
389        assert!(report.tuning.mean_score.is_none());
390        assert!(report.holdout.mean_score.is_none());
391        assert!(report.cells.is_empty());
392    }
393
394    #[test]
395    fn aggregates_by_task_and_model() {
396        let cfg = sample_config();
397        let t1a = trial("t1", "m1", 100, 2);
398        let t1b = trial("t1", "m1", 200, 3);
399        let t2 = trial("t2", "m2", 150, 2);
400        let v1a = verdict("t1", "m1", 1.0, true, vec![], false, Some(0.0));
401        let v1b = verdict(
402            "t1",
403            "m1",
404            0.5,
405            true,
406            vec!["demo tree show"],
407            false,
408            Some(0.1),
409        );
410        let v2 = verdict("t2", "m2", 0.0, false, vec![], true, None);
411
412        let scored = vec![
413            ScoredTrial {
414                section: Section::Tuning,
415                trial: &t1a,
416                verdict: &v1a,
417            },
418            ScoredTrial {
419                section: Section::Tuning,
420                trial: &t1b,
421                verdict: &v1b,
422            },
423            ScoredTrial {
424                section: Section::Tuning,
425                trial: &t2,
426                verdict: &v2,
427            },
428        ];
429
430        let r = build_report(&cfg, &scored, "t".into(), "judge".into());
431        assert_eq!(r.tuning.n_trials, 3);
432        assert!((r.tuning.mean_score.unwrap() - 0.5).abs() < 1e-9);
433        assert!((r.tuning.completion_rate.unwrap() - 2.0 / 3.0).abs() < 1e-9);
434        assert_eq!(r.tuning.total_invented_commands, 1);
435        assert_eq!(r.tuning.total_fallback_to_sql, 1);
436
437        assert_eq!(r.cells.len(), 2);
438        let t1m1 = r
439            .cells
440            .iter()
441            .find(|c| c.task_id == "t1" && c.model == "m1")
442            .unwrap();
443        assert_eq!(t1m1.n, 2);
444        assert!((t1m1.mean_score - 0.75).abs() < 1e-9);
445        assert!(t1m1.score_stddev > 0.0);
446        assert_eq!(t1m1.invented_commands, vec!["demo tree show".to_string()]);
447        assert!((t1m1.mean_irr_delta.unwrap() - 0.05).abs() < 1e-9);
448    }
449
450    #[test]
451    fn holdout_section_isolated_from_tuning() {
452        let cfg = sample_config();
453        let t = trial("h1", "m", 100, 1);
454        let v = verdict("h1", "m", 0.9, true, vec![], false, None);
455        let scored = vec![ScoredTrial {
456            section: Section::Holdout,
457            trial: &t,
458            verdict: &v,
459        }];
460        let r = build_report(&cfg, &scored, "t".into(), "j".into());
461        assert_eq!(r.tuning.n_trials, 0);
462        assert_eq!(r.holdout.n_trials, 1);
463        assert!((r.holdout.mean_score.unwrap() - 0.9).abs() < 1e-9);
464    }
465
466    #[test]
467    fn markdown_notes_empty_holdout() {
468        let cfg = sample_config();
469        let t = trial("t1", "m", 10, 1);
470        let v = verdict("t1", "m", 1.0, true, vec![], false, None);
471        let scored = vec![ScoredTrial {
472            section: Section::Tuning,
473            trial: &t,
474            verdict: &v,
475        }];
476        let r = build_report(&cfg, &scored, "t".into(), "j".into());
477        let md = emit_markdown(&r);
478        assert!(md.contains("## Holdout battery"));
479        assert!(md.contains("empty in v1"));
480        assert!(md.contains("# agent-shape report: demo"));
481    }
482
483    #[test]
484    fn json_roundtrips() {
485        let cfg = sample_config();
486        let t = trial("t1", "m", 10, 1);
487        let v = verdict("t1", "m", 1.0, true, vec![], false, None);
488        let scored = vec![ScoredTrial {
489            section: Section::Tuning,
490            trial: &t,
491            verdict: &v,
492        }];
493        let r = build_report(&cfg, &scored, "t".into(), "j".into());
494        let j = emit_json(&r);
495        let back: Report = serde_json::from_str(&j).expect("roundtrip");
496        assert_eq!(back.subject, "demo");
497        assert_eq!(back.cells.len(), 1);
498    }
499}