1use std::collections::{HashMap, HashSet};
11use std::fs;
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use crate::adapters::{PluginShadowReport, shadow_validity_warnings};
18use crate::core::{ConditionsRecord, GradingResult, Mode, TimingRecord, TimingSource};
19use crate::pipeline::error::PipelineError;
20use crate::pipeline::io::{now_iso8601, write_json};
21use crate::pipeline::slots::run_slots;
22use crate::validation::{SchemaName, validate_against_schema};
23
24fn mean(values: &[f64]) -> f64 {
26 if values.is_empty() {
27 return 0.0;
28 }
29 values.iter().sum::<f64>() / values.len() as f64
30}
31
32fn stddev(values: &[f64], m: f64) -> f64 {
34 if values.len() < 2 {
35 return 0.0;
36 }
37 let variance = values.iter().map(|x| (x - m).powi(2)).sum::<f64>() / values.len() as f64;
38 variance.sqrt()
39}
40
41fn round(n: f64, dp: i32) -> f64 {
43 let p = 10f64.powi(dp);
44 (n * p).round() / p
45}
46
47fn stats(values: &[f64], dp: i32) -> Stats {
49 let m = mean(values);
50 Stats {
51 mean: round(m, dp),
52 stddev: round(stddev(values, m), dp),
53 n: values.len(),
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
59pub struct Stats {
60 pub mean: f64,
61 pub stddev: f64,
62 pub n: usize,
63}
64
65#[derive(Debug, Clone, Serialize)]
68struct ConditionSummary {
69 pass_rate: Stats,
70 duration_ms: Stats,
71 total_tokens: Stats,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 skill_invocation_n: Option<usize>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 skill_invocation_rate: Option<Option<f64>>,
77}
78
79#[derive(Debug, Clone, Serialize)]
81struct Delta {
82 direction: String,
83 pass_rate: f64,
84 duration_ms: f64,
85 total_tokens: f64,
86}
87
88#[derive(Debug, Clone, Serialize)]
90pub struct Benchmark {
91 pub generated: String,
92 pub mode: Mode,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub baseline: Option<String>,
95 pub conditions_compared: Vec<String>,
96 pub missing_gradings: usize,
97 pub validity_warnings: Vec<String>,
98 pub run_summary: Value,
99 delta: Delta,
100}
101
102#[derive(Default)]
104struct Bucket {
105 pass_rates: Vec<f64>,
106 durations: Vec<f64>,
107 tokens: Vec<f64>,
108 skill_invoked: Vec<bool>,
109 had_skill_loaded: bool,
110}
111
112#[derive(Debug, Deserialize)]
114struct StrayReport {
115 #[serde(default)]
116 runs: Vec<StrayRun>,
117}
118
119#[derive(Debug, Deserialize)]
120struct StrayRun {
121 eval_id: String,
122 condition: String,
123 #[serde(default)]
124 violations: Vec<Value>,
125 #[serde(default)]
126 live_source_reads: Vec<Value>,
127}
128
129pub fn aggregate(
132 iteration_dir: &Path,
133 conditions: &ConditionsRecord,
134) -> Result<Benchmark, PipelineError> {
135 let condition_names: Vec<String> = conditions
136 .conditions
137 .iter()
138 .map(|c| c.name.clone())
139 .collect();
140 if condition_names.len() != 2 {
141 return Err(PipelineError::Message(format!(
142 "expected exactly 2 conditions, got {}",
143 condition_names.len()
144 )));
145 }
146
147 let mut eval_dirs: Vec<String> = fs::read_dir(iteration_dir)?
148 .flatten()
149 .filter_map(|e| {
150 let name = e.file_name().to_string_lossy().into_owned();
151 name.starts_with("eval-").then_some(name)
152 })
153 .collect();
154 eval_dirs.sort();
155 if eval_dirs.is_empty() {
156 return Err(PipelineError::Message(
157 "no eval directories found".to_string(),
158 ));
159 }
160
161 let mut by_condition: HashMap<String, Bucket> = HashMap::new();
162 for c in &conditions.conditions {
163 by_condition.insert(
164 c.name.clone(),
165 Bucket {
166 had_skill_loaded: c.skill_path.is_some(),
167 ..Bucket::default()
168 },
169 );
170 }
171
172 let mut missing_gradings = 0usize;
173 let mut timing_sources: HashSet<String> = HashSet::new();
174
175 for eval_dir in &eval_dirs {
176 for cond in &condition_names {
177 let cond_dir = iteration_dir.join(eval_dir).join(cond);
178 for slot in run_slots(&cond_dir) {
179 let grading_path = slot.dir.join("grading.json");
180 let timing_path = slot.dir.join("timing.json");
181
182 if !grading_path.exists() {
183 let run = slot
184 .run_index
185 .map(|k| format!("/run-{k}"))
186 .unwrap_or_default();
187 eprintln!("warn: missing grading for {eval_dir}/{cond}{run}");
188 missing_gradings += 1;
189 continue;
190 }
191 let grading: GradingResult =
192 serde_json::from_str(&fs::read_to_string(&grading_path)?)?;
193 let bucket = by_condition.get_mut(cond).expect("condition bucket");
194 bucket.pass_rates.push(grading.summary.pass_rate);
195 if let Some(meta) = &grading.meta_summary
196 && let Some(invoked) = meta.skill_invoked
197 {
198 bucket.skill_invoked.push(invoked);
199 }
200
201 if timing_path.exists() {
202 let timing: TimingRecord =
203 serde_json::from_str(&fs::read_to_string(&timing_path)?)?;
204 let has_tokens = matches!(timing.total_tokens, Some(Some(_)));
205 let has_duration = matches!(timing.duration_ms, Some(Some(_)));
206 if let Some(Some(tokens)) = timing.total_tokens {
207 bucket.tokens.push(tokens as f64);
208 }
209 if let Some(Some(duration)) = timing.duration_ms {
210 bucket.durations.push(duration as f64);
211 }
212 if has_tokens || has_duration {
213 timing_sources.insert(timing_source_label(timing.source));
214 }
215 }
216 }
217 }
218 }
219
220 let mut run_summary = serde_json::Map::new();
222 let mut summaries: HashMap<String, ConditionSummary> = HashMap::new();
223 for cond in &condition_names {
224 let bucket = &by_condition[cond];
225 let (skill_invocation_n, skill_invocation_rate) = if bucket.had_skill_loaded {
226 let n = bucket.skill_invoked.len();
227 let rate = if n == 0 {
228 None
229 } else {
230 let passed = bucket.skill_invoked.iter().filter(|&&b| b).count();
231 Some(round(passed as f64 / n as f64, 3))
232 };
233 (Some(n), Some(rate))
234 } else {
235 (None, None)
236 };
237 let summary = ConditionSummary {
238 pass_rate: stats(&bucket.pass_rates, 3),
239 duration_ms: stats(&bucket.durations, 0),
240 total_tokens: stats(&bucket.tokens, 0),
241 skill_invocation_n,
242 skill_invocation_rate,
243 };
244 run_summary.insert(cond.clone(), serde_json::to_value(&summary)?);
245 summaries.insert(cond.clone(), summary);
246 }
247
248 let a = &condition_names[0];
249 let b = &condition_names[1];
250 let sa = &summaries[a];
251 let sb = &summaries[b];
252 let delta = Delta {
253 direction: format!("{a} - {b}"),
254 pass_rate: round(sa.pass_rate.mean - sb.pass_rate.mean, 3),
255 duration_ms: round(sa.duration_ms.mean - sb.duration_ms.mean, 0),
256 total_tokens: round(sa.total_tokens.mean - sb.total_tokens.mean, 0),
257 };
258
259 let mut validity_warnings: Vec<String> = Vec::new();
260 if timing_sources.len() > 1 {
261 let mut sorted: Vec<&String> = timing_sources.iter().collect();
262 sorted.sort();
263 let joined = sorted
264 .iter()
265 .map(|s| s.as_str())
266 .collect::<Vec<_>>()
267 .join(", ");
268 validity_warnings.push(format!(
269 "runs mix timing sources ({joined}) — transcript-derived totals include cache \
270 accounting, so the token/duration delta compares two different metrics. Re-record \
271 one side or read the delta as a rough signal only."
272 ));
273 }
274 let (n_a, n_b) = (
275 by_condition[a].pass_rates.len(),
276 by_condition[b].pass_rates.len(),
277 );
278 if n_a != n_b {
279 validity_warnings.push(format!(
280 "conditions have uneven run counts ({a}: {n_a}, {b}: {n_b}) — the delta compares \
281 differently-sized samples, weakening the comparison."
282 ));
283 }
284 for cond in &condition_names {
285 if let Some(Some(rate)) = summaries[cond].skill_invocation_rate
286 && rate < 1.0
287 {
288 let n = summaries[cond].skill_invocation_n.unwrap_or(0);
289 validity_warnings.push(format!(
290 "condition '{cond}' had skill loaded but invocation rate {:.0}% ({n} runs \
291 checked) — substantive results may not reflect skill effectiveness.",
292 rate * 100.0
293 ));
294 }
295 }
296
297 collect_stray_warnings(iteration_dir, &mut validity_warnings);
298 collect_shadow_warnings(iteration_dir, &mut validity_warnings);
299
300 let benchmark = Benchmark {
301 generated: now_iso8601(),
302 mode: conditions.mode,
303 baseline: conditions.baseline.clone(),
304 conditions_compared: vec![a.clone(), b.clone()],
305 missing_gradings,
306 validity_warnings,
307 run_summary: Value::Object(run_summary),
308 delta,
309 };
310
311 let out_path = iteration_dir.join("benchmark.json");
312 validate_against_schema::<Value>(
313 SchemaName::Benchmark,
314 &serde_json::to_value(&benchmark)?,
315 &out_path.to_string_lossy(),
316 )?;
317 write_json(&out_path, &benchmark)?;
318 Ok(benchmark)
319}
320
321fn timing_source_label(source: Option<TimingSource>) -> String {
323 match source {
324 Some(TimingSource::Transcript) => "transcript",
325 Some(TimingSource::CompletionEvent) | None => "completion-event",
326 }
327 .to_string()
328}
329
330fn collect_stray_warnings(iteration_dir: &Path, warnings: &mut Vec<String>) {
334 let Ok(raw) = fs::read_to_string(iteration_dir.join("stray-writes.json")) else {
335 return;
336 };
337 let Ok(report) = serde_json::from_str::<StrayReport>(&raw) else {
338 return;
339 };
340 for r in &report.runs {
341 if !r.violations.is_empty() {
342 warnings.push(format!(
343 "{}/{} wrote {} file(s) outside its outputs dir — data point may be tainted (see stray-writes.json).",
344 r.eval_id,
345 r.condition,
346 r.violations.len()
347 ));
348 }
349 if !r.live_source_reads.is_empty() {
350 warnings.push(format!(
351 "{}/{} read the live skill source {} time(s) instead of its staged copy — the arm may be contaminated (staged-slug resolution race; see stray-writes.json).",
352 r.eval_id,
353 r.condition,
354 r.live_source_reads.len()
355 ));
356 }
357 }
358}
359
360fn collect_shadow_warnings(iteration_dir: &Path, warnings: &mut Vec<String>) {
362 let Ok(raw) = fs::read_to_string(iteration_dir.join("plugin-shadow.json")) else {
363 return;
364 };
365 let Ok(report) = serde_json::from_str::<PluginShadowReport>(&raw) else {
366 return;
367 };
368 warnings.extend(shadow_validity_warnings(&report));
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn mean_of_empty_is_zero() {
377 assert_eq!(mean(&[]), 0.0);
378 }
379
380 #[test]
381 fn mean_and_stddev() {
382 let v = [1.0, 2.0, 3.0];
383 assert_eq!(mean(&v), 2.0);
384 assert!((stddev(&v, 2.0) - (2.0f64 / 3.0).sqrt()).abs() < 1e-12);
386 }
387
388 #[test]
389 fn stddev_zero_for_fewer_than_two() {
390 assert_eq!(stddev(&[5.0], 5.0), 0.0);
391 assert_eq!(stddev(&[], 0.0), 0.0);
392 }
393
394 #[test]
395 fn round_to_places() {
396 assert_eq!(round(1.23456, 3), 1.235);
397 assert_eq!(round(1999.6, 0), 2000.0);
398 }
399
400 #[test]
401 fn stats_reports_n_and_rounds() {
402 let s = stats(&[1.0, 1.0, 1.0], 3);
403 assert_eq!(s.mean, 1.0);
404 assert_eq!(s.stddev, 0.0);
405 assert_eq!(s.n, 3);
406 }
407
408 #[test]
409 fn timing_label_defaults_to_completion_event() {
410 assert_eq!(timing_source_label(None), "completion-event");
411 assert_eq!(
412 timing_source_label(Some(TimingSource::Transcript)),
413 "transcript"
414 );
415 }
416}