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    /// Runs that reached a tally.
147    pub tallied: usize,
148    /// Tallies where the judges' first choices disagreed.
149    pub split: usize,
150    /// Tallies that went through deliberation.
151    pub deliberated: usize,
152    /// Deliberated runs where at least one judge moved.
153    pub minds_changed: usize,
154    /// Deliberated runs that ended unanimous.
155    pub converged: usize,
156    /// Review rounds across all runs.
157    pub review_rounds: usize,
158}
159
160impl Totals {
161    /// Merged or ready over all runs.
162    pub fn completion_rate(&self) -> f64 {
163        if self.runs == 0 {
164            0.0
165        } else {
166            100.0 * (self.merged + self.ready) as f64 / self.runs as f64
167        }
168    }
169
170    /// Share of tallies that were split.
171    pub fn split_rate(&self) -> f64 {
172        if self.tallied == 0 {
173            0.0
174        } else {
175            100.0 * self.split as f64 / self.tallied as f64
176        }
177    }
178}
179
180/// Everything, aggregated.
181#[derive(Debug, Clone, Default)]
182pub struct Stats {
183    /// Run counters.
184    pub totals: Totals,
185    /// Per-agent implementation record, best win rate first.
186    pub agents: Vec<AgentStats>,
187    /// Per-agent review record, most adopted-per-round first.
188    pub reviewers: Vec<ReviewerStats>,
189    /// Verification record.
190    pub e2e: E2eStats,
191}
192
193/// Load every run on disk, skipping any that cannot be read.
194pub fn load_all() -> Vec<RunState> {
195    list_ids()
196        .into_iter()
197        .filter_map(|id| RunState::load(&id).ok())
198        .collect()
199}
200
201/// Aggregate `states`.
202pub fn collect(states: &[RunState]) -> Stats {
203    let mut totals = Totals::default();
204    let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
205    let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
206    let mut e2e = E2eStats::default();
207
208    for state in states {
209        totals.runs += 1;
210        match state.status {
211            RunStatus::Merged => totals.merged += 1,
212            RunStatus::Ready => totals.ready += 1,
213            RunStatus::Blocked => totals.blocked += 1,
214            RunStatus::Failed => totals.failed += 1,
215            _ => {}
216        }
217
218        for c in &state.candidates {
219            let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
220                agent: c.agent.clone(),
221                ..AgentStats::default()
222            });
223            if c.empty {
224                entry.empty += 1;
225            }
226            if c.viable() {
227                entry.entered += 1;
228            }
229        }
230
231        if let Some(t) = &state.tally {
232            // A tally with no panel (`uncontested`) never split, never
233            // deliberated and never converged — it never happened, and
234            // folding it into the denominator would understate the real
235            // split rate with runs that carry no panel-agreement signal at
236            // all. The winner still earns its agent a win either way: an
237            // uncontested candidate is still the one that shipped.
238            if t.uncontested.is_none() {
239                totals.tallied += 1;
240                if !t.unanimous_initial {
241                    totals.split += 1;
242                }
243                if t.deliberated {
244                    totals.deliberated += 1;
245                    if t.changed_votes > 0 {
246                        totals.minds_changed += 1;
247                    }
248                    if t.unanimous_final {
249                        totals.converged += 1;
250                    }
251                }
252            }
253            if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
254                agents
255                    .entry(w.agent.clone())
256                    .or_insert_with(|| AgentStats {
257                        agent: w.agent.clone(),
258                        ..AgentStats::default()
259                    })
260                    .wins += 1;
261            }
262        }
263
264        for round in &state.reviews {
265            totals.review_rounds += 1;
266
267            // A round whose fixer never reported back (crashed, timed out, or
268            // replied with something magi could not parse) leaves adoption
269            // unknown, not zero. Counting it would score every reviewer in
270            // that round as having been ignored, when the truth is simply
271            // unrecorded — so it stays out of the adoption-rate denominator
272            // entirely rather than silently becoming a round of 0 adoptions.
273            let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
274            let adopted: Vec<&String> = round
275                .fix
276                .as_ref()
277                .map(|f| f.addressed.iter().collect())
278                .unwrap_or_default();
279
280            for rec in &round.reviews {
281                let entry = reviewers
282                    .entry(rec.agent.clone())
283                    .or_insert_with(|| ReviewerStats {
284                        agent: rec.agent.clone(),
285                        ..ReviewerStats::default()
286                    });
287                // Seating and answering are facts about the seat itself: they
288                // hold whether or not this round's adoption is scoreable, so
289                // they are counted before the lost-report guard. A seat that
290                // never answered stays out of every scoring denominator —
291                // silence is not a review that found nothing.
292                entry.seated += 1;
293                if rec.failed.is_some() {
294                    entry.timeouts += 1;
295                    continue;
296                }
297                if report_lost {
298                    continue;
299                }
300                entry.rounds += 1;
301                entry.submitted += rec.findings.len();
302                for f in &rec.findings {
303                    if adopted.iter().any(|a| **a == f.id) {
304                        entry.adopted += 1;
305                    }
306                    let overlapped = round
307                        .reviews
308                        .iter()
309                        .filter(|other| other.reviewer != rec.reviewer)
310                        .flat_map(|other| other.findings.iter())
311                        .any(|g| same_defect(f, g));
312                    if !overlapped {
313                        entry.unique += 1;
314                    }
315                }
316            }
317
318            if round.e2e_deferred {
319                e2e.deferred += 1;
320            } else if !round.e2e.is_empty() {
321                e2e.rounds += 1;
322                if round.e2e.iter().any(|o| !o.ok()) {
323                    e2e.failures += 1;
324                    if round.blocking == 0 {
325                        e2e.sole_detections += 1;
326                    }
327                }
328            }
329        }
330    }
331
332    let mut agents: Vec<AgentStats> = agents.into_values().collect();
333    agents.sort_by(|a, b| {
334        b.win_rate()
335            .total_cmp(&a.win_rate())
336            .then(b.entered.cmp(&a.entered))
337    });
338    let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
339    // A seat only sighted in rounds whose adoption could not be scored has
340    // nothing to report: no scoreable round, no silence to flag. It stays out
341    // of the table entirely rather than appearing as a row of zeroes, which
342    // would read as a reviewer that produced nothing.
343    reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
344    reviewers.sort_by(|a, b| {
345        b.adopted_per_round()
346            .total_cmp(&a.adopted_per_round())
347            .then(b.rounds.cmp(&a.rounds))
348    });
349
350    Stats {
351        totals,
352        agents,
353        reviewers,
354        e2e,
355    }
356}
357
358/// Do two findings describe the same defect?
359///
360/// A deliberate heuristic: same normalised title, or the same file within five
361/// lines. Two reviewers rarely word a finding identically, and exact matching
362/// would report every overlap as a unique find.
363fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
364    if normalize(&a.title) == normalize(&b.title) {
365        return true;
366    }
367    match (&a.file, &b.file) {
368        (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
369            (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
370            _ => false,
371        },
372        _ => false,
373    }
374}
375
376fn normalize(title: &str) -> String {
377    title
378        .chars()
379        .filter(|c| c.is_alphanumeric())
380        .map(|c| c.to_ascii_lowercase())
381        .collect()
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::config::Config;
388    use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
389    use crate::verdict::{Finding, Severity};
390    use std::path::PathBuf;
391
392    fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
393        Finding {
394            id: id.to_owned(),
395            severity: sev,
396            file: Some(file.to_owned()),
397            line: Some(line),
398            title: title.to_owned(),
399            detail: String::new(),
400        }
401    }
402
403    fn candidate(label: char, agent: &str) -> Candidate {
404        Candidate {
405            index: 0,
406            label,
407            agent: agent.to_owned(),
408            branch: format!("magi/x/{label}"),
409            worktree: PathBuf::from("/w"),
410            summary: String::new(),
411            stat: String::new(),
412            files: 1,
413            commits: 1,
414            empty: false,
415            failed: None,
416            duration_ms: 0,
417            folded: false,
418        }
419    }
420
421    fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
422        let mut s = RunState::new(
423            PathBuf::from("/repo"),
424            "main".to_owned(),
425            "abcdef".to_owned(),
426            "task".to_owned(),
427            Config::default(),
428        );
429        s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
430        s.tally = Some(Tally {
431            first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
432            borda: BTreeMap::new(),
433            winner,
434            rankings: 3,
435            unanimous_initial: false,
436            deliberated: true,
437            changed_votes: 1,
438            unanimous_final: true,
439            tie_break: None,
440            judges: 3,
441            present: 3,
442            quorum: 2,
443            met_quorum: true,
444            uncontested: None,
445        });
446        s.reviews = reviews;
447        s.status = status;
448        s
449    }
450
451    #[test]
452    fn win_rates_and_completion_are_counted_per_agent() {
453        let states = vec![
454            state_with(Vec::new(), 'B', RunStatus::Merged),
455            state_with(Vec::new(), 'A', RunStatus::Blocked),
456        ];
457        let stats = collect(&states);
458        assert_eq!(stats.totals.runs, 2);
459        assert_eq!(stats.totals.merged, 1);
460        assert_eq!(stats.totals.blocked, 1);
461        assert_eq!(stats.totals.completion_rate(), 50.0);
462        assert_eq!(stats.totals.split, 2);
463        assert_eq!(stats.totals.minds_changed, 2);
464        assert_eq!(stats.totals.converged, 2);
465
466        let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
467        assert_eq!(beta.entered, 2);
468        assert_eq!(beta.wins, 1);
469        assert_eq!(beta.win_rate(), 50.0);
470    }
471
472    #[test]
473    fn reviewer_precision_and_uniqueness() {
474        let round = ReviewRound {
475            round: 1,
476            head: "h".to_owned(),
477            verified_head: None,
478            reviews: vec![
479                ReviewRecord {
480                    reviewer: 1,
481                    agent: "alpha".to_owned(),
482                    summary: String::new(),
483                    findings: vec![
484                        finding(
485                            "R1-1-1",
486                            "src/a.rs",
487                            10,
488                            "panics on empty",
489                            Severity::Blocker,
490                        ),
491                        finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
492                    ],
493                    vote: None,
494                    failed: None,
495                    duration_ms: 0,
496                },
497                ReviewRecord {
498                    reviewer: 2,
499                    agent: "beta".to_owned(),
500                    summary: String::new(),
501                    // Same defect as R1-1-1, three lines off: an overlap.
502                    findings: vec![finding(
503                        "R1-2-1",
504                        "src/a.rs",
505                        13,
506                        "empty input panic",
507                        Severity::Blocker,
508                    )],
509                    vote: None,
510                    failed: None,
511                    duration_ms: 0,
512                },
513            ],
514            e2e: Vec::new(),
515            verify_retried: false,
516            e2e_deferred: false,
517            e2e_defer_reason: None,
518            fix: Some(FixRecord {
519                agent: "alpha".to_owned(),
520                addressed: vec!["R1-1-1".to_owned()],
521                rejected: Vec::new(),
522                notes: String::new(),
523                committed: true,
524                failed: None,
525                duration_ms: 0,
526            }),
527            blocking: 3,
528            answered: 2,
529            expected: 2,
530            clean: false,
531            progressed: true,
532            vote_split: false,
533            reconsideration: Vec::new(),
534            verdict: None,
535        };
536        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
537        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
538        assert_eq!(alpha.submitted, 2);
539        assert_eq!(alpha.adopted, 1);
540        assert_eq!(alpha.precision(), 50.0);
541        assert_eq!(alpha.adopted_per_round(), 1.0);
542        // The src/a.rs finding overlaps beta's; src/b.rs does not.
543        assert_eq!(alpha.unique, 1);
544
545        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
546        assert_eq!(beta.submitted, 1);
547        assert_eq!(beta.adopted, 0);
548        assert_eq!(beta.unique, 0);
549    }
550
551    #[test]
552    fn a_lost_fix_report_does_not_count_as_zero_adoption() {
553        let submitted = ReviewRound {
554            round: 1,
555            head: "h".to_owned(),
556            verified_head: None,
557            reviews: vec![ReviewRecord {
558                reviewer: 1,
559                agent: "alpha".to_owned(),
560                summary: String::new(),
561                findings: vec![finding(
562                    "R1-1-1",
563                    "src/a.rs",
564                    10,
565                    "panics on empty",
566                    Severity::Blocker,
567                )],
568                vote: None,
569                failed: None,
570                duration_ms: 0,
571            }],
572            e2e: Vec::new(),
573            verify_retried: false,
574            e2e_deferred: false,
575            e2e_defer_reason: None,
576            // The fixer's diff may well have landed (blocking counts do fall
577            // round over round) — only its adoption report never came back.
578            fix: Some(FixRecord {
579                agent: "alpha".to_owned(),
580                addressed: Vec::new(),
581                rejected: Vec::new(),
582                notes: String::new(),
583                committed: true,
584                failed: Some("unparsable fix report".to_owned()),
585                duration_ms: 0,
586            }),
587            blocking: 4,
588            answered: 1,
589            expected: 1,
590            clean: false,
591            progressed: false,
592            vote_split: false,
593            reconsideration: Vec::new(),
594            verdict: None,
595        };
596        let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
597        assert!(
598            stats.reviewers.is_empty(),
599            "a round with no adoption signal must not enter any reviewer's \
600             denominator: {:?}",
601            stats.reviewers
602        );
603    }
604
605    #[test]
606    fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
607        let round = ReviewRound {
608            round: 1,
609            head: "h".to_owned(),
610            verified_head: None,
611            reviews: vec![
612                ReviewRecord {
613                    reviewer: 1,
614                    agent: "alpha".to_owned(),
615                    summary: String::new(),
616                    findings: Vec::new(),
617                    vote: None,
618                    failed: None,
619                    duration_ms: 0,
620                },
621                ReviewRecord {
622                    reviewer: 2,
623                    agent: "beta".to_owned(),
624                    summary: String::new(),
625                    findings: Vec::new(),
626                    vote: None,
627                    failed: Some("agent timed out".to_owned()),
628                    duration_ms: 0,
629                },
630            ],
631            e2e: Vec::new(),
632            verify_retried: false,
633            e2e_deferred: false,
634            e2e_defer_reason: None,
635            fix: None,
636            blocking: 0,
637            answered: 1,
638            expected: 2,
639            clean: false,
640            progressed: false,
641            vote_split: false,
642            reconsideration: Vec::new(),
643            verdict: None,
644        };
645        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
646
647        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
648        assert_eq!(alpha.seated, 1);
649        assert_eq!(alpha.rounds, 1);
650        assert_eq!(alpha.timeouts, 0);
651        assert_eq!(alpha.submitted, 0);
652
653        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
654        assert_eq!(beta.seated, 1);
655        assert_eq!(beta.timeouts, 1);
656        assert_eq!(beta.submitted, 0);
657        // A timeout must never read as a submission with nothing found: it
658        // stays out of the scoring denominators entirely rather than becoming
659        // a 0/0 that looks identical to a reviewer who answered and passed.
660        assert_eq!(beta.rounds, 0);
661        assert_eq!(beta.timeout_rate(), 100.0);
662    }
663
664    #[test]
665    fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
666        // Two independent gaps in one round: `beta` never answered, and the
667        // fixer's adoption report never came back. The lost report suppresses
668        // adoption scoring (see `a_lost_fix_report_does_not_count_as_zero_
669        // adoption`) — it must not also swallow the fact that a seat was
670        // silent, which is a property of the seat and not of the fixer.
671        let round = ReviewRound {
672            round: 1,
673            head: "h".to_owned(),
674            verified_head: None,
675            reviews: vec![
676                ReviewRecord {
677                    reviewer: 1,
678                    agent: "alpha".to_owned(),
679                    summary: String::new(),
680                    findings: vec![finding(
681                        "R1-1-1",
682                        "src/a.rs",
683                        10,
684                        "panics on empty",
685                        Severity::Blocker,
686                    )],
687                    vote: None,
688                    failed: None,
689                    duration_ms: 0,
690                },
691                ReviewRecord {
692                    reviewer: 2,
693                    agent: "beta".to_owned(),
694                    summary: String::new(),
695                    findings: Vec::new(),
696                    vote: None,
697                    failed: Some("agent timed out".to_owned()),
698                    duration_ms: 0,
699                },
700            ],
701            e2e: Vec::new(),
702            verify_retried: false,
703            e2e_deferred: false,
704            e2e_defer_reason: None,
705            fix: Some(FixRecord {
706                agent: "alpha".to_owned(),
707                addressed: Vec::new(),
708                rejected: Vec::new(),
709                notes: String::new(),
710                committed: true,
711                failed: Some("unparsable fix report".to_owned()),
712                duration_ms: 0,
713            }),
714            blocking: 1,
715            answered: 1,
716            expected: 2,
717            clean: false,
718            progressed: false,
719            vote_split: false,
720            reconsideration: Vec::new(),
721            verdict: None,
722        };
723        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
724
725        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
726        assert_eq!(beta.timeouts, 1);
727        assert_eq!(beta.timeout_rate(), 100.0);
728        // `alpha` answered, so the lost report keeps it out of the table
729        // altogether — nothing about its findings can be scored.
730        assert!(
731            !stats.reviewers.iter().any(|r| r.agent == "alpha"),
732            "{:?}",
733            stats.reviewers
734        );
735    }
736
737    #[test]
738    fn e2e_sole_detection_needs_a_clean_static_review() {
739        let fail = CommandOutcome {
740            command: "cargo test".to_owned(),
741            code: Some(101),
742            output_tail: "boom".to_owned(),
743            duration_ms: 1,
744        };
745        let sole = ReviewRound {
746            round: 1,
747            head: "h".to_owned(),
748            verified_head: None,
749            reviews: Vec::new(),
750            e2e: vec![fail.clone()],
751            verify_retried: false,
752            e2e_deferred: false,
753            e2e_defer_reason: None,
754            fix: None,
755            blocking: 0,
756            answered: 0,
757            expected: 0,
758            clean: false,
759            progressed: false,
760            vote_split: false,
761            reconsideration: Vec::new(),
762            verdict: None,
763        };
764        let alongside = ReviewRound {
765            round: 2,
766            head: "h".to_owned(),
767            verified_head: None,
768            reviews: Vec::new(),
769            e2e: vec![fail],
770            verify_retried: false,
771            e2e_deferred: false,
772            e2e_defer_reason: None,
773            fix: None,
774            blocking: 2,
775            answered: 0,
776            expected: 0,
777            clean: false,
778            progressed: false,
779            vote_split: false,
780            reconsideration: Vec::new(),
781            verdict: None,
782        };
783        let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
784        assert_eq!(stats.e2e.rounds, 2);
785        assert_eq!(stats.e2e.failures, 2);
786        assert_eq!(stats.e2e.sole_detections, 1);
787        assert_eq!(stats.e2e.sole_rate(), 50.0);
788    }
789
790    #[test]
791    fn empty_input_yields_zeroed_rates_not_nan() {
792        let stats = collect(&[]);
793        assert_eq!(stats.totals.completion_rate(), 0.0);
794        assert_eq!(stats.totals.split_rate(), 0.0);
795        assert_eq!(stats.e2e.sole_rate(), 0.0);
796        assert!(stats.agents.is_empty());
797    }
798
799    #[test]
800    fn same_defect_matches_titles_across_files() {
801        let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
802        let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
803        assert!(same_defect(&a, &b));
804        let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
805        assert!(!same_defect(&a, &c));
806    }
807}