1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Section {
19 Tuning,
20 Holdout,
21}
22
23pub 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 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
68pub 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 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}