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