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;
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}
436
437/// Load every run on disk, skipping any that cannot be read.
438pub fn load_all() -> Vec<RunState> {
439    list_ids()
440        .into_iter()
441        .filter_map(|id| RunState::load(&id).ok())
442        .collect()
443}
444
445/// One repository's [`Stats`], as grouped by [`by_repo`].
446#[derive(Debug, Clone)]
447pub struct RepoStats {
448    /// The grouping key: `RunState.repo` exactly as recorded, a normalised
449    /// full path. Never a display name — this is what a caller (the CLI's
450    /// `--repo` fallback, the web `?repo=` query) matches back against, and
451    /// matching by name would conflate two different checkouts that happen
452    /// to share a leaf directory.
453    pub repo: PathBuf,
454    /// Display name: `repo`'s file name, or the full path when it has none
455    /// (e.g. `/`). Collisions between repositories are the caller's problem
456    /// to disambiguate (see `report::repo_summary`), not this struct's.
457    pub name: String,
458    /// This repository's own aggregate, counted exactly as [`collect`]
459    /// counts the whole workload.
460    pub stats: Stats,
461}
462
463/// Group `states` by [`RunState::repo`] and aggregate each group with
464/// [`collect_refs`] — the same counting logic as [`collect`], just scoped to
465/// one repository at a time.
466///
467/// Sorted by run count descending, then by repo path ascending on ties, so
468/// the busiest repository leads the summary table.
469pub fn by_repo(states: &[RunState]) -> Vec<RepoStats> {
470    let mut groups: BTreeMap<PathBuf, Vec<&RunState>> = BTreeMap::new();
471    for state in states {
472        groups.entry(state.repo.clone()).or_default().push(state);
473    }
474
475    let mut out: Vec<RepoStats> = groups
476        .into_iter()
477        .map(|(repo, group)| {
478            let stats = collect_refs(group);
479            let name = repo
480                .file_name()
481                .map(|n| n.to_string_lossy().into_owned())
482                .unwrap_or_else(|| repo.to_string_lossy().into_owned());
483            RepoStats { repo, name, stats }
484        })
485        .collect();
486    out.sort_by(|a, b| {
487        b.stats
488            .totals
489            .runs
490            .cmp(&a.stats.totals.runs)
491            .then(a.repo.cmp(&b.repo))
492    });
493    out
494}
495
496/// Narrow `states` to those recorded against `repo` exactly.
497///
498/// Matches [`RunState::repo`] by full-path equality only, never by name — the
499/// name-fallback resolution a caller may want (a checkout that has since
500/// moved or been deleted) belongs one layer up, where a config or filesystem
501/// lookup can decide what "the same repository" means; this function has no
502/// such context and would otherwise risk conflating two different checkouts
503/// that happen to share a leaf directory.
504pub fn filter_repo<'a>(states: &'a [RunState], repo: &Path) -> Vec<&'a RunState> {
505    states.iter().filter(|s| s.repo == repo).collect()
506}
507
508/// Aggregate `states`.
509pub fn collect(states: &[RunState]) -> Stats {
510    collect_refs(states)
511}
512
513/// Aggregate `states`, over any iterator of references rather than a slice —
514/// what [`by_repo`] uses to run the same counting logic over each repository's
515/// own group without collecting it into an owned `Vec<RunState>` first.
516/// [`collect`] is a thin wrapper around this for the common whole-slice case.
517pub fn collect_refs<'a>(states: impl IntoIterator<Item = &'a RunState>) -> Stats {
518    let states: Vec<&'a RunState> = states.into_iter().collect();
519    let mut totals = Totals::default();
520    let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
521    let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
522    let mut advisors: BTreeMap<String, AdvisorStats> = BTreeMap::new();
523    let mut e2e = E2eStats::default();
524    let mut release_bumps = ReleaseBumpStats::default();
525
526    for state in &states {
527        totals.runs += 1;
528        if state.status == RunStatus::Merged {
529            release_bumps.merged += 1;
530            if let Some(b) = &state.release_bump {
531                release_bumps.recorded += 1;
532                if b.pr_url.is_some() {
533                    release_bumps.pr_opened += 1;
534                }
535                if b.automerge_enabled {
536                    release_bumps.automerge_enabled += 1;
537                }
538                if b.merged_directly {
539                    release_bumps.merged_directly += 1;
540                }
541            }
542            if state.needs_attention() {
543                release_bumps.needs_attention += 1;
544            }
545        }
546        match state.status {
547            RunStatus::Merged => totals.merged += 1,
548            RunStatus::Ready => totals.ready += 1,
549            RunStatus::Blocked => totals.blocked += 1,
550            RunStatus::Failed => totals.failed += 1,
551            RunStatus::Stalled => totals.stalled += 1,
552            RunStatus::VerifiedNoop => totals.verified_noop += 1,
553            // Counted on its own rather than folded into `blocked`/`stalled`:
554            // the task it belongs to already landed through a later run,
555            // which is the one this tally counts as the merge/ready outcome,
556            // and folding it back in would inflate the denominator with two
557            // outcomes for one task.
558            //
559            // `AlreadyInBase` is the same story from the other side: the
560            // change reached the base by another route, so it shares the
561            // bucket instead of becoming a second outcome for one change.
562            RunStatus::Superseded | RunStatus::AlreadyInBase => totals.superseded += 1,
563            RunStatus::Prep
564            | RunStatus::Implementing
565            | RunStatus::Judging
566            | RunStatus::Deliberating
567            | RunStatus::Voting
568            | RunStatus::Reviewing
569            | RunStatus::Gating
570            | RunStatus::Landing => totals.in_progress += 1,
571        }
572
573        for c in &state.candidates {
574            let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
575                agent: c.agent.clone(),
576                ..AgentStats::default()
577            });
578            // A verified no-op is not counted as the ordinary empty loss it
579            // would otherwise look like: the candidate gave evidence for
580            // writing nothing, which `entry.empty` exists to flag the
581            // *absence* of.
582            if c.empty && c.verified_noop.is_none() {
583                entry.empty += 1;
584            }
585            if c.viable() {
586                entry.entered += 1;
587            }
588        }
589
590        if let Some(t) = &state.tally {
591            // A tally with no panel (`uncontested`) never split, never
592            // deliberated and never converged — it never happened, and
593            // folding it into the denominator would understate the real
594            // split rate with runs that carry no panel-agreement signal at
595            // all. The winner still earns its agent a win either way: an
596            // uncontested candidate is still the one that shipped.
597            if t.uncontested.is_none() {
598                totals.tallied += 1;
599                if !t.unanimous_initial {
600                    totals.split += 1;
601                }
602                if t.deliberated {
603                    totals.deliberated += 1;
604                    if t.changed_votes > 0 {
605                        totals.minds_changed += 1;
606                    }
607                    if t.unanimous_final {
608                        totals.converged += 1;
609                    }
610                }
611            }
612            if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
613                agents
614                    .entry(w.agent.clone())
615                    .or_insert_with(|| AgentStats {
616                        agent: w.agent.clone(),
617                        ..AgentStats::default()
618                    })
619                    .wins += 1;
620            }
621        }
622
623        if let Some(advice) = &state.advice {
624            for rec in &advice.records {
625                let entry = advisors
626                    .entry(rec.agent.clone())
627                    .or_insert_with(|| AdvisorStats {
628                        agent: rec.agent.clone(),
629                        ..AdvisorStats::default()
630                    });
631                entry.seated += 1;
632                if rec.proposal.is_none() {
633                    entry.absent += 1;
634                    continue;
635                }
636                entry.proposed += 1;
637                match rec.reflection {
638                    crate::advise::Reflection::Strong => entry.strong += 1,
639                    crate::advise::Reflection::Faint => entry.faint += 1,
640                    // A proposal exists, so this is not a real "no proposal"
641                    // reading — see `AdvisorStats::absent`'s own doc for why
642                    // that count comes from `proposal.is_none()` instead of
643                    // this field. `graph::Runner::advise` always calls
644                    // `apply_reflection` before saving, so the only way a
645                    // proposed record keeps the default `Absent` is a run.json
646                    // predating the `reflection` field. Fold it into `faint`
647                    // rather than dropping it from the breakdown entirely:
648                    // that is what `classify()` itself falls back to when
649                    // there is nothing to score against.
650                    crate::advise::Reflection::Absent => entry.faint += 1,
651                }
652            }
653        }
654
655        for round in &state.reviews {
656            totals.review_rounds += 1;
657
658            // A round whose fixer never reported back (crashed, timed out, or
659            // replied with something magi could not parse) leaves adoption
660            // unknown, not zero. Counting it would score every reviewer in
661            // that round as having been ignored, when the truth is simply
662            // unrecorded — so it stays out of the adoption-rate denominator
663            // entirely rather than silently becoming a round of 0 adoptions.
664            let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
665            let adopted: Vec<&String> = round
666                .fix
667                .as_ref()
668                .map(|f| f.addressed.iter().collect())
669                .unwrap_or_default();
670
671            for rec in &round.reviews {
672                let entry = reviewers
673                    .entry(rec.agent.clone())
674                    .or_insert_with(|| ReviewerStats {
675                        agent: rec.agent.clone(),
676                        ..ReviewerStats::default()
677                    });
678                // Seating and answering are facts about the seat itself: they
679                // hold whether or not this round's adoption is scoreable, so
680                // they are counted before the lost-report guard. A seat that
681                // never answered stays out of every scoring denominator —
682                // silence is not a review that found nothing.
683                entry.seated += 1;
684                if rec.failed.is_some() {
685                    entry.timeouts += 1;
686                    continue;
687                }
688                if report_lost {
689                    continue;
690                }
691                entry.rounds += 1;
692                entry.submitted += rec.findings.len();
693                for f in &rec.findings {
694                    if adopted.iter().any(|a| **a == f.id) {
695                        entry.adopted += 1;
696                    }
697                    let overlapped = round
698                        .reviews
699                        .iter()
700                        .filter(|other| other.reviewer != rec.reviewer)
701                        .flat_map(|other| other.findings.iter())
702                        .any(|g| same_defect(f, g));
703                    if !overlapped {
704                        entry.unique += 1;
705                    }
706                }
707            }
708
709            if round.e2e_deferred {
710                e2e.deferred += 1;
711            } else if !round.e2e.is_empty() {
712                e2e.rounds += 1;
713                if round.e2e.iter().any(|o| !o.ok()) {
714                    e2e.failures += 1;
715                    if round.blocking == 0 {
716                        e2e.sole_detections += 1;
717                    }
718                }
719            }
720        }
721    }
722
723    let mut agents: Vec<AgentStats> = agents.into_values().collect();
724    agents.sort_by(|a, b| {
725        b.win_rate()
726            .total_cmp(&a.win_rate())
727            .then(b.entered.cmp(&a.entered))
728    });
729    let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
730    // A seat only sighted in rounds whose adoption could not be scored has
731    // nothing to report: no scoreable round, no silence to flag. It stays out
732    // of the table entirely rather than appearing as a row of zeroes, which
733    // would read as a reviewer that produced nothing.
734    reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
735    reviewers.sort_by(|a, b| {
736        b.adopted_per_round()
737            .total_cmp(&a.adopted_per_round())
738            .then(b.rounds.cmp(&a.rounds))
739    });
740
741    let mut advisors: Vec<AdvisorStats> = advisors.into_values().collect();
742    advisors.sort_by(|a, b| {
743        b.reflection_rate()
744            .total_cmp(&a.reflection_rate())
745            .then(b.proposed.cmp(&a.proposed))
746    });
747
748    let nodes = node_durations(states);
749
750    Stats {
751        totals,
752        agents,
753        reviewers,
754        advisors,
755        e2e,
756        nodes,
757        release_bumps,
758    }
759}
760
761/// Do two findings describe the same defect?
762///
763/// A deliberate heuristic: same normalised title, or the same file within five
764/// lines. Two reviewers rarely word a finding identically, and exact matching
765/// would report every overlap as a unique find.
766fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
767    if normalize(&a.title) == normalize(&b.title) {
768        return true;
769    }
770    match (&a.file, &b.file) {
771        (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
772            (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
773            _ => false,
774        },
775        _ => false,
776    }
777}
778
779fn normalize(title: &str) -> String {
780    title
781        .chars()
782        .filter(|c| c.is_alphanumeric())
783        .map(|c| c.to_ascii_lowercase())
784        .collect()
785}
786
787/// One local calendar day of the daily-runs chart.
788#[derive(Debug, Clone, PartialEq, Eq)]
789pub struct DayBucket {
790    /// The day, in the timezone the buckets were cut in.
791    pub date: jiff::civil::Date,
792    /// Runs created that day.
793    pub runs: usize,
794    /// Of those, runs whose status is `Merged` now.
795    pub merged: usize,
796    /// Of those, runs whose status is `Ready` now.
797    pub ready: usize,
798    /// Everything else, in-flight runs included: `runs - merged - ready`.
799    pub other: usize,
800}
801
802/// Runs per local day over the last `days` days ending at `today`, oldest
803/// first, always exactly `days` buckets (empty days are zero-filled).
804///
805/// Cut by `created_at` in `tz` and classified by the run's *current* status
806/// (the same merged / ready split [`Totals`] uses), so an in-flight run counts
807/// as `other` until it finishes and a past day's mix can change later. Runs
808/// outside the window are ignored. `today` and `tz` are arguments so the cut
809/// is testable; calendar arithmetic is civil, never 24-hour subtraction.
810pub fn daily<'a>(
811    states: impl IntoIterator<Item = &'a RunState>,
812    today: jiff::civil::Date,
813    tz: &jiff::tz::TimeZone,
814    days: usize,
815) -> Vec<DayBucket> {
816    let mut buckets: Vec<DayBucket> = (0..days)
817        .rev()
818        .filter_map(|back| {
819            let span = jiff::Span::new().days(i64::try_from(back).ok()?);
820            today.checked_sub(span).ok()
821        })
822        .map(|date| DayBucket {
823            date,
824            runs: 0,
825            merged: 0,
826            ready: 0,
827            other: 0,
828        })
829        .collect();
830    for state in states {
831        let date = state.created_at.to_zoned(tz.clone()).date();
832        let Some(bucket) = buckets.iter_mut().find(|b| b.date == date) else {
833            continue;
834        };
835        bucket.runs += 1;
836        match state.status {
837            RunStatus::Merged => bucket.merged += 1,
838            RunStatus::Ready => bucket.ready += 1,
839            _ => bucket.other += 1,
840        }
841    }
842    buckets
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848    use crate::config::Config;
849    use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
850    use crate::verdict::{Finding, Severity};
851    use std::path::PathBuf;
852
853    fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
854        Finding {
855            id: id.to_owned(),
856            severity: sev,
857            file: Some(file.to_owned()),
858            line: Some(line),
859            title: title.to_owned(),
860            detail: String::new(),
861        }
862    }
863
864    fn candidate(label: char, agent: &str) -> Candidate {
865        Candidate {
866            index: 0,
867            label,
868            agent: agent.to_owned(),
869            branch: format!("magi/x/{label}"),
870            worktree: PathBuf::from("/w"),
871            summary: String::new(),
872            stat: String::new(),
873            files: 1,
874            commits: 1,
875            empty: false,
876            failed: None,
877            verified_noop: None,
878            duration_ms: 0,
879            folded: false,
880        }
881    }
882
883    fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
884        let mut s = RunState::new(
885            PathBuf::from("/repo"),
886            "main".to_owned(),
887            "abcdef".to_owned(),
888            "task".to_owned(),
889            Config::default(),
890        );
891        s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
892        s.tally = Some(Tally {
893            first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
894            borda: BTreeMap::new(),
895            winner,
896            rankings: 3,
897            unanimous_initial: false,
898            deliberated: true,
899            changed_votes: 1,
900            unanimous_final: true,
901            tie_break: None,
902            judges: 3,
903            present: 3,
904            quorum: 2,
905            met_quorum: true,
906            uncontested: None,
907        });
908        s.reviews = reviews;
909        s.status = status;
910        s
911    }
912
913    fn state_with_repo(
914        reviews: Vec<ReviewRound>,
915        winner: char,
916        status: RunStatus,
917        repo: &str,
918    ) -> RunState {
919        let mut s = state_with(reviews, winner, status);
920        s.repo = PathBuf::from(repo);
921        s
922    }
923
924    #[test]
925    fn win_rates_and_completion_are_counted_per_agent() {
926        let states = vec![
927            state_with(Vec::new(), 'B', RunStatus::Merged),
928            state_with(Vec::new(), 'A', RunStatus::Blocked),
929        ];
930        let stats = collect(&states);
931        assert_eq!(stats.totals.runs, 2);
932        assert_eq!(stats.totals.merged, 1);
933        assert_eq!(stats.totals.blocked, 1);
934        assert_eq!(stats.totals.completion_rate(), 50.0);
935        assert_eq!(stats.totals.split, 2);
936        assert_eq!(stats.totals.minds_changed, 2);
937        assert_eq!(stats.totals.converged, 2);
938
939        let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
940        assert_eq!(beta.entered, 2);
941        assert_eq!(beta.wins, 1);
942        assert_eq!(beta.win_rate(), 50.0);
943    }
944
945    #[test]
946    fn reviewer_precision_and_uniqueness() {
947        let round = ReviewRound {
948            round: 1,
949            head: "h".to_owned(),
950            verified_head: None,
951            verified_at: None,
952            reviews: vec![
953                ReviewRecord {
954                    attempts: 0,
955                    reviewer: 1,
956                    agent: "alpha".to_owned(),
957                    summary: String::new(),
958                    findings: vec![
959                        finding(
960                            "R1-1-1",
961                            "src/a.rs",
962                            10,
963                            "panics on empty",
964                            Severity::Blocker,
965                        ),
966                        finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
967                    ],
968                    vote: None,
969                    failed: None,
970                    duration_ms: 0,
971                },
972                ReviewRecord {
973                    attempts: 0,
974                    reviewer: 2,
975                    agent: "beta".to_owned(),
976                    summary: String::new(),
977                    // Same defect as R1-1-1, three lines off: an overlap.
978                    findings: vec![finding(
979                        "R1-2-1",
980                        "src/a.rs",
981                        13,
982                        "empty input panic",
983                        Severity::Blocker,
984                    )],
985                    vote: None,
986                    failed: None,
987                    duration_ms: 0,
988                },
989            ],
990            e2e: Vec::new(),
991            verify_retried: false,
992            e2e_deferred: false,
993            e2e_defer_reason: None,
994            fix: Some(FixRecord {
995                agent: "alpha".to_owned(),
996                addressed: vec!["R1-1-1".to_owned()],
997                rejected: Vec::new(),
998                notes: String::new(),
999                committed: true,
1000                failed: None,
1001                duration_ms: 0,
1002                continuation: None,
1003            }),
1004            blocking: 3,
1005            answered: 2,
1006            expected: 2,
1007            clean: false,
1008            progressed: true,
1009            vote_split: false,
1010            reconsideration: Vec::new(),
1011            verdict: None,
1012        };
1013        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
1014        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
1015        assert_eq!(alpha.submitted, 2);
1016        assert_eq!(alpha.adopted, 1);
1017        assert_eq!(alpha.precision(), 50.0);
1018        assert_eq!(alpha.adopted_per_round(), 1.0);
1019        // The src/a.rs finding overlaps beta's; src/b.rs does not.
1020        assert_eq!(alpha.unique, 1);
1021
1022        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1023        assert_eq!(beta.submitted, 1);
1024        assert_eq!(beta.adopted, 0);
1025        assert_eq!(beta.unique, 0);
1026    }
1027
1028    #[test]
1029    fn a_lost_fix_report_does_not_count_as_zero_adoption() {
1030        let submitted = ReviewRound {
1031            round: 1,
1032            head: "h".to_owned(),
1033            verified_head: None,
1034            verified_at: None,
1035            reviews: vec![ReviewRecord {
1036                attempts: 0,
1037                reviewer: 1,
1038                agent: "alpha".to_owned(),
1039                summary: String::new(),
1040                findings: vec![finding(
1041                    "R1-1-1",
1042                    "src/a.rs",
1043                    10,
1044                    "panics on empty",
1045                    Severity::Blocker,
1046                )],
1047                vote: None,
1048                failed: None,
1049                duration_ms: 0,
1050            }],
1051            e2e: Vec::new(),
1052            verify_retried: false,
1053            e2e_deferred: false,
1054            e2e_defer_reason: None,
1055            // The fixer's diff may well have landed (blocking counts do fall
1056            // round over round) — only its adoption report never came back.
1057            fix: Some(FixRecord {
1058                agent: "alpha".to_owned(),
1059                addressed: Vec::new(),
1060                rejected: Vec::new(),
1061                notes: String::new(),
1062                committed: true,
1063                failed: Some("unparsable fix report".to_owned()),
1064                duration_ms: 0,
1065                continuation: None,
1066            }),
1067            blocking: 4,
1068            answered: 1,
1069            expected: 1,
1070            clean: false,
1071            progressed: false,
1072            vote_split: false,
1073            reconsideration: Vec::new(),
1074            verdict: None,
1075        };
1076        let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
1077        assert!(
1078            stats.reviewers.is_empty(),
1079            "a round with no adoption signal must not enter any reviewer's \
1080             denominator: {:?}",
1081            stats.reviewers
1082        );
1083    }
1084
1085    #[test]
1086    fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
1087        let round = ReviewRound {
1088            round: 1,
1089            head: "h".to_owned(),
1090            verified_head: None,
1091            verified_at: None,
1092            reviews: vec![
1093                ReviewRecord {
1094                    attempts: 0,
1095                    reviewer: 1,
1096                    agent: "alpha".to_owned(),
1097                    summary: String::new(),
1098                    findings: Vec::new(),
1099                    vote: None,
1100                    failed: None,
1101                    duration_ms: 0,
1102                },
1103                ReviewRecord {
1104                    attempts: 0,
1105                    reviewer: 2,
1106                    agent: "beta".to_owned(),
1107                    summary: String::new(),
1108                    findings: Vec::new(),
1109                    vote: None,
1110                    failed: Some("agent timed out".to_owned()),
1111                    duration_ms: 0,
1112                },
1113            ],
1114            e2e: Vec::new(),
1115            verify_retried: false,
1116            e2e_deferred: false,
1117            e2e_defer_reason: None,
1118            fix: None,
1119            blocking: 0,
1120            answered: 1,
1121            expected: 2,
1122            clean: false,
1123            progressed: false,
1124            vote_split: false,
1125            reconsideration: Vec::new(),
1126            verdict: None,
1127        };
1128        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
1129
1130        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
1131        assert_eq!(alpha.seated, 1);
1132        assert_eq!(alpha.rounds, 1);
1133        assert_eq!(alpha.timeouts, 0);
1134        assert_eq!(alpha.submitted, 0);
1135
1136        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1137        assert_eq!(beta.seated, 1);
1138        assert_eq!(beta.timeouts, 1);
1139        assert_eq!(beta.submitted, 0);
1140        // A timeout must never read as a submission with nothing found: it
1141        // stays out of the scoring denominators entirely rather than becoming
1142        // a 0/0 that looks identical to a reviewer who answered and passed.
1143        assert_eq!(beta.rounds, 0);
1144        assert_eq!(beta.timeout_rate(), 100.0);
1145    }
1146
1147    #[test]
1148    fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
1149        // Two independent gaps in one round: `beta` never answered, and the
1150        // fixer's adoption report never came back. The lost report suppresses
1151        // adoption scoring (see `a_lost_fix_report_does_not_count_as_zero_
1152        // adoption`) — it must not also swallow the fact that a seat was
1153        // silent, which is a property of the seat and not of the fixer.
1154        let round = ReviewRound {
1155            round: 1,
1156            head: "h".to_owned(),
1157            verified_head: None,
1158            verified_at: None,
1159            reviews: vec![
1160                ReviewRecord {
1161                    attempts: 0,
1162                    reviewer: 1,
1163                    agent: "alpha".to_owned(),
1164                    summary: String::new(),
1165                    findings: vec![finding(
1166                        "R1-1-1",
1167                        "src/a.rs",
1168                        10,
1169                        "panics on empty",
1170                        Severity::Blocker,
1171                    )],
1172                    vote: None,
1173                    failed: None,
1174                    duration_ms: 0,
1175                },
1176                ReviewRecord {
1177                    attempts: 0,
1178                    reviewer: 2,
1179                    agent: "beta".to_owned(),
1180                    summary: String::new(),
1181                    findings: Vec::new(),
1182                    vote: None,
1183                    failed: Some("agent timed out".to_owned()),
1184                    duration_ms: 0,
1185                },
1186            ],
1187            e2e: Vec::new(),
1188            verify_retried: false,
1189            e2e_deferred: false,
1190            e2e_defer_reason: None,
1191            fix: Some(FixRecord {
1192                agent: "alpha".to_owned(),
1193                addressed: Vec::new(),
1194                rejected: Vec::new(),
1195                notes: String::new(),
1196                committed: true,
1197                failed: Some("unparsable fix report".to_owned()),
1198                duration_ms: 0,
1199                continuation: None,
1200            }),
1201            blocking: 1,
1202            answered: 1,
1203            expected: 2,
1204            clean: false,
1205            progressed: false,
1206            vote_split: false,
1207            reconsideration: Vec::new(),
1208            verdict: None,
1209        };
1210        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
1211
1212        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1213        assert_eq!(beta.timeouts, 1);
1214        assert_eq!(beta.timeout_rate(), 100.0);
1215        // `alpha` answered, so the lost report keeps it out of the table
1216        // altogether — nothing about its findings can be scored.
1217        assert!(
1218            !stats.reviewers.iter().any(|r| r.agent == "alpha"),
1219            "{:?}",
1220            stats.reviewers
1221        );
1222    }
1223
1224    #[test]
1225    fn e2e_sole_detection_needs_a_clean_static_review() {
1226        let fail = CommandOutcome {
1227            command: "cargo test".to_owned(),
1228            code: Some(101),
1229            output_tail: "boom".to_owned(),
1230            duration_ms: 1,
1231            resource_blocked: false,
1232        };
1233        let sole = ReviewRound {
1234            round: 1,
1235            head: "h".to_owned(),
1236            verified_head: None,
1237            verified_at: None,
1238            reviews: Vec::new(),
1239            e2e: vec![fail.clone()],
1240            verify_retried: false,
1241            e2e_deferred: false,
1242            e2e_defer_reason: None,
1243            fix: None,
1244            blocking: 0,
1245            answered: 0,
1246            expected: 0,
1247            clean: false,
1248            progressed: false,
1249            vote_split: false,
1250            reconsideration: Vec::new(),
1251            verdict: None,
1252        };
1253        let alongside = ReviewRound {
1254            round: 2,
1255            head: "h".to_owned(),
1256            verified_head: None,
1257            verified_at: None,
1258            reviews: Vec::new(),
1259            e2e: vec![fail],
1260            verify_retried: false,
1261            e2e_deferred: false,
1262            e2e_defer_reason: None,
1263            fix: None,
1264            blocking: 2,
1265            answered: 0,
1266            expected: 0,
1267            clean: false,
1268            progressed: false,
1269            vote_split: false,
1270            reconsideration: Vec::new(),
1271            verdict: None,
1272        };
1273        let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
1274        assert_eq!(stats.e2e.rounds, 2);
1275        assert_eq!(stats.e2e.failures, 2);
1276        assert_eq!(stats.e2e.sole_detections, 1);
1277        assert_eq!(stats.e2e.sole_rate(), 50.0);
1278    }
1279
1280    #[test]
1281    fn every_run_status_lands_in_exactly_one_breakdown_bucket() {
1282        let states = vec![
1283            state_with(Vec::new(), 'A', RunStatus::Merged),
1284            state_with(Vec::new(), 'A', RunStatus::Ready),
1285            state_with(Vec::new(), 'A', RunStatus::Blocked),
1286            state_with(Vec::new(), 'A', RunStatus::Failed),
1287            state_with(Vec::new(), 'A', RunStatus::Stalled),
1288            state_with(Vec::new(), 'A', RunStatus::VerifiedNoop),
1289            state_with(Vec::new(), 'A', RunStatus::Superseded),
1290            state_with(Vec::new(), 'A', RunStatus::Implementing),
1291            state_with(Vec::new(), 'A', RunStatus::Landing),
1292        ];
1293        let stats = collect(&states);
1294        let t = &stats.totals;
1295        assert_eq!(t.runs, 9);
1296        assert_eq!(t.merged, 1);
1297        assert_eq!(t.ready, 1);
1298        assert_eq!(t.blocked, 1);
1299        assert_eq!(t.failed, 1);
1300        assert_eq!(t.stalled, 1);
1301        assert_eq!(t.verified_noop, 1);
1302        assert_eq!(t.superseded, 1);
1303        // `Implementing` and `Landing` both fall into the one non-terminal
1304        // bucket.
1305        assert_eq!(t.in_progress, 2);
1306        assert_eq!(
1307            t.merged
1308                + t.ready
1309                + t.blocked
1310                + t.failed
1311                + t.stalled
1312                + t.verified_noop
1313                + t.superseded
1314                + t.in_progress,
1315            t.runs,
1316            "every run must land in exactly one bucket of the breakdown"
1317        );
1318    }
1319
1320    #[test]
1321    fn empty_input_yields_zeroed_rates_not_nan() {
1322        let stats = collect(&[]);
1323        assert_eq!(stats.totals.completion_rate(), 0.0);
1324        assert_eq!(stats.totals.split_rate(), 0.0);
1325        assert_eq!(stats.e2e.sole_rate(), 0.0);
1326        assert!(stats.agents.is_empty());
1327        assert!(stats.advisors.is_empty());
1328        assert_eq!(AdvisorStats::default().reflection_rate(), 0.0);
1329    }
1330
1331    fn advisor_record(
1332        seat: &str,
1333        agent: &str,
1334        proposal: Option<crate::verdict::Proposal>,
1335        reflection: crate::advise::Reflection,
1336    ) -> crate::advise::AdvisorRecord {
1337        crate::advise::AdvisorRecord {
1338            seat: seat.to_owned(),
1339            agent: agent.to_owned(),
1340            proposal,
1341            error: None,
1342            duration_ms: 0,
1343            reflection,
1344        }
1345    }
1346
1347    fn a_proposal() -> crate::verdict::Proposal {
1348        crate::verdict::Proposal {
1349            approach: "do the thing".to_owned(),
1350            key_tradeoff: "speed over memory".to_owned(),
1351            risks: Vec::new(),
1352            touches: Vec::new(),
1353            why_not_naive: "the naive version breaks under load".to_owned(),
1354        }
1355    }
1356
1357    #[test]
1358    fn advisor_stats_count_proposed_absent_and_reflection_split() {
1359        use crate::advise::{Advice, Reflection};
1360
1361        let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1362        s.advice = Some(Advice {
1363            records: vec![
1364                advisor_record("advisor-1", "alpha", Some(a_proposal()), Reflection::Strong),
1365                advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1366                advisor_record("advisor-3", "alpha", None, Reflection::Absent),
1367            ],
1368            synthesis: Some("blended brief".to_owned()),
1369        });
1370
1371        let stats = collect(&[s]);
1372        let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1373        assert_eq!(alpha.seated, 3);
1374        assert_eq!(alpha.proposed, 2);
1375        assert_eq!(alpha.absent, 1);
1376        assert_eq!(alpha.strong, 1);
1377        assert_eq!(alpha.faint, 1);
1378        assert_eq!(alpha.reflection_rate(), 50.0);
1379    }
1380
1381    #[test]
1382    fn advisor_stats_count_absent_from_the_proposal_not_the_reflection_default() {
1383        // A record whose `proposal` is `None` but whose `reflection` was
1384        // never classified (predates `apply_reflection`, or the field's own
1385        // serde default) must still count as `absent` — and a run whose
1386        // synthesis never ran leaves every *proposed* record `Faint` by the
1387        // same default, which must not spill into `absent` either.
1388        //
1389        // A third case: a proposed record whose `reflection` was *never*
1390        // classified at all (a run.json predating the `reflection` field)
1391        // must not vanish from the breakdown either — it has to land
1392        // somewhere in faint/strong, not be silently dropped from all three
1393        // counters while still counting toward `proposed`.
1394        use crate::advise::{Advice, Reflection};
1395
1396        let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1397        s.advice = Some(Advice {
1398            records: vec![
1399                advisor_record("advisor-1", "alpha", None, Reflection::Absent),
1400                advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1401                advisor_record("advisor-3", "alpha", Some(a_proposal()), Reflection::Absent),
1402            ],
1403            synthesis: None,
1404        });
1405
1406        let stats = collect(&[s]);
1407        let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1408        assert_eq!(alpha.seated, 3);
1409        assert_eq!(alpha.proposed, 2);
1410        assert_eq!(alpha.absent, 1);
1411        assert_eq!(alpha.faint, 2);
1412        assert_eq!(alpha.strong, 0);
1413    }
1414
1415    #[test]
1416    fn advisor_stats_ignore_runs_with_advise_off() {
1417        let s = state_with(Vec::new(), 'A', RunStatus::Merged);
1418        assert!(s.advice.is_none());
1419        let stats = collect(&[s]);
1420        assert!(stats.advisors.is_empty());
1421    }
1422
1423    #[test]
1424    fn same_defect_matches_titles_across_files() {
1425        let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
1426        let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
1427        assert!(same_defect(&a, &b));
1428        let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
1429        assert!(!same_defect(&a, &c));
1430    }
1431
1432    #[test]
1433    fn release_bump_stats_split_clean_from_attention_and_track_coverage() {
1434        use crate::run::ReleaseBump;
1435
1436        // Not recorded at all: uses the release-bump step? unknown.
1437        let unrecorded = state_with(Vec::new(), 'A', RunStatus::Merged);
1438
1439        // Clean: automerge worked, nobody had to look at it.
1440        let mut automerged = state_with(Vec::new(), 'A', RunStatus::Merged);
1441        automerged.release_bump = Some(ReleaseBump {
1442            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
1443            version: Some("1.2.3".to_owned()),
1444            automerge_enabled: true,
1445            merged_directly: false,
1446            local: false,
1447            release: None,
1448            problem: None,
1449            action_required: None,
1450        });
1451
1452        // Also clean, but automerge itself was rejected by GitHub and magi
1453        // merged the already-green PR directly - `automerge_enabled` reads
1454        // false here, and that must not make this count as needing a human.
1455        let mut merged_directly = state_with(Vec::new(), 'A', RunStatus::Merged);
1456        merged_directly.release_bump = Some(ReleaseBump {
1457            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
1458            version: Some("1.2.4".to_owned()),
1459            automerge_enabled: false,
1460            merged_directly: true,
1461            local: false,
1462            release: None,
1463            problem: None,
1464            action_required: None,
1465        });
1466
1467        // Needs a human, PR opened.
1468        let mut blocked_with_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1469        blocked_with_pr.release_bump = Some(ReleaseBump {
1470            pr_url: Some("https://github.com/o/r/pull/3".to_owned()),
1471            version: Some("1.2.5".to_owned()),
1472            automerge_enabled: false,
1473            merged_directly: false,
1474            local: false,
1475            release: None,
1476            problem: Some("checks red".to_owned()),
1477            action_required: Some("look at the PR".to_owned()),
1478        });
1479
1480        // Needs a human, no PR ever opened.
1481        let mut blocked_without_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1482        blocked_without_pr.release_bump = Some(ReleaseBump {
1483            pr_url: None,
1484            version: Some("1.2.6".to_owned()),
1485            automerge_enabled: false,
1486            merged_directly: false,
1487            local: false,
1488            release: None,
1489            problem: Some("gh pr create failed".to_owned()),
1490            action_required: Some("open the PR by hand".to_owned()),
1491        });
1492
1493        let stats = collect(&[
1494            unrecorded,
1495            automerged,
1496            merged_directly,
1497            blocked_with_pr,
1498            blocked_without_pr,
1499        ]);
1500        let b = &stats.release_bumps;
1501        assert_eq!(b.merged, 5);
1502        assert_eq!(b.recorded, 4);
1503        assert_eq!(b.pr_opened, 3);
1504        assert_eq!(b.automerge_enabled, 1);
1505        assert_eq!(b.merged_directly, 1);
1506        assert_eq!(b.needs_attention, 2);
1507        assert_eq!(b.clean(), 2);
1508        // Clean and attention must always split `recorded` exactly.
1509        assert_eq!(b.clean() + b.needs_attention, b.recorded);
1510        assert_eq!(b.coverage_rate(), 80.0);
1511        assert!((b.automerge_rate() - 33.333_333_333_333_336).abs() < 1e-9);
1512        assert_eq!(b.attention_rate(), 50.0);
1513    }
1514
1515    #[test]
1516    fn release_bump_ignores_runs_that_are_not_merged() {
1517        use crate::run::ReleaseBump;
1518
1519        let mut blocked = state_with(Vec::new(), 'A', RunStatus::Blocked);
1520        blocked.release_bump = Some(ReleaseBump {
1521            pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
1522            version: Some("9.9.9".to_owned()),
1523            automerge_enabled: true,
1524            merged_directly: false,
1525            local: false,
1526            release: None,
1527            problem: None,
1528            action_required: None,
1529        });
1530
1531        let stats = collect(&[blocked]);
1532        let b = &stats.release_bumps;
1533        assert_eq!(b.merged, 0);
1534        assert_eq!(b.recorded, 0);
1535        assert_eq!(b.pr_opened, 0);
1536    }
1537
1538    #[test]
1539    fn release_bump_stats_are_zero_on_merged_runs_with_no_bump_or_no_runs() {
1540        let stats = collect(&[state_with(Vec::new(), 'A', RunStatus::Merged)]);
1541        let b = &stats.release_bumps;
1542        assert_eq!(b.merged, 1);
1543        assert_eq!(b.recorded, 0);
1544        assert_eq!(b.coverage_rate(), 0.0);
1545        assert_eq!(b.automerge_rate(), 0.0);
1546        assert_eq!(b.attention_rate(), 0.0);
1547        assert_eq!(b.clean(), 0);
1548
1549        let empty = collect(&[]);
1550        let b = &empty.release_bumps;
1551        assert_eq!(b.merged, 0);
1552        assert_eq!(b.coverage_rate(), 0.0);
1553        assert_eq!(b.automerge_rate(), 0.0);
1554        assert_eq!(b.attention_rate(), 0.0);
1555    }
1556
1557    fn event(node: &str, at_secs: i64, message: &str) -> crate::run::Event {
1558        crate::run::Event {
1559            at: jiff::Timestamp::from_second(at_secs).unwrap(),
1560            node: node.to_owned(),
1561            message: message.to_owned(),
1562        }
1563    }
1564
1565    fn state_with_events(events: Vec<crate::run::Event>) -> RunState {
1566        let mut s = RunState::new(
1567            PathBuf::from("/repo"),
1568            "main".to_owned(),
1569            "abcdef".to_owned(),
1570            "task".to_owned(),
1571            Config::default(),
1572        );
1573        s.events = events;
1574        s
1575    }
1576
1577    #[test]
1578    fn a_node_with_multiple_events_spans_first_to_last() {
1579        let s = state_with_events(vec![
1580            event("implement", 1_000, "start"),
1581            event("implement", 1_030, "still running"),
1582            event("implement", 1_090, "done"),
1583        ]);
1584        let nodes = node_durations(&[s]);
1585        assert_eq!(nodes.len(), 1);
1586        assert_eq!(nodes[0].node, "implement");
1587        assert_eq!(nodes[0].runs, 1);
1588        assert_eq!(nodes[0].total_secs, 90);
1589        assert_eq!(nodes[0].max_secs, 90);
1590        assert_eq!(nodes[0].single, 0);
1591        assert_eq!(nodes[0].mean_secs(), 90.0);
1592    }
1593
1594    #[test]
1595    fn a_node_with_a_single_event_is_unmeasured_not_zero() {
1596        let s = state_with_events(vec![event("gate", 2_000, "ran once")]);
1597        let nodes = node_durations(&[s]);
1598        assert_eq!(nodes.len(), 1);
1599        assert_eq!(nodes[0].node, "gate");
1600        assert_eq!(nodes[0].runs, 0);
1601        assert_eq!(nodes[0].total_secs, 0);
1602        assert_eq!(nodes[0].single, 1);
1603        // No measured runs: the mean must read as zero, never NaN or a
1604        // fabricated span from the lone event.
1605        assert_eq!(nodes[0].mean_secs(), 0.0);
1606    }
1607
1608    #[test]
1609    fn a_run_with_no_events_produces_no_node_rows() {
1610        let s = state_with_events(vec![]);
1611        let nodes = node_durations(&[s]);
1612        assert!(nodes.is_empty());
1613    }
1614
1615    #[test]
1616    fn multiple_runs_aggregate_the_same_node() {
1617        let a = state_with_events(vec![event("judge", 0, "start"), event("judge", 60, "done")]);
1618        let b = state_with_events(vec![
1619            event("judge", 0, "start"),
1620            event("judge", 200, "done"),
1621        ]);
1622        // A single-event run for the same node must add to `single` without
1623        // disturbing the measured runs' total or max.
1624        let c = state_with_events(vec![event("judge", 5, "start")]);
1625        let nodes = node_durations(&[a, b, c]);
1626        assert_eq!(nodes.len(), 1);
1627        let judge = &nodes[0];
1628        assert_eq!(judge.node, "judge");
1629        assert_eq!(judge.runs, 2);
1630        assert_eq!(judge.total_secs, 260);
1631        assert_eq!(judge.max_secs, 200);
1632        assert_eq!(judge.single, 1);
1633        assert_eq!(judge.mean_secs(), 130.0);
1634    }
1635
1636    #[test]
1637    fn by_repo_splits_states_and_group_totals_sum_to_the_whole() {
1638        let states = vec![
1639            state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1640            state_with_repo(Vec::new(), 'A', RunStatus::Blocked, "/repos/a"),
1641            state_with_repo(Vec::new(), 'B', RunStatus::Merged, "/repos/b"),
1642        ];
1643        let groups = by_repo(&states);
1644        assert_eq!(groups.len(), 2);
1645
1646        let total_runs: usize = groups.iter().map(|g| g.stats.totals.runs).sum();
1647        assert_eq!(total_runs, collect(&states).totals.runs);
1648        let total_merged: usize = groups.iter().map(|g| g.stats.totals.merged).sum();
1649        assert_eq!(total_merged, collect(&states).totals.merged);
1650
1651        // Busiest repository (2 runs) sorts first.
1652        assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
1653        assert_eq!(groups[0].name, "a");
1654        assert_eq!(groups[0].stats.totals.runs, 2);
1655        assert_eq!(groups[1].repo, PathBuf::from("/repos/b"));
1656        assert_eq!(groups[1].name, "b");
1657        assert_eq!(groups[1].stats.totals.runs, 1);
1658    }
1659
1660    #[test]
1661    fn by_repo_breaks_a_run_count_tie_by_path() {
1662        let states = vec![
1663            state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/z"),
1664            state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1665        ];
1666        let groups = by_repo(&states);
1667        assert_eq!(groups.len(), 2);
1668        assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
1669        assert_eq!(groups[1].repo, PathBuf::from("/repos/z"));
1670    }
1671
1672    #[test]
1673    fn by_repo_on_empty_input_yields_no_groups() {
1674        assert!(by_repo(&[]).is_empty());
1675    }
1676
1677    #[test]
1678    fn filter_repo_matches_the_full_path_exactly() {
1679        let states = vec![
1680            state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1681            state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/ab"),
1682        ];
1683        let hits = filter_repo(&states, Path::new("/repos/a"));
1684        assert_eq!(hits.len(), 1);
1685        assert_eq!(hits[0].repo, PathBuf::from("/repos/a"));
1686    }
1687
1688    #[test]
1689    fn filter_repo_returns_nothing_for_an_unknown_repo_or_empty_input() {
1690        let states = vec![state_with_repo(
1691            Vec::new(),
1692            'A',
1693            RunStatus::Merged,
1694            "/repos/a",
1695        )];
1696        assert!(filter_repo(&states, Path::new("/repos/nope")).is_empty());
1697        assert!(filter_repo(&[], Path::new("/repos/a")).is_empty());
1698    }
1699
1700    #[test]
1701    fn daily_cuts_by_local_date_zero_fills_and_ignores_the_window_outside() {
1702        let tz = jiff::tz::TimeZone::fixed(jiff::tz::offset(9));
1703        let today = jiff::civil::date(2026, 3, 2);
1704        let at = |ts: &str, status| {
1705            let mut s = state_with(Vec::new(), 'A', status);
1706            s.created_at = ts.parse().unwrap();
1707            s
1708        };
1709        let states = vec![
1710            // 23:30 UTC on the 1st is 08:30 on the 2nd at +09:00.
1711            at("2026-03-01T23:30:00Z", RunStatus::Merged),
1712            at("2026-03-02T01:00:00Z", RunStatus::Ready),
1713            at("2026-03-02T02:00:00Z", RunStatus::Implementing),
1714            at("2026-03-02T03:00:00Z", RunStatus::Failed),
1715            // Last day of the window across a month boundary (Feb has 28).
1716            at("2026-02-01T00:00:00Z", RunStatus::Merged),
1717            // Outside the window, and in the future.
1718            at("2026-01-01T00:00:00Z", RunStatus::Merged),
1719            at("2026-03-05T00:00:00Z", RunStatus::Merged),
1720        ];
1721        let d = daily(&states, today, &tz, 30);
1722        assert_eq!(d.len(), 30);
1723        assert_eq!(d.last().unwrap().date, today);
1724        assert_eq!(d[0].date, jiff::civil::date(2026, 2, 1));
1725        assert!(d.windows(2).all(|w| w[0].date < w[1].date));
1726        let last = d.last().unwrap();
1727        assert_eq!(
1728            (last.runs, last.merged, last.ready, last.other),
1729            (4, 1, 1, 2)
1730        );
1731        assert_eq!(d[0].runs, 1);
1732        assert_eq!(d.iter().map(|b| b.runs).sum::<usize>(), 5);
1733        assert!(d.iter().all(|b| b.merged + b.ready + b.other == b.runs));
1734    }
1735
1736    #[test]
1737    fn daily_survives_a_dst_boundary() {
1738        let tz = jiff::tz::TimeZone::get("America/New_York").unwrap();
1739        let today = jiff::civil::date(2026, 3, 9);
1740        let d = daily(&[], today, &tz, 30);
1741        assert_eq!(d.len(), 30);
1742        assert!(
1743            d.windows(2)
1744                .all(|w| w[0].date.tomorrow().unwrap() == w[1].date)
1745        );
1746    }
1747}