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            // A tally with no panel (`uncontested`) never split, never
203            // deliberated and never converged — it never happened, and
204            // folding it into the denominator would understate the real
205            // split rate with runs that carry no panel-agreement signal at
206            // all. The winner still earns its agent a win either way: an
207            // uncontested candidate is still the one that shipped.
208            if t.uncontested.is_none() {
209                totals.tallied += 1;
210                if !t.unanimous_initial {
211                    totals.split += 1;
212                }
213                if t.deliberated {
214                    totals.deliberated += 1;
215                    if t.changed_votes > 0 {
216                        totals.minds_changed += 1;
217                    }
218                    if t.unanimous_final {
219                        totals.converged += 1;
220                    }
221                }
222            }
223            if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
224                agents
225                    .entry(w.agent.clone())
226                    .or_insert_with(|| AgentStats {
227                        agent: w.agent.clone(),
228                        ..AgentStats::default()
229                    })
230                    .wins += 1;
231            }
232        }
233
234        for round in &state.reviews {
235            totals.review_rounds += 1;
236
237            // A round whose fixer never reported back (crashed, timed out, or
238            // replied with something magi could not parse) leaves adoption
239            // unknown, not zero. Counting it would score every reviewer in
240            // that round as having been ignored, when the truth is simply
241            // unrecorded — so it stays out of the adoption-rate denominator
242            // entirely rather than silently becoming a round of 0 adoptions.
243            let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
244            if !report_lost {
245                let adopted: Vec<&String> = round
246                    .fix
247                    .as_ref()
248                    .map(|f| f.addressed.iter().collect())
249                    .unwrap_or_default();
250
251                for rec in &round.reviews {
252                    let entry =
253                        reviewers
254                            .entry(rec.agent.clone())
255                            .or_insert_with(|| ReviewerStats {
256                                agent: rec.agent.clone(),
257                                ..ReviewerStats::default()
258                            });
259                    entry.rounds += 1;
260                    entry.submitted += rec.findings.len();
261                    for f in &rec.findings {
262                        if adopted.iter().any(|a| **a == f.id) {
263                            entry.adopted += 1;
264                        }
265                        let overlapped = round
266                            .reviews
267                            .iter()
268                            .filter(|other| other.reviewer != rec.reviewer)
269                            .flat_map(|other| other.findings.iter())
270                            .any(|g| same_defect(f, g));
271                        if !overlapped {
272                            entry.unique += 1;
273                        }
274                    }
275                }
276            }
277
278            if !round.e2e.is_empty() {
279                e2e.rounds += 1;
280                if round.e2e.iter().any(|o| !o.ok()) {
281                    e2e.failures += 1;
282                    if round.blocking == 0 {
283                        e2e.sole_detections += 1;
284                    }
285                }
286            }
287        }
288    }
289
290    let mut agents: Vec<AgentStats> = agents.into_values().collect();
291    agents.sort_by(|a, b| {
292        b.win_rate()
293            .total_cmp(&a.win_rate())
294            .then(b.entered.cmp(&a.entered))
295    });
296    let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
297    reviewers.sort_by(|a, b| {
298        b.adopted_per_round()
299            .total_cmp(&a.adopted_per_round())
300            .then(b.rounds.cmp(&a.rounds))
301    });
302
303    Stats {
304        totals,
305        agents,
306        reviewers,
307        e2e,
308    }
309}
310
311/// Do two findings describe the same defect?
312///
313/// A deliberate heuristic: same normalised title, or the same file within five
314/// lines. Two reviewers rarely word a finding identically, and exact matching
315/// would report every overlap as a unique find.
316fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
317    if normalize(&a.title) == normalize(&b.title) {
318        return true;
319    }
320    match (&a.file, &b.file) {
321        (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
322            (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
323            _ => false,
324        },
325        _ => false,
326    }
327}
328
329fn normalize(title: &str) -> String {
330    title
331        .chars()
332        .filter(|c| c.is_alphanumeric())
333        .map(|c| c.to_ascii_lowercase())
334        .collect()
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::config::Config;
341    use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
342    use crate::verdict::{Finding, Severity};
343    use std::path::PathBuf;
344
345    fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
346        Finding {
347            id: id.to_owned(),
348            severity: sev,
349            file: Some(file.to_owned()),
350            line: Some(line),
351            title: title.to_owned(),
352            detail: String::new(),
353        }
354    }
355
356    fn candidate(label: char, agent: &str) -> Candidate {
357        Candidate {
358            index: 0,
359            label,
360            agent: agent.to_owned(),
361            branch: format!("magi/x/{label}"),
362            worktree: PathBuf::from("/w"),
363            summary: String::new(),
364            stat: String::new(),
365            files: 1,
366            commits: 1,
367            empty: false,
368            failed: None,
369            duration_ms: 0,
370            folded: false,
371        }
372    }
373
374    fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
375        let mut s = RunState::new(
376            PathBuf::from("/repo"),
377            "main".to_owned(),
378            "abcdef".to_owned(),
379            "task".to_owned(),
380            Config::default(),
381        );
382        s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
383        s.tally = Some(Tally {
384            first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
385            borda: BTreeMap::new(),
386            winner,
387            rankings: 3,
388            unanimous_initial: false,
389            deliberated: true,
390            changed_votes: 1,
391            unanimous_final: true,
392            tie_break: None,
393            judges: 3,
394            present: 3,
395            quorum: 2,
396            met_quorum: true,
397            uncontested: None,
398        });
399        s.reviews = reviews;
400        s.status = status;
401        s
402    }
403
404    #[test]
405    fn win_rates_and_completion_are_counted_per_agent() {
406        let states = vec![
407            state_with(Vec::new(), 'B', RunStatus::Merged),
408            state_with(Vec::new(), 'A', RunStatus::Blocked),
409        ];
410        let stats = collect(&states);
411        assert_eq!(stats.totals.runs, 2);
412        assert_eq!(stats.totals.merged, 1);
413        assert_eq!(stats.totals.blocked, 1);
414        assert_eq!(stats.totals.completion_rate(), 50.0);
415        assert_eq!(stats.totals.split, 2);
416        assert_eq!(stats.totals.minds_changed, 2);
417        assert_eq!(stats.totals.converged, 2);
418
419        let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
420        assert_eq!(beta.entered, 2);
421        assert_eq!(beta.wins, 1);
422        assert_eq!(beta.win_rate(), 50.0);
423    }
424
425    #[test]
426    fn reviewer_precision_and_uniqueness() {
427        let round = ReviewRound {
428            round: 1,
429            head: "h".to_owned(),
430            reviews: vec![
431                ReviewRecord {
432                    reviewer: 1,
433                    agent: "alpha".to_owned(),
434                    summary: String::new(),
435                    findings: vec![
436                        finding(
437                            "R1-1-1",
438                            "src/a.rs",
439                            10,
440                            "panics on empty",
441                            Severity::Blocker,
442                        ),
443                        finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
444                    ],
445                    failed: None,
446                    duration_ms: 0,
447                },
448                ReviewRecord {
449                    reviewer: 2,
450                    agent: "beta".to_owned(),
451                    summary: String::new(),
452                    // Same defect as R1-1-1, three lines off: an overlap.
453                    findings: vec![finding(
454                        "R1-2-1",
455                        "src/a.rs",
456                        13,
457                        "empty input panic",
458                        Severity::Blocker,
459                    )],
460                    failed: None,
461                    duration_ms: 0,
462                },
463            ],
464            e2e: Vec::new(),
465            verify_retried: false,
466            fix: Some(FixRecord {
467                agent: "alpha".to_owned(),
468                addressed: vec!["R1-1-1".to_owned()],
469                rejected: Vec::new(),
470                notes: String::new(),
471                committed: true,
472                failed: None,
473                duration_ms: 0,
474            }),
475            blocking: 3,
476            clean: false,
477        };
478        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
479        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
480        assert_eq!(alpha.submitted, 2);
481        assert_eq!(alpha.adopted, 1);
482        assert_eq!(alpha.precision(), 50.0);
483        assert_eq!(alpha.adopted_per_round(), 1.0);
484        // The src/a.rs finding overlaps beta's; src/b.rs does not.
485        assert_eq!(alpha.unique, 1);
486
487        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
488        assert_eq!(beta.submitted, 1);
489        assert_eq!(beta.adopted, 0);
490        assert_eq!(beta.unique, 0);
491    }
492
493    #[test]
494    fn a_lost_fix_report_does_not_count_as_zero_adoption() {
495        let submitted = ReviewRound {
496            round: 1,
497            head: "h".to_owned(),
498            reviews: vec![ReviewRecord {
499                reviewer: 1,
500                agent: "alpha".to_owned(),
501                summary: String::new(),
502                findings: vec![finding(
503                    "R1-1-1",
504                    "src/a.rs",
505                    10,
506                    "panics on empty",
507                    Severity::Blocker,
508                )],
509                failed: None,
510                duration_ms: 0,
511            }],
512            e2e: Vec::new(),
513            verify_retried: false,
514            // The fixer's diff may well have landed (blocking counts do fall
515            // round over round) — only its adoption report never came back.
516            fix: Some(FixRecord {
517                agent: "alpha".to_owned(),
518                addressed: Vec::new(),
519                rejected: Vec::new(),
520                notes: String::new(),
521                committed: true,
522                failed: Some("unparsable fix report".to_owned()),
523                duration_ms: 0,
524            }),
525            blocking: 4,
526            clean: false,
527        };
528        let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
529        assert!(
530            stats.reviewers.is_empty(),
531            "a round with no adoption signal must not enter any reviewer's \
532             denominator: {:?}",
533            stats.reviewers
534        );
535    }
536
537    #[test]
538    fn e2e_sole_detection_needs_a_clean_static_review() {
539        let fail = CommandOutcome {
540            command: "cargo test".to_owned(),
541            code: Some(101),
542            output_tail: "boom".to_owned(),
543            duration_ms: 1,
544        };
545        let sole = ReviewRound {
546            round: 1,
547            head: "h".to_owned(),
548            reviews: Vec::new(),
549            e2e: vec![fail.clone()],
550            verify_retried: false,
551            fix: None,
552            blocking: 0,
553            clean: false,
554        };
555        let alongside = ReviewRound {
556            round: 2,
557            head: "h".to_owned(),
558            reviews: Vec::new(),
559            e2e: vec![fail],
560            verify_retried: false,
561            fix: None,
562            blocking: 2,
563            clean: false,
564        };
565        let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
566        assert_eq!(stats.e2e.rounds, 2);
567        assert_eq!(stats.e2e.failures, 2);
568        assert_eq!(stats.e2e.sole_detections, 1);
569        assert_eq!(stats.e2e.sole_rate(), 50.0);
570    }
571
572    #[test]
573    fn empty_input_yields_zeroed_rates_not_nan() {
574        let stats = collect(&[]);
575        assert_eq!(stats.totals.completion_rate(), 0.0);
576        assert_eq!(stats.totals.split_rate(), 0.0);
577        assert_eq!(stats.e2e.sole_rate(), 0.0);
578        assert!(stats.agents.is_empty());
579    }
580
581    #[test]
582    fn same_defect_matches_titles_across_files() {
583        let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
584        let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
585        assert!(same_defect(&a, &b));
586        let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
587        assert!(!same_defect(&a, &c));
588    }
589}