Skip to main content

magi/
stats.rs

1//! Aggregate statistics over every recorded run.
2//!
3//! These tables are a by-product of running the graph, not a benchmark. The
4//! seat assignment rotates, the task distribution is whatever the operator
5//! happened to ask for, and a model that draws harder tasks looks worse. Read
6//! them as "relative performance on my workload", which is the only claim the
7//! data supports.
8use std::collections::BTreeMap;
9
10use crate::run::{RunState, RunStatus, list_ids};
11
12/// Implementation record for one agent.
13#[derive(Debug, Clone, Default)]
14pub struct AgentStats {
15    /// Agent id.
16    pub agent: String,
17    /// Candidates it produced that were judged.
18    pub entered: usize,
19    /// Competitions it won.
20    pub wins: usize,
21    /// Candidates that produced no change at all.
22    pub empty: usize,
23}
24
25impl AgentStats {
26    /// Win rate over entries, as a percentage.
27    pub fn win_rate(&self) -> f64 {
28        if self.entered == 0 {
29            0.0
30        } else {
31            100.0 * self.wins as f64 / self.entered as f64
32        }
33    }
34}
35
36/// Review record for one agent.
37#[derive(Debug, Clone, Default)]
38pub struct ReviewerStats {
39    /// Agent id.
40    pub agent: String,
41    /// Review rounds it sat in.
42    pub rounds: usize,
43    /// Findings it submitted.
44    pub submitted: usize,
45    /// Findings the fixer acted on.
46    pub adopted: usize,
47    /// Findings no other reviewer in the same round also raised.
48    pub unique: usize,
49}
50
51impl ReviewerStats {
52    /// Adopted findings per round: how much signal one seat produces.
53    pub fn adopted_per_round(&self) -> f64 {
54        if self.rounds == 0 {
55            0.0
56        } else {
57            self.adopted as f64 / self.rounds as f64
58        }
59    }
60
61    /// Adopted over submitted: how often its findings are real.
62    pub fn precision(&self) -> f64 {
63        if self.submitted == 0 {
64            0.0
65        } else {
66            100.0 * self.adopted as f64 / self.submitted as f64
67        }
68    }
69
70    /// Share of its findings that only it saw.
71    pub fn unique_rate(&self) -> f64 {
72        if self.submitted == 0 {
73            0.0
74        } else {
75            100.0 * self.unique as f64 / self.submitted as f64
76        }
77    }
78}
79
80/// What real-machine verification caught that static review did not.
81#[derive(Debug, Clone, Default)]
82pub struct E2eStats {
83    /// Rounds where E2E commands ran.
84    pub rounds: usize,
85    /// Rounds where E2E failed.
86    pub failures: usize,
87    /// Rounds where E2E failed and no reviewer had raised a blocking finding —
88    /// a runtime defect that only execution found.
89    pub sole_detections: usize,
90}
91
92impl E2eStats {
93    /// Share of E2E failures that static review had missed entirely.
94    pub fn sole_rate(&self) -> f64 {
95        if self.failures == 0 {
96            0.0
97        } else {
98            100.0 * self.sole_detections as f64 / self.failures as f64
99        }
100    }
101}
102
103/// Run-level counters.
104#[derive(Debug, Clone, Default)]
105pub struct Totals {
106    /// Runs on disk.
107    pub runs: usize,
108    /// Reached a merge.
109    pub merged: usize,
110    /// Passed the gate, merge not requested.
111    pub ready: usize,
112    /// Stopped with findings open or a red gate.
113    pub blocked: usize,
114    /// Could not complete.
115    pub failed: usize,
116    /// Runs that reached a tally.
117    pub tallied: usize,
118    /// Tallies where the judges' first choices disagreed.
119    pub split: usize,
120    /// Tallies that went through deliberation.
121    pub deliberated: usize,
122    /// Deliberated runs where at least one judge moved.
123    pub minds_changed: usize,
124    /// Deliberated runs that ended unanimous.
125    pub converged: usize,
126    /// Review rounds across all runs.
127    pub review_rounds: usize,
128}
129
130impl Totals {
131    /// Merged or ready over all runs.
132    pub fn completion_rate(&self) -> f64 {
133        if self.runs == 0 {
134            0.0
135        } else {
136            100.0 * (self.merged + self.ready) as f64 / self.runs as f64
137        }
138    }
139
140    /// Share of tallies that were split.
141    pub fn split_rate(&self) -> f64 {
142        if self.tallied == 0 {
143            0.0
144        } else {
145            100.0 * self.split as f64 / self.tallied as f64
146        }
147    }
148}
149
150/// Everything, aggregated.
151#[derive(Debug, Clone, Default)]
152pub struct Stats {
153    /// Run counters.
154    pub totals: Totals,
155    /// Per-agent implementation record, best win rate first.
156    pub agents: Vec<AgentStats>,
157    /// Per-agent review record, most adopted-per-round first.
158    pub reviewers: Vec<ReviewerStats>,
159    /// Verification record.
160    pub e2e: E2eStats,
161}
162
163/// Load every run on disk, skipping any that cannot be read.
164pub fn load_all() -> Vec<RunState> {
165    list_ids()
166        .into_iter()
167        .filter_map(|id| RunState::load(&id).ok())
168        .collect()
169}
170
171/// Aggregate `states`.
172pub fn collect(states: &[RunState]) -> Stats {
173    let mut totals = Totals::default();
174    let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
175    let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
176    let mut e2e = E2eStats::default();
177
178    for state in states {
179        totals.runs += 1;
180        match state.status {
181            RunStatus::Merged => totals.merged += 1,
182            RunStatus::Ready => totals.ready += 1,
183            RunStatus::Blocked => totals.blocked += 1,
184            RunStatus::Failed => totals.failed += 1,
185            _ => {}
186        }
187
188        for c in &state.candidates {
189            let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
190                agent: c.agent.clone(),
191                ..AgentStats::default()
192            });
193            if c.empty {
194                entry.empty += 1;
195            }
196            if c.viable() {
197                entry.entered += 1;
198            }
199        }
200
201        if let Some(t) = &state.tally {
202            totals.tallied += 1;
203            if !t.unanimous_initial {
204                totals.split += 1;
205            }
206            if t.deliberated {
207                totals.deliberated += 1;
208                if t.changed_votes > 0 {
209                    totals.minds_changed += 1;
210                }
211                if t.unanimous_final {
212                    totals.converged += 1;
213                }
214            }
215            if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
216                agents
217                    .entry(w.agent.clone())
218                    .or_insert_with(|| AgentStats {
219                        agent: w.agent.clone(),
220                        ..AgentStats::default()
221                    })
222                    .wins += 1;
223            }
224        }
225
226        for round in &state.reviews {
227            totals.review_rounds += 1;
228            let adopted: Vec<&String> = round
229                .fix
230                .as_ref()
231                .map(|f| f.addressed.iter().collect())
232                .unwrap_or_default();
233
234            for rec in &round.reviews {
235                let entry = reviewers
236                    .entry(rec.agent.clone())
237                    .or_insert_with(|| ReviewerStats {
238                        agent: rec.agent.clone(),
239                        ..ReviewerStats::default()
240                    });
241                entry.rounds += 1;
242                entry.submitted += rec.findings.len();
243                for f in &rec.findings {
244                    if adopted.iter().any(|a| **a == f.id) {
245                        entry.adopted += 1;
246                    }
247                    let overlapped = round
248                        .reviews
249                        .iter()
250                        .filter(|other| other.reviewer != rec.reviewer)
251                        .flat_map(|other| other.findings.iter())
252                        .any(|g| same_defect(f, g));
253                    if !overlapped {
254                        entry.unique += 1;
255                    }
256                }
257            }
258
259            if !round.e2e.is_empty() {
260                e2e.rounds += 1;
261                if round.e2e.iter().any(|o| !o.ok()) {
262                    e2e.failures += 1;
263                    if round.blocking == 0 {
264                        e2e.sole_detections += 1;
265                    }
266                }
267            }
268        }
269    }
270
271    let mut agents: Vec<AgentStats> = agents.into_values().collect();
272    agents.sort_by(|a, b| {
273        b.win_rate()
274            .total_cmp(&a.win_rate())
275            .then(b.entered.cmp(&a.entered))
276    });
277    let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
278    reviewers.sort_by(|a, b| {
279        b.adopted_per_round()
280            .total_cmp(&a.adopted_per_round())
281            .then(b.rounds.cmp(&a.rounds))
282    });
283
284    Stats {
285        totals,
286        agents,
287        reviewers,
288        e2e,
289    }
290}
291
292/// Do two findings describe the same defect?
293///
294/// A deliberate heuristic: same normalised title, or the same file within five
295/// lines. Two reviewers rarely word a finding identically, and exact matching
296/// would report every overlap as a unique find.
297fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
298    if normalize(&a.title) == normalize(&b.title) {
299        return true;
300    }
301    match (&a.file, &b.file) {
302        (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
303            (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
304            _ => false,
305        },
306        _ => false,
307    }
308}
309
310fn normalize(title: &str) -> String {
311    title
312        .chars()
313        .filter(|c| c.is_alphanumeric())
314        .map(|c| c.to_ascii_lowercase())
315        .collect()
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::config::Config;
322    use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
323    use crate::verdict::{Finding, Severity};
324    use std::path::PathBuf;
325
326    fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
327        Finding {
328            id: id.to_owned(),
329            severity: sev,
330            file: Some(file.to_owned()),
331            line: Some(line),
332            title: title.to_owned(),
333            detail: String::new(),
334        }
335    }
336
337    fn candidate(label: char, agent: &str) -> Candidate {
338        Candidate {
339            index: 0,
340            label,
341            agent: agent.to_owned(),
342            branch: format!("magi/x/{label}"),
343            worktree: PathBuf::from("/w"),
344            summary: String::new(),
345            stat: String::new(),
346            files: 1,
347            commits: 1,
348            empty: false,
349            failed: None,
350            duration_ms: 0,
351            folded: false,
352        }
353    }
354
355    fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
356        let mut s = RunState::new(
357            PathBuf::from("/repo"),
358            "main".to_owned(),
359            "abcdef".to_owned(),
360            "task".to_owned(),
361            Config::default(),
362        );
363        s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
364        s.tally = Some(Tally {
365            first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
366            borda: BTreeMap::new(),
367            winner,
368            rankings: 3,
369            unanimous_initial: false,
370            deliberated: true,
371            changed_votes: 1,
372            unanimous_final: true,
373            tie_break: None,
374            judges: 3,
375            present: 3,
376            quorum: 2,
377            met_quorum: true,
378        });
379        s.reviews = reviews;
380        s.status = status;
381        s
382    }
383
384    #[test]
385    fn win_rates_and_completion_are_counted_per_agent() {
386        let states = vec![
387            state_with(Vec::new(), 'B', RunStatus::Merged),
388            state_with(Vec::new(), 'A', RunStatus::Blocked),
389        ];
390        let stats = collect(&states);
391        assert_eq!(stats.totals.runs, 2);
392        assert_eq!(stats.totals.merged, 1);
393        assert_eq!(stats.totals.blocked, 1);
394        assert_eq!(stats.totals.completion_rate(), 50.0);
395        assert_eq!(stats.totals.split, 2);
396        assert_eq!(stats.totals.minds_changed, 2);
397        assert_eq!(stats.totals.converged, 2);
398
399        let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
400        assert_eq!(beta.entered, 2);
401        assert_eq!(beta.wins, 1);
402        assert_eq!(beta.win_rate(), 50.0);
403    }
404
405    #[test]
406    fn reviewer_precision_and_uniqueness() {
407        let round = ReviewRound {
408            round: 1,
409            head: "h".to_owned(),
410            reviews: vec![
411                ReviewRecord {
412                    reviewer: 1,
413                    agent: "alpha".to_owned(),
414                    summary: String::new(),
415                    findings: vec![
416                        finding(
417                            "R1-1-1",
418                            "src/a.rs",
419                            10,
420                            "panics on empty",
421                            Severity::Blocker,
422                        ),
423                        finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
424                    ],
425                    failed: None,
426                    duration_ms: 0,
427                },
428                ReviewRecord {
429                    reviewer: 2,
430                    agent: "beta".to_owned(),
431                    summary: String::new(),
432                    // Same defect as R1-1-1, three lines off: an overlap.
433                    findings: vec![finding(
434                        "R1-2-1",
435                        "src/a.rs",
436                        13,
437                        "empty input panic",
438                        Severity::Blocker,
439                    )],
440                    failed: None,
441                    duration_ms: 0,
442                },
443            ],
444            e2e: Vec::new(),
445            fix: Some(FixRecord {
446                agent: "alpha".to_owned(),
447                addressed: vec!["R1-1-1".to_owned()],
448                rejected: Vec::new(),
449                notes: String::new(),
450                committed: true,
451                failed: None,
452                duration_ms: 0,
453            }),
454            blocking: 3,
455            clean: false,
456        };
457        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
458        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
459        assert_eq!(alpha.submitted, 2);
460        assert_eq!(alpha.adopted, 1);
461        assert_eq!(alpha.precision(), 50.0);
462        assert_eq!(alpha.adopted_per_round(), 1.0);
463        // The src/a.rs finding overlaps beta's; src/b.rs does not.
464        assert_eq!(alpha.unique, 1);
465
466        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
467        assert_eq!(beta.submitted, 1);
468        assert_eq!(beta.adopted, 0);
469        assert_eq!(beta.unique, 0);
470    }
471
472    #[test]
473    fn e2e_sole_detection_needs_a_clean_static_review() {
474        let fail = CommandOutcome {
475            command: "cargo test".to_owned(),
476            code: Some(101),
477            output_tail: "boom".to_owned(),
478            duration_ms: 1,
479        };
480        let sole = ReviewRound {
481            round: 1,
482            head: "h".to_owned(),
483            reviews: Vec::new(),
484            e2e: vec![fail.clone()],
485            fix: None,
486            blocking: 0,
487            clean: false,
488        };
489        let alongside = ReviewRound {
490            round: 2,
491            head: "h".to_owned(),
492            reviews: Vec::new(),
493            e2e: vec![fail],
494            fix: None,
495            blocking: 2,
496            clean: false,
497        };
498        let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
499        assert_eq!(stats.e2e.rounds, 2);
500        assert_eq!(stats.e2e.failures, 2);
501        assert_eq!(stats.e2e.sole_detections, 1);
502        assert_eq!(stats.e2e.sole_rate(), 50.0);
503    }
504
505    #[test]
506    fn empty_input_yields_zeroed_rates_not_nan() {
507        let stats = collect(&[]);
508        assert_eq!(stats.totals.completion_rate(), 0.0);
509        assert_eq!(stats.totals.split_rate(), 0.0);
510        assert_eq!(stats.e2e.sole_rate(), 0.0);
511        assert!(stats.agents.is_empty());
512    }
513
514    #[test]
515    fn same_defect_matches_titles_across_files() {
516        let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
517        let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
518        assert!(same_defect(&a, &b));
519        let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
520        assert!(!same_defect(&a, &c));
521    }
522}