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                continuation: None,
527            }),
528            blocking: 3,
529            answered: 2,
530            expected: 2,
531            clean: false,
532            progressed: true,
533            vote_split: false,
534            reconsideration: Vec::new(),
535            verdict: None,
536        };
537        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
538        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
539        assert_eq!(alpha.submitted, 2);
540        assert_eq!(alpha.adopted, 1);
541        assert_eq!(alpha.precision(), 50.0);
542        assert_eq!(alpha.adopted_per_round(), 1.0);
543        // The src/a.rs finding overlaps beta's; src/b.rs does not.
544        assert_eq!(alpha.unique, 1);
545
546        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
547        assert_eq!(beta.submitted, 1);
548        assert_eq!(beta.adopted, 0);
549        assert_eq!(beta.unique, 0);
550    }
551
552    #[test]
553    fn a_lost_fix_report_does_not_count_as_zero_adoption() {
554        let submitted = ReviewRound {
555            round: 1,
556            head: "h".to_owned(),
557            verified_head: None,
558            reviews: vec![ReviewRecord {
559                reviewer: 1,
560                agent: "alpha".to_owned(),
561                summary: String::new(),
562                findings: vec![finding(
563                    "R1-1-1",
564                    "src/a.rs",
565                    10,
566                    "panics on empty",
567                    Severity::Blocker,
568                )],
569                vote: None,
570                failed: None,
571                duration_ms: 0,
572            }],
573            e2e: Vec::new(),
574            verify_retried: false,
575            e2e_deferred: false,
576            e2e_defer_reason: None,
577            // The fixer's diff may well have landed (blocking counts do fall
578            // round over round) — only its adoption report never came back.
579            fix: Some(FixRecord {
580                agent: "alpha".to_owned(),
581                addressed: Vec::new(),
582                rejected: Vec::new(),
583                notes: String::new(),
584                committed: true,
585                failed: Some("unparsable fix report".to_owned()),
586                duration_ms: 0,
587                continuation: None,
588            }),
589            blocking: 4,
590            answered: 1,
591            expected: 1,
592            clean: false,
593            progressed: false,
594            vote_split: false,
595            reconsideration: Vec::new(),
596            verdict: None,
597        };
598        let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
599        assert!(
600            stats.reviewers.is_empty(),
601            "a round with no adoption signal must not enter any reviewer's \
602             denominator: {:?}",
603            stats.reviewers
604        );
605    }
606
607    #[test]
608    fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
609        let round = ReviewRound {
610            round: 1,
611            head: "h".to_owned(),
612            verified_head: None,
613            reviews: vec![
614                ReviewRecord {
615                    reviewer: 1,
616                    agent: "alpha".to_owned(),
617                    summary: String::new(),
618                    findings: Vec::new(),
619                    vote: None,
620                    failed: None,
621                    duration_ms: 0,
622                },
623                ReviewRecord {
624                    reviewer: 2,
625                    agent: "beta".to_owned(),
626                    summary: String::new(),
627                    findings: Vec::new(),
628                    vote: None,
629                    failed: Some("agent timed out".to_owned()),
630                    duration_ms: 0,
631                },
632            ],
633            e2e: Vec::new(),
634            verify_retried: false,
635            e2e_deferred: false,
636            e2e_defer_reason: None,
637            fix: None,
638            blocking: 0,
639            answered: 1,
640            expected: 2,
641            clean: false,
642            progressed: false,
643            vote_split: false,
644            reconsideration: Vec::new(),
645            verdict: None,
646        };
647        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
648
649        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
650        assert_eq!(alpha.seated, 1);
651        assert_eq!(alpha.rounds, 1);
652        assert_eq!(alpha.timeouts, 0);
653        assert_eq!(alpha.submitted, 0);
654
655        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
656        assert_eq!(beta.seated, 1);
657        assert_eq!(beta.timeouts, 1);
658        assert_eq!(beta.submitted, 0);
659        // A timeout must never read as a submission with nothing found: it
660        // stays out of the scoring denominators entirely rather than becoming
661        // a 0/0 that looks identical to a reviewer who answered and passed.
662        assert_eq!(beta.rounds, 0);
663        assert_eq!(beta.timeout_rate(), 100.0);
664    }
665
666    #[test]
667    fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
668        // Two independent gaps in one round: `beta` never answered, and the
669        // fixer's adoption report never came back. The lost report suppresses
670        // adoption scoring (see `a_lost_fix_report_does_not_count_as_zero_
671        // adoption`) — it must not also swallow the fact that a seat was
672        // silent, which is a property of the seat and not of the fixer.
673        let round = ReviewRound {
674            round: 1,
675            head: "h".to_owned(),
676            verified_head: None,
677            reviews: vec![
678                ReviewRecord {
679                    reviewer: 1,
680                    agent: "alpha".to_owned(),
681                    summary: String::new(),
682                    findings: vec![finding(
683                        "R1-1-1",
684                        "src/a.rs",
685                        10,
686                        "panics on empty",
687                        Severity::Blocker,
688                    )],
689                    vote: None,
690                    failed: None,
691                    duration_ms: 0,
692                },
693                ReviewRecord {
694                    reviewer: 2,
695                    agent: "beta".to_owned(),
696                    summary: String::new(),
697                    findings: Vec::new(),
698                    vote: None,
699                    failed: Some("agent timed out".to_owned()),
700                    duration_ms: 0,
701                },
702            ],
703            e2e: Vec::new(),
704            verify_retried: false,
705            e2e_deferred: false,
706            e2e_defer_reason: None,
707            fix: Some(FixRecord {
708                agent: "alpha".to_owned(),
709                addressed: Vec::new(),
710                rejected: Vec::new(),
711                notes: String::new(),
712                committed: true,
713                failed: Some("unparsable fix report".to_owned()),
714                duration_ms: 0,
715                continuation: None,
716            }),
717            blocking: 1,
718            answered: 1,
719            expected: 2,
720            clean: false,
721            progressed: false,
722            vote_split: false,
723            reconsideration: Vec::new(),
724            verdict: None,
725        };
726        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
727
728        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
729        assert_eq!(beta.timeouts, 1);
730        assert_eq!(beta.timeout_rate(), 100.0);
731        // `alpha` answered, so the lost report keeps it out of the table
732        // altogether — nothing about its findings can be scored.
733        assert!(
734            !stats.reviewers.iter().any(|r| r.agent == "alpha"),
735            "{:?}",
736            stats.reviewers
737        );
738    }
739
740    #[test]
741    fn e2e_sole_detection_needs_a_clean_static_review() {
742        let fail = CommandOutcome {
743            command: "cargo test".to_owned(),
744            code: Some(101),
745            output_tail: "boom".to_owned(),
746            duration_ms: 1,
747            resource_blocked: false,
748        };
749        let sole = ReviewRound {
750            round: 1,
751            head: "h".to_owned(),
752            verified_head: None,
753            reviews: Vec::new(),
754            e2e: vec![fail.clone()],
755            verify_retried: false,
756            e2e_deferred: false,
757            e2e_defer_reason: None,
758            fix: None,
759            blocking: 0,
760            answered: 0,
761            expected: 0,
762            clean: false,
763            progressed: false,
764            vote_split: false,
765            reconsideration: Vec::new(),
766            verdict: None,
767        };
768        let alongside = ReviewRound {
769            round: 2,
770            head: "h".to_owned(),
771            verified_head: None,
772            reviews: Vec::new(),
773            e2e: vec![fail],
774            verify_retried: false,
775            e2e_deferred: false,
776            e2e_defer_reason: None,
777            fix: None,
778            blocking: 2,
779            answered: 0,
780            expected: 0,
781            clean: false,
782            progressed: false,
783            vote_split: false,
784            reconsideration: Vec::new(),
785            verdict: None,
786        };
787        let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
788        assert_eq!(stats.e2e.rounds, 2);
789        assert_eq!(stats.e2e.failures, 2);
790        assert_eq!(stats.e2e.sole_detections, 1);
791        assert_eq!(stats.e2e.sole_rate(), 50.0);
792    }
793
794    #[test]
795    fn empty_input_yields_zeroed_rates_not_nan() {
796        let stats = collect(&[]);
797        assert_eq!(stats.totals.completion_rate(), 0.0);
798        assert_eq!(stats.totals.split_rate(), 0.0);
799        assert_eq!(stats.e2e.sole_rate(), 0.0);
800        assert!(stats.agents.is_empty());
801    }
802
803    #[test]
804    fn same_defect_matches_titles_across_files() {
805        let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
806        let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
807        assert!(same_defect(&a, &b));
808        let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
809        assert!(!same_defect(&a, &c));
810    }
811}