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, plugin shadows); and writes `benchmark.json`.
9
10use 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
25/// Mean of a series (0 for an empty series).
26fn 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
33/// Population standard deviation about `m` (0 for fewer than two samples).
34fn 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
42/// Round `n` to `dp` decimal places.
43fn round(n: f64, dp: i32) -> f64 {
44    let p = 10f64.powi(dp);
45    (n * p).round() / p
46}
47
48/// Mean/stddev/n for a series, each rounded to `dp` places.
49fn 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/// Summary statistics for one metric.
59#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
60pub struct Stats {
61    pub mean: f64,
62    pub stddev: f64,
63    pub n: usize,
64}
65
66/// Per-condition rollup. Skill-invocation fields appear only when the condition
67/// had the skill loaded.
68#[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    /// Present (possibly `null`) only when the skill was loaded.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    skill_invocation_rate: Option<Option<f64>>,
78}
79
80/// The `a - b` differences between the two compared conditions.
81#[derive(Debug, Clone, Serialize)]
82struct Delta {
83    direction: String,
84    pass_rate: f64,
85    duration_ms: f64,
86    total_tokens: f64,
87}
88
89/// The full `benchmark.json`.
90#[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/// Per-condition accumulators.
115#[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/// `stray-writes.json` runs, read leniently (only finding counts matter).
125#[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
141/// Compute and write `benchmark.json` for the iteration. Requires exactly two
142/// conditions and at least one `eval-*` directory.
143pub 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    // Build the per-condition summaries, preserving condition order.
263    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
390/// The provenance label for a timing record (`completion-event` when absent).
391fn 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
399/// Add a warning per stray-write violation / live-source read. A malformed
400/// report is ignored rather than failing aggregation — the warnings are
401/// advisory, not a gate.
402fn 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
429/// Add plugin-shadow validity warnings. A malformed report is ignored.
430fn 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        // population stddev of [1,2,3] about 2 = sqrt(2/3)
462        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}