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`), and the skill-invocation determination per condition; computes
6//! mean/stddev and the `a - b` delta; accumulates validity warnings (mixed timing
7//! sources, sub-100% invocation rate, stray-write violations + live-source reads,
8//! 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, 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
24/// Mean of a series (0 for an empty series).
25fn 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
32/// Population standard deviation about `m` (0 for fewer than two samples).
33fn 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
41/// Round `n` to `dp` decimal places.
42fn round(n: f64, dp: i32) -> f64 {
43    let p = 10f64.powi(dp);
44    (n * p).round() / p
45}
46
47/// Mean/stddev/n for a series, each rounded to `dp` places.
48fn 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/// Summary statistics for one metric.
58#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
59pub struct Stats {
60    pub mean: f64,
61    pub stddev: f64,
62    pub n: usize,
63}
64
65/// Per-condition rollup. Skill-invocation fields appear only when the condition
66/// had the skill loaded.
67#[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    /// Present (possibly `null`) only when the skill was loaded.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    skill_invocation_rate: Option<Option<f64>>,
77}
78
79/// The `a - b` differences between the two compared conditions.
80#[derive(Debug, Clone, Serialize)]
81struct Delta {
82    direction: String,
83    pass_rate: f64,
84    duration_ms: f64,
85    total_tokens: f64,
86}
87
88/// The full `benchmark.json`.
89#[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/// Per-condition accumulators.
103#[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/// `stray-writes.json` runs, read leniently (only finding counts matter).
113#[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
129/// Compute and write `benchmark.json` for the iteration. Requires exactly two
130/// conditions and at least one `eval-*` directory.
131pub 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    // Build the per-condition summaries, preserving condition order.
221    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
321/// The provenance label for a timing record (`completion-event` when absent).
322fn 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
330/// Add a warning per stray-write violation / live-source read. A malformed
331/// report is ignored rather than failing aggregation — the warnings are
332/// advisory, not a gate.
333fn 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
360/// Add plugin-shadow validity warnings. A malformed report is ignored.
361fn 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        // population stddev of [1,2,3] about 2 = sqrt(2/3)
385        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}