Skip to main content

magi/
stats.rs

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