Skip to main content

eval_magic/pipeline/
aggregate.rs

1//! Stage 5 — `aggregate`.
2//!
3//! Compares exactly two conditions: collects
4//! `pass_rate` (from `grading.json`), `total_tokens`/`duration_ms` (from
5//! `timing.json`), raw per-run diff scope, and the skill-invocation determination
6//! per condition; computes mean/stddev and the `a - b` delta; accumulates validity
7//! warnings (mixed timing sources, sub-100% invocation rate, stray-write
8//! violations + live-source reads, guard denials, plugin shadows); and writes
9//! `benchmark.json`.
10
11use 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
28/// Mean of a series (0 for an empty series).
29fn 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
36/// Population standard deviation about `m` (0 for fewer than two samples).
37fn 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
45/// Round `n` to `dp` decimal places.
46fn round(n: f64, dp: i32) -> f64 {
47    let p = 10f64.powi(dp);
48    (n * p).round() / p
49}
50
51/// Mean/stddev/n for a series, each rounded to `dp` places.
52fn 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/// Summary statistics for one metric.
62#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
63pub struct Stats {
64    pub mean: f64,
65    pub stddev: f64,
66    /// Available sample count. `0` means unavailable, not a measured zero.
67    pub n: usize,
68}
69
70/// Per-condition rollup. Skill-invocation fields appear only when the condition
71/// had the skill loaded.
72#[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    /// Present (possibly `null`) only when the skill was loaded.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    skill_invocation_rate: Option<Option<f64>>,
82}
83
84/// The `a - b` differences between the two compared conditions.
85#[derive(Debug, Clone, Serialize)]
86struct Delta {
87    direction: String,
88    pass_rate: f64,
89    duration_ms: f64,
90    total_tokens: f64,
91}
92
93/// The full `benchmark.json`.
94#[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/// Per-condition accumulators.
119#[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/// `stray-writes.json` runs, read leniently (only finding counts matter).
129#[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
145/// Compute and write `benchmark.json` for the iteration. Requires exactly two
146/// conditions and at least one `eval-*` directory.
147pub 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    // Build the per-condition summaries, preserving condition order.
267    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
407/// Add exactly one warning per task affected by the write guard. Boundary
408/// blocks are included: whether legitimate or erroneous, each denial changed
409/// the agent's available actions and therefore the observed eval behavior.
410fn 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
436/// The provenance label for a timing record (`completion-event` when absent).
437fn 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
445/// Add a warning per stray-write violation / live-source read. A malformed
446/// report is ignored rather than failing aggregation — the warnings are
447/// advisory, not a gate.
448fn 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
475/// Add plugin-shadow validity warnings. A malformed report is ignored.
476fn 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        // population stddev of [1,2,3] about 2 = sqrt(2/3)
508        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}