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 whose adoption could be scored — a round whose
42    /// fixer never reported back is excluded, so this is the denominator of
43    /// [`Self::adopted_per_round`], not a headcount of appearances. For that,
44    /// see [`Self::seated`].
45    pub rounds: usize,
46    /// Review rounds it was on the panel for at all, scoreable or not.
47    /// Whether a seat answered is a fact about the seat and does not depend
48    /// on what later became of the fixer's report, so this — not `rounds` —
49    /// is the honest denominator for [`Self::timeout_rate`].
50    pub seated: usize,
51    /// Findings it submitted.
52    pub submitted: usize,
53    /// Findings the fixer acted on.
54    pub adopted: usize,
55    /// Findings no other reviewer in the same round also raised.
56    pub unique: usize,
57    /// Rounds it was seated in but never answered (timeout, crash, unparsable
58    /// output) — kept apart from `submitted`/`adopted` so a silent seat
59    /// cannot read as a seat with nothing to say.
60    pub timeouts: usize,
61}
62
63impl ReviewerStats {
64    /// Adopted findings per round: how much signal one seat produces.
65    pub fn adopted_per_round(&self) -> f64 {
66        if self.rounds == 0 {
67            0.0
68        } else {
69            self.adopted as f64 / self.rounds as f64
70        }
71    }
72
73    /// Adopted over submitted: how often its findings are real. Rounds where
74    /// the seat never answered are not in `submitted`, so a timeout cannot
75    /// dilute (or hide behind) this rate.
76    pub fn precision(&self) -> f64 {
77        if self.submitted == 0 {
78            0.0
79        } else {
80            100.0 * self.adopted as f64 / self.submitted as f64
81        }
82    }
83
84    /// Share of its findings that only it saw.
85    pub fn unique_rate(&self) -> f64 {
86        if self.submitted == 0 {
87            0.0
88        } else {
89            100.0 * self.unique as f64 / self.submitted as f64
90        }
91    }
92
93    /// Share of the rounds it was seated in where it never answered.
94    pub fn timeout_rate(&self) -> f64 {
95        if self.seated == 0 {
96            0.0
97        } else {
98            100.0 * self.timeouts as f64 / self.seated as f64
99        }
100    }
101}
102
103/// What real-machine verification caught that static review did not.
104#[derive(Debug, Clone, Default)]
105pub struct E2eStats {
106    /// Rounds where E2E commands ran.
107    pub rounds: usize,
108    /// Rounds where E2E failed.
109    pub failures: usize,
110    /// Rounds where E2E failed and no reviewer had raised a blocking finding —
111    /// a runtime defect that only execution found.
112    pub sole_detections: usize,
113    /// Rounds where E2E was deferred rather than run: blocking findings
114    /// already required a fix, so the round went straight to the fixer
115    /// instead of spending a full verify run on a head about to change. Kept
116    /// separate from [`Self::rounds`] on purpose — a deferred round never
117    /// ran anything, so counting it there would misreport how often E2E
118    /// actually executed.
119    pub deferred: usize,
120}
121
122impl E2eStats {
123    /// Share of E2E failures that static review had missed entirely.
124    pub fn sole_rate(&self) -> f64 {
125        if self.failures == 0 {
126            0.0
127        } else {
128            100.0 * self.sole_detections as f64 / self.failures as f64
129        }
130    }
131}
132
133/// Run-level counters.
134#[derive(Debug, Clone, Default)]
135pub struct Totals {
136    /// Runs on disk.
137    pub runs: usize,
138    /// Reached a merge.
139    pub merged: usize,
140    /// Passed the gate, merge not requested.
141    pub ready: usize,
142    /// Stopped with findings open or a red gate.
143    pub blocked: usize,
144    /// Could not complete.
145    pub failed: usize,
146    /// The judging panel never reached a quorum; the work is kept but the
147    /// verdict is not trustworthy. Never counted as `blocked` — see
148    /// `RunStatus::Stalled`'s own doc — so it needs its own counter to stay
149    /// visible in a breakdown rather than vanishing from every bucket.
150    pub stalled: usize,
151    /// Every candidate wrote nothing, and said why in a way that survived
152    /// the adoption guard: a claim, not a failure, and not yet verified.
153    pub verified_noop: usize,
154    /// A later attempt at the same task already finished it; this run's own
155    /// `blocked`/`stalled` no longer needs anyone's attention.
156    pub superseded: usize,
157    /// Still moving: any non-terminal status (prep through landing). Kept as
158    /// one bucket rather than one counter per node — the per-node position of
159    /// a live run belongs to `magi show`/the deck, not to a workload-wide
160    /// tally that is read well after the run in question has finished.
161    pub in_progress: usize,
162    /// Runs that reached a tally.
163    pub tallied: usize,
164    /// Tallies where the judges' first choices disagreed.
165    pub split: usize,
166    /// Tallies that went through deliberation.
167    pub deliberated: usize,
168    /// Deliberated runs where at least one judge moved.
169    pub minds_changed: usize,
170    /// Deliberated runs that ended unanimous.
171    pub converged: usize,
172    /// Review rounds across all runs.
173    pub review_rounds: usize,
174}
175
176impl Totals {
177    /// Merged or ready over all runs.
178    pub fn completion_rate(&self) -> f64 {
179        if self.runs == 0 {
180            0.0
181        } else {
182            100.0 * (self.merged + self.ready) as f64 / self.runs as f64
183        }
184    }
185
186    /// Share of tallies that were split.
187    pub fn split_rate(&self) -> f64 {
188        if self.tallied == 0 {
189            0.0
190        } else {
191            100.0 * self.split as f64 / self.tallied as f64
192        }
193    }
194}
195
196/// Everything, aggregated.
197#[derive(Debug, Clone, Default)]
198pub struct Stats {
199    /// Run counters.
200    pub totals: Totals,
201    /// Per-agent implementation record, best win rate first.
202    pub agents: Vec<AgentStats>,
203    /// Per-agent review record, most adopted-per-round first.
204    pub reviewers: Vec<ReviewerStats>,
205    /// Verification record.
206    pub e2e: E2eStats,
207}
208
209/// Load every run on disk, skipping any that cannot be read.
210pub fn load_all() -> Vec<RunState> {
211    list_ids()
212        .into_iter()
213        .filter_map(|id| RunState::load(&id).ok())
214        .collect()
215}
216
217/// Aggregate `states`.
218pub fn collect(states: &[RunState]) -> Stats {
219    let mut totals = Totals::default();
220    let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
221    let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
222    let mut e2e = E2eStats::default();
223
224    for state in states {
225        totals.runs += 1;
226        match state.status {
227            RunStatus::Merged => totals.merged += 1,
228            RunStatus::Ready => totals.ready += 1,
229            RunStatus::Blocked => totals.blocked += 1,
230            RunStatus::Failed => totals.failed += 1,
231            RunStatus::Stalled => totals.stalled += 1,
232            RunStatus::VerifiedNoop => totals.verified_noop += 1,
233            // Counted on its own rather than folded into `blocked`/`stalled`:
234            // the task it belongs to already landed through a later run,
235            // which is the one this tally counts as the merge/ready outcome,
236            // and folding it back in would inflate the denominator with two
237            // outcomes for one task.
238            RunStatus::Superseded => totals.superseded += 1,
239            RunStatus::Prep
240            | RunStatus::Implementing
241            | RunStatus::Judging
242            | RunStatus::Deliberating
243            | RunStatus::Voting
244            | RunStatus::Reviewing
245            | RunStatus::Gating
246            | RunStatus::Landing => totals.in_progress += 1,
247        }
248
249        for c in &state.candidates {
250            let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
251                agent: c.agent.clone(),
252                ..AgentStats::default()
253            });
254            // A verified no-op is not counted as the ordinary empty loss it
255            // would otherwise look like: the candidate gave evidence for
256            // writing nothing, which `entry.empty` exists to flag the
257            // *absence* of.
258            if c.empty && c.verified_noop.is_none() {
259                entry.empty += 1;
260            }
261            if c.viable() {
262                entry.entered += 1;
263            }
264        }
265
266        if let Some(t) = &state.tally {
267            // A tally with no panel (`uncontested`) never split, never
268            // deliberated and never converged — it never happened, and
269            // folding it into the denominator would understate the real
270            // split rate with runs that carry no panel-agreement signal at
271            // all. The winner still earns its agent a win either way: an
272            // uncontested candidate is still the one that shipped.
273            if t.uncontested.is_none() {
274                totals.tallied += 1;
275                if !t.unanimous_initial {
276                    totals.split += 1;
277                }
278                if t.deliberated {
279                    totals.deliberated += 1;
280                    if t.changed_votes > 0 {
281                        totals.minds_changed += 1;
282                    }
283                    if t.unanimous_final {
284                        totals.converged += 1;
285                    }
286                }
287            }
288            if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
289                agents
290                    .entry(w.agent.clone())
291                    .or_insert_with(|| AgentStats {
292                        agent: w.agent.clone(),
293                        ..AgentStats::default()
294                    })
295                    .wins += 1;
296            }
297        }
298
299        for round in &state.reviews {
300            totals.review_rounds += 1;
301
302            // A round whose fixer never reported back (crashed, timed out, or
303            // replied with something magi could not parse) leaves adoption
304            // unknown, not zero. Counting it would score every reviewer in
305            // that round as having been ignored, when the truth is simply
306            // unrecorded — so it stays out of the adoption-rate denominator
307            // entirely rather than silently becoming a round of 0 adoptions.
308            let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
309            let adopted: Vec<&String> = round
310                .fix
311                .as_ref()
312                .map(|f| f.addressed.iter().collect())
313                .unwrap_or_default();
314
315            for rec in &round.reviews {
316                let entry = reviewers
317                    .entry(rec.agent.clone())
318                    .or_insert_with(|| ReviewerStats {
319                        agent: rec.agent.clone(),
320                        ..ReviewerStats::default()
321                    });
322                // Seating and answering are facts about the seat itself: they
323                // hold whether or not this round's adoption is scoreable, so
324                // they are counted before the lost-report guard. A seat that
325                // never answered stays out of every scoring denominator —
326                // silence is not a review that found nothing.
327                entry.seated += 1;
328                if rec.failed.is_some() {
329                    entry.timeouts += 1;
330                    continue;
331                }
332                if report_lost {
333                    continue;
334                }
335                entry.rounds += 1;
336                entry.submitted += rec.findings.len();
337                for f in &rec.findings {
338                    if adopted.iter().any(|a| **a == f.id) {
339                        entry.adopted += 1;
340                    }
341                    let overlapped = round
342                        .reviews
343                        .iter()
344                        .filter(|other| other.reviewer != rec.reviewer)
345                        .flat_map(|other| other.findings.iter())
346                        .any(|g| same_defect(f, g));
347                    if !overlapped {
348                        entry.unique += 1;
349                    }
350                }
351            }
352
353            if round.e2e_deferred {
354                e2e.deferred += 1;
355            } else if !round.e2e.is_empty() {
356                e2e.rounds += 1;
357                if round.e2e.iter().any(|o| !o.ok()) {
358                    e2e.failures += 1;
359                    if round.blocking == 0 {
360                        e2e.sole_detections += 1;
361                    }
362                }
363            }
364        }
365    }
366
367    let mut agents: Vec<AgentStats> = agents.into_values().collect();
368    agents.sort_by(|a, b| {
369        b.win_rate()
370            .total_cmp(&a.win_rate())
371            .then(b.entered.cmp(&a.entered))
372    });
373    let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
374    // A seat only sighted in rounds whose adoption could not be scored has
375    // nothing to report: no scoreable round, no silence to flag. It stays out
376    // of the table entirely rather than appearing as a row of zeroes, which
377    // would read as a reviewer that produced nothing.
378    reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
379    reviewers.sort_by(|a, b| {
380        b.adopted_per_round()
381            .total_cmp(&a.adopted_per_round())
382            .then(b.rounds.cmp(&a.rounds))
383    });
384
385    Stats {
386        totals,
387        agents,
388        reviewers,
389        e2e,
390    }
391}
392
393/// Do two findings describe the same defect?
394///
395/// A deliberate heuristic: same normalised title, or the same file within five
396/// lines. Two reviewers rarely word a finding identically, and exact matching
397/// would report every overlap as a unique find.
398fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
399    if normalize(&a.title) == normalize(&b.title) {
400        return true;
401    }
402    match (&a.file, &b.file) {
403        (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
404            (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
405            _ => false,
406        },
407        _ => false,
408    }
409}
410
411fn normalize(title: &str) -> String {
412    title
413        .chars()
414        .filter(|c| c.is_alphanumeric())
415        .map(|c| c.to_ascii_lowercase())
416        .collect()
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::config::Config;
423    use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
424    use crate::verdict::{Finding, Severity};
425    use std::path::PathBuf;
426
427    fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
428        Finding {
429            id: id.to_owned(),
430            severity: sev,
431            file: Some(file.to_owned()),
432            line: Some(line),
433            title: title.to_owned(),
434            detail: String::new(),
435        }
436    }
437
438    fn candidate(label: char, agent: &str) -> Candidate {
439        Candidate {
440            index: 0,
441            label,
442            agent: agent.to_owned(),
443            branch: format!("magi/x/{label}"),
444            worktree: PathBuf::from("/w"),
445            summary: String::new(),
446            stat: String::new(),
447            files: 1,
448            commits: 1,
449            empty: false,
450            failed: None,
451            verified_noop: None,
452            duration_ms: 0,
453            folded: false,
454        }
455    }
456
457    fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
458        let mut s = RunState::new(
459            PathBuf::from("/repo"),
460            "main".to_owned(),
461            "abcdef".to_owned(),
462            "task".to_owned(),
463            Config::default(),
464        );
465        s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
466        s.tally = Some(Tally {
467            first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
468            borda: BTreeMap::new(),
469            winner,
470            rankings: 3,
471            unanimous_initial: false,
472            deliberated: true,
473            changed_votes: 1,
474            unanimous_final: true,
475            tie_break: None,
476            judges: 3,
477            present: 3,
478            quorum: 2,
479            met_quorum: true,
480            uncontested: None,
481        });
482        s.reviews = reviews;
483        s.status = status;
484        s
485    }
486
487    #[test]
488    fn win_rates_and_completion_are_counted_per_agent() {
489        let states = vec![
490            state_with(Vec::new(), 'B', RunStatus::Merged),
491            state_with(Vec::new(), 'A', RunStatus::Blocked),
492        ];
493        let stats = collect(&states);
494        assert_eq!(stats.totals.runs, 2);
495        assert_eq!(stats.totals.merged, 1);
496        assert_eq!(stats.totals.blocked, 1);
497        assert_eq!(stats.totals.completion_rate(), 50.0);
498        assert_eq!(stats.totals.split, 2);
499        assert_eq!(stats.totals.minds_changed, 2);
500        assert_eq!(stats.totals.converged, 2);
501
502        let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
503        assert_eq!(beta.entered, 2);
504        assert_eq!(beta.wins, 1);
505        assert_eq!(beta.win_rate(), 50.0);
506    }
507
508    #[test]
509    fn reviewer_precision_and_uniqueness() {
510        let round = ReviewRound {
511            round: 1,
512            head: "h".to_owned(),
513            verified_head: None,
514            verified_at: None,
515            reviews: vec![
516                ReviewRecord {
517                    attempts: 0,
518                    reviewer: 1,
519                    agent: "alpha".to_owned(),
520                    summary: String::new(),
521                    findings: vec![
522                        finding(
523                            "R1-1-1",
524                            "src/a.rs",
525                            10,
526                            "panics on empty",
527                            Severity::Blocker,
528                        ),
529                        finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
530                    ],
531                    vote: None,
532                    failed: None,
533                    duration_ms: 0,
534                },
535                ReviewRecord {
536                    attempts: 0,
537                    reviewer: 2,
538                    agent: "beta".to_owned(),
539                    summary: String::new(),
540                    // Same defect as R1-1-1, three lines off: an overlap.
541                    findings: vec![finding(
542                        "R1-2-1",
543                        "src/a.rs",
544                        13,
545                        "empty input panic",
546                        Severity::Blocker,
547                    )],
548                    vote: None,
549                    failed: None,
550                    duration_ms: 0,
551                },
552            ],
553            e2e: Vec::new(),
554            verify_retried: false,
555            e2e_deferred: false,
556            e2e_defer_reason: None,
557            fix: Some(FixRecord {
558                agent: "alpha".to_owned(),
559                addressed: vec!["R1-1-1".to_owned()],
560                rejected: Vec::new(),
561                notes: String::new(),
562                committed: true,
563                failed: None,
564                duration_ms: 0,
565                continuation: None,
566            }),
567            blocking: 3,
568            answered: 2,
569            expected: 2,
570            clean: false,
571            progressed: true,
572            vote_split: false,
573            reconsideration: Vec::new(),
574            verdict: None,
575        };
576        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
577        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
578        assert_eq!(alpha.submitted, 2);
579        assert_eq!(alpha.adopted, 1);
580        assert_eq!(alpha.precision(), 50.0);
581        assert_eq!(alpha.adopted_per_round(), 1.0);
582        // The src/a.rs finding overlaps beta's; src/b.rs does not.
583        assert_eq!(alpha.unique, 1);
584
585        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
586        assert_eq!(beta.submitted, 1);
587        assert_eq!(beta.adopted, 0);
588        assert_eq!(beta.unique, 0);
589    }
590
591    #[test]
592    fn a_lost_fix_report_does_not_count_as_zero_adoption() {
593        let submitted = ReviewRound {
594            round: 1,
595            head: "h".to_owned(),
596            verified_head: None,
597            verified_at: None,
598            reviews: vec![ReviewRecord {
599                attempts: 0,
600                reviewer: 1,
601                agent: "alpha".to_owned(),
602                summary: String::new(),
603                findings: vec![finding(
604                    "R1-1-1",
605                    "src/a.rs",
606                    10,
607                    "panics on empty",
608                    Severity::Blocker,
609                )],
610                vote: None,
611                failed: None,
612                duration_ms: 0,
613            }],
614            e2e: Vec::new(),
615            verify_retried: false,
616            e2e_deferred: false,
617            e2e_defer_reason: None,
618            // The fixer's diff may well have landed (blocking counts do fall
619            // round over round) — only its adoption report never came back.
620            fix: Some(FixRecord {
621                agent: "alpha".to_owned(),
622                addressed: Vec::new(),
623                rejected: Vec::new(),
624                notes: String::new(),
625                committed: true,
626                failed: Some("unparsable fix report".to_owned()),
627                duration_ms: 0,
628                continuation: None,
629            }),
630            blocking: 4,
631            answered: 1,
632            expected: 1,
633            clean: false,
634            progressed: false,
635            vote_split: false,
636            reconsideration: Vec::new(),
637            verdict: None,
638        };
639        let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
640        assert!(
641            stats.reviewers.is_empty(),
642            "a round with no adoption signal must not enter any reviewer's \
643             denominator: {:?}",
644            stats.reviewers
645        );
646    }
647
648    #[test]
649    fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
650        let round = ReviewRound {
651            round: 1,
652            head: "h".to_owned(),
653            verified_head: None,
654            verified_at: None,
655            reviews: vec![
656                ReviewRecord {
657                    attempts: 0,
658                    reviewer: 1,
659                    agent: "alpha".to_owned(),
660                    summary: String::new(),
661                    findings: Vec::new(),
662                    vote: None,
663                    failed: None,
664                    duration_ms: 0,
665                },
666                ReviewRecord {
667                    attempts: 0,
668                    reviewer: 2,
669                    agent: "beta".to_owned(),
670                    summary: String::new(),
671                    findings: Vec::new(),
672                    vote: None,
673                    failed: Some("agent timed out".to_owned()),
674                    duration_ms: 0,
675                },
676            ],
677            e2e: Vec::new(),
678            verify_retried: false,
679            e2e_deferred: false,
680            e2e_defer_reason: None,
681            fix: None,
682            blocking: 0,
683            answered: 1,
684            expected: 2,
685            clean: false,
686            progressed: false,
687            vote_split: false,
688            reconsideration: Vec::new(),
689            verdict: None,
690        };
691        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
692
693        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
694        assert_eq!(alpha.seated, 1);
695        assert_eq!(alpha.rounds, 1);
696        assert_eq!(alpha.timeouts, 0);
697        assert_eq!(alpha.submitted, 0);
698
699        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
700        assert_eq!(beta.seated, 1);
701        assert_eq!(beta.timeouts, 1);
702        assert_eq!(beta.submitted, 0);
703        // A timeout must never read as a submission with nothing found: it
704        // stays out of the scoring denominators entirely rather than becoming
705        // a 0/0 that looks identical to a reviewer who answered and passed.
706        assert_eq!(beta.rounds, 0);
707        assert_eq!(beta.timeout_rate(), 100.0);
708    }
709
710    #[test]
711    fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
712        // Two independent gaps in one round: `beta` never answered, and the
713        // fixer's adoption report never came back. The lost report suppresses
714        // adoption scoring (see `a_lost_fix_report_does_not_count_as_zero_
715        // adoption`) — it must not also swallow the fact that a seat was
716        // silent, which is a property of the seat and not of the fixer.
717        let round = ReviewRound {
718            round: 1,
719            head: "h".to_owned(),
720            verified_head: None,
721            verified_at: None,
722            reviews: vec![
723                ReviewRecord {
724                    attempts: 0,
725                    reviewer: 1,
726                    agent: "alpha".to_owned(),
727                    summary: String::new(),
728                    findings: vec![finding(
729                        "R1-1-1",
730                        "src/a.rs",
731                        10,
732                        "panics on empty",
733                        Severity::Blocker,
734                    )],
735                    vote: None,
736                    failed: None,
737                    duration_ms: 0,
738                },
739                ReviewRecord {
740                    attempts: 0,
741                    reviewer: 2,
742                    agent: "beta".to_owned(),
743                    summary: String::new(),
744                    findings: Vec::new(),
745                    vote: None,
746                    failed: Some("agent timed out".to_owned()),
747                    duration_ms: 0,
748                },
749            ],
750            e2e: Vec::new(),
751            verify_retried: false,
752            e2e_deferred: false,
753            e2e_defer_reason: None,
754            fix: Some(FixRecord {
755                agent: "alpha".to_owned(),
756                addressed: Vec::new(),
757                rejected: Vec::new(),
758                notes: String::new(),
759                committed: true,
760                failed: Some("unparsable fix report".to_owned()),
761                duration_ms: 0,
762                continuation: None,
763            }),
764            blocking: 1,
765            answered: 1,
766            expected: 2,
767            clean: false,
768            progressed: false,
769            vote_split: false,
770            reconsideration: Vec::new(),
771            verdict: None,
772        };
773        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
774
775        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
776        assert_eq!(beta.timeouts, 1);
777        assert_eq!(beta.timeout_rate(), 100.0);
778        // `alpha` answered, so the lost report keeps it out of the table
779        // altogether — nothing about its findings can be scored.
780        assert!(
781            !stats.reviewers.iter().any(|r| r.agent == "alpha"),
782            "{:?}",
783            stats.reviewers
784        );
785    }
786
787    #[test]
788    fn e2e_sole_detection_needs_a_clean_static_review() {
789        let fail = CommandOutcome {
790            command: "cargo test".to_owned(),
791            code: Some(101),
792            output_tail: "boom".to_owned(),
793            duration_ms: 1,
794            resource_blocked: false,
795        };
796        let sole = ReviewRound {
797            round: 1,
798            head: "h".to_owned(),
799            verified_head: None,
800            verified_at: None,
801            reviews: Vec::new(),
802            e2e: vec![fail.clone()],
803            verify_retried: false,
804            e2e_deferred: false,
805            e2e_defer_reason: None,
806            fix: None,
807            blocking: 0,
808            answered: 0,
809            expected: 0,
810            clean: false,
811            progressed: false,
812            vote_split: false,
813            reconsideration: Vec::new(),
814            verdict: None,
815        };
816        let alongside = ReviewRound {
817            round: 2,
818            head: "h".to_owned(),
819            verified_head: None,
820            verified_at: None,
821            reviews: Vec::new(),
822            e2e: vec![fail],
823            verify_retried: false,
824            e2e_deferred: false,
825            e2e_defer_reason: None,
826            fix: None,
827            blocking: 2,
828            answered: 0,
829            expected: 0,
830            clean: false,
831            progressed: false,
832            vote_split: false,
833            reconsideration: Vec::new(),
834            verdict: None,
835        };
836        let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
837        assert_eq!(stats.e2e.rounds, 2);
838        assert_eq!(stats.e2e.failures, 2);
839        assert_eq!(stats.e2e.sole_detections, 1);
840        assert_eq!(stats.e2e.sole_rate(), 50.0);
841    }
842
843    #[test]
844    fn every_run_status_lands_in_exactly_one_breakdown_bucket() {
845        let states = vec![
846            state_with(Vec::new(), 'A', RunStatus::Merged),
847            state_with(Vec::new(), 'A', RunStatus::Ready),
848            state_with(Vec::new(), 'A', RunStatus::Blocked),
849            state_with(Vec::new(), 'A', RunStatus::Failed),
850            state_with(Vec::new(), 'A', RunStatus::Stalled),
851            state_with(Vec::new(), 'A', RunStatus::VerifiedNoop),
852            state_with(Vec::new(), 'A', RunStatus::Superseded),
853            state_with(Vec::new(), 'A', RunStatus::Implementing),
854            state_with(Vec::new(), 'A', RunStatus::Landing),
855        ];
856        let stats = collect(&states);
857        let t = &stats.totals;
858        assert_eq!(t.runs, 9);
859        assert_eq!(t.merged, 1);
860        assert_eq!(t.ready, 1);
861        assert_eq!(t.blocked, 1);
862        assert_eq!(t.failed, 1);
863        assert_eq!(t.stalled, 1);
864        assert_eq!(t.verified_noop, 1);
865        assert_eq!(t.superseded, 1);
866        // `Implementing` and `Landing` both fall into the one non-terminal
867        // bucket.
868        assert_eq!(t.in_progress, 2);
869        assert_eq!(
870            t.merged
871                + t.ready
872                + t.blocked
873                + t.failed
874                + t.stalled
875                + t.verified_noop
876                + t.superseded
877                + t.in_progress,
878            t.runs,
879            "every run must land in exactly one bucket of the breakdown"
880        );
881    }
882
883    #[test]
884    fn empty_input_yields_zeroed_rates_not_nan() {
885        let stats = collect(&[]);
886        assert_eq!(stats.totals.completion_rate(), 0.0);
887        assert_eq!(stats.totals.split_rate(), 0.0);
888        assert_eq!(stats.e2e.sole_rate(), 0.0);
889        assert!(stats.agents.is_empty());
890    }
891
892    #[test]
893    fn same_defect_matches_titles_across_files() {
894        let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
895        let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
896        assert!(same_defect(&a, &b));
897        let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
898        assert!(!same_defect(&a, &c));
899    }
900}