Skip to main content

magi/
stats.rs

1//! Aggregate statistics over every recorded run.
2//!
3//! These tables are a by-product of running the graph, not a benchmark. The
4//! seat assignment rotates, the task distribution is whatever the operator
5//! happened to ask for, and a model that draws harder tasks looks worse. Read
6//! them as "relative performance on my workload", which is the only claim the
7//! data supports.
8use std::collections::BTreeMap;
9
10use crate::run::{RunState, RunStatus, list_ids};
11
12/// Implementation record for one agent.
13#[derive(Debug, Clone, Default)]
14pub struct AgentStats {
15    /// Agent id.
16    pub agent: String,
17    /// Candidates it produced that were judged.
18    pub entered: usize,
19    /// Competitions it won.
20    pub wins: usize,
21    /// Candidates that produced no change at all.
22    pub empty: usize,
23}
24
25impl AgentStats {
26    /// Win rate over entries, as a percentage.
27    pub fn win_rate(&self) -> f64 {
28        if self.entered == 0 {
29            0.0
30        } else {
31            100.0 * self.wins as f64 / self.entered as f64
32        }
33    }
34}
35
36/// Review record for one agent.
37#[derive(Debug, Clone, Default)]
38pub struct ReviewerStats {
39    /// Agent id.
40    pub agent: String,
41    /// Review rounds it sat in whose adoption could be scored — a round whose
42    /// fixer never reported back is excluded, so this is the denominator of
43    /// [`Self::adopted_per_round`], not a headcount of appearances. For that,
44    /// see [`Self::seated`].
45    pub rounds: usize,
46    /// Review rounds it was on the panel for at all, scoreable or not.
47    /// Whether a seat answered is a fact about the seat and does not depend
48    /// on what later became of the fixer's report, so this — not `rounds` —
49    /// is the honest denominator for [`Self::timeout_rate`].
50    pub seated: usize,
51    /// Findings it submitted.
52    pub submitted: usize,
53    /// Findings the fixer acted on.
54    pub adopted: usize,
55    /// Findings no other reviewer in the same round also raised.
56    pub unique: usize,
57    /// Rounds it was seated in but never answered (timeout, crash, unparsable
58    /// output) — kept apart from `submitted`/`adopted` so a silent seat
59    /// cannot read as a seat with nothing to say.
60    pub timeouts: usize,
61}
62
63impl ReviewerStats {
64    /// Adopted findings per round: how much signal one seat produces.
65    pub fn adopted_per_round(&self) -> f64 {
66        if self.rounds == 0 {
67            0.0
68        } else {
69            self.adopted as f64 / self.rounds as f64
70        }
71    }
72
73    /// Adopted over submitted: how often its findings are real. Rounds where
74    /// the seat never answered are not in `submitted`, so a timeout cannot
75    /// dilute (or hide behind) this rate.
76    pub fn precision(&self) -> f64 {
77        if self.submitted == 0 {
78            0.0
79        } else {
80            100.0 * self.adopted as f64 / self.submitted as f64
81        }
82    }
83
84    /// Share of its findings that only it saw.
85    pub fn unique_rate(&self) -> f64 {
86        if self.submitted == 0 {
87            0.0
88        } else {
89            100.0 * self.unique as f64 / self.submitted as f64
90        }
91    }
92
93    /// Share of the rounds it was seated in where it never answered.
94    pub fn timeout_rate(&self) -> f64 {
95        if self.seated == 0 {
96            0.0
97        } else {
98            100.0 * self.timeouts as f64 / self.seated as f64
99        }
100    }
101}
102
103/// Design-deliberation record for one agent.
104///
105/// Approximate by construction, same as [`crate::advise::Reflection`] itself:
106/// `strong`/`faint` come from a word-overlap heuristic against the synthesis
107/// brief, not from an explicit attribution, so [`Self::reflection_rate`] is a
108/// rough read on whose ideas seemed to land, not a precise credit split.
109#[derive(Debug, Clone, Default)]
110pub struct AdvisorStats {
111    /// Agent id.
112    pub agent: String,
113    /// Advisor seats it occupied, across every run with `[graph] advise` on.
114    pub seated: usize,
115    /// Seats where it produced a usable proposal (`record.proposal.is_some()`).
116    pub proposed: usize,
117    /// Seats where it produced no usable proposal at all (crashed, timed
118    /// out, or answered with nothing a proposal could be parsed out of).
119    /// Counted from `record.proposal.is_none()` directly rather than from
120    /// `record.reflection == Absent` — `reflection` is `#[serde(default)]`
121    /// and can be left at its default on a record from before
122    /// `apply_reflection` ran, which would otherwise double as a false
123    /// "absent".
124    pub absent: usize,
125    /// Proposals the synthesis brief carried little or no recognisable
126    /// trace of.
127    pub faint: usize,
128    /// Proposals the synthesis brief named outright or carried enough of to
129    /// count as a clear match.
130    pub strong: usize,
131}
132
133impl AdvisorStats {
134    /// Share of its proposals rated `Strong`, as a percentage. The
135    /// denominator is `proposed`, not `seated` — a seat that never produced
136    /// a proposal had nothing for the synthesis to reflect, so it cannot
137    /// count against this rate any more than a reviewer's silence counts
138    /// against [`ReviewerStats::precision`].
139    ///
140    /// Same caveat as the struct itself: this is a heuristic read on
141    /// reflection, not a precise attribution.
142    pub fn reflection_rate(&self) -> f64 {
143        if self.proposed == 0 {
144            0.0
145        } else {
146            100.0 * self.strong as f64 / self.proposed as f64
147        }
148    }
149}
150
151/// What real-machine verification caught that static review did not.
152#[derive(Debug, Clone, Default)]
153pub struct E2eStats {
154    /// Rounds where E2E commands ran.
155    pub rounds: usize,
156    /// Rounds where E2E failed.
157    pub failures: usize,
158    /// Rounds where E2E failed and no reviewer had raised a blocking finding —
159    /// a runtime defect that only execution found.
160    pub sole_detections: usize,
161    /// Rounds where E2E was deferred rather than run: blocking findings
162    /// already required a fix, so the round went straight to the fixer
163    /// instead of spending a full verify run on a head about to change. Kept
164    /// separate from [`Self::rounds`] on purpose — a deferred round never
165    /// ran anything, so counting it there would misreport how often E2E
166    /// actually executed.
167    pub deferred: usize,
168}
169
170impl E2eStats {
171    /// Share of E2E failures that static review had missed entirely.
172    pub fn sole_rate(&self) -> f64 {
173        if self.failures == 0 {
174            0.0
175        } else {
176            100.0 * self.sole_detections as f64 / self.failures as f64
177        }
178    }
179}
180
181/// Run-level counters.
182#[derive(Debug, Clone, Default)]
183pub struct Totals {
184    /// Runs on disk.
185    pub runs: usize,
186    /// Reached a merge.
187    pub merged: usize,
188    /// Passed the gate, merge not requested.
189    pub ready: usize,
190    /// Stopped with findings open or a red gate.
191    pub blocked: usize,
192    /// Could not complete.
193    pub failed: usize,
194    /// The judging panel never reached a quorum; the work is kept but the
195    /// verdict is not trustworthy. Never counted as `blocked` — see
196    /// `RunStatus::Stalled`'s own doc — so it needs its own counter to stay
197    /// visible in a breakdown rather than vanishing from every bucket.
198    pub stalled: usize,
199    /// Every candidate wrote nothing, and said why in a way that survived
200    /// the adoption guard: a claim, not a failure, and not yet verified.
201    pub verified_noop: usize,
202    /// A later attempt at the same task already finished it; this run's own
203    /// `blocked`/`stalled` no longer needs anyone's attention.
204    pub superseded: usize,
205    /// Still moving: any non-terminal status (prep through landing). Kept as
206    /// one bucket rather than one counter per node — the per-node position of
207    /// a live run belongs to `magi show`/the deck, not to a workload-wide
208    /// tally that is read well after the run in question has finished.
209    pub in_progress: usize,
210    /// Runs that reached a tally.
211    pub tallied: usize,
212    /// Tallies where the judges' first choices disagreed.
213    pub split: usize,
214    /// Tallies that went through deliberation.
215    pub deliberated: usize,
216    /// Deliberated runs where at least one judge moved.
217    pub minds_changed: usize,
218    /// Deliberated runs that ended unanimous.
219    pub converged: usize,
220    /// Review rounds across all runs.
221    pub review_rounds: usize,
222}
223
224impl Totals {
225    /// Merged or ready over all runs.
226    pub fn completion_rate(&self) -> f64 {
227        if self.runs == 0 {
228            0.0
229        } else {
230            100.0 * (self.merged + self.ready) as f64 / self.runs as f64
231        }
232    }
233
234    /// Share of tallies that were split.
235    pub fn split_rate(&self) -> f64 {
236        if self.tallied == 0 {
237            0.0
238        } else {
239            100.0 * self.split as f64 / self.tallied as f64
240        }
241    }
242}
243
244/// Per-node duration breakdown, aggregated across every loaded run.
245///
246/// A duration here is the span between a node's *first* and *last* recorded
247/// event within one run — not time actually spent working. A node visited
248/// more than twice in one run (a retry, or a park/resume gap) has any idle
249/// time in between folded into that span, so this reads as an upper bound on
250/// the node's wall-clock cost, not a measurement of it.
251#[derive(Debug, Clone, Default, PartialEq)]
252pub struct NodeDuration {
253    /// Node name, as it appears in `Event::node`.
254    pub node: String,
255    /// Runs where the node's span could be measured (at least two events).
256    pub runs: usize,
257    /// Sum of every measured run's span, in seconds.
258    pub total_secs: i64,
259    /// The longest single run's span, in seconds.
260    pub max_secs: i64,
261    /// Runs where the node had exactly one event — visited, but with no
262    /// second timestamp to measure a span against. Counted apart from `runs`
263    /// so a caller never has to guess whether a low mean hides unmeasured
264    /// visits.
265    pub single: usize,
266}
267
268impl NodeDuration {
269    /// Mean span across the runs that could be measured, in seconds.
270    ///
271    /// `0.0` when `runs` is zero, never a division by zero.
272    pub fn mean_secs(&self) -> f64 {
273        if self.runs == 0 {
274            0.0
275        } else {
276            self.total_secs as f64 / self.runs as f64
277        }
278    }
279}
280
281/// Derive a per-node duration breakdown from every loaded run's events.
282///
283/// For each run, a node's span is the gap between its earliest and latest
284/// [`crate::run::Event`] in that run. A node with fewer than two events in a
285/// run contributes no span (see [`NodeDuration::single`] for the one-event
286/// case); a run with no events for a node contributes nothing at all. Spans
287/// from every run are then summed per node.
288pub fn node_durations(states: &[RunState]) -> Vec<NodeDuration> {
289    let mut nodes: BTreeMap<String, NodeDuration> = BTreeMap::new();
290
291    for state in states {
292        let mut spans: BTreeMap<&str, (jiff::Timestamp, jiff::Timestamp, usize)> = BTreeMap::new();
293        for e in &state.events {
294            spans
295                .entry(e.node.as_str())
296                .and_modify(|(min, max, count)| {
297                    if e.at < *min {
298                        *min = e.at;
299                    }
300                    if e.at > *max {
301                        *max = e.at;
302                    }
303                    *count += 1;
304                })
305                .or_insert((e.at, e.at, 1));
306        }
307
308        for (node, (min, max, count)) in spans {
309            let entry = nodes
310                .entry(node.to_owned())
311                .or_insert_with(|| NodeDuration {
312                    node: node.to_owned(),
313                    ..NodeDuration::default()
314                });
315            if count < 2 {
316                entry.single += 1;
317                continue;
318            }
319            // `max - min` is never negative: both are the extremes of the
320            // same event set, and equal timestamps yield a zero-second span
321            // rather than being mistaken for an unmeasured visit.
322            let span_secs = (max - min).get_seconds();
323            entry.runs += 1;
324            entry.total_secs += span_secs;
325            if span_secs > entry.max_secs {
326                entry.max_secs = span_secs;
327            }
328        }
329    }
330
331    let mut nodes: Vec<NodeDuration> = nodes.into_values().collect();
332    nodes.sort_by(|a, b| b.total_secs.cmp(&a.total_secs).then(a.node.cmp(&b.node)));
333    nodes
334}
335
336/// What the post-merge release-bump step did, across every merged run.
337///
338/// `merged` is the denominator for [`Self::coverage_rate`]: a repository
339/// that never uses the release-bump step (no `auto-tag.yml`, see
340/// `kata:agents:rust:*`) should read as "0 of N merged runs recorded a
341/// bump", not vanish from the report the way it would if `recorded` were the
342/// denominator instead — silently excluding those runs would hide the
343/// coverage gap itself.
344#[derive(Debug, Clone, Default)]
345pub struct ReleaseBumpStats {
346    /// Merged runs, the denominator for [`Self::coverage_rate`].
347    pub merged: usize,
348    /// Merged runs that carry a [`crate::run::ReleaseBump`] record at all.
349    pub recorded: usize,
350    /// Recorded bumps that opened a release pull request.
351    pub pr_opened: usize,
352    /// Recorded bumps whose `automerge_enabled` is `true` at the point the
353    /// bump step finished. This is the *final* state, not "was automerge
354    /// enabled at some point" — `bump::surface_problem` flips it back to
355    /// `false` when GitHub later rejects automerge, so a bump that briefly
356    /// enabled it and then had it rejected counts here as not enabled.
357    pub automerge_enabled: usize,
358    /// Recorded bumps magi merged directly because GitHub refused automerge
359    /// on an already-green pull request. Counted apart from
360    /// `automerge_enabled` on purpose: a `merged_directly` bump needed no
361    /// human even though automerge itself never took, so folding it into
362    /// (or leaving it out of) the automerge count would misstate either
363    /// number.
364    pub merged_directly: usize,
365    /// Recorded bumps that ended with [`crate::run::RunState::needs_attention`]
366    /// true — a human has something to do.
367    pub needs_attention: usize,
368}
369
370impl ReleaseBumpStats {
371    /// Share of merged runs that recorded a release bump at all.
372    pub fn coverage_rate(&self) -> f64 {
373        if self.merged == 0 {
374            0.0
375        } else {
376            100.0 * self.recorded as f64 / self.merged as f64
377        }
378    }
379
380    /// Share of opened release PRs that ended with automerge enabled.
381    pub fn automerge_rate(&self) -> f64 {
382        if self.pr_opened == 0 {
383            0.0
384        } else {
385            100.0 * self.automerge_enabled as f64 / self.pr_opened as f64
386        }
387    }
388
389    /// Share of recorded bumps that needed a human.
390    pub fn attention_rate(&self) -> f64 {
391        if self.recorded == 0 {
392            0.0
393        } else {
394            100.0 * self.needs_attention as f64 / self.recorded as f64
395        }
396    }
397
398    /// Recorded bumps that finished with nothing for a human to do.
399    ///
400    /// Deliberately the difference `recorded - needs_attention`, not a
401    /// separate counter kept in step with `automerge_enabled`: a bump that
402    /// GitHub refused automerge on but that magi merged directly
403    /// (`merged_directly`) is clean — nobody had to act — even though
404    /// `automerge_enabled` is `false` for it. Counting clean bumps as
405    /// `automerge_enabled` alone would leave `merged_directly` cases in
406    /// neither the clean nor the attention bucket, and the two would stop
407    /// summing to `recorded`.
408    pub fn clean(&self) -> usize {
409        self.recorded.saturating_sub(self.needs_attention)
410    }
411}
412
413/// Everything, aggregated.
414#[derive(Debug, Clone, Default)]
415pub struct Stats {
416    /// Run counters.
417    pub totals: Totals,
418    /// Per-agent implementation record, best win rate first.
419    pub agents: Vec<AgentStats>,
420    /// Per-agent review record, most adopted-per-round first.
421    pub reviewers: Vec<ReviewerStats>,
422    /// Per-agent design-deliberation record, highest reflection rate first.
423    /// Only runs where `[graph] advise` produced an [`crate::advise::Advice`]
424    /// contribute — a run with the stage off carries no signal either way,
425    /// and counting it would water down every agent's rate with seats that
426    /// were never asked.
427    pub advisors: Vec<AdvisorStats>,
428    /// Verification record.
429    pub e2e: E2eStats,
430    /// Per-node duration breakdown, longest total first.
431    pub nodes: Vec<NodeDuration>,
432    /// Post-merge release-bump record, over every merged run.
433    pub release_bumps: ReleaseBumpStats,
434}
435
436/// Load every run on disk, skipping any that cannot be read.
437pub fn load_all() -> Vec<RunState> {
438    list_ids()
439        .into_iter()
440        .filter_map(|id| RunState::load(&id).ok())
441        .collect()
442}
443
444/// Aggregate `states`.
445pub fn collect(states: &[RunState]) -> Stats {
446    let mut totals = Totals::default();
447    let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
448    let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
449    let mut advisors: BTreeMap<String, AdvisorStats> = BTreeMap::new();
450    let mut e2e = E2eStats::default();
451    let mut release_bumps = ReleaseBumpStats::default();
452
453    for state in states {
454        totals.runs += 1;
455        if state.status == RunStatus::Merged {
456            release_bumps.merged += 1;
457            if let Some(b) = &state.release_bump {
458                release_bumps.recorded += 1;
459                if b.pr_url.is_some() {
460                    release_bumps.pr_opened += 1;
461                }
462                if b.automerge_enabled {
463                    release_bumps.automerge_enabled += 1;
464                }
465                if b.merged_directly {
466                    release_bumps.merged_directly += 1;
467                }
468            }
469            if state.needs_attention() {
470                release_bumps.needs_attention += 1;
471            }
472        }
473        match state.status {
474            RunStatus::Merged => totals.merged += 1,
475            RunStatus::Ready => totals.ready += 1,
476            RunStatus::Blocked => totals.blocked += 1,
477            RunStatus::Failed => totals.failed += 1,
478            RunStatus::Stalled => totals.stalled += 1,
479            RunStatus::VerifiedNoop => totals.verified_noop += 1,
480            // Counted on its own rather than folded into `blocked`/`stalled`:
481            // the task it belongs to already landed through a later run,
482            // which is the one this tally counts as the merge/ready outcome,
483            // and folding it back in would inflate the denominator with two
484            // outcomes for one task.
485            RunStatus::Superseded => totals.superseded += 1,
486            RunStatus::Prep
487            | RunStatus::Implementing
488            | RunStatus::Judging
489            | RunStatus::Deliberating
490            | RunStatus::Voting
491            | RunStatus::Reviewing
492            | RunStatus::Gating
493            | RunStatus::Landing => totals.in_progress += 1,
494        }
495
496        for c in &state.candidates {
497            let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
498                agent: c.agent.clone(),
499                ..AgentStats::default()
500            });
501            // A verified no-op is not counted as the ordinary empty loss it
502            // would otherwise look like: the candidate gave evidence for
503            // writing nothing, which `entry.empty` exists to flag the
504            // *absence* of.
505            if c.empty && c.verified_noop.is_none() {
506                entry.empty += 1;
507            }
508            if c.viable() {
509                entry.entered += 1;
510            }
511        }
512
513        if let Some(t) = &state.tally {
514            // A tally with no panel (`uncontested`) never split, never
515            // deliberated and never converged — it never happened, and
516            // folding it into the denominator would understate the real
517            // split rate with runs that carry no panel-agreement signal at
518            // all. The winner still earns its agent a win either way: an
519            // uncontested candidate is still the one that shipped.
520            if t.uncontested.is_none() {
521                totals.tallied += 1;
522                if !t.unanimous_initial {
523                    totals.split += 1;
524                }
525                if t.deliberated {
526                    totals.deliberated += 1;
527                    if t.changed_votes > 0 {
528                        totals.minds_changed += 1;
529                    }
530                    if t.unanimous_final {
531                        totals.converged += 1;
532                    }
533                }
534            }
535            if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
536                agents
537                    .entry(w.agent.clone())
538                    .or_insert_with(|| AgentStats {
539                        agent: w.agent.clone(),
540                        ..AgentStats::default()
541                    })
542                    .wins += 1;
543            }
544        }
545
546        if let Some(advice) = &state.advice {
547            for rec in &advice.records {
548                let entry = advisors
549                    .entry(rec.agent.clone())
550                    .or_insert_with(|| AdvisorStats {
551                        agent: rec.agent.clone(),
552                        ..AdvisorStats::default()
553                    });
554                entry.seated += 1;
555                if rec.proposal.is_none() {
556                    entry.absent += 1;
557                    continue;
558                }
559                entry.proposed += 1;
560                match rec.reflection {
561                    crate::advise::Reflection::Strong => entry.strong += 1,
562                    crate::advise::Reflection::Faint => entry.faint += 1,
563                    // A proposal exists, so this is not a real "no proposal"
564                    // reading — see `AdvisorStats::absent`'s own doc for why
565                    // that count comes from `proposal.is_none()` instead of
566                    // this field. `graph::Runner::advise` always calls
567                    // `apply_reflection` before saving, so the only way a
568                    // proposed record keeps the default `Absent` is a run.json
569                    // predating the `reflection` field. Fold it into `faint`
570                    // rather than dropping it from the breakdown entirely:
571                    // that is what `classify()` itself falls back to when
572                    // there is nothing to score against.
573                    crate::advise::Reflection::Absent => entry.faint += 1,
574                }
575            }
576        }
577
578        for round in &state.reviews {
579            totals.review_rounds += 1;
580
581            // A round whose fixer never reported back (crashed, timed out, or
582            // replied with something magi could not parse) leaves adoption
583            // unknown, not zero. Counting it would score every reviewer in
584            // that round as having been ignored, when the truth is simply
585            // unrecorded — so it stays out of the adoption-rate denominator
586            // entirely rather than silently becoming a round of 0 adoptions.
587            let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
588            let adopted: Vec<&String> = round
589                .fix
590                .as_ref()
591                .map(|f| f.addressed.iter().collect())
592                .unwrap_or_default();
593
594            for rec in &round.reviews {
595                let entry = reviewers
596                    .entry(rec.agent.clone())
597                    .or_insert_with(|| ReviewerStats {
598                        agent: rec.agent.clone(),
599                        ..ReviewerStats::default()
600                    });
601                // Seating and answering are facts about the seat itself: they
602                // hold whether or not this round's adoption is scoreable, so
603                // they are counted before the lost-report guard. A seat that
604                // never answered stays out of every scoring denominator —
605                // silence is not a review that found nothing.
606                entry.seated += 1;
607                if rec.failed.is_some() {
608                    entry.timeouts += 1;
609                    continue;
610                }
611                if report_lost {
612                    continue;
613                }
614                entry.rounds += 1;
615                entry.submitted += rec.findings.len();
616                for f in &rec.findings {
617                    if adopted.iter().any(|a| **a == f.id) {
618                        entry.adopted += 1;
619                    }
620                    let overlapped = round
621                        .reviews
622                        .iter()
623                        .filter(|other| other.reviewer != rec.reviewer)
624                        .flat_map(|other| other.findings.iter())
625                        .any(|g| same_defect(f, g));
626                    if !overlapped {
627                        entry.unique += 1;
628                    }
629                }
630            }
631
632            if round.e2e_deferred {
633                e2e.deferred += 1;
634            } else if !round.e2e.is_empty() {
635                e2e.rounds += 1;
636                if round.e2e.iter().any(|o| !o.ok()) {
637                    e2e.failures += 1;
638                    if round.blocking == 0 {
639                        e2e.sole_detections += 1;
640                    }
641                }
642            }
643        }
644    }
645
646    let mut agents: Vec<AgentStats> = agents.into_values().collect();
647    agents.sort_by(|a, b| {
648        b.win_rate()
649            .total_cmp(&a.win_rate())
650            .then(b.entered.cmp(&a.entered))
651    });
652    let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
653    // A seat only sighted in rounds whose adoption could not be scored has
654    // nothing to report: no scoreable round, no silence to flag. It stays out
655    // of the table entirely rather than appearing as a row of zeroes, which
656    // would read as a reviewer that produced nothing.
657    reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
658    reviewers.sort_by(|a, b| {
659        b.adopted_per_round()
660            .total_cmp(&a.adopted_per_round())
661            .then(b.rounds.cmp(&a.rounds))
662    });
663
664    let mut advisors: Vec<AdvisorStats> = advisors.into_values().collect();
665    advisors.sort_by(|a, b| {
666        b.reflection_rate()
667            .total_cmp(&a.reflection_rate())
668            .then(b.proposed.cmp(&a.proposed))
669    });
670
671    let nodes = node_durations(states);
672
673    Stats {
674        totals,
675        agents,
676        reviewers,
677        advisors,
678        e2e,
679        nodes,
680        release_bumps,
681    }
682}
683
684/// Do two findings describe the same defect?
685///
686/// A deliberate heuristic: same normalised title, or the same file within five
687/// lines. Two reviewers rarely word a finding identically, and exact matching
688/// would report every overlap as a unique find.
689fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
690    if normalize(&a.title) == normalize(&b.title) {
691        return true;
692    }
693    match (&a.file, &b.file) {
694        (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
695            (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
696            _ => false,
697        },
698        _ => false,
699    }
700}
701
702fn normalize(title: &str) -> String {
703    title
704        .chars()
705        .filter(|c| c.is_alphanumeric())
706        .map(|c| c.to_ascii_lowercase())
707        .collect()
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use crate::config::Config;
714    use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
715    use crate::verdict::{Finding, Severity};
716    use std::path::PathBuf;
717
718    fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
719        Finding {
720            id: id.to_owned(),
721            severity: sev,
722            file: Some(file.to_owned()),
723            line: Some(line),
724            title: title.to_owned(),
725            detail: String::new(),
726        }
727    }
728
729    fn candidate(label: char, agent: &str) -> Candidate {
730        Candidate {
731            index: 0,
732            label,
733            agent: agent.to_owned(),
734            branch: format!("magi/x/{label}"),
735            worktree: PathBuf::from("/w"),
736            summary: String::new(),
737            stat: String::new(),
738            files: 1,
739            commits: 1,
740            empty: false,
741            failed: None,
742            verified_noop: None,
743            duration_ms: 0,
744            folded: false,
745        }
746    }
747
748    fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
749        let mut s = RunState::new(
750            PathBuf::from("/repo"),
751            "main".to_owned(),
752            "abcdef".to_owned(),
753            "task".to_owned(),
754            Config::default(),
755        );
756        s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
757        s.tally = Some(Tally {
758            first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
759            borda: BTreeMap::new(),
760            winner,
761            rankings: 3,
762            unanimous_initial: false,
763            deliberated: true,
764            changed_votes: 1,
765            unanimous_final: true,
766            tie_break: None,
767            judges: 3,
768            present: 3,
769            quorum: 2,
770            met_quorum: true,
771            uncontested: None,
772        });
773        s.reviews = reviews;
774        s.status = status;
775        s
776    }
777
778    #[test]
779    fn win_rates_and_completion_are_counted_per_agent() {
780        let states = vec![
781            state_with(Vec::new(), 'B', RunStatus::Merged),
782            state_with(Vec::new(), 'A', RunStatus::Blocked),
783        ];
784        let stats = collect(&states);
785        assert_eq!(stats.totals.runs, 2);
786        assert_eq!(stats.totals.merged, 1);
787        assert_eq!(stats.totals.blocked, 1);
788        assert_eq!(stats.totals.completion_rate(), 50.0);
789        assert_eq!(stats.totals.split, 2);
790        assert_eq!(stats.totals.minds_changed, 2);
791        assert_eq!(stats.totals.converged, 2);
792
793        let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
794        assert_eq!(beta.entered, 2);
795        assert_eq!(beta.wins, 1);
796        assert_eq!(beta.win_rate(), 50.0);
797    }
798
799    #[test]
800    fn reviewer_precision_and_uniqueness() {
801        let round = ReviewRound {
802            round: 1,
803            head: "h".to_owned(),
804            verified_head: None,
805            verified_at: None,
806            reviews: vec![
807                ReviewRecord {
808                    attempts: 0,
809                    reviewer: 1,
810                    agent: "alpha".to_owned(),
811                    summary: String::new(),
812                    findings: vec![
813                        finding(
814                            "R1-1-1",
815                            "src/a.rs",
816                            10,
817                            "panics on empty",
818                            Severity::Blocker,
819                        ),
820                        finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
821                    ],
822                    vote: None,
823                    failed: None,
824                    duration_ms: 0,
825                },
826                ReviewRecord {
827                    attempts: 0,
828                    reviewer: 2,
829                    agent: "beta".to_owned(),
830                    summary: String::new(),
831                    // Same defect as R1-1-1, three lines off: an overlap.
832                    findings: vec![finding(
833                        "R1-2-1",
834                        "src/a.rs",
835                        13,
836                        "empty input panic",
837                        Severity::Blocker,
838                    )],
839                    vote: None,
840                    failed: None,
841                    duration_ms: 0,
842                },
843            ],
844            e2e: Vec::new(),
845            verify_retried: false,
846            e2e_deferred: false,
847            e2e_defer_reason: None,
848            fix: Some(FixRecord {
849                agent: "alpha".to_owned(),
850                addressed: vec!["R1-1-1".to_owned()],
851                rejected: Vec::new(),
852                notes: String::new(),
853                committed: true,
854                failed: None,
855                duration_ms: 0,
856                continuation: None,
857            }),
858            blocking: 3,
859            answered: 2,
860            expected: 2,
861            clean: false,
862            progressed: true,
863            vote_split: false,
864            reconsideration: Vec::new(),
865            verdict: None,
866        };
867        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
868        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
869        assert_eq!(alpha.submitted, 2);
870        assert_eq!(alpha.adopted, 1);
871        assert_eq!(alpha.precision(), 50.0);
872        assert_eq!(alpha.adopted_per_round(), 1.0);
873        // The src/a.rs finding overlaps beta's; src/b.rs does not.
874        assert_eq!(alpha.unique, 1);
875
876        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
877        assert_eq!(beta.submitted, 1);
878        assert_eq!(beta.adopted, 0);
879        assert_eq!(beta.unique, 0);
880    }
881
882    #[test]
883    fn a_lost_fix_report_does_not_count_as_zero_adoption() {
884        let submitted = ReviewRound {
885            round: 1,
886            head: "h".to_owned(),
887            verified_head: None,
888            verified_at: None,
889            reviews: vec![ReviewRecord {
890                attempts: 0,
891                reviewer: 1,
892                agent: "alpha".to_owned(),
893                summary: String::new(),
894                findings: vec![finding(
895                    "R1-1-1",
896                    "src/a.rs",
897                    10,
898                    "panics on empty",
899                    Severity::Blocker,
900                )],
901                vote: None,
902                failed: None,
903                duration_ms: 0,
904            }],
905            e2e: Vec::new(),
906            verify_retried: false,
907            e2e_deferred: false,
908            e2e_defer_reason: None,
909            // The fixer's diff may well have landed (blocking counts do fall
910            // round over round) — only its adoption report never came back.
911            fix: Some(FixRecord {
912                agent: "alpha".to_owned(),
913                addressed: Vec::new(),
914                rejected: Vec::new(),
915                notes: String::new(),
916                committed: true,
917                failed: Some("unparsable fix report".to_owned()),
918                duration_ms: 0,
919                continuation: None,
920            }),
921            blocking: 4,
922            answered: 1,
923            expected: 1,
924            clean: false,
925            progressed: false,
926            vote_split: false,
927            reconsideration: Vec::new(),
928            verdict: None,
929        };
930        let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
931        assert!(
932            stats.reviewers.is_empty(),
933            "a round with no adoption signal must not enter any reviewer's \
934             denominator: {:?}",
935            stats.reviewers
936        );
937    }
938
939    #[test]
940    fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
941        let round = ReviewRound {
942            round: 1,
943            head: "h".to_owned(),
944            verified_head: None,
945            verified_at: None,
946            reviews: vec![
947                ReviewRecord {
948                    attempts: 0,
949                    reviewer: 1,
950                    agent: "alpha".to_owned(),
951                    summary: String::new(),
952                    findings: Vec::new(),
953                    vote: None,
954                    failed: None,
955                    duration_ms: 0,
956                },
957                ReviewRecord {
958                    attempts: 0,
959                    reviewer: 2,
960                    agent: "beta".to_owned(),
961                    summary: String::new(),
962                    findings: Vec::new(),
963                    vote: None,
964                    failed: Some("agent timed out".to_owned()),
965                    duration_ms: 0,
966                },
967            ],
968            e2e: Vec::new(),
969            verify_retried: false,
970            e2e_deferred: false,
971            e2e_defer_reason: None,
972            fix: None,
973            blocking: 0,
974            answered: 1,
975            expected: 2,
976            clean: false,
977            progressed: false,
978            vote_split: false,
979            reconsideration: Vec::new(),
980            verdict: None,
981        };
982        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
983
984        let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
985        assert_eq!(alpha.seated, 1);
986        assert_eq!(alpha.rounds, 1);
987        assert_eq!(alpha.timeouts, 0);
988        assert_eq!(alpha.submitted, 0);
989
990        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
991        assert_eq!(beta.seated, 1);
992        assert_eq!(beta.timeouts, 1);
993        assert_eq!(beta.submitted, 0);
994        // A timeout must never read as a submission with nothing found: it
995        // stays out of the scoring denominators entirely rather than becoming
996        // a 0/0 that looks identical to a reviewer who answered and passed.
997        assert_eq!(beta.rounds, 0);
998        assert_eq!(beta.timeout_rate(), 100.0);
999    }
1000
1001    #[test]
1002    fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
1003        // Two independent gaps in one round: `beta` never answered, and the
1004        // fixer's adoption report never came back. The lost report suppresses
1005        // adoption scoring (see `a_lost_fix_report_does_not_count_as_zero_
1006        // adoption`) — it must not also swallow the fact that a seat was
1007        // silent, which is a property of the seat and not of the fixer.
1008        let round = ReviewRound {
1009            round: 1,
1010            head: "h".to_owned(),
1011            verified_head: None,
1012            verified_at: None,
1013            reviews: vec![
1014                ReviewRecord {
1015                    attempts: 0,
1016                    reviewer: 1,
1017                    agent: "alpha".to_owned(),
1018                    summary: String::new(),
1019                    findings: vec![finding(
1020                        "R1-1-1",
1021                        "src/a.rs",
1022                        10,
1023                        "panics on empty",
1024                        Severity::Blocker,
1025                    )],
1026                    vote: None,
1027                    failed: None,
1028                    duration_ms: 0,
1029                },
1030                ReviewRecord {
1031                    attempts: 0,
1032                    reviewer: 2,
1033                    agent: "beta".to_owned(),
1034                    summary: String::new(),
1035                    findings: Vec::new(),
1036                    vote: None,
1037                    failed: Some("agent timed out".to_owned()),
1038                    duration_ms: 0,
1039                },
1040            ],
1041            e2e: Vec::new(),
1042            verify_retried: false,
1043            e2e_deferred: false,
1044            e2e_defer_reason: None,
1045            fix: Some(FixRecord {
1046                agent: "alpha".to_owned(),
1047                addressed: Vec::new(),
1048                rejected: Vec::new(),
1049                notes: String::new(),
1050                committed: true,
1051                failed: Some("unparsable fix report".to_owned()),
1052                duration_ms: 0,
1053                continuation: None,
1054            }),
1055            blocking: 1,
1056            answered: 1,
1057            expected: 2,
1058            clean: false,
1059            progressed: false,
1060            vote_split: false,
1061            reconsideration: Vec::new(),
1062            verdict: None,
1063        };
1064        let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
1065
1066        let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1067        assert_eq!(beta.timeouts, 1);
1068        assert_eq!(beta.timeout_rate(), 100.0);
1069        // `alpha` answered, so the lost report keeps it out of the table
1070        // altogether — nothing about its findings can be scored.
1071        assert!(
1072            !stats.reviewers.iter().any(|r| r.agent == "alpha"),
1073            "{:?}",
1074            stats.reviewers
1075        );
1076    }
1077
1078    #[test]
1079    fn e2e_sole_detection_needs_a_clean_static_review() {
1080        let fail = CommandOutcome {
1081            command: "cargo test".to_owned(),
1082            code: Some(101),
1083            output_tail: "boom".to_owned(),
1084            duration_ms: 1,
1085            resource_blocked: false,
1086        };
1087        let sole = ReviewRound {
1088            round: 1,
1089            head: "h".to_owned(),
1090            verified_head: None,
1091            verified_at: None,
1092            reviews: Vec::new(),
1093            e2e: vec![fail.clone()],
1094            verify_retried: false,
1095            e2e_deferred: false,
1096            e2e_defer_reason: None,
1097            fix: None,
1098            blocking: 0,
1099            answered: 0,
1100            expected: 0,
1101            clean: false,
1102            progressed: false,
1103            vote_split: false,
1104            reconsideration: Vec::new(),
1105            verdict: None,
1106        };
1107        let alongside = ReviewRound {
1108            round: 2,
1109            head: "h".to_owned(),
1110            verified_head: None,
1111            verified_at: None,
1112            reviews: Vec::new(),
1113            e2e: vec![fail],
1114            verify_retried: false,
1115            e2e_deferred: false,
1116            e2e_defer_reason: None,
1117            fix: None,
1118            blocking: 2,
1119            answered: 0,
1120            expected: 0,
1121            clean: false,
1122            progressed: false,
1123            vote_split: false,
1124            reconsideration: Vec::new(),
1125            verdict: None,
1126        };
1127        let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
1128        assert_eq!(stats.e2e.rounds, 2);
1129        assert_eq!(stats.e2e.failures, 2);
1130        assert_eq!(stats.e2e.sole_detections, 1);
1131        assert_eq!(stats.e2e.sole_rate(), 50.0);
1132    }
1133
1134    #[test]
1135    fn every_run_status_lands_in_exactly_one_breakdown_bucket() {
1136        let states = vec![
1137            state_with(Vec::new(), 'A', RunStatus::Merged),
1138            state_with(Vec::new(), 'A', RunStatus::Ready),
1139            state_with(Vec::new(), 'A', RunStatus::Blocked),
1140            state_with(Vec::new(), 'A', RunStatus::Failed),
1141            state_with(Vec::new(), 'A', RunStatus::Stalled),
1142            state_with(Vec::new(), 'A', RunStatus::VerifiedNoop),
1143            state_with(Vec::new(), 'A', RunStatus::Superseded),
1144            state_with(Vec::new(), 'A', RunStatus::Implementing),
1145            state_with(Vec::new(), 'A', RunStatus::Landing),
1146        ];
1147        let stats = collect(&states);
1148        let t = &stats.totals;
1149        assert_eq!(t.runs, 9);
1150        assert_eq!(t.merged, 1);
1151        assert_eq!(t.ready, 1);
1152        assert_eq!(t.blocked, 1);
1153        assert_eq!(t.failed, 1);
1154        assert_eq!(t.stalled, 1);
1155        assert_eq!(t.verified_noop, 1);
1156        assert_eq!(t.superseded, 1);
1157        // `Implementing` and `Landing` both fall into the one non-terminal
1158        // bucket.
1159        assert_eq!(t.in_progress, 2);
1160        assert_eq!(
1161            t.merged
1162                + t.ready
1163                + t.blocked
1164                + t.failed
1165                + t.stalled
1166                + t.verified_noop
1167                + t.superseded
1168                + t.in_progress,
1169            t.runs,
1170            "every run must land in exactly one bucket of the breakdown"
1171        );
1172    }
1173
1174    #[test]
1175    fn empty_input_yields_zeroed_rates_not_nan() {
1176        let stats = collect(&[]);
1177        assert_eq!(stats.totals.completion_rate(), 0.0);
1178        assert_eq!(stats.totals.split_rate(), 0.0);
1179        assert_eq!(stats.e2e.sole_rate(), 0.0);
1180        assert!(stats.agents.is_empty());
1181        assert!(stats.advisors.is_empty());
1182        assert_eq!(AdvisorStats::default().reflection_rate(), 0.0);
1183    }
1184
1185    fn advisor_record(
1186        seat: &str,
1187        agent: &str,
1188        proposal: Option<crate::verdict::Proposal>,
1189        reflection: crate::advise::Reflection,
1190    ) -> crate::advise::AdvisorRecord {
1191        crate::advise::AdvisorRecord {
1192            seat: seat.to_owned(),
1193            agent: agent.to_owned(),
1194            proposal,
1195            error: None,
1196            duration_ms: 0,
1197            reflection,
1198        }
1199    }
1200
1201    fn a_proposal() -> crate::verdict::Proposal {
1202        crate::verdict::Proposal {
1203            approach: "do the thing".to_owned(),
1204            key_tradeoff: "speed over memory".to_owned(),
1205            risks: Vec::new(),
1206            touches: Vec::new(),
1207            why_not_naive: "the naive version breaks under load".to_owned(),
1208        }
1209    }
1210
1211    #[test]
1212    fn advisor_stats_count_proposed_absent_and_reflection_split() {
1213        use crate::advise::{Advice, Reflection};
1214
1215        let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1216        s.advice = Some(Advice {
1217            records: vec![
1218                advisor_record("advisor-1", "alpha", Some(a_proposal()), Reflection::Strong),
1219                advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1220                advisor_record("advisor-3", "alpha", None, Reflection::Absent),
1221            ],
1222            synthesis: Some("blended brief".to_owned()),
1223        });
1224
1225        let stats = collect(&[s]);
1226        let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1227        assert_eq!(alpha.seated, 3);
1228        assert_eq!(alpha.proposed, 2);
1229        assert_eq!(alpha.absent, 1);
1230        assert_eq!(alpha.strong, 1);
1231        assert_eq!(alpha.faint, 1);
1232        assert_eq!(alpha.reflection_rate(), 50.0);
1233    }
1234
1235    #[test]
1236    fn advisor_stats_count_absent_from_the_proposal_not_the_reflection_default() {
1237        // A record whose `proposal` is `None` but whose `reflection` was
1238        // never classified (predates `apply_reflection`, or the field's own
1239        // serde default) must still count as `absent` — and a run whose
1240        // synthesis never ran leaves every *proposed* record `Faint` by the
1241        // same default, which must not spill into `absent` either.
1242        //
1243        // A third case: a proposed record whose `reflection` was *never*
1244        // classified at all (a run.json predating the `reflection` field)
1245        // must not vanish from the breakdown either — it has to land
1246        // somewhere in faint/strong, not be silently dropped from all three
1247        // counters while still counting toward `proposed`.
1248        use crate::advise::{Advice, Reflection};
1249
1250        let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1251        s.advice = Some(Advice {
1252            records: vec![
1253                advisor_record("advisor-1", "alpha", None, Reflection::Absent),
1254                advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1255                advisor_record("advisor-3", "alpha", Some(a_proposal()), Reflection::Absent),
1256            ],
1257            synthesis: None,
1258        });
1259
1260        let stats = collect(&[s]);
1261        let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1262        assert_eq!(alpha.seated, 3);
1263        assert_eq!(alpha.proposed, 2);
1264        assert_eq!(alpha.absent, 1);
1265        assert_eq!(alpha.faint, 2);
1266        assert_eq!(alpha.strong, 0);
1267    }
1268
1269    #[test]
1270    fn advisor_stats_ignore_runs_with_advise_off() {
1271        let s = state_with(Vec::new(), 'A', RunStatus::Merged);
1272        assert!(s.advice.is_none());
1273        let stats = collect(&[s]);
1274        assert!(stats.advisors.is_empty());
1275    }
1276
1277    #[test]
1278    fn same_defect_matches_titles_across_files() {
1279        let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
1280        let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
1281        assert!(same_defect(&a, &b));
1282        let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
1283        assert!(!same_defect(&a, &c));
1284    }
1285
1286    #[test]
1287    fn release_bump_stats_split_clean_from_attention_and_track_coverage() {
1288        use crate::run::ReleaseBump;
1289
1290        // Not recorded at all: uses the release-bump step? unknown.
1291        let unrecorded = state_with(Vec::new(), 'A', RunStatus::Merged);
1292
1293        // Clean: automerge worked, nobody had to look at it.
1294        let mut automerged = state_with(Vec::new(), 'A', RunStatus::Merged);
1295        automerged.release_bump = Some(ReleaseBump {
1296            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
1297            version: Some("1.2.3".to_owned()),
1298            automerge_enabled: true,
1299            merged_directly: false,
1300            problem: None,
1301            action_required: None,
1302        });
1303
1304        // Also clean, but automerge itself was rejected by GitHub and magi
1305        // merged the already-green PR directly - `automerge_enabled` reads
1306        // false here, and that must not make this count as needing a human.
1307        let mut merged_directly = state_with(Vec::new(), 'A', RunStatus::Merged);
1308        merged_directly.release_bump = Some(ReleaseBump {
1309            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
1310            version: Some("1.2.4".to_owned()),
1311            automerge_enabled: false,
1312            merged_directly: true,
1313            problem: None,
1314            action_required: None,
1315        });
1316
1317        // Needs a human, PR opened.
1318        let mut blocked_with_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1319        blocked_with_pr.release_bump = Some(ReleaseBump {
1320            pr_url: Some("https://github.com/o/r/pull/3".to_owned()),
1321            version: Some("1.2.5".to_owned()),
1322            automerge_enabled: false,
1323            merged_directly: false,
1324            problem: Some("checks red".to_owned()),
1325            action_required: Some("look at the PR".to_owned()),
1326        });
1327
1328        // Needs a human, no PR ever opened.
1329        let mut blocked_without_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1330        blocked_without_pr.release_bump = Some(ReleaseBump {
1331            pr_url: None,
1332            version: Some("1.2.6".to_owned()),
1333            automerge_enabled: false,
1334            merged_directly: false,
1335            problem: Some("gh pr create failed".to_owned()),
1336            action_required: Some("open the PR by hand".to_owned()),
1337        });
1338
1339        let stats = collect(&[
1340            unrecorded,
1341            automerged,
1342            merged_directly,
1343            blocked_with_pr,
1344            blocked_without_pr,
1345        ]);
1346        let b = &stats.release_bumps;
1347        assert_eq!(b.merged, 5);
1348        assert_eq!(b.recorded, 4);
1349        assert_eq!(b.pr_opened, 3);
1350        assert_eq!(b.automerge_enabled, 1);
1351        assert_eq!(b.merged_directly, 1);
1352        assert_eq!(b.needs_attention, 2);
1353        assert_eq!(b.clean(), 2);
1354        // Clean and attention must always split `recorded` exactly.
1355        assert_eq!(b.clean() + b.needs_attention, b.recorded);
1356        assert_eq!(b.coverage_rate(), 80.0);
1357        assert!((b.automerge_rate() - 33.333_333_333_333_336).abs() < 1e-9);
1358        assert_eq!(b.attention_rate(), 50.0);
1359    }
1360
1361    #[test]
1362    fn release_bump_ignores_runs_that_are_not_merged() {
1363        use crate::run::ReleaseBump;
1364
1365        let mut blocked = state_with(Vec::new(), 'A', RunStatus::Blocked);
1366        blocked.release_bump = Some(ReleaseBump {
1367            pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
1368            version: Some("9.9.9".to_owned()),
1369            automerge_enabled: true,
1370            merged_directly: false,
1371            problem: None,
1372            action_required: None,
1373        });
1374
1375        let stats = collect(&[blocked]);
1376        let b = &stats.release_bumps;
1377        assert_eq!(b.merged, 0);
1378        assert_eq!(b.recorded, 0);
1379        assert_eq!(b.pr_opened, 0);
1380    }
1381
1382    #[test]
1383    fn release_bump_stats_are_zero_on_merged_runs_with_no_bump_or_no_runs() {
1384        let stats = collect(&[state_with(Vec::new(), 'A', RunStatus::Merged)]);
1385        let b = &stats.release_bumps;
1386        assert_eq!(b.merged, 1);
1387        assert_eq!(b.recorded, 0);
1388        assert_eq!(b.coverage_rate(), 0.0);
1389        assert_eq!(b.automerge_rate(), 0.0);
1390        assert_eq!(b.attention_rate(), 0.0);
1391        assert_eq!(b.clean(), 0);
1392
1393        let empty = collect(&[]);
1394        let b = &empty.release_bumps;
1395        assert_eq!(b.merged, 0);
1396        assert_eq!(b.coverage_rate(), 0.0);
1397        assert_eq!(b.automerge_rate(), 0.0);
1398        assert_eq!(b.attention_rate(), 0.0);
1399    }
1400
1401    fn event(node: &str, at_secs: i64, message: &str) -> crate::run::Event {
1402        crate::run::Event {
1403            at: jiff::Timestamp::from_second(at_secs).unwrap(),
1404            node: node.to_owned(),
1405            message: message.to_owned(),
1406        }
1407    }
1408
1409    fn state_with_events(events: Vec<crate::run::Event>) -> RunState {
1410        let mut s = RunState::new(
1411            PathBuf::from("/repo"),
1412            "main".to_owned(),
1413            "abcdef".to_owned(),
1414            "task".to_owned(),
1415            Config::default(),
1416        );
1417        s.events = events;
1418        s
1419    }
1420
1421    #[test]
1422    fn a_node_with_multiple_events_spans_first_to_last() {
1423        let s = state_with_events(vec![
1424            event("implement", 1_000, "start"),
1425            event("implement", 1_030, "still running"),
1426            event("implement", 1_090, "done"),
1427        ]);
1428        let nodes = node_durations(&[s]);
1429        assert_eq!(nodes.len(), 1);
1430        assert_eq!(nodes[0].node, "implement");
1431        assert_eq!(nodes[0].runs, 1);
1432        assert_eq!(nodes[0].total_secs, 90);
1433        assert_eq!(nodes[0].max_secs, 90);
1434        assert_eq!(nodes[0].single, 0);
1435        assert_eq!(nodes[0].mean_secs(), 90.0);
1436    }
1437
1438    #[test]
1439    fn a_node_with_a_single_event_is_unmeasured_not_zero() {
1440        let s = state_with_events(vec![event("gate", 2_000, "ran once")]);
1441        let nodes = node_durations(&[s]);
1442        assert_eq!(nodes.len(), 1);
1443        assert_eq!(nodes[0].node, "gate");
1444        assert_eq!(nodes[0].runs, 0);
1445        assert_eq!(nodes[0].total_secs, 0);
1446        assert_eq!(nodes[0].single, 1);
1447        // No measured runs: the mean must read as zero, never NaN or a
1448        // fabricated span from the lone event.
1449        assert_eq!(nodes[0].mean_secs(), 0.0);
1450    }
1451
1452    #[test]
1453    fn a_run_with_no_events_produces_no_node_rows() {
1454        let s = state_with_events(vec![]);
1455        let nodes = node_durations(&[s]);
1456        assert!(nodes.is_empty());
1457    }
1458
1459    #[test]
1460    fn multiple_runs_aggregate_the_same_node() {
1461        let a = state_with_events(vec![event("judge", 0, "start"), event("judge", 60, "done")]);
1462        let b = state_with_events(vec![
1463            event("judge", 0, "start"),
1464            event("judge", 200, "done"),
1465        ]);
1466        // A single-event run for the same node must add to `single` without
1467        // disturbing the measured runs' total or max.
1468        let c = state_with_events(vec![event("judge", 5, "start")]);
1469        let nodes = node_durations(&[a, b, c]);
1470        assert_eq!(nodes.len(), 1);
1471        let judge = &nodes[0];
1472        assert_eq!(judge.node, "judge");
1473        assert_eq!(judge.runs, 2);
1474        assert_eq!(judge.total_secs, 260);
1475        assert_eq!(judge.max_secs, 200);
1476        assert_eq!(judge.single, 1);
1477        assert_eq!(judge.mean_secs(), 130.0);
1478    }
1479}