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, BTreeSet};
9use std::path::{Path, PathBuf};
10
11use crate::run::{RunState, RunStatus, list_ids};
12
13/// Implementation record for one agent.
14#[derive(Debug, Clone, Default)]
15pub struct AgentStats {
16    /// Agent id.
17    pub agent: String,
18    /// Candidates it produced that were judged.
19    pub entered: usize,
20    /// Competitions it won.
21    pub wins: usize,
22    /// Candidates that produced no change at all.
23    pub empty: usize,
24}
25
26impl AgentStats {
27    /// Win rate over entries, as a percentage.
28    pub fn win_rate(&self) -> f64 {
29        if self.entered == 0 {
30            0.0
31        } else {
32            100.0 * self.wins as f64 / self.entered as f64
33        }
34    }
35}
36
37/// Review record for one agent.
38#[derive(Debug, Clone, Default)]
39pub struct ReviewerStats {
40    /// Agent id.
41    pub agent: String,
42    /// Review rounds it sat in whose adoption could be scored — a round whose
43    /// fixer never reported back is excluded, so this is the denominator of
44    /// [`Self::adopted_per_round`], not a headcount of appearances. For that,
45    /// see [`Self::seated`].
46    pub rounds: usize,
47    /// Review rounds it was on the panel for at all, scoreable or not.
48    /// Whether a seat answered is a fact about the seat and does not depend
49    /// on what later became of the fixer's report, so this — not `rounds` —
50    /// is the honest denominator for [`Self::timeout_rate`].
51    pub seated: usize,
52    /// Findings it submitted.
53    pub submitted: usize,
54    /// Findings the fixer acted on.
55    pub adopted: usize,
56    /// Findings no other reviewer in the same round also raised.
57    pub unique: usize,
58    /// Rounds it was seated in but never answered (timeout, crash, unparsable
59    /// output) — kept apart from `submitted`/`adopted` so a silent seat
60    /// cannot read as a seat with nothing to say.
61    pub timeouts: usize,
62}
63
64impl ReviewerStats {
65    /// Adopted findings per round: how much signal one seat produces.
66    pub fn adopted_per_round(&self) -> f64 {
67        if self.rounds == 0 {
68            0.0
69        } else {
70            self.adopted as f64 / self.rounds as f64
71        }
72    }
73
74    /// Adopted over submitted: how often its findings are real. Rounds where
75    /// the seat never answered are not in `submitted`, so a timeout cannot
76    /// dilute (or hide behind) this rate.
77    pub fn precision(&self) -> f64 {
78        if self.submitted == 0 {
79            0.0
80        } else {
81            100.0 * self.adopted as f64 / self.submitted as f64
82        }
83    }
84
85    /// Share of its findings that only it saw.
86    pub fn unique_rate(&self) -> f64 {
87        if self.submitted == 0 {
88            0.0
89        } else {
90            100.0 * self.unique as f64 / self.submitted as f64
91        }
92    }
93
94    /// Share of the rounds it was seated in where it never answered.
95    pub fn timeout_rate(&self) -> f64 {
96        if self.seated == 0 {
97            0.0
98        } else {
99            100.0 * self.timeouts as f64 / self.seated as f64
100        }
101    }
102}
103
104/// Design-deliberation record for one agent.
105///
106/// Approximate by construction, same as [`crate::advise::Reflection`] itself:
107/// `strong`/`faint` come from a word-overlap heuristic against the synthesis
108/// brief, not from an explicit attribution, so [`Self::reflection_rate`] is a
109/// rough read on whose ideas seemed to land, not a precise credit split.
110#[derive(Debug, Clone, Default)]
111pub struct AdvisorStats {
112    /// Agent id.
113    pub agent: String,
114    /// Advisor seats it occupied, across every run with `[graph] advise` on.
115    pub seated: usize,
116    /// Seats where it produced a usable proposal (`record.proposal.is_some()`).
117    pub proposed: usize,
118    /// Seats where it produced no usable proposal at all (crashed, timed
119    /// out, or answered with nothing a proposal could be parsed out of).
120    /// Counted from `record.proposal.is_none()` directly rather than from
121    /// `record.reflection == Absent` — `reflection` is `#[serde(default)]`
122    /// and can be left at its default on a record from before
123    /// `apply_reflection` ran, which would otherwise double as a false
124    /// "absent".
125    pub absent: usize,
126    /// Proposals the synthesis brief carried little or no recognisable
127    /// trace of.
128    pub faint: usize,
129    /// Proposals the synthesis brief named outright or carried enough of to
130    /// count as a clear match.
131    pub strong: usize,
132}
133
134impl AdvisorStats {
135    /// Share of its proposals rated `Strong`, as a percentage. The
136    /// denominator is `proposed`, not `seated` — a seat that never produced
137    /// a proposal had nothing for the synthesis to reflect, so it cannot
138    /// count against this rate any more than a reviewer's silence counts
139    /// against [`ReviewerStats::precision`].
140    ///
141    /// Same caveat as the struct itself: this is a heuristic read on
142    /// reflection, not a precise attribution.
143    pub fn reflection_rate(&self) -> f64 {
144        if self.proposed == 0 {
145            0.0
146        } else {
147            100.0 * self.strong as f64 / self.proposed as f64
148        }
149    }
150}
151
152/// What real-machine verification caught that static review did not.
153#[derive(Debug, Clone, Default)]
154pub struct E2eStats {
155    /// Rounds where E2E commands ran.
156    pub rounds: usize,
157    /// Rounds where E2E failed.
158    pub failures: usize,
159    /// Rounds where E2E failed and no reviewer had raised a blocking finding —
160    /// a runtime defect that only execution found.
161    pub sole_detections: usize,
162    /// Rounds where E2E was deferred rather than run: blocking findings
163    /// already required a fix, so the round went straight to the fixer
164    /// instead of spending a full verify run on a head about to change. Kept
165    /// separate from [`Self::rounds`] on purpose — a deferred round never
166    /// ran anything, so counting it there would misreport how often E2E
167    /// actually executed.
168    pub deferred: usize,
169}
170
171impl E2eStats {
172    /// Share of E2E failures that static review had missed entirely.
173    pub fn sole_rate(&self) -> f64 {
174        if self.failures == 0 {
175            0.0
176        } else {
177            100.0 * self.sole_detections as f64 / self.failures as f64
178        }
179    }
180}
181
182/// Run-level counters.
183#[derive(Debug, Clone, Default)]
184pub struct Totals {
185    /// Runs on disk.
186    pub runs: usize,
187    /// Reached a merge.
188    pub merged: usize,
189    /// Passed the gate, merge not requested.
190    pub ready: usize,
191    /// Stopped with findings open or a red gate.
192    pub blocked: usize,
193    /// Could not complete.
194    pub failed: usize,
195    /// The judging panel never reached a quorum; the work is kept but the
196    /// verdict is not trustworthy. Never counted as `blocked` — see
197    /// `RunStatus::Stalled`'s own doc — so it needs its own counter to stay
198    /// visible in a breakdown rather than vanishing from every bucket.
199    pub stalled: usize,
200    /// Every candidate wrote nothing, and said why in a way that survived
201    /// the adoption guard: a claim, not a failure, and not yet verified.
202    pub verified_noop: usize,
203    /// A later attempt at the same task already finished it; this run's own
204    /// `blocked`/`stalled` no longer needs anyone's attention.
205    pub superseded: usize,
206    /// Still moving: any non-terminal status (prep through landing). Kept as
207    /// one bucket rather than one counter per node — the per-node position of
208    /// a live run belongs to `magi show`/the deck, not to a workload-wide
209    /// tally that is read well after the run in question has finished.
210    pub in_progress: usize,
211    /// Runs that reached a tally.
212    pub tallied: usize,
213    /// Tallies where the judges' first choices disagreed.
214    pub split: usize,
215    /// Tallies that went through deliberation.
216    pub deliberated: usize,
217    /// Deliberated runs where at least one judge moved.
218    pub minds_changed: usize,
219    /// Deliberated runs that ended unanimous.
220    pub converged: usize,
221    /// Review rounds across all runs.
222    pub review_rounds: usize,
223}
224
225impl Totals {
226    /// Merged or ready over all runs.
227    pub fn completion_rate(&self) -> f64 {
228        if self.runs == 0 {
229            0.0
230        } else {
231            100.0 * (self.merged + self.ready) as f64 / self.runs as f64
232        }
233    }
234
235    /// Share of tallies that were split.
236    pub fn split_rate(&self) -> f64 {
237        if self.tallied == 0 {
238            0.0
239        } else {
240            100.0 * self.split as f64 / self.tallied as f64
241        }
242    }
243}
244
245/// Per-node duration breakdown, aggregated across every loaded run.
246///
247/// A duration here is the span between a node's *first* and *last* recorded
248/// event within one run — not time actually spent working. A node visited
249/// more than twice in one run (a retry, or a park/resume gap) has any idle
250/// time in between folded into that span, so this reads as an upper bound on
251/// the node's wall-clock cost, not a measurement of it.
252#[derive(Debug, Clone, Default, PartialEq)]
253pub struct NodeDuration {
254    /// Node name, as it appears in `Event::node`.
255    pub node: String,
256    /// Runs where the node's span could be measured (at least two events).
257    pub runs: usize,
258    /// Sum of every measured run's span, in seconds.
259    pub total_secs: i64,
260    /// The longest single run's span, in seconds.
261    pub max_secs: i64,
262    /// Runs where the node had exactly one event — visited, but with no
263    /// second timestamp to measure a span against. Counted apart from `runs`
264    /// so a caller never has to guess whether a low mean hides unmeasured
265    /// visits.
266    pub single: usize,
267}
268
269impl NodeDuration {
270    /// Mean span across the runs that could be measured, in seconds.
271    ///
272    /// `0.0` when `runs` is zero, never a division by zero.
273    pub fn mean_secs(&self) -> f64 {
274        if self.runs == 0 {
275            0.0
276        } else {
277            self.total_secs as f64 / self.runs as f64
278        }
279    }
280}
281
282/// Derive a per-node duration breakdown from every loaded run's events.
283///
284/// For each run, a node's span is the gap between its earliest and latest
285/// [`crate::run::Event`] in that run. A node with fewer than two events in a
286/// run contributes no span (see [`NodeDuration::single`] for the one-event
287/// case); a run with no events for a node contributes nothing at all. Spans
288/// from every run are then summed per node.
289pub fn node_durations<'a>(states: impl IntoIterator<Item = &'a RunState>) -> Vec<NodeDuration> {
290    let mut nodes: BTreeMap<String, NodeDuration> = BTreeMap::new();
291
292    for state in states {
293        let mut spans: BTreeMap<&str, (jiff::Timestamp, jiff::Timestamp, usize)> = BTreeMap::new();
294        for e in &state.events {
295            spans
296                .entry(e.node.as_str())
297                .and_modify(|(min, max, count)| {
298                    if e.at < *min {
299                        *min = e.at;
300                    }
301                    if e.at > *max {
302                        *max = e.at;
303                    }
304                    *count += 1;
305                })
306                .or_insert((e.at, e.at, 1));
307        }
308
309        for (node, (min, max, count)) in spans {
310            let entry = nodes
311                .entry(node.to_owned())
312                .or_insert_with(|| NodeDuration {
313                    node: node.to_owned(),
314                    ..NodeDuration::default()
315                });
316            if count < 2 {
317                entry.single += 1;
318                continue;
319            }
320            // `max - min` is never negative: both are the extremes of the
321            // same event set, and equal timestamps yield a zero-second span
322            // rather than being mistaken for an unmeasured visit.
323            let span_secs = (max - min).get_seconds();
324            entry.runs += 1;
325            entry.total_secs += span_secs;
326            if span_secs > entry.max_secs {
327                entry.max_secs = span_secs;
328            }
329        }
330    }
331
332    let mut nodes: Vec<NodeDuration> = nodes.into_values().collect();
333    nodes.sort_by(|a, b| b.total_secs.cmp(&a.total_secs).then(a.node.cmp(&b.node)));
334    nodes
335}
336
337/// What the post-merge release-bump step did, across every merged run.
338///
339/// `merged` is the denominator for [`Self::coverage_rate`]: a repository
340/// that never uses the release-bump step (no `auto-tag.yml`, see
341/// `kata:agents:rust:*`) should read as "0 of N merged runs recorded a
342/// bump", not vanish from the report the way it would if `recorded` were the
343/// denominator instead — silently excluding those runs would hide the
344/// coverage gap itself.
345#[derive(Debug, Clone, Default)]
346pub struct ReleaseBumpStats {
347    /// Merged runs, the denominator for [`Self::coverage_rate`].
348    pub merged: usize,
349    /// Merged runs that carry a [`crate::run::ReleaseBump`] record at all.
350    pub recorded: usize,
351    /// Recorded bumps that opened a release pull request.
352    pub pr_opened: usize,
353    /// Recorded bumps whose `automerge_enabled` is `true` at the point the
354    /// bump step finished. This is the *final* state, not "was automerge
355    /// enabled at some point" — `bump::surface_problem` flips it back to
356    /// `false` when GitHub later rejects automerge, so a bump that briefly
357    /// enabled it and then had it rejected counts here as not enabled.
358    pub automerge_enabled: usize,
359    /// Recorded bumps magi merged directly because GitHub refused automerge
360    /// on an already-green pull request. Counted apart from
361    /// `automerge_enabled` on purpose: a `merged_directly` bump needed no
362    /// human even though automerge itself never took, so folding it into
363    /// (or leaving it out of) the automerge count would misstate either
364    /// number.
365    pub merged_directly: usize,
366    /// Recorded bumps that ended with [`crate::run::RunState::needs_attention`]
367    /// true — a human has something to do.
368    pub needs_attention: usize,
369}
370
371impl ReleaseBumpStats {
372    /// Share of merged runs that recorded a release bump at all.
373    pub fn coverage_rate(&self) -> f64 {
374        if self.merged == 0 {
375            0.0
376        } else {
377            100.0 * self.recorded as f64 / self.merged as f64
378        }
379    }
380
381    /// Share of opened release PRs that ended with automerge enabled.
382    pub fn automerge_rate(&self) -> f64 {
383        if self.pr_opened == 0 {
384            0.0
385        } else {
386            100.0 * self.automerge_enabled as f64 / self.pr_opened as f64
387        }
388    }
389
390    /// Share of recorded bumps that needed a human.
391    pub fn attention_rate(&self) -> f64 {
392        if self.recorded == 0 {
393            0.0
394        } else {
395            100.0 * self.needs_attention as f64 / self.recorded as f64
396        }
397    }
398
399    /// Recorded bumps that finished with nothing for a human to do.
400    ///
401    /// Deliberately the difference `recorded - needs_attention`, not a
402    /// separate counter kept in step with `automerge_enabled`: a bump that
403    /// GitHub refused automerge on but that magi merged directly
404    /// (`merged_directly`) is clean — nobody had to act — even though
405    /// `automerge_enabled` is `false` for it. Counting clean bumps as
406    /// `automerge_enabled` alone would leave `merged_directly` cases in
407    /// neither the clean nor the attention bucket, and the two would stop
408    /// summing to `recorded`.
409    pub fn clean(&self) -> usize {
410        self.recorded.saturating_sub(self.needs_attention)
411    }
412}
413
414/// Everything, aggregated.
415#[derive(Debug, Clone, Default)]
416pub struct Stats {
417    /// Run counters.
418    pub totals: Totals,
419    /// Per-agent implementation record, best win rate first.
420    pub agents: Vec<AgentStats>,
421    /// Per-agent review record, most adopted-per-round first.
422    pub reviewers: Vec<ReviewerStats>,
423    /// Per-agent design-deliberation record, highest reflection rate first.
424    /// Only runs where `[graph] advise` produced an [`crate::advise::Advice`]
425    /// contribute — a run with the stage off carries no signal either way,
426    /// and counting it would water down every agent's rate with seats that
427    /// were never asked.
428    pub advisors: Vec<AdvisorStats>,
429    /// Verification record.
430    pub e2e: E2eStats,
431    /// Per-node duration breakdown, longest total first.
432    pub nodes: Vec<NodeDuration>,
433    /// Post-merge release-bump record, over every merged run.
434    pub release_bumps: ReleaseBumpStats,
435    /// Agent ids whose rows [`retain_roster`] left out of `agents`,
436    /// `reviewers` and `advisors`, sorted, once each. Empty when nothing was
437    /// filtered.
438    pub retired_hidden: Vec<String>,
439}
440
441/// The agent ids of the current configured roster. A successfully loaded
442/// empty roster (`agents = []`) is a statement, so it hides every agent row;
443/// only a config that cannot be read means "no filtering".
444pub fn roster_of(cfg: &crate::config::Config) -> BTreeSet<String> {
445    cfg.agents.iter().map(|a| a.id.clone()).collect()
446}
447
448/// Drop the per-agent rows of agents outside `roster`, keeping the order.
449/// Only `agents`, `reviewers` and `advisors` are touched: totals, e2e, nodes
450/// and release bumps are not per-agent and stay as aggregated. The hidden ids
451/// are recorded in [`Stats::retired_hidden`]. Run history is never altered.
452pub fn retain_roster(stats: &mut Stats, roster: &BTreeSet<String>) {
453    let mut hidden = BTreeSet::new();
454    let mut keep = |agent: &str| {
455        let keep = roster.contains(agent);
456        if !keep {
457            hidden.insert(agent.to_owned());
458        }
459        keep
460    };
461    stats.agents.retain(|a| keep(&a.agent));
462    stats.reviewers.retain(|a| keep(&a.agent));
463    stats.advisors.retain(|a| keep(&a.agent));
464    stats.retired_hidden = hidden.into_iter().collect();
465}
466
467/// [`retain_roster`] against the layered config discovered for `repo`; an
468/// unreadable config leaves `stats` unfiltered.
469pub fn retain_current_roster(stats: &mut Stats, repo: &Path) {
470    if let Some(roster) = crate::config::Config::discover(repo, None)
471        .ok()
472        .map(|(cfg, _)| roster_of(&cfg))
473    {
474        retain_roster(stats, &roster);
475    }
476}
477
478/// Load every run on disk, skipping any that cannot be read.
479pub fn load_all() -> Vec<RunState> {
480    list_ids()
481        .into_iter()
482        .filter_map(|id| RunState::load(&id).ok())
483        .collect()
484}
485
486/// One repository's [`Stats`], as grouped by [`by_repo`].
487#[derive(Debug, Clone)]
488pub struct RepoStats {
489    /// The grouping key: `RunState.repo` exactly as recorded, a normalised
490    /// full path. Never a display name — this is what a caller (the CLI's
491    /// `--repo` fallback, the web `?repo=` query) matches back against, and
492    /// matching by name would conflate two different checkouts that happen
493    /// to share a leaf directory.
494    pub repo: PathBuf,
495    /// Display name: `repo`'s file name, or the full path when it has none
496    /// (e.g. `/`). Collisions between repositories are the caller's problem
497    /// to disambiguate (see `report::repo_summary`), not this struct's.
498    pub name: String,
499    /// This repository's own aggregate, counted exactly as [`collect`]
500    /// counts the whole workload.
501    pub stats: Stats,
502}
503
504/// Group `states` by [`RunState::repo`] and aggregate each group with
505/// [`collect_refs`] — the same counting logic as [`collect`], just scoped to
506/// one repository at a time.
507///
508/// Sorted by run count descending, then by repo path ascending on ties, so
509/// the busiest repository leads the summary table.
510pub fn by_repo(states: &[RunState]) -> Vec<RepoStats> {
511    let mut groups: BTreeMap<PathBuf, Vec<&RunState>> = BTreeMap::new();
512    for state in states {
513        groups.entry(state.repo.clone()).or_default().push(state);
514    }
515
516    let mut out: Vec<RepoStats> = groups
517        .into_iter()
518        .map(|(repo, group)| {
519            let stats = collect_refs(group);
520            let name = repo
521                .file_name()
522                .map(|n| n.to_string_lossy().into_owned())
523                .unwrap_or_else(|| repo.to_string_lossy().into_owned());
524            RepoStats { repo, name, stats }
525        })
526        .collect();
527    out.sort_by(|a, b| {
528        b.stats
529            .totals
530            .runs
531            .cmp(&a.stats.totals.runs)
532            .then(a.repo.cmp(&b.repo))
533    });
534    out
535}
536
537/// Narrow `states` to those recorded against `repo` exactly.
538///
539/// Matches [`RunState::repo`] by full-path equality only, never by name — the
540/// name-fallback resolution a caller may want (a checkout that has since
541/// moved or been deleted) belongs one layer up, where a config or filesystem
542/// lookup can decide what "the same repository" means; this function has no
543/// such context and would otherwise risk conflating two different checkouts
544/// that happen to share a leaf directory.
545pub fn filter_repo<'a>(states: &'a [RunState], repo: &Path) -> Vec<&'a RunState> {
546    states.iter().filter(|s| s.repo == repo).collect()
547}
548
549/// Aggregate `states`.
550pub fn collect(states: &[RunState]) -> Stats {
551    collect_refs(states)
552}
553
554/// Aggregate `states`, over any iterator of references rather than a slice —
555/// what [`by_repo`] uses to run the same counting logic over each repository's
556/// own group without collecting it into an owned `Vec<RunState>` first.
557/// [`collect`] is a thin wrapper around this for the common whole-slice case.
558pub fn collect_refs<'a>(states: impl IntoIterator<Item = &'a RunState>) -> Stats {
559    let states: Vec<&'a RunState> = states.into_iter().collect();
560    let mut totals = Totals::default();
561    let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
562    let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
563    let mut advisors: BTreeMap<String, AdvisorStats> = BTreeMap::new();
564    let mut e2e = E2eStats::default();
565    let mut release_bumps = ReleaseBumpStats::default();
566
567    for state in &states {
568        totals.runs += 1;
569        if state.status == RunStatus::Merged {
570            release_bumps.merged += 1;
571            if let Some(b) = &state.release_bump {
572                release_bumps.recorded += 1;
573                if b.pr_url.is_some() {
574                    release_bumps.pr_opened += 1;
575                }
576                if b.automerge_enabled {
577                    release_bumps.automerge_enabled += 1;
578                }
579                if b.merged_directly {
580                    release_bumps.merged_directly += 1;
581                }
582            }
583            if state.needs_attention() {
584                release_bumps.needs_attention += 1;
585            }
586        }
587        match state.status {
588            RunStatus::Merged => totals.merged += 1,
589            RunStatus::Ready => totals.ready += 1,
590            RunStatus::Blocked => totals.blocked += 1,
591            RunStatus::Failed => totals.failed += 1,
592            RunStatus::Stalled => totals.stalled += 1,
593            RunStatus::VerifiedNoop => totals.verified_noop += 1,
594            // Counted on its own rather than folded into `blocked`/`stalled`:
595            // the task it belongs to already landed through a later run,
596            // which is the one this tally counts as the merge/ready outcome,
597            // and folding it back in would inflate the denominator with two
598            // outcomes for one task.
599            //
600            // `AlreadyInBase` is the same story from the other side: the
601            // change reached the base by another route, so it shares the
602            // bucket instead of becoming a second outcome for one change.
603            RunStatus::Superseded | RunStatus::AlreadyInBase => totals.superseded += 1,
604            RunStatus::Prep
605            | RunStatus::Implementing
606            | RunStatus::Judging
607            | RunStatus::Deliberating
608            | RunStatus::Voting
609            | RunStatus::Reviewing
610            | RunStatus::Gating
611            | RunStatus::Landing => totals.in_progress += 1,
612        }
613
614        for c in &state.candidates {
615            let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
616                agent: c.agent.clone(),
617                ..AgentStats::default()
618            });
619            // A verified no-op is not counted as the ordinary empty loss it
620            // would otherwise look like: the candidate gave evidence for
621            // writing nothing, which `entry.empty` exists to flag the
622            // *absence* of.
623            if c.empty && c.verified_noop.is_none() {
624                entry.empty += 1;
625            }
626            if c.viable() {
627                entry.entered += 1;
628            }
629        }
630
631        if let Some(t) = &state.tally {
632            // A tally with no panel (`uncontested`) never split, never
633            // deliberated and never converged — it never happened, and
634            // folding it into the denominator would understate the real
635            // split rate with runs that carry no panel-agreement signal at
636            // all. The winner still earns its agent a win either way: an
637            // uncontested candidate is still the one that shipped.
638            if t.uncontested.is_none() {
639                totals.tallied += 1;
640                if !t.unanimous_initial {
641                    totals.split += 1;
642                }
643                if t.deliberated {
644                    totals.deliberated += 1;
645                    if t.changed_votes > 0 {
646                        totals.minds_changed += 1;
647                    }
648                    if t.unanimous_final {
649                        totals.converged += 1;
650                    }
651                }
652            }
653            if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
654                agents
655                    .entry(w.agent.clone())
656                    .or_insert_with(|| AgentStats {
657                        agent: w.agent.clone(),
658                        ..AgentStats::default()
659                    })
660                    .wins += 1;
661            }
662        }
663
664        if let Some(advice) = &state.advice {
665            for rec in &advice.records {
666                let entry = advisors
667                    .entry(rec.agent.clone())
668                    .or_insert_with(|| AdvisorStats {
669                        agent: rec.agent.clone(),
670                        ..AdvisorStats::default()
671                    });
672                entry.seated += 1;
673                if rec.proposal.is_none() {
674                    entry.absent += 1;
675                    continue;
676                }
677                entry.proposed += 1;
678                match rec.reflection {
679                    crate::advise::Reflection::Strong => entry.strong += 1,
680                    crate::advise::Reflection::Faint => entry.faint += 1,
681                    // A proposal exists, so this is not a real "no proposal"
682                    // reading — see `AdvisorStats::absent`'s own doc for why
683                    // that count comes from `proposal.is_none()` instead of
684                    // this field. `graph::Runner::advise` always calls
685                    // `apply_reflection` before saving, so the only way a
686                    // proposed record keeps the default `Absent` is a run.json
687                    // predating the `reflection` field. Fold it into `faint`
688                    // rather than dropping it from the breakdown entirely:
689                    // that is what `classify()` itself falls back to when
690                    // there is nothing to score against.
691                    crate::advise::Reflection::Absent => entry.faint += 1,
692                }
693            }
694        }
695
696        for round in &state.reviews {
697            totals.review_rounds += 1;
698
699            // A round whose fixer never reported back (crashed, timed out, or
700            // replied with something magi could not parse) leaves adoption
701            // unknown, not zero. Counting it would score every reviewer in
702            // that round as having been ignored, when the truth is simply
703            // unrecorded — so it stays out of the adoption-rate denominator
704            // entirely rather than silently becoming a round of 0 adoptions.
705            let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
706            let adopted: Vec<&String> = round
707                .fix
708                .as_ref()
709                .map(|f| f.addressed.iter().collect())
710                .unwrap_or_default();
711
712            for rec in &round.reviews {
713                let entry = reviewers
714                    .entry(rec.agent.clone())
715                    .or_insert_with(|| ReviewerStats {
716                        agent: rec.agent.clone(),
717                        ..ReviewerStats::default()
718                    });
719                // Seating and answering are facts about the seat itself: they
720                // hold whether or not this round's adoption is scoreable, so
721                // they are counted before the lost-report guard. A seat that
722                // never answered stays out of every scoring denominator —
723                // silence is not a review that found nothing.
724                entry.seated += 1;
725                if rec.failed.is_some() {
726                    entry.timeouts += 1;
727                    continue;
728                }
729                if report_lost {
730                    continue;
731                }
732                entry.rounds += 1;
733                entry.submitted += rec.findings.len();
734                for f in &rec.findings {
735                    if adopted.iter().any(|a| **a == f.id) {
736                        entry.adopted += 1;
737                    }
738                    let overlapped = round
739                        .reviews
740                        .iter()
741                        .filter(|other| other.reviewer != rec.reviewer)
742                        .flat_map(|other| other.findings.iter())
743                        .any(|g| same_defect(f, g));
744                    if !overlapped {
745                        entry.unique += 1;
746                    }
747                }
748            }
749
750            if round.e2e_deferred {
751                e2e.deferred += 1;
752            } else if !round.e2e.is_empty() {
753                e2e.rounds += 1;
754                if round.e2e.iter().any(|o| !o.ok()) {
755                    e2e.failures += 1;
756                    if round.blocking == 0 {
757                        e2e.sole_detections += 1;
758                    }
759                }
760            }
761        }
762    }
763
764    let mut agents: Vec<AgentStats> = agents.into_values().collect();
765    agents.sort_by(|a, b| {
766        b.win_rate()
767            .total_cmp(&a.win_rate())
768            .then(b.entered.cmp(&a.entered))
769    });
770    let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
771    // A seat only sighted in rounds whose adoption could not be scored has
772    // nothing to report: no scoreable round, no silence to flag. It stays out
773    // of the table entirely rather than appearing as a row of zeroes, which
774    // would read as a reviewer that produced nothing.
775    reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
776    reviewers.sort_by(|a, b| {
777        b.adopted_per_round()
778            .total_cmp(&a.adopted_per_round())
779            .then(b.rounds.cmp(&a.rounds))
780    });
781
782    let mut advisors: Vec<AdvisorStats> = advisors.into_values().collect();
783    advisors.sort_by(|a, b| {
784        b.reflection_rate()
785            .total_cmp(&a.reflection_rate())
786            .then(b.proposed.cmp(&a.proposed))
787    });
788
789    let nodes = node_durations(states);
790
791    Stats {
792        totals,
793        agents,
794        reviewers,
795        advisors,
796        e2e,
797        nodes,
798        release_bumps,
799        retired_hidden: Vec::new(),
800    }
801}
802
803/// Do two findings describe the same defect?
804///
805/// A deliberate heuristic: same normalised title, or the same file within five
806/// lines. Two reviewers rarely word a finding identically, and exact matching
807/// would report every overlap as a unique find.
808fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
809    if normalize(&a.title) == normalize(&b.title) {
810        return true;
811    }
812    match (&a.file, &b.file) {
813        (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
814            (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
815            _ => false,
816        },
817        _ => false,
818    }
819}
820
821fn normalize(title: &str) -> String {
822    title
823        .chars()
824        .filter(|c| c.is_alphanumeric())
825        .map(|c| c.to_ascii_lowercase())
826        .collect()
827}
828
829/// One local calendar day of the daily-runs chart.
830#[derive(Debug, Clone, PartialEq, Eq)]
831pub struct DayBucket {
832    /// The day, in the timezone the buckets were cut in.
833    pub date: jiff::civil::Date,
834    /// Runs created that day.
835    pub runs: usize,
836    /// Of those, runs whose status is `Merged` now.
837    pub merged: usize,
838    /// Of those, runs whose status is `Ready` now.
839    pub ready: usize,
840    /// Everything else, in-flight runs included: `runs - merged - ready`.
841    pub other: usize,
842}
843
844/// Runs per local day over the last `days` days ending at `today`, oldest
845/// first, always exactly `days` buckets (empty days are zero-filled).
846///
847/// Cut by `created_at` in `tz` and classified by the run's *current* status
848/// (the same merged / ready split [`Totals`] uses), so an in-flight run counts
849/// as `other` until it finishes and a past day's mix can change later. Runs
850/// outside the window are ignored. `today` and `tz` are arguments so the cut
851/// is testable; calendar arithmetic is civil, never 24-hour subtraction.
852pub fn daily<'a>(
853    states: impl IntoIterator<Item = &'a RunState>,
854    today: jiff::civil::Date,
855    tz: &jiff::tz::TimeZone,
856    days: usize,
857) -> Vec<DayBucket> {
858    let mut buckets: Vec<DayBucket> = (0..days)
859        .rev()
860        .filter_map(|back| {
861            let span = jiff::Span::new().days(i64::try_from(back).ok()?);
862            today.checked_sub(span).ok()
863        })
864        .map(|date| DayBucket {
865            date,
866            runs: 0,
867            merged: 0,
868            ready: 0,
869            other: 0,
870        })
871        .collect();
872    for state in states {
873        let date = state.created_at.to_zoned(tz.clone()).date();
874        let Some(bucket) = buckets.iter_mut().find(|b| b.date == date) else {
875            continue;
876        };
877        bucket.runs += 1;
878        match state.status {
879            RunStatus::Merged => bucket.merged += 1,
880            RunStatus::Ready => bucket.ready += 1,
881            _ => bucket.other += 1,
882        }
883    }
884    buckets
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890    use crate::config::Config;
891    use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
892    use crate::verdict::{Finding, Severity};
893    use std::path::PathBuf;
894
895    fn mixed_stats() -> Stats {
896        Stats {
897            totals: Totals {
898                runs: 7,
899                ..Totals::default()
900            },
901            agents: ["cc", "cx", "oc"]
902                .map(|a| AgentStats {
903                    agent: a.into(),
904                    entered: 2,
905                    ..AgentStats::default()
906                })
907                .to_vec(),
908            reviewers: ["cx", "cc"]
909                .map(|a| ReviewerStats {
910                    agent: a.into(),
911                    ..ReviewerStats::default()
912                })
913                .to_vec(),
914            advisors: ["cx", "gone"]
915                .map(|a| AdvisorStats {
916                    agent: a.into(),
917                    ..AdvisorStats::default()
918                })
919                .to_vec(),
920            ..Stats::default()
921        }
922    }
923
924    fn roster(ids: &[&str]) -> BTreeSet<String> {
925        ids.iter().map(|s| (*s).to_owned()).collect()
926    }
927
928    #[test]
929    fn retain_roster_drops_retired_agents_from_every_table() {
930        let mut st = mixed_stats();
931        retain_roster(&mut st, &roster(&["cc", "oc"]));
932        let agents: Vec<&str> = st.agents.iter().map(|a| a.agent.as_str()).collect();
933        assert_eq!(agents, ["cc", "oc"]);
934        let reviewers: Vec<&str> = st.reviewers.iter().map(|a| a.agent.as_str()).collect();
935        assert_eq!(reviewers, ["cc"]);
936        assert!(st.advisors.is_empty());
937        assert_eq!(st.retired_hidden, ["cx", "gone"]);
938    }
939
940    #[test]
941    fn retain_roster_leaves_non_agent_totals_alone() {
942        let mut st = mixed_stats();
943        retain_roster(&mut st, &roster(&["cc"]));
944        assert_eq!(st.totals.runs, 7);
945    }
946
947    #[test]
948    fn retain_roster_with_everyone_present_hides_nothing() {
949        let mut st = mixed_stats();
950        retain_roster(&mut st, &roster(&["cc", "cx", "oc", "gone"]));
951        assert_eq!(st.agents.len(), 3);
952        assert!(st.retired_hidden.is_empty());
953    }
954
955    #[test]
956    fn a_loaded_empty_roster_hides_every_agent_row() {
957        let dir = tempfile::tempdir().unwrap();
958        std::fs::write(dir.path().join("magi.toml"), "agents = []\n").unwrap();
959        let mut st = mixed_stats();
960        retain_current_roster(&mut st, dir.path());
961        assert!(st.agents.is_empty() && st.reviewers.is_empty() && st.advisors.is_empty());
962        assert_eq!(st.retired_hidden, ["cc", "cx", "gone", "oc"]);
963        assert_eq!(st.totals.runs, 7);
964    }
965
966    #[test]
967    fn an_unreadable_config_does_not_filter() {
968        let dir = tempfile::tempdir().unwrap();
969        std::fs::write(dir.path().join("magi.toml"), "this is = = not toml").unwrap();
970        let mut st = mixed_stats();
971        retain_current_roster(&mut st, dir.path());
972        assert_eq!(st.agents.len(), 3);
973        assert!(st.retired_hidden.is_empty());
974    }
975
976    fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
977        Finding {
978            id: id.to_owned(),
979            severity: sev,
980            file: Some(file.to_owned()),
981            line: Some(line),
982            title: title.to_owned(),
983            detail: String::new(),
984        }
985    }
986
987    fn candidate(label: char, agent: &str) -> Candidate {
988        Candidate {
989            index: 0,
990            label,
991            agent: agent.to_owned(),
992            branch: format!("magi/x/{label}"),
993            worktree: PathBuf::from("/w"),
994            summary: String::new(),
995            stat: String::new(),
996            files: 1,
997            commits: 1,
998            empty: false,
999            failed: None,
1000            verified_noop: None,
1001            duration_ms: 0,
1002            folded: false,
1003        }
1004    }
1005
1006    fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> 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.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
1015        s.tally = Some(Tally {
1016            first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
1017            borda: BTreeMap::new(),
1018            winner,
1019            rankings: 3,
1020            unanimous_initial: false,
1021            deliberated: true,
1022            changed_votes: 1,
1023            unanimous_final: true,
1024            tie_break: None,
1025            judges: 3,
1026            present: 3,
1027            quorum: 2,
1028            met_quorum: true,
1029            uncontested: None,
1030        });
1031        s.reviews = reviews;
1032        s.status = status;
1033        s
1034    }
1035
1036    fn state_with_repo(
1037        reviews: Vec<ReviewRound>,
1038        winner: char,
1039        status: RunStatus,
1040        repo: &str,
1041    ) -> RunState {
1042        let mut s = state_with(reviews, winner, status);
1043        s.repo = PathBuf::from(repo);
1044        s
1045    }
1046
1047    #[test]
1048    fn win_rates_and_completion_are_counted_per_agent() {
1049        let states = vec![
1050            state_with(Vec::new(), 'B', RunStatus::Merged),
1051            state_with(Vec::new(), 'A', RunStatus::Blocked),
1052        ];
1053        let stats = collect(&states);
1054        assert_eq!(stats.totals.runs, 2);
1055        assert_eq!(stats.totals.merged, 1);
1056        assert_eq!(stats.totals.blocked, 1);
1057        assert_eq!(stats.totals.completion_rate(), 50.0);
1058        assert_eq!(stats.totals.split, 2);
1059        assert_eq!(stats.totals.minds_changed, 2);
1060        assert_eq!(stats.totals.converged, 2);
1061
1062        let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
1063        assert_eq!(beta.entered, 2);
1064        assert_eq!(beta.wins, 1);
1065        assert_eq!(beta.win_rate(), 50.0);
1066    }
1067
1068    #[test]
1069    fn reviewer_precision_and_uniqueness() {
1070        let round = ReviewRound {
1071            round: 1,
1072            head: "h".to_owned(),
1073            verified_head: None,
1074            verified_at: None,
1075            reviews: vec![
1076                ReviewRecord {
1077                    attempts: 0,
1078                    reviewer: 1,
1079                    agent: "alpha".to_owned(),
1080                    summary: String::new(),
1081                    findings: vec![
1082                        finding(
1083                            "R1-1-1",
1084                            "src/a.rs",
1085                            10,
1086                            "panics on empty",
1087                            Severity::Blocker,
1088                        ),
1089                        finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
1090                    ],
1091                    vote: None,
1092                    failed: None,
1093                    duration_ms: 0,
1094                },
1095                ReviewRecord {
1096                    attempts: 0,
1097                    reviewer: 2,
1098                    agent: "beta".to_owned(),
1099                    summary: String::new(),
1100                    // Same defect as R1-1-1, three lines off: an overlap.
1101                    findings: vec![finding(
1102                        "R1-2-1",
1103                        "src/a.rs",
1104                        13,
1105                        "empty input panic",
1106                        Severity::Blocker,
1107                    )],
1108                    vote: None,
1109                    failed: None,
1110                    duration_ms: 0,
1111                },
1112            ],
1113            e2e: Vec::new(),
1114            verify_retried: false,
1115            e2e_deferred: false,
1116            e2e_defer_reason: None,
1117            fix: Some(FixRecord {
1118                agent: "alpha".to_owned(),
1119                addressed: vec!["R1-1-1".to_owned()],
1120                rejected: Vec::new(),
1121                notes: String::new(),
1122                committed: true,
1123                failed: None,
1124                duration_ms: 0,
1125                continuation: None,
1126            }),
1127            blocking: 3,
1128            answered: 2,
1129            expected: 2,
1130            clean: false,
1131            progressed: true,
1132            vote_split: false,
1133            reconsideration: Vec::new(),
1134            verdict: None,
1135        };
1136        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
1137        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
1138        assert_eq!(alpha.submitted, 2);
1139        assert_eq!(alpha.adopted, 1);
1140        assert_eq!(alpha.precision(), 50.0);
1141        assert_eq!(alpha.adopted_per_round(), 1.0);
1142        // The src/a.rs finding overlaps beta's; src/b.rs does not.
1143        assert_eq!(alpha.unique, 1);
1144
1145        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1146        assert_eq!(beta.submitted, 1);
1147        assert_eq!(beta.adopted, 0);
1148        assert_eq!(beta.unique, 0);
1149    }
1150
1151    #[test]
1152    fn a_lost_fix_report_does_not_count_as_zero_adoption() {
1153        let submitted = ReviewRound {
1154            round: 1,
1155            head: "h".to_owned(),
1156            verified_head: None,
1157            verified_at: None,
1158            reviews: vec![ReviewRecord {
1159                attempts: 0,
1160                reviewer: 1,
1161                agent: "alpha".to_owned(),
1162                summary: String::new(),
1163                findings: vec![finding(
1164                    "R1-1-1",
1165                    "src/a.rs",
1166                    10,
1167                    "panics on empty",
1168                    Severity::Blocker,
1169                )],
1170                vote: None,
1171                failed: None,
1172                duration_ms: 0,
1173            }],
1174            e2e: Vec::new(),
1175            verify_retried: false,
1176            e2e_deferred: false,
1177            e2e_defer_reason: None,
1178            // The fixer's diff may well have landed (blocking counts do fall
1179            // round over round) — only its adoption report never came back.
1180            fix: Some(FixRecord {
1181                agent: "alpha".to_owned(),
1182                addressed: Vec::new(),
1183                rejected: Vec::new(),
1184                notes: String::new(),
1185                committed: true,
1186                failed: Some("unparsable fix report".to_owned()),
1187                duration_ms: 0,
1188                continuation: None,
1189            }),
1190            blocking: 4,
1191            answered: 1,
1192            expected: 1,
1193            clean: false,
1194            progressed: false,
1195            vote_split: false,
1196            reconsideration: Vec::new(),
1197            verdict: None,
1198        };
1199        let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
1200        assert!(
1201            stats.reviewers.is_empty(),
1202            "a round with no adoption signal must not enter any reviewer's \
1203             denominator: {:?}",
1204            stats.reviewers
1205        );
1206    }
1207
1208    #[test]
1209    fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
1210        let round = ReviewRound {
1211            round: 1,
1212            head: "h".to_owned(),
1213            verified_head: None,
1214            verified_at: None,
1215            reviews: vec![
1216                ReviewRecord {
1217                    attempts: 0,
1218                    reviewer: 1,
1219                    agent: "alpha".to_owned(),
1220                    summary: String::new(),
1221                    findings: Vec::new(),
1222                    vote: None,
1223                    failed: None,
1224                    duration_ms: 0,
1225                },
1226                ReviewRecord {
1227                    attempts: 0,
1228                    reviewer: 2,
1229                    agent: "beta".to_owned(),
1230                    summary: String::new(),
1231                    findings: Vec::new(),
1232                    vote: None,
1233                    failed: Some("agent timed out".to_owned()),
1234                    duration_ms: 0,
1235                },
1236            ],
1237            e2e: Vec::new(),
1238            verify_retried: false,
1239            e2e_deferred: false,
1240            e2e_defer_reason: None,
1241            fix: None,
1242            blocking: 0,
1243            answered: 1,
1244            expected: 2,
1245            clean: false,
1246            progressed: false,
1247            vote_split: false,
1248            reconsideration: Vec::new(),
1249            verdict: None,
1250        };
1251        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
1252
1253        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
1254        assert_eq!(alpha.seated, 1);
1255        assert_eq!(alpha.rounds, 1);
1256        assert_eq!(alpha.timeouts, 0);
1257        assert_eq!(alpha.submitted, 0);
1258
1259        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1260        assert_eq!(beta.seated, 1);
1261        assert_eq!(beta.timeouts, 1);
1262        assert_eq!(beta.submitted, 0);
1263        // A timeout must never read as a submission with nothing found: it
1264        // stays out of the scoring denominators entirely rather than becoming
1265        // a 0/0 that looks identical to a reviewer who answered and passed.
1266        assert_eq!(beta.rounds, 0);
1267        assert_eq!(beta.timeout_rate(), 100.0);
1268    }
1269
1270    #[test]
1271    fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
1272        // Two independent gaps in one round: `beta` never answered, and the
1273        // fixer's adoption report never came back. The lost report suppresses
1274        // adoption scoring (see `a_lost_fix_report_does_not_count_as_zero_
1275        // adoption`) — it must not also swallow the fact that a seat was
1276        // silent, which is a property of the seat and not of the fixer.
1277        let round = ReviewRound {
1278            round: 1,
1279            head: "h".to_owned(),
1280            verified_head: None,
1281            verified_at: None,
1282            reviews: vec![
1283                ReviewRecord {
1284                    attempts: 0,
1285                    reviewer: 1,
1286                    agent: "alpha".to_owned(),
1287                    summary: String::new(),
1288                    findings: vec![finding(
1289                        "R1-1-1",
1290                        "src/a.rs",
1291                        10,
1292                        "panics on empty",
1293                        Severity::Blocker,
1294                    )],
1295                    vote: None,
1296                    failed: None,
1297                    duration_ms: 0,
1298                },
1299                ReviewRecord {
1300                    attempts: 0,
1301                    reviewer: 2,
1302                    agent: "beta".to_owned(),
1303                    summary: String::new(),
1304                    findings: Vec::new(),
1305                    vote: None,
1306                    failed: Some("agent timed out".to_owned()),
1307                    duration_ms: 0,
1308                },
1309            ],
1310            e2e: Vec::new(),
1311            verify_retried: false,
1312            e2e_deferred: false,
1313            e2e_defer_reason: None,
1314            fix: Some(FixRecord {
1315                agent: "alpha".to_owned(),
1316                addressed: Vec::new(),
1317                rejected: Vec::new(),
1318                notes: String::new(),
1319                committed: true,
1320                failed: Some("unparsable fix report".to_owned()),
1321                duration_ms: 0,
1322                continuation: None,
1323            }),
1324            blocking: 1,
1325            answered: 1,
1326            expected: 2,
1327            clean: false,
1328            progressed: false,
1329            vote_split: false,
1330            reconsideration: Vec::new(),
1331            verdict: None,
1332        };
1333        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
1334
1335        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1336        assert_eq!(beta.timeouts, 1);
1337        assert_eq!(beta.timeout_rate(), 100.0);
1338        // `alpha` answered, so the lost report keeps it out of the table
1339        // altogether — nothing about its findings can be scored.
1340        assert!(
1341            !stats.reviewers.iter().any(|r| r.agent == "alpha"),
1342            "{:?}",
1343            stats.reviewers
1344        );
1345    }
1346
1347    #[test]
1348    fn e2e_sole_detection_needs_a_clean_static_review() {
1349        let fail = CommandOutcome {
1350            command: "cargo test".to_owned(),
1351            code: Some(101),
1352            output_tail: "boom".to_owned(),
1353            duration_ms: 1,
1354            resource_blocked: false,
1355        };
1356        let sole = ReviewRound {
1357            round: 1,
1358            head: "h".to_owned(),
1359            verified_head: None,
1360            verified_at: None,
1361            reviews: Vec::new(),
1362            e2e: vec![fail.clone()],
1363            verify_retried: false,
1364            e2e_deferred: false,
1365            e2e_defer_reason: None,
1366            fix: None,
1367            blocking: 0,
1368            answered: 0,
1369            expected: 0,
1370            clean: false,
1371            progressed: false,
1372            vote_split: false,
1373            reconsideration: Vec::new(),
1374            verdict: None,
1375        };
1376        let alongside = ReviewRound {
1377            round: 2,
1378            head: "h".to_owned(),
1379            verified_head: None,
1380            verified_at: None,
1381            reviews: Vec::new(),
1382            e2e: vec![fail],
1383            verify_retried: false,
1384            e2e_deferred: false,
1385            e2e_defer_reason: None,
1386            fix: None,
1387            blocking: 2,
1388            answered: 0,
1389            expected: 0,
1390            clean: false,
1391            progressed: false,
1392            vote_split: false,
1393            reconsideration: Vec::new(),
1394            verdict: None,
1395        };
1396        let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
1397        assert_eq!(stats.e2e.rounds, 2);
1398        assert_eq!(stats.e2e.failures, 2);
1399        assert_eq!(stats.e2e.sole_detections, 1);
1400        assert_eq!(stats.e2e.sole_rate(), 50.0);
1401    }
1402
1403    #[test]
1404    fn every_run_status_lands_in_exactly_one_breakdown_bucket() {
1405        let states = vec![
1406            state_with(Vec::new(), 'A', RunStatus::Merged),
1407            state_with(Vec::new(), 'A', RunStatus::Ready),
1408            state_with(Vec::new(), 'A', RunStatus::Blocked),
1409            state_with(Vec::new(), 'A', RunStatus::Failed),
1410            state_with(Vec::new(), 'A', RunStatus::Stalled),
1411            state_with(Vec::new(), 'A', RunStatus::VerifiedNoop),
1412            state_with(Vec::new(), 'A', RunStatus::Superseded),
1413            state_with(Vec::new(), 'A', RunStatus::Implementing),
1414            state_with(Vec::new(), 'A', RunStatus::Landing),
1415        ];
1416        let stats = collect(&states);
1417        let t = &stats.totals;
1418        assert_eq!(t.runs, 9);
1419        assert_eq!(t.merged, 1);
1420        assert_eq!(t.ready, 1);
1421        assert_eq!(t.blocked, 1);
1422        assert_eq!(t.failed, 1);
1423        assert_eq!(t.stalled, 1);
1424        assert_eq!(t.verified_noop, 1);
1425        assert_eq!(t.superseded, 1);
1426        // `Implementing` and `Landing` both fall into the one non-terminal
1427        // bucket.
1428        assert_eq!(t.in_progress, 2);
1429        assert_eq!(
1430            t.merged
1431                + t.ready
1432                + t.blocked
1433                + t.failed
1434                + t.stalled
1435                + t.verified_noop
1436                + t.superseded
1437                + t.in_progress,
1438            t.runs,
1439            "every run must land in exactly one bucket of the breakdown"
1440        );
1441    }
1442
1443    #[test]
1444    fn empty_input_yields_zeroed_rates_not_nan() {
1445        let stats = collect(&[]);
1446        assert_eq!(stats.totals.completion_rate(), 0.0);
1447        assert_eq!(stats.totals.split_rate(), 0.0);
1448        assert_eq!(stats.e2e.sole_rate(), 0.0);
1449        assert!(stats.agents.is_empty());
1450        assert!(stats.advisors.is_empty());
1451        assert_eq!(AdvisorStats::default().reflection_rate(), 0.0);
1452    }
1453
1454    fn advisor_record(
1455        seat: &str,
1456        agent: &str,
1457        proposal: Option<crate::verdict::Proposal>,
1458        reflection: crate::advise::Reflection,
1459    ) -> crate::advise::AdvisorRecord {
1460        crate::advise::AdvisorRecord {
1461            seat: seat.to_owned(),
1462            agent: agent.to_owned(),
1463            proposal,
1464            error: None,
1465            duration_ms: 0,
1466            reflection,
1467        }
1468    }
1469
1470    fn a_proposal() -> crate::verdict::Proposal {
1471        crate::verdict::Proposal {
1472            approach: "do the thing".to_owned(),
1473            key_tradeoff: "speed over memory".to_owned(),
1474            risks: Vec::new(),
1475            touches: Vec::new(),
1476            why_not_naive: "the naive version breaks under load".to_owned(),
1477        }
1478    }
1479
1480    #[test]
1481    fn advisor_stats_count_proposed_absent_and_reflection_split() {
1482        use crate::advise::{Advice, Reflection};
1483
1484        let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1485        s.advice = Some(Advice {
1486            records: vec![
1487                advisor_record("advisor-1", "alpha", Some(a_proposal()), Reflection::Strong),
1488                advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1489                advisor_record("advisor-3", "alpha", None, Reflection::Absent),
1490            ],
1491            synthesis: Some("blended brief".to_owned()),
1492        });
1493
1494        let stats = collect(&[s]);
1495        let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1496        assert_eq!(alpha.seated, 3);
1497        assert_eq!(alpha.proposed, 2);
1498        assert_eq!(alpha.absent, 1);
1499        assert_eq!(alpha.strong, 1);
1500        assert_eq!(alpha.faint, 1);
1501        assert_eq!(alpha.reflection_rate(), 50.0);
1502    }
1503
1504    #[test]
1505    fn advisor_stats_count_absent_from_the_proposal_not_the_reflection_default() {
1506        // A record whose `proposal` is `None` but whose `reflection` was
1507        // never classified (predates `apply_reflection`, or the field's own
1508        // serde default) must still count as `absent` — and a run whose
1509        // synthesis never ran leaves every *proposed* record `Faint` by the
1510        // same default, which must not spill into `absent` either.
1511        //
1512        // A third case: a proposed record whose `reflection` was *never*
1513        // classified at all (a run.json predating the `reflection` field)
1514        // must not vanish from the breakdown either — it has to land
1515        // somewhere in faint/strong, not be silently dropped from all three
1516        // counters while still counting toward `proposed`.
1517        use crate::advise::{Advice, Reflection};
1518
1519        let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1520        s.advice = Some(Advice {
1521            records: vec![
1522                advisor_record("advisor-1", "alpha", None, Reflection::Absent),
1523                advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1524                advisor_record("advisor-3", "alpha", Some(a_proposal()), Reflection::Absent),
1525            ],
1526            synthesis: None,
1527        });
1528
1529        let stats = collect(&[s]);
1530        let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1531        assert_eq!(alpha.seated, 3);
1532        assert_eq!(alpha.proposed, 2);
1533        assert_eq!(alpha.absent, 1);
1534        assert_eq!(alpha.faint, 2);
1535        assert_eq!(alpha.strong, 0);
1536    }
1537
1538    #[test]
1539    fn advisor_stats_ignore_runs_with_advise_off() {
1540        let s = state_with(Vec::new(), 'A', RunStatus::Merged);
1541        assert!(s.advice.is_none());
1542        let stats = collect(&[s]);
1543        assert!(stats.advisors.is_empty());
1544    }
1545
1546    #[test]
1547    fn same_defect_matches_titles_across_files() {
1548        let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
1549        let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
1550        assert!(same_defect(&a, &b));
1551        let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
1552        assert!(!same_defect(&a, &c));
1553    }
1554
1555    #[test]
1556    fn release_bump_stats_split_clean_from_attention_and_track_coverage() {
1557        use crate::run::ReleaseBump;
1558
1559        // Not recorded at all: uses the release-bump step? unknown.
1560        let unrecorded = state_with(Vec::new(), 'A', RunStatus::Merged);
1561
1562        // Clean: automerge worked, nobody had to look at it.
1563        let mut automerged = state_with(Vec::new(), 'A', RunStatus::Merged);
1564        automerged.release_bump = Some(ReleaseBump {
1565            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
1566            version: Some("1.2.3".to_owned()),
1567            automerge_enabled: true,
1568            merged_directly: false,
1569            local: false,
1570            release: None,
1571            problem: None,
1572            action_required: None,
1573        });
1574
1575        // Also clean, but automerge itself was rejected by GitHub and magi
1576        // merged the already-green PR directly - `automerge_enabled` reads
1577        // false here, and that must not make this count as needing a human.
1578        let mut merged_directly = state_with(Vec::new(), 'A', RunStatus::Merged);
1579        merged_directly.release_bump = Some(ReleaseBump {
1580            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
1581            version: Some("1.2.4".to_owned()),
1582            automerge_enabled: false,
1583            merged_directly: true,
1584            local: false,
1585            release: None,
1586            problem: None,
1587            action_required: None,
1588        });
1589
1590        // Needs a human, PR opened.
1591        let mut blocked_with_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1592        blocked_with_pr.release_bump = Some(ReleaseBump {
1593            pr_url: Some("https://github.com/o/r/pull/3".to_owned()),
1594            version: Some("1.2.5".to_owned()),
1595            automerge_enabled: false,
1596            merged_directly: false,
1597            local: false,
1598            release: None,
1599            problem: Some("checks red".to_owned()),
1600            action_required: Some("look at the PR".to_owned()),
1601        });
1602
1603        // Needs a human, no PR ever opened.
1604        let mut blocked_without_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1605        blocked_without_pr.release_bump = Some(ReleaseBump {
1606            pr_url: None,
1607            version: Some("1.2.6".to_owned()),
1608            automerge_enabled: false,
1609            merged_directly: false,
1610            local: false,
1611            release: None,
1612            problem: Some("gh pr create failed".to_owned()),
1613            action_required: Some("open the PR by hand".to_owned()),
1614        });
1615
1616        let stats = collect(&[
1617            unrecorded,
1618            automerged,
1619            merged_directly,
1620            blocked_with_pr,
1621            blocked_without_pr,
1622        ]);
1623        let b = &stats.release_bumps;
1624        assert_eq!(b.merged, 5);
1625        assert_eq!(b.recorded, 4);
1626        assert_eq!(b.pr_opened, 3);
1627        assert_eq!(b.automerge_enabled, 1);
1628        assert_eq!(b.merged_directly, 1);
1629        assert_eq!(b.needs_attention, 2);
1630        assert_eq!(b.clean(), 2);
1631        // Clean and attention must always split `recorded` exactly.
1632        assert_eq!(b.clean() + b.needs_attention, b.recorded);
1633        assert_eq!(b.coverage_rate(), 80.0);
1634        assert!((b.automerge_rate() - 33.333_333_333_333_336).abs() < 1e-9);
1635        assert_eq!(b.attention_rate(), 50.0);
1636    }
1637
1638    #[test]
1639    fn release_bump_ignores_runs_that_are_not_merged() {
1640        use crate::run::ReleaseBump;
1641
1642        let mut blocked = state_with(Vec::new(), 'A', RunStatus::Blocked);
1643        blocked.release_bump = Some(ReleaseBump {
1644            pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
1645            version: Some("9.9.9".to_owned()),
1646            automerge_enabled: true,
1647            merged_directly: false,
1648            local: false,
1649            release: None,
1650            problem: None,
1651            action_required: None,
1652        });
1653
1654        let stats = collect(&[blocked]);
1655        let b = &stats.release_bumps;
1656        assert_eq!(b.merged, 0);
1657        assert_eq!(b.recorded, 0);
1658        assert_eq!(b.pr_opened, 0);
1659    }
1660
1661    #[test]
1662    fn release_bump_stats_are_zero_on_merged_runs_with_no_bump_or_no_runs() {
1663        let stats = collect(&[state_with(Vec::new(), 'A', RunStatus::Merged)]);
1664        let b = &stats.release_bumps;
1665        assert_eq!(b.merged, 1);
1666        assert_eq!(b.recorded, 0);
1667        assert_eq!(b.coverage_rate(), 0.0);
1668        assert_eq!(b.automerge_rate(), 0.0);
1669        assert_eq!(b.attention_rate(), 0.0);
1670        assert_eq!(b.clean(), 0);
1671
1672        let empty = collect(&[]);
1673        let b = &empty.release_bumps;
1674        assert_eq!(b.merged, 0);
1675        assert_eq!(b.coverage_rate(), 0.0);
1676        assert_eq!(b.automerge_rate(), 0.0);
1677        assert_eq!(b.attention_rate(), 0.0);
1678    }
1679
1680    fn event(node: &str, at_secs: i64, message: &str) -> crate::run::Event {
1681        crate::run::Event {
1682            at: jiff::Timestamp::from_second(at_secs).unwrap(),
1683            node: node.to_owned(),
1684            message: message.to_owned(),
1685        }
1686    }
1687
1688    fn state_with_events(events: Vec<crate::run::Event>) -> RunState {
1689        let mut s = RunState::new(
1690            PathBuf::from("/repo"),
1691            "main".to_owned(),
1692            "abcdef".to_owned(),
1693            "task".to_owned(),
1694            Config::default(),
1695        );
1696        s.events = events;
1697        s
1698    }
1699
1700    #[test]
1701    fn a_node_with_multiple_events_spans_first_to_last() {
1702        let s = state_with_events(vec![
1703            event("implement", 1_000, "start"),
1704            event("implement", 1_030, "still running"),
1705            event("implement", 1_090, "done"),
1706        ]);
1707        let nodes = node_durations(&[s]);
1708        assert_eq!(nodes.len(), 1);
1709        assert_eq!(nodes[0].node, "implement");
1710        assert_eq!(nodes[0].runs, 1);
1711        assert_eq!(nodes[0].total_secs, 90);
1712        assert_eq!(nodes[0].max_secs, 90);
1713        assert_eq!(nodes[0].single, 0);
1714        assert_eq!(nodes[0].mean_secs(), 90.0);
1715    }
1716
1717    #[test]
1718    fn a_node_with_a_single_event_is_unmeasured_not_zero() {
1719        let s = state_with_events(vec![event("gate", 2_000, "ran once")]);
1720        let nodes = node_durations(&[s]);
1721        assert_eq!(nodes.len(), 1);
1722        assert_eq!(nodes[0].node, "gate");
1723        assert_eq!(nodes[0].runs, 0);
1724        assert_eq!(nodes[0].total_secs, 0);
1725        assert_eq!(nodes[0].single, 1);
1726        // No measured runs: the mean must read as zero, never NaN or a
1727        // fabricated span from the lone event.
1728        assert_eq!(nodes[0].mean_secs(), 0.0);
1729    }
1730
1731    #[test]
1732    fn a_run_with_no_events_produces_no_node_rows() {
1733        let s = state_with_events(vec![]);
1734        let nodes = node_durations(&[s]);
1735        assert!(nodes.is_empty());
1736    }
1737
1738    #[test]
1739    fn multiple_runs_aggregate_the_same_node() {
1740        let a = state_with_events(vec![event("judge", 0, "start"), event("judge", 60, "done")]);
1741        let b = state_with_events(vec![
1742            event("judge", 0, "start"),
1743            event("judge", 200, "done"),
1744        ]);
1745        // A single-event run for the same node must add to `single` without
1746        // disturbing the measured runs' total or max.
1747        let c = state_with_events(vec![event("judge", 5, "start")]);
1748        let nodes = node_durations(&[a, b, c]);
1749        assert_eq!(nodes.len(), 1);
1750        let judge = &nodes[0];
1751        assert_eq!(judge.node, "judge");
1752        assert_eq!(judge.runs, 2);
1753        assert_eq!(judge.total_secs, 260);
1754        assert_eq!(judge.max_secs, 200);
1755        assert_eq!(judge.single, 1);
1756        assert_eq!(judge.mean_secs(), 130.0);
1757    }
1758
1759    #[test]
1760    fn by_repo_splits_states_and_group_totals_sum_to_the_whole() {
1761        let states = vec![
1762            state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1763            state_with_repo(Vec::new(), 'A', RunStatus::Blocked, "/repos/a"),
1764            state_with_repo(Vec::new(), 'B', RunStatus::Merged, "/repos/b"),
1765        ];
1766        let groups = by_repo(&states);
1767        assert_eq!(groups.len(), 2);
1768
1769        let total_runs: usize = groups.iter().map(|g| g.stats.totals.runs).sum();
1770        assert_eq!(total_runs, collect(&states).totals.runs);
1771        let total_merged: usize = groups.iter().map(|g| g.stats.totals.merged).sum();
1772        assert_eq!(total_merged, collect(&states).totals.merged);
1773
1774        // Busiest repository (2 runs) sorts first.
1775        assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
1776        assert_eq!(groups[0].name, "a");
1777        assert_eq!(groups[0].stats.totals.runs, 2);
1778        assert_eq!(groups[1].repo, PathBuf::from("/repos/b"));
1779        assert_eq!(groups[1].name, "b");
1780        assert_eq!(groups[1].stats.totals.runs, 1);
1781    }
1782
1783    #[test]
1784    fn by_repo_breaks_a_run_count_tie_by_path() {
1785        let states = vec![
1786            state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/z"),
1787            state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1788        ];
1789        let groups = by_repo(&states);
1790        assert_eq!(groups.len(), 2);
1791        assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
1792        assert_eq!(groups[1].repo, PathBuf::from("/repos/z"));
1793    }
1794
1795    #[test]
1796    fn by_repo_on_empty_input_yields_no_groups() {
1797        assert!(by_repo(&[]).is_empty());
1798    }
1799
1800    #[test]
1801    fn filter_repo_matches_the_full_path_exactly() {
1802        let states = vec![
1803            state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1804            state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/ab"),
1805        ];
1806        let hits = filter_repo(&states, Path::new("/repos/a"));
1807        assert_eq!(hits.len(), 1);
1808        assert_eq!(hits[0].repo, PathBuf::from("/repos/a"));
1809    }
1810
1811    #[test]
1812    fn filter_repo_returns_nothing_for_an_unknown_repo_or_empty_input() {
1813        let states = vec![state_with_repo(
1814            Vec::new(),
1815            'A',
1816            RunStatus::Merged,
1817            "/repos/a",
1818        )];
1819        assert!(filter_repo(&states, Path::new("/repos/nope")).is_empty());
1820        assert!(filter_repo(&[], Path::new("/repos/a")).is_empty());
1821    }
1822
1823    #[test]
1824    fn daily_cuts_by_local_date_zero_fills_and_ignores_the_window_outside() {
1825        let tz = jiff::tz::TimeZone::fixed(jiff::tz::offset(9));
1826        let today = jiff::civil::date(2026, 3, 2);
1827        let at = |ts: &str, status| {
1828            let mut s = state_with(Vec::new(), 'A', status);
1829            s.created_at = ts.parse().unwrap();
1830            s
1831        };
1832        let states = vec![
1833            // 23:30 UTC on the 1st is 08:30 on the 2nd at +09:00.
1834            at("2026-03-01T23:30:00Z", RunStatus::Merged),
1835            at("2026-03-02T01:00:00Z", RunStatus::Ready),
1836            at("2026-03-02T02:00:00Z", RunStatus::Implementing),
1837            at("2026-03-02T03:00:00Z", RunStatus::Failed),
1838            // Last day of the window across a month boundary (Feb has 28).
1839            at("2026-02-01T00:00:00Z", RunStatus::Merged),
1840            // Outside the window, and in the future.
1841            at("2026-01-01T00:00:00Z", RunStatus::Merged),
1842            at("2026-03-05T00:00:00Z", RunStatus::Merged),
1843        ];
1844        let d = daily(&states, today, &tz, 30);
1845        assert_eq!(d.len(), 30);
1846        assert_eq!(d.last().unwrap().date, today);
1847        assert_eq!(d[0].date, jiff::civil::date(2026, 2, 1));
1848        assert!(d.windows(2).all(|w| w[0].date < w[1].date));
1849        let last = d.last().unwrap();
1850        assert_eq!(
1851            (last.runs, last.merged, last.ready, last.other),
1852            (4, 1, 1, 2)
1853        );
1854        assert_eq!(d[0].runs, 1);
1855        assert_eq!(d.iter().map(|b| b.runs).sum::<usize>(), 5);
1856        assert!(d.iter().all(|b| b.merged + b.ready + b.other == b.runs));
1857    }
1858
1859    #[test]
1860    fn daily_survives_a_dst_boundary() {
1861        let tz = jiff::tz::TimeZone::get("America/New_York").unwrap();
1862        let today = jiff::civil::date(2026, 3, 9);
1863        let d = daily(&[], today, &tz, 30);
1864        assert_eq!(d.len(), 30);
1865        assert!(
1866            d.windows(2)
1867                .all(|w| w[0].date.tomorrow().unwrap() == w[1].date)
1868        );
1869    }
1870}