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