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