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