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