1use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Path, PathBuf};
10
11use crate::run::{RunState, RunStatus, list_ids};
12
13#[derive(Debug, Clone, Default)]
15pub struct AgentStats {
16 pub agent: String,
18 pub entered: usize,
20 pub wins: usize,
22 pub empty: usize,
24 pub competitive_entered: usize,
26 pub competitive_wins: usize,
28 pub solo_entered: usize,
30 pub solo_adopted: usize,
32}
33
34impl AgentStats {
35 pub fn win_rate(&self) -> f64 {
37 if self.entered == 0 {
38 0.0
39 } else {
40 100.0 * self.wins as f64 / self.entered as f64
41 }
42 }
43
44 pub fn competitive_win_rate(&self) -> f64 {
46 if self.competitive_entered == 0 {
47 0.0
48 } else {
49 100.0 * self.competitive_wins as f64 / self.competitive_entered as f64
50 }
51 }
52
53 pub fn solo_adoption_rate(&self) -> f64 {
55 if self.solo_entered == 0 {
56 0.0
57 } else {
58 100.0 * self.solo_adopted as f64 / self.solo_entered as f64
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct ReviewerStats {
66 pub agent: String,
68 pub rounds: usize,
73 pub seated: usize,
78 pub submitted: usize,
80 pub adopted: usize,
82 pub unique: usize,
84 pub timeouts: usize,
88}
89
90impl ReviewerStats {
91 pub fn adopted_per_round(&self) -> f64 {
93 if self.rounds == 0 {
94 0.0
95 } else {
96 self.adopted as f64 / self.rounds as f64
97 }
98 }
99
100 pub fn precision(&self) -> f64 {
104 if self.submitted == 0 {
105 0.0
106 } else {
107 100.0 * self.adopted as f64 / self.submitted as f64
108 }
109 }
110
111 pub fn unique_rate(&self) -> f64 {
113 if self.submitted == 0 {
114 0.0
115 } else {
116 100.0 * self.unique as f64 / self.submitted as f64
117 }
118 }
119
120 pub fn timeout_rate(&self) -> f64 {
122 if self.seated == 0 {
123 0.0
124 } else {
125 100.0 * self.timeouts as f64 / self.seated as f64
126 }
127 }
128}
129
130#[derive(Debug, Clone, Default)]
137pub struct AdvisorStats {
138 pub agent: String,
140 pub seated: usize,
142 pub proposed: usize,
144 pub absent: usize,
152 pub faint: usize,
155 pub strong: usize,
158}
159
160impl AdvisorStats {
161 pub fn reflection_rate(&self) -> f64 {
170 if self.proposed == 0 {
171 0.0
172 } else {
173 100.0 * self.strong as f64 / self.proposed as f64
174 }
175 }
176}
177
178#[derive(Debug, Clone, Default)]
180pub struct E2eStats {
181 pub rounds: usize,
183 pub failures: usize,
185 pub sole_detections: usize,
188 pub deferred: usize,
195}
196
197impl E2eStats {
198 pub fn sole_rate(&self) -> f64 {
200 if self.failures == 0 {
201 0.0
202 } else {
203 100.0 * self.sole_detections as f64 / self.failures as f64
204 }
205 }
206}
207
208#[derive(Debug, Clone, Default)]
210pub struct Totals {
211 pub runs: usize,
213 pub merged: usize,
215 pub ready: usize,
217 pub blocked: usize,
219 pub failed: usize,
221 pub stalled: usize,
226 pub verified_noop: usize,
229 pub superseded: usize,
232 pub in_progress: usize,
237 pub tallied: usize,
239 pub split: usize,
241 pub deliberated: usize,
243 pub minds_changed: usize,
245 pub converged: usize,
247 pub review_rounds: usize,
249}
250
251impl Totals {
252 pub fn completion_rate(&self) -> f64 {
254 if self.runs == 0 {
255 0.0
256 } else {
257 100.0 * (self.merged + self.ready) as f64 / self.runs as f64
258 }
259 }
260
261 pub fn split_rate(&self) -> f64 {
263 if self.tallied == 0 {
264 0.0
265 } else {
266 100.0 * self.split as f64 / self.tallied as f64
267 }
268 }
269}
270
271#[derive(Debug, Clone, Default, PartialEq)]
279pub struct NodeDuration {
280 pub node: String,
282 pub runs: usize,
284 pub total_secs: i64,
286 pub max_secs: i64,
288 pub single: usize,
293}
294
295impl NodeDuration {
296 pub fn mean_secs(&self) -> f64 {
300 if self.runs == 0 {
301 0.0
302 } else {
303 self.total_secs as f64 / self.runs as f64
304 }
305 }
306}
307
308pub fn node_durations<'a>(states: impl IntoIterator<Item = &'a RunState>) -> Vec<NodeDuration> {
316 let mut nodes: BTreeMap<String, NodeDuration> = BTreeMap::new();
317
318 for state in states {
319 let mut spans: BTreeMap<&str, (jiff::Timestamp, jiff::Timestamp, usize)> = BTreeMap::new();
320 for e in &state.events {
321 spans
322 .entry(e.node.as_str())
323 .and_modify(|(min, max, count)| {
324 if e.at < *min {
325 *min = e.at;
326 }
327 if e.at > *max {
328 *max = e.at;
329 }
330 *count += 1;
331 })
332 .or_insert((e.at, e.at, 1));
333 }
334
335 for (node, (min, max, count)) in spans {
336 let entry = nodes
337 .entry(node.to_owned())
338 .or_insert_with(|| NodeDuration {
339 node: node.to_owned(),
340 ..NodeDuration::default()
341 });
342 if count < 2 {
343 entry.single += 1;
344 continue;
345 }
346 let span_secs = (max - min).get_seconds();
350 entry.runs += 1;
351 entry.total_secs += span_secs;
352 if span_secs > entry.max_secs {
353 entry.max_secs = span_secs;
354 }
355 }
356 }
357
358 let mut nodes: Vec<NodeDuration> = nodes.into_values().collect();
359 nodes.sort_by(|a, b| b.total_secs.cmp(&a.total_secs).then(a.node.cmp(&b.node)));
360 nodes
361}
362
363#[derive(Debug, Clone, Default)]
372pub struct ReleaseBumpStats {
373 pub merged: usize,
375 pub recorded: usize,
377 pub pr_opened: usize,
379 pub automerge_enabled: usize,
385 pub merged_directly: usize,
392 pub needs_attention: usize,
395}
396
397impl ReleaseBumpStats {
398 pub fn coverage_rate(&self) -> f64 {
400 if self.merged == 0 {
401 0.0
402 } else {
403 100.0 * self.recorded as f64 / self.merged as f64
404 }
405 }
406
407 pub fn automerge_rate(&self) -> f64 {
409 if self.pr_opened == 0 {
410 0.0
411 } else {
412 100.0 * self.automerge_enabled as f64 / self.pr_opened as f64
413 }
414 }
415
416 pub fn attention_rate(&self) -> f64 {
418 if self.recorded == 0 {
419 0.0
420 } else {
421 100.0 * self.needs_attention as f64 / self.recorded as f64
422 }
423 }
424
425 pub fn clean(&self) -> usize {
436 self.recorded.saturating_sub(self.needs_attention)
437 }
438}
439
440#[derive(Debug, Clone, Default)]
442pub struct Stats {
443 pub totals: Totals,
445 pub agents: Vec<AgentStats>,
447 pub reviewers: Vec<ReviewerStats>,
449 pub advisors: Vec<AdvisorStats>,
455 pub e2e: E2eStats,
457 pub nodes: Vec<NodeDuration>,
459 pub release_bumps: ReleaseBumpStats,
461 pub retired_hidden: Vec<String>,
465}
466
467pub fn roster_of(cfg: &crate::config::Config) -> BTreeSet<String> {
471 cfg.agents.iter().map(|a| a.id.clone()).collect()
472}
473
474pub fn retain_roster(stats: &mut Stats, roster: &BTreeSet<String>) {
479 let mut hidden = BTreeSet::new();
480 let mut keep = |agent: &str| {
481 let keep = roster.contains(agent);
482 if !keep {
483 hidden.insert(agent.to_owned());
484 }
485 keep
486 };
487 stats.agents.retain(|a| keep(&a.agent));
488 stats.reviewers.retain(|a| keep(&a.agent));
489 stats.advisors.retain(|a| keep(&a.agent));
490 stats.retired_hidden = hidden.into_iter().collect();
491}
492
493pub fn retain_current_roster(stats: &mut Stats, repo: &Path) {
496 if let Some(roster) = crate::config::Config::discover(repo, None)
497 .ok()
498 .map(|(cfg, _)| roster_of(&cfg))
499 {
500 retain_roster(stats, &roster);
501 }
502}
503
504pub fn load_all() -> Vec<RunState> {
506 list_ids()
507 .into_iter()
508 .filter_map(|id| RunState::load(&id).ok())
509 .collect()
510}
511
512#[derive(Debug, Clone)]
514pub struct RepoStats {
515 pub repo: PathBuf,
521 pub name: String,
525 pub stats: Stats,
528}
529
530pub fn by_repo(states: &[RunState]) -> Vec<RepoStats> {
537 let mut groups: BTreeMap<PathBuf, Vec<&RunState>> = BTreeMap::new();
538 for state in states {
539 groups.entry(state.repo.clone()).or_default().push(state);
540 }
541
542 let mut out: Vec<RepoStats> = groups
543 .into_iter()
544 .map(|(repo, group)| {
545 let stats = collect_refs(group);
546 let name = repo
547 .file_name()
548 .map(|n| n.to_string_lossy().into_owned())
549 .unwrap_or_else(|| repo.to_string_lossy().into_owned());
550 RepoStats { repo, name, stats }
551 })
552 .collect();
553 out.sort_by(|a, b| {
554 b.stats
555 .totals
556 .runs
557 .cmp(&a.stats.totals.runs)
558 .then(a.repo.cmp(&b.repo))
559 });
560 out
561}
562
563pub fn filter_repo<'a>(states: &'a [RunState], repo: &Path) -> Vec<&'a RunState> {
572 states.iter().filter(|s| s.repo == repo).collect()
573}
574
575pub fn collect(states: &[RunState]) -> Stats {
577 collect_refs(states)
578}
579
580pub fn collect_refs<'a>(states: impl IntoIterator<Item = &'a RunState>) -> Stats {
585 let states: Vec<&'a RunState> = states.into_iter().collect();
586 let mut totals = Totals::default();
587 let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
588 let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
589 let mut advisors: BTreeMap<String, AdvisorStats> = BTreeMap::new();
590 let mut e2e = E2eStats::default();
591 let mut release_bumps = ReleaseBumpStats::default();
592
593 for state in &states {
594 totals.runs += 1;
595 if state.status == RunStatus::Merged {
596 release_bumps.merged += 1;
597 if let Some(b) = &state.release_bump {
598 release_bumps.recorded += 1;
599 if b.pr_url.is_some() {
600 release_bumps.pr_opened += 1;
601 }
602 if b.automerge_enabled {
603 release_bumps.automerge_enabled += 1;
604 }
605 if b.merged_directly {
606 release_bumps.merged_directly += 1;
607 }
608 }
609 if state.needs_attention() {
610 release_bumps.needs_attention += 1;
611 }
612 }
613 match state.status {
614 RunStatus::Merged => totals.merged += 1,
615 RunStatus::Ready => totals.ready += 1,
616 RunStatus::Blocked => totals.blocked += 1,
617 RunStatus::Failed => totals.failed += 1,
618 RunStatus::Stalled => totals.stalled += 1,
619 RunStatus::VerifiedNoop => totals.verified_noop += 1,
620 RunStatus::Superseded | RunStatus::AlreadyInBase => totals.superseded += 1,
630 RunStatus::Prep
631 | RunStatus::Implementing
632 | RunStatus::Judging
633 | RunStatus::Deliberating
634 | RunStatus::Voting
635 | RunStatus::Reviewing
636 | RunStatus::Gating
637 | RunStatus::Landing => totals.in_progress += 1,
638 }
639
640 let is_review = state.reviewed_commits.is_some()
643 || state
644 .candidates
645 .iter()
646 .any(|c| c.agent == "(existing branch)");
647
648 let competitive = !is_review && state.candidates.iter().filter(|c| c.viable()).count() >= 2;
649 let solo = !is_review && state.candidates.len() == 1;
650
651 for c in &state.candidates {
652 let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
653 agent: c.agent.clone(),
654 ..AgentStats::default()
655 });
656 if c.empty && c.verified_noop.is_none() {
661 entry.empty += 1;
662 }
663 if c.viable() {
664 entry.entered += 1;
665 }
666 }
667
668 if competitive || solo {
669 if competitive {
670 for c in &state.candidates {
672 if c.viable() {
673 agents
674 .entry(c.agent.clone())
675 .or_insert_with(|| AgentStats {
676 agent: c.agent.clone(),
677 ..AgentStats::default()
678 })
679 .competitive_entered += 1;
680 }
681 }
682 } else if solo {
683 let c = &state.candidates[0];
685 if c.verified_noop.is_none() {
686 agents
687 .entry(c.agent.clone())
688 .or_insert_with(|| AgentStats {
689 agent: c.agent.clone(),
690 ..AgentStats::default()
691 })
692 .solo_entered += 1;
693 }
694 }
695 }
696
697 if let Some(t) = &state.tally {
698 if t.uncontested.is_none() {
705 totals.tallied += 1;
706 if !t.unanimous_initial {
707 totals.split += 1;
708 }
709 if t.deliberated {
710 totals.deliberated += 1;
711 if t.changed_votes > 0 {
712 totals.minds_changed += 1;
713 }
714 if t.unanimous_final {
715 totals.converged += 1;
716 }
717 }
718 }
719 if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
720 let entry = agents.entry(w.agent.clone()).or_insert_with(|| AgentStats {
721 agent: w.agent.clone(),
722 ..AgentStats::default()
723 });
724 entry.wins += 1;
725
726 if competitive && w.viable() {
727 entry.competitive_wins += 1;
728 } else if solo && w.verified_noop.is_none() {
729 entry.solo_adopted += 1;
730 }
731 }
732 }
733
734 if let Some(advice) = &state.advice {
735 for rec in &advice.records {
736 let entry = advisors
737 .entry(rec.agent.clone())
738 .or_insert_with(|| AdvisorStats {
739 agent: rec.agent.clone(),
740 ..AdvisorStats::default()
741 });
742 entry.seated += 1;
743 if rec.proposal.is_none() {
744 entry.absent += 1;
745 continue;
746 }
747 entry.proposed += 1;
748 match rec.reflection {
749 crate::advise::Reflection::Strong => entry.strong += 1,
750 crate::advise::Reflection::Faint => entry.faint += 1,
751 crate::advise::Reflection::Absent => entry.faint += 1,
762 }
763 }
764 }
765
766 for round in &state.reviews {
767 totals.review_rounds += 1;
768
769 let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
776 let adopted: Vec<&String> = round
777 .fix
778 .as_ref()
779 .map(|f| f.addressed.iter().collect())
780 .unwrap_or_default();
781
782 for rec in &round.reviews {
783 let entry = reviewers
784 .entry(rec.agent.clone())
785 .or_insert_with(|| ReviewerStats {
786 agent: rec.agent.clone(),
787 ..ReviewerStats::default()
788 });
789 entry.seated += 1;
795 if rec.failed.is_some() {
796 entry.timeouts += 1;
797 continue;
798 }
799 if report_lost {
800 continue;
801 }
802 entry.rounds += 1;
803 entry.submitted += rec.findings.len();
804 for f in &rec.findings {
805 if adopted.iter().any(|a| **a == f.id) {
806 entry.adopted += 1;
807 }
808 let overlapped = round
809 .reviews
810 .iter()
811 .filter(|other| other.reviewer != rec.reviewer)
812 .flat_map(|other| other.findings.iter())
813 .any(|g| same_defect(f, g));
814 if !overlapped {
815 entry.unique += 1;
816 }
817 }
818 }
819
820 if round.e2e_deferred {
821 e2e.deferred += 1;
822 } else if !round.e2e.is_empty() {
823 e2e.rounds += 1;
824 if round.e2e.iter().any(|o| !o.ok()) {
825 e2e.failures += 1;
826 if round.blocking == 0 {
827 e2e.sole_detections += 1;
828 }
829 }
830 }
831 }
832 }
833
834 let mut agents: Vec<AgentStats> = agents.into_values().collect();
835 agents.sort_by(|a, b| {
836 b.win_rate()
837 .total_cmp(&a.win_rate())
838 .then(b.entered.cmp(&a.entered))
839 });
840 let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
841 reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
846 reviewers.sort_by(|a, b| {
847 b.adopted_per_round()
848 .total_cmp(&a.adopted_per_round())
849 .then(b.rounds.cmp(&a.rounds))
850 });
851
852 let mut advisors: Vec<AdvisorStats> = advisors.into_values().collect();
853 advisors.sort_by(|a, b| {
854 b.reflection_rate()
855 .total_cmp(&a.reflection_rate())
856 .then(b.proposed.cmp(&a.proposed))
857 });
858
859 let nodes = node_durations(states);
860
861 Stats {
862 totals,
863 agents,
864 reviewers,
865 advisors,
866 e2e,
867 nodes,
868 release_bumps,
869 retired_hidden: Vec::new(),
870 }
871}
872
873fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
879 if normalize(&a.title) == normalize(&b.title) {
880 return true;
881 }
882 match (&a.file, &b.file) {
883 (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
884 (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
885 _ => false,
886 },
887 _ => false,
888 }
889}
890
891fn normalize(title: &str) -> String {
892 title
893 .chars()
894 .filter(|c| c.is_alphanumeric())
895 .map(|c| c.to_ascii_lowercase())
896 .collect()
897}
898
899#[derive(Debug, Clone, PartialEq, Eq)]
901pub struct DayBucket {
902 pub date: jiff::civil::Date,
904 pub runs: usize,
906 pub merged: usize,
908 pub ready: usize,
910 pub other: usize,
912}
913
914pub fn daily<'a>(
923 states: impl IntoIterator<Item = &'a RunState>,
924 today: jiff::civil::Date,
925 tz: &jiff::tz::TimeZone,
926 days: usize,
927) -> Vec<DayBucket> {
928 let mut buckets: Vec<DayBucket> = (0..days)
929 .rev()
930 .filter_map(|back| {
931 let span = jiff::Span::new().days(i64::try_from(back).ok()?);
932 today.checked_sub(span).ok()
933 })
934 .map(|date| DayBucket {
935 date,
936 runs: 0,
937 merged: 0,
938 ready: 0,
939 other: 0,
940 })
941 .collect();
942 for state in states {
943 let date = state.created_at.to_zoned(tz.clone()).date();
944 let Some(bucket) = buckets.iter_mut().find(|b| b.date == date) else {
945 continue;
946 };
947 bucket.runs += 1;
948 match state.status {
949 RunStatus::Merged => bucket.merged += 1,
950 RunStatus::Ready => bucket.ready += 1,
951 _ => bucket.other += 1,
952 }
953 }
954 buckets
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960 use crate::config::Config;
961 use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
962 use crate::verdict::{Finding, Severity};
963 use std::path::PathBuf;
964
965 fn mixed_stats() -> Stats {
966 Stats {
967 totals: Totals {
968 runs: 7,
969 ..Totals::default()
970 },
971 agents: ["cc", "cx", "oc"]
972 .map(|a| AgentStats {
973 agent: a.into(),
974 entered: 2,
975 ..AgentStats::default()
976 })
977 .to_vec(),
978 reviewers: ["cx", "cc"]
979 .map(|a| ReviewerStats {
980 agent: a.into(),
981 ..ReviewerStats::default()
982 })
983 .to_vec(),
984 advisors: ["cx", "gone"]
985 .map(|a| AdvisorStats {
986 agent: a.into(),
987 ..AdvisorStats::default()
988 })
989 .to_vec(),
990 ..Stats::default()
991 }
992 }
993
994 fn roster(ids: &[&str]) -> BTreeSet<String> {
995 ids.iter().map(|s| (*s).to_owned()).collect()
996 }
997
998 #[test]
999 fn retain_roster_drops_retired_agents_from_every_table() {
1000 let mut st = mixed_stats();
1001 retain_roster(&mut st, &roster(&["cc", "oc"]));
1002 let agents: Vec<&str> = st.agents.iter().map(|a| a.agent.as_str()).collect();
1003 assert_eq!(agents, ["cc", "oc"]);
1004 let reviewers: Vec<&str> = st.reviewers.iter().map(|a| a.agent.as_str()).collect();
1005 assert_eq!(reviewers, ["cc"]);
1006 assert!(st.advisors.is_empty());
1007 assert_eq!(st.retired_hidden, ["cx", "gone"]);
1008 }
1009
1010 #[test]
1011 fn retain_roster_leaves_non_agent_totals_alone() {
1012 let mut st = mixed_stats();
1013 retain_roster(&mut st, &roster(&["cc"]));
1014 assert_eq!(st.totals.runs, 7);
1015 }
1016
1017 #[test]
1018 fn retain_roster_with_everyone_present_hides_nothing() {
1019 let mut st = mixed_stats();
1020 retain_roster(&mut st, &roster(&["cc", "cx", "oc", "gone"]));
1021 assert_eq!(st.agents.len(), 3);
1022 assert!(st.retired_hidden.is_empty());
1023 }
1024
1025 #[test]
1026 fn a_loaded_empty_roster_hides_every_agent_row() {
1027 let dir = tempfile::tempdir().unwrap();
1028 std::fs::write(dir.path().join("magi.toml"), "agents = []\n").unwrap();
1029 let mut st = mixed_stats();
1030 retain_current_roster(&mut st, dir.path());
1031 assert!(st.agents.is_empty() && st.reviewers.is_empty() && st.advisors.is_empty());
1032 assert_eq!(st.retired_hidden, ["cc", "cx", "gone", "oc"]);
1033 assert_eq!(st.totals.runs, 7);
1034 }
1035
1036 #[test]
1037 fn an_unreadable_config_does_not_filter() {
1038 let dir = tempfile::tempdir().unwrap();
1039 std::fs::write(dir.path().join("magi.toml"), "this is = = not toml").unwrap();
1040 let mut st = mixed_stats();
1041 retain_current_roster(&mut st, dir.path());
1042 assert_eq!(st.agents.len(), 3);
1043 assert!(st.retired_hidden.is_empty());
1044 }
1045
1046 fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
1047 Finding {
1048 id: id.to_owned(),
1049 severity: sev,
1050 file: Some(file.to_owned()),
1051 line: Some(line),
1052 title: title.to_owned(),
1053 detail: String::new(),
1054 }
1055 }
1056
1057 fn candidate(label: char, agent: &str) -> Candidate {
1058 Candidate {
1059 index: 0,
1060 label,
1061 agent: agent.to_owned(),
1062 branch: format!("magi/x/{label}"),
1063 worktree: PathBuf::from("/w"),
1064 summary: String::new(),
1065 stat: String::new(),
1066 files: 1,
1067 commits: 1,
1068 empty: false,
1069 failed: None,
1070 verified_noop: None,
1071 duration_ms: 0,
1072 folded: false,
1073 }
1074 }
1075
1076 fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
1077 let mut s = RunState::new(
1078 PathBuf::from("/repo"),
1079 "main".to_owned(),
1080 "abcdef".to_owned(),
1081 "task".to_owned(),
1082 Config::default(),
1083 );
1084 s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
1085 s.tally = Some(Tally {
1086 first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
1087 borda: BTreeMap::new(),
1088 winner,
1089 rankings: 3,
1090 unanimous_initial: false,
1091 deliberated: true,
1092 changed_votes: 1,
1093 unanimous_final: true,
1094 tie_break: None,
1095 judges: 3,
1096 present: 3,
1097 quorum: 2,
1098 met_quorum: true,
1099 uncontested: None,
1100 });
1101 s.reviews = reviews;
1102 s.status = status;
1103 s
1104 }
1105
1106 fn state_with_repo(
1107 reviews: Vec<ReviewRound>,
1108 winner: char,
1109 status: RunStatus,
1110 repo: &str,
1111 ) -> RunState {
1112 let mut s = state_with(reviews, winner, status);
1113 s.repo = PathBuf::from(repo);
1114 s
1115 }
1116
1117 #[test]
1118 fn win_rates_and_completion_are_counted_per_agent() {
1119 let states = vec![
1120 state_with(Vec::new(), 'B', RunStatus::Merged),
1121 state_with(Vec::new(), 'A', RunStatus::Blocked),
1122 ];
1123 let stats = collect(&states);
1124 assert_eq!(stats.totals.runs, 2);
1125 assert_eq!(stats.totals.merged, 1);
1126 assert_eq!(stats.totals.blocked, 1);
1127 assert_eq!(stats.totals.completion_rate(), 50.0);
1128 assert_eq!(stats.totals.split, 2);
1129 assert_eq!(stats.totals.minds_changed, 2);
1130 assert_eq!(stats.totals.converged, 2);
1131
1132 let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
1133 assert_eq!(beta.entered, 2);
1134 assert_eq!(beta.wins, 1);
1135 assert_eq!(beta.win_rate(), 50.0);
1136 assert_eq!(beta.competitive_entered, 2);
1137 assert_eq!(beta.competitive_wins, 1);
1138 assert_eq!(beta.competitive_win_rate(), 50.0);
1139 assert_eq!(beta.solo_entered, 0);
1140 assert_eq!(beta.solo_adopted, 0);
1141 assert_eq!(beta.solo_adoption_rate(), 0.0);
1142 }
1143
1144 #[test]
1145 fn competitive_adoption_requires_a_counted_entry() {
1146 let mut state = state_with(Vec::new(), 'C', RunStatus::Ready);
1147 let mut failed = candidate('C', "gamma");
1148 failed.failed = Some("failed".to_owned());
1149 state.candidates.push(failed);
1150 let stats = collect(&[state]);
1151 let gamma = stats.agents.iter().find(|a| a.agent == "gamma").unwrap();
1152 assert_eq!((gamma.competitive_entered, gamma.competitive_wins), (0, 0));
1153 assert_eq!(gamma.wins, 1); }
1155
1156 #[test]
1157 fn competitive_and_solo_win_rates_are_counted_separately() {
1158 let mut solo_state = RunState::new(
1159 PathBuf::from("/repo"),
1160 "main".to_owned(),
1161 "111111".to_owned(),
1162 "solo task".to_owned(),
1163 Config::default(),
1164 );
1165 solo_state.candidates = vec![candidate('A', "solo_agent")];
1166 solo_state.tally = Some(Tally {
1167 first_choice: BTreeMap::new(),
1168 borda: BTreeMap::new(),
1169 winner: 'A',
1170 rankings: 0,
1171 unanimous_initial: true,
1172 deliberated: false,
1173 changed_votes: 0,
1174 unanimous_final: true,
1175 tie_break: None,
1176 judges: 0,
1177 present: 0,
1178 quorum: 0,
1179 met_quorum: true,
1180 uncontested: Some("solo".to_owned()),
1181 });
1182 solo_state.status = RunStatus::Merged;
1183
1184 let mut solo_failed = RunState::new(
1186 PathBuf::from("/repo"),
1187 "main".to_owned(),
1188 "222222".to_owned(),
1189 "solo fail task".to_owned(),
1190 Config::default(),
1191 );
1192 let mut c_failed = candidate('A', "solo_agent");
1193 c_failed.empty = true;
1194 solo_failed.candidates = vec![c_failed];
1195 solo_failed.status = RunStatus::Failed;
1196
1197 let mut solo_noop = RunState::new(
1199 PathBuf::from("/repo"),
1200 "main".to_owned(),
1201 "333333".to_owned(),
1202 "noop task".to_owned(),
1203 Config::default(),
1204 );
1205 let mut c_noop = candidate('A', "solo_agent");
1206 c_noop.empty = true;
1207 c_noop.verified_noop = Some("no change needed".to_owned());
1208 solo_noop.candidates = vec![c_noop];
1209 solo_noop.status = RunStatus::VerifiedNoop;
1210
1211 let mut comp_state = RunState::new(
1213 PathBuf::from("/repo"),
1214 "main".to_owned(),
1215 "444444".to_owned(),
1216 "comp task".to_owned(),
1217 Config::default(),
1218 );
1219 comp_state.candidates = vec![
1220 candidate('A', "competitive_agent"),
1221 candidate('B', "rival_agent"),
1222 ];
1223 comp_state.tally = Some(Tally {
1224 first_choice: BTreeMap::from([('A', 2), ('B', 1)]),
1225 borda: BTreeMap::new(),
1226 winner: 'A',
1227 rankings: 3,
1228 unanimous_initial: false,
1229 deliberated: false,
1230 changed_votes: 0,
1231 unanimous_final: false,
1232 tie_break: None,
1233 judges: 3,
1234 present: 3,
1235 quorum: 2,
1236 met_quorum: true,
1237 uncontested: None,
1238 });
1239 comp_state.status = RunStatus::Merged;
1240
1241 let mut walkover_state = RunState::new(
1244 PathBuf::from("/repo"),
1245 "main".to_owned(),
1246 "555555".to_owned(),
1247 "walkover task".to_owned(),
1248 Config::default(),
1249 );
1250 let mut rival_failed = candidate('B', "rival_agent");
1251 rival_failed.failed = Some("crashed".to_owned());
1252 walkover_state.candidates = vec![candidate('A', "competitive_agent"), rival_failed];
1253 walkover_state.tally = Some(Tally {
1254 first_choice: BTreeMap::new(),
1255 borda: BTreeMap::new(),
1256 winner: 'A',
1257 rankings: 0,
1258 unanimous_initial: true,
1259 deliberated: false,
1260 changed_votes: 0,
1261 unanimous_final: true,
1262 tie_break: None,
1263 judges: 0,
1264 present: 0,
1265 quorum: 0,
1266 met_quorum: true,
1267 uncontested: Some("walkover".to_owned()),
1268 });
1269 walkover_state.status = RunStatus::Merged;
1270
1271 let mut review_state = RunState::new(
1273 PathBuf::from("/repo"),
1274 "main".to_owned(),
1275 "666666".to_owned(),
1276 "review task".to_owned(),
1277 Config::default(),
1278 );
1279 let c_rev = candidate('A', "(existing branch)");
1280 review_state.candidates = vec![c_rev];
1281 review_state.reviewed_commits = Some(vec!["feat: stuff".to_owned()]);
1282 review_state.status = RunStatus::Merged;
1283
1284 let states = vec![
1285 solo_state,
1286 solo_failed,
1287 solo_noop,
1288 comp_state,
1289 walkover_state,
1290 review_state,
1291 ];
1292 let stats = collect(&states);
1293
1294 let solo = stats
1295 .agents
1296 .iter()
1297 .find(|a| a.agent == "solo_agent")
1298 .unwrap();
1299 assert_eq!(solo.entered, 1);
1301 assert_eq!(solo.wins, 1);
1302 assert_eq!(solo.empty, 1);
1303 assert_eq!(solo.solo_entered, 2);
1306 assert_eq!(solo.solo_adopted, 1);
1307 assert_eq!(solo.solo_adoption_rate(), 50.0);
1308 assert_eq!(solo.competitive_entered, 0);
1310 assert_eq!(solo.competitive_wins, 0);
1311 assert_eq!(solo.competitive_win_rate(), 0.0);
1312
1313 let comp = stats
1314 .agents
1315 .iter()
1316 .find(|a| a.agent == "competitive_agent")
1317 .unwrap();
1318 assert_eq!(comp.entered, 2);
1321 assert_eq!(comp.wins, 2);
1322 assert_eq!(comp.competitive_entered, 1);
1324 assert_eq!(comp.competitive_wins, 1);
1325 assert_eq!(comp.competitive_win_rate(), 100.0);
1326 assert_eq!(comp.solo_entered, 0);
1328 assert_eq!(comp.solo_adopted, 0);
1329
1330 let rival = stats
1331 .agents
1332 .iter()
1333 .find(|a| a.agent == "rival_agent")
1334 .unwrap();
1335 assert_eq!(rival.entered, 1);
1337 assert_eq!(rival.wins, 0);
1338 assert_eq!(rival.competitive_entered, 1);
1339 assert_eq!(rival.competitive_wins, 0);
1340 assert_eq!(rival.competitive_win_rate(), 0.0);
1341
1342 if let Some(rev) = stats.agents.iter().find(|a| a.agent == "(existing branch)") {
1344 assert_eq!(rev.competitive_entered, 0);
1345 assert_eq!(rev.solo_entered, 0);
1346 }
1347 }
1348
1349 #[test]
1350 fn reviewer_precision_and_uniqueness() {
1351 let round = ReviewRound {
1352 round: 1,
1353 head: "h".to_owned(),
1354 verified_head: None,
1355 verified_at: None,
1356 reviews: vec![
1357 ReviewRecord {
1358 attempts: 0,
1359 reviewer: 1,
1360 agent: "alpha".to_owned(),
1361 summary: String::new(),
1362 findings: vec![
1363 finding(
1364 "R1-1-1",
1365 "src/a.rs",
1366 10,
1367 "panics on empty",
1368 Severity::Blocker,
1369 ),
1370 finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
1371 ],
1372 vote: None,
1373 failed: None,
1374 duration_ms: 0,
1375 },
1376 ReviewRecord {
1377 attempts: 0,
1378 reviewer: 2,
1379 agent: "beta".to_owned(),
1380 summary: String::new(),
1381 findings: vec![finding(
1383 "R1-2-1",
1384 "src/a.rs",
1385 13,
1386 "empty input panic",
1387 Severity::Blocker,
1388 )],
1389 vote: None,
1390 failed: None,
1391 duration_ms: 0,
1392 },
1393 ],
1394 e2e: Vec::new(),
1395 verify_retried: false,
1396 e2e_deferred: false,
1397 e2e_defer_reason: None,
1398 fix: Some(FixRecord {
1399 agent: "alpha".to_owned(),
1400 addressed: vec!["R1-1-1".to_owned()],
1401 rejected: Vec::new(),
1402 notes: String::new(),
1403 committed: true,
1404 failed: None,
1405 duration_ms: 0,
1406 continuation: None,
1407 }),
1408 blocking: 3,
1409 answered: 2,
1410 expected: 2,
1411 clean: false,
1412 progressed: true,
1413 vote_split: false,
1414 reconsideration: Vec::new(),
1415 verdict: None,
1416 };
1417 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
1418 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
1419 assert_eq!(alpha.submitted, 2);
1420 assert_eq!(alpha.adopted, 1);
1421 assert_eq!(alpha.precision(), 50.0);
1422 assert_eq!(alpha.adopted_per_round(), 1.0);
1423 assert_eq!(alpha.unique, 1);
1425
1426 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1427 assert_eq!(beta.submitted, 1);
1428 assert_eq!(beta.adopted, 0);
1429 assert_eq!(beta.unique, 0);
1430 }
1431
1432 #[test]
1433 fn a_lost_fix_report_does_not_count_as_zero_adoption() {
1434 let submitted = ReviewRound {
1435 round: 1,
1436 head: "h".to_owned(),
1437 verified_head: None,
1438 verified_at: None,
1439 reviews: vec![ReviewRecord {
1440 attempts: 0,
1441 reviewer: 1,
1442 agent: "alpha".to_owned(),
1443 summary: String::new(),
1444 findings: vec![finding(
1445 "R1-1-1",
1446 "src/a.rs",
1447 10,
1448 "panics on empty",
1449 Severity::Blocker,
1450 )],
1451 vote: None,
1452 failed: None,
1453 duration_ms: 0,
1454 }],
1455 e2e: Vec::new(),
1456 verify_retried: false,
1457 e2e_deferred: false,
1458 e2e_defer_reason: None,
1459 fix: Some(FixRecord {
1462 agent: "alpha".to_owned(),
1463 addressed: Vec::new(),
1464 rejected: Vec::new(),
1465 notes: String::new(),
1466 committed: true,
1467 failed: Some("unparsable fix report".to_owned()),
1468 duration_ms: 0,
1469 continuation: None,
1470 }),
1471 blocking: 4,
1472 answered: 1,
1473 expected: 1,
1474 clean: false,
1475 progressed: false,
1476 vote_split: false,
1477 reconsideration: Vec::new(),
1478 verdict: None,
1479 };
1480 let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
1481 assert!(
1482 stats.reviewers.is_empty(),
1483 "a round with no adoption signal must not enter any reviewer's \
1484 denominator: {:?}",
1485 stats.reviewers
1486 );
1487 }
1488
1489 #[test]
1490 fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
1491 let round = ReviewRound {
1492 round: 1,
1493 head: "h".to_owned(),
1494 verified_head: None,
1495 verified_at: None,
1496 reviews: vec![
1497 ReviewRecord {
1498 attempts: 0,
1499 reviewer: 1,
1500 agent: "alpha".to_owned(),
1501 summary: String::new(),
1502 findings: Vec::new(),
1503 vote: None,
1504 failed: None,
1505 duration_ms: 0,
1506 },
1507 ReviewRecord {
1508 attempts: 0,
1509 reviewer: 2,
1510 agent: "beta".to_owned(),
1511 summary: String::new(),
1512 findings: Vec::new(),
1513 vote: None,
1514 failed: Some("agent timed out".to_owned()),
1515 duration_ms: 0,
1516 },
1517 ],
1518 e2e: Vec::new(),
1519 verify_retried: false,
1520 e2e_deferred: false,
1521 e2e_defer_reason: None,
1522 fix: None,
1523 blocking: 0,
1524 answered: 1,
1525 expected: 2,
1526 clean: false,
1527 progressed: false,
1528 vote_split: false,
1529 reconsideration: Vec::new(),
1530 verdict: None,
1531 };
1532 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
1533
1534 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
1535 assert_eq!(alpha.seated, 1);
1536 assert_eq!(alpha.rounds, 1);
1537 assert_eq!(alpha.timeouts, 0);
1538 assert_eq!(alpha.submitted, 0);
1539
1540 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1541 assert_eq!(beta.seated, 1);
1542 assert_eq!(beta.timeouts, 1);
1543 assert_eq!(beta.submitted, 0);
1544 assert_eq!(beta.rounds, 0);
1548 assert_eq!(beta.timeout_rate(), 100.0);
1549 }
1550
1551 #[test]
1552 fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
1553 let round = ReviewRound {
1559 round: 1,
1560 head: "h".to_owned(),
1561 verified_head: None,
1562 verified_at: None,
1563 reviews: vec![
1564 ReviewRecord {
1565 attempts: 0,
1566 reviewer: 1,
1567 agent: "alpha".to_owned(),
1568 summary: String::new(),
1569 findings: vec![finding(
1570 "R1-1-1",
1571 "src/a.rs",
1572 10,
1573 "panics on empty",
1574 Severity::Blocker,
1575 )],
1576 vote: None,
1577 failed: None,
1578 duration_ms: 0,
1579 },
1580 ReviewRecord {
1581 attempts: 0,
1582 reviewer: 2,
1583 agent: "beta".to_owned(),
1584 summary: String::new(),
1585 findings: Vec::new(),
1586 vote: None,
1587 failed: Some("agent timed out".to_owned()),
1588 duration_ms: 0,
1589 },
1590 ],
1591 e2e: Vec::new(),
1592 verify_retried: false,
1593 e2e_deferred: false,
1594 e2e_defer_reason: None,
1595 fix: Some(FixRecord {
1596 agent: "alpha".to_owned(),
1597 addressed: Vec::new(),
1598 rejected: Vec::new(),
1599 notes: String::new(),
1600 committed: true,
1601 failed: Some("unparsable fix report".to_owned()),
1602 duration_ms: 0,
1603 continuation: None,
1604 }),
1605 blocking: 1,
1606 answered: 1,
1607 expected: 2,
1608 clean: false,
1609 progressed: false,
1610 vote_split: false,
1611 reconsideration: Vec::new(),
1612 verdict: None,
1613 };
1614 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
1615
1616 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1617 assert_eq!(beta.timeouts, 1);
1618 assert_eq!(beta.timeout_rate(), 100.0);
1619 assert!(
1622 !stats.reviewers.iter().any(|r| r.agent == "alpha"),
1623 "{:?}",
1624 stats.reviewers
1625 );
1626 }
1627
1628 #[test]
1629 fn e2e_sole_detection_needs_a_clean_static_review() {
1630 let fail = CommandOutcome {
1631 command: "cargo test".to_owned(),
1632 code: Some(101),
1633 output_tail: "boom".to_owned(),
1634 duration_ms: 1,
1635 resource_blocked: false,
1636 };
1637 let sole = ReviewRound {
1638 round: 1,
1639 head: "h".to_owned(),
1640 verified_head: None,
1641 verified_at: None,
1642 reviews: Vec::new(),
1643 e2e: vec![fail.clone()],
1644 verify_retried: false,
1645 e2e_deferred: false,
1646 e2e_defer_reason: None,
1647 fix: None,
1648 blocking: 0,
1649 answered: 0,
1650 expected: 0,
1651 clean: false,
1652 progressed: false,
1653 vote_split: false,
1654 reconsideration: Vec::new(),
1655 verdict: None,
1656 };
1657 let alongside = ReviewRound {
1658 round: 2,
1659 head: "h".to_owned(),
1660 verified_head: None,
1661 verified_at: None,
1662 reviews: Vec::new(),
1663 e2e: vec![fail],
1664 verify_retried: false,
1665 e2e_deferred: false,
1666 e2e_defer_reason: None,
1667 fix: None,
1668 blocking: 2,
1669 answered: 0,
1670 expected: 0,
1671 clean: false,
1672 progressed: false,
1673 vote_split: false,
1674 reconsideration: Vec::new(),
1675 verdict: None,
1676 };
1677 let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
1678 assert_eq!(stats.e2e.rounds, 2);
1679 assert_eq!(stats.e2e.failures, 2);
1680 assert_eq!(stats.e2e.sole_detections, 1);
1681 assert_eq!(stats.e2e.sole_rate(), 50.0);
1682 }
1683
1684 #[test]
1685 fn every_run_status_lands_in_exactly_one_breakdown_bucket() {
1686 let states = vec![
1687 state_with(Vec::new(), 'A', RunStatus::Merged),
1688 state_with(Vec::new(), 'A', RunStatus::Ready),
1689 state_with(Vec::new(), 'A', RunStatus::Blocked),
1690 state_with(Vec::new(), 'A', RunStatus::Failed),
1691 state_with(Vec::new(), 'A', RunStatus::Stalled),
1692 state_with(Vec::new(), 'A', RunStatus::VerifiedNoop),
1693 state_with(Vec::new(), 'A', RunStatus::Superseded),
1694 state_with(Vec::new(), 'A', RunStatus::Implementing),
1695 state_with(Vec::new(), 'A', RunStatus::Landing),
1696 ];
1697 let stats = collect(&states);
1698 let t = &stats.totals;
1699 assert_eq!(t.runs, 9);
1700 assert_eq!(t.merged, 1);
1701 assert_eq!(t.ready, 1);
1702 assert_eq!(t.blocked, 1);
1703 assert_eq!(t.failed, 1);
1704 assert_eq!(t.stalled, 1);
1705 assert_eq!(t.verified_noop, 1);
1706 assert_eq!(t.superseded, 1);
1707 assert_eq!(t.in_progress, 2);
1710 assert_eq!(
1711 t.merged
1712 + t.ready
1713 + t.blocked
1714 + t.failed
1715 + t.stalled
1716 + t.verified_noop
1717 + t.superseded
1718 + t.in_progress,
1719 t.runs,
1720 "every run must land in exactly one bucket of the breakdown"
1721 );
1722 }
1723
1724 #[test]
1725 fn empty_input_yields_zeroed_rates_not_nan() {
1726 let stats = collect(&[]);
1727 assert_eq!(stats.totals.completion_rate(), 0.0);
1728 assert_eq!(stats.totals.split_rate(), 0.0);
1729 assert_eq!(stats.e2e.sole_rate(), 0.0);
1730 assert!(stats.agents.is_empty());
1731 assert!(stats.advisors.is_empty());
1732 assert_eq!(AdvisorStats::default().reflection_rate(), 0.0);
1733 }
1734
1735 fn advisor_record(
1736 seat: &str,
1737 agent: &str,
1738 proposal: Option<crate::verdict::Proposal>,
1739 reflection: crate::advise::Reflection,
1740 ) -> crate::advise::AdvisorRecord {
1741 crate::advise::AdvisorRecord {
1742 seat: seat.to_owned(),
1743 agent: agent.to_owned(),
1744 proposal,
1745 error: None,
1746 duration_ms: 0,
1747 reflection,
1748 }
1749 }
1750
1751 fn a_proposal() -> crate::verdict::Proposal {
1752 crate::verdict::Proposal {
1753 approach: "do the thing".to_owned(),
1754 key_tradeoff: "speed over memory".to_owned(),
1755 risks: Vec::new(),
1756 touches: Vec::new(),
1757 why_not_naive: "the naive version breaks under load".to_owned(),
1758 }
1759 }
1760
1761 #[test]
1762 fn advisor_stats_count_proposed_absent_and_reflection_split() {
1763 use crate::advise::{Advice, Reflection};
1764
1765 let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1766 s.advice = Some(Advice {
1767 records: vec![
1768 advisor_record("advisor-1", "alpha", Some(a_proposal()), Reflection::Strong),
1769 advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1770 advisor_record("advisor-3", "alpha", None, Reflection::Absent),
1771 ],
1772 synthesis: Some("blended brief".to_owned()),
1773 });
1774
1775 let stats = collect(&[s]);
1776 let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1777 assert_eq!(alpha.seated, 3);
1778 assert_eq!(alpha.proposed, 2);
1779 assert_eq!(alpha.absent, 1);
1780 assert_eq!(alpha.strong, 1);
1781 assert_eq!(alpha.faint, 1);
1782 assert_eq!(alpha.reflection_rate(), 50.0);
1783 }
1784
1785 #[test]
1786 fn advisor_stats_count_absent_from_the_proposal_not_the_reflection_default() {
1787 use crate::advise::{Advice, Reflection};
1799
1800 let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1801 s.advice = Some(Advice {
1802 records: vec![
1803 advisor_record("advisor-1", "alpha", None, Reflection::Absent),
1804 advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1805 advisor_record("advisor-3", "alpha", Some(a_proposal()), Reflection::Absent),
1806 ],
1807 synthesis: None,
1808 });
1809
1810 let stats = collect(&[s]);
1811 let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1812 assert_eq!(alpha.seated, 3);
1813 assert_eq!(alpha.proposed, 2);
1814 assert_eq!(alpha.absent, 1);
1815 assert_eq!(alpha.faint, 2);
1816 assert_eq!(alpha.strong, 0);
1817 }
1818
1819 #[test]
1820 fn advisor_stats_ignore_runs_with_advise_off() {
1821 let s = state_with(Vec::new(), 'A', RunStatus::Merged);
1822 assert!(s.advice.is_none());
1823 let stats = collect(&[s]);
1824 assert!(stats.advisors.is_empty());
1825 }
1826
1827 #[test]
1828 fn same_defect_matches_titles_across_files() {
1829 let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
1830 let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
1831 assert!(same_defect(&a, &b));
1832 let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
1833 assert!(!same_defect(&a, &c));
1834 }
1835
1836 #[test]
1837 fn release_bump_stats_split_clean_from_attention_and_track_coverage() {
1838 use crate::run::ReleaseBump;
1839
1840 let unrecorded = state_with(Vec::new(), 'A', RunStatus::Merged);
1842
1843 let mut automerged = state_with(Vec::new(), 'A', RunStatus::Merged);
1845 automerged.release_bump = Some(ReleaseBump {
1846 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
1847 version: Some("1.2.3".to_owned()),
1848 automerge_enabled: true,
1849 merged_directly: false,
1850 local: false,
1851 release: None,
1852 problem: None,
1853 action_required: None,
1854 });
1855
1856 let mut merged_directly = state_with(Vec::new(), 'A', RunStatus::Merged);
1860 merged_directly.release_bump = Some(ReleaseBump {
1861 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
1862 version: Some("1.2.4".to_owned()),
1863 automerge_enabled: false,
1864 merged_directly: true,
1865 local: false,
1866 release: None,
1867 problem: None,
1868 action_required: None,
1869 });
1870
1871 let mut blocked_with_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1873 blocked_with_pr.release_bump = Some(ReleaseBump {
1874 pr_url: Some("https://github.com/o/r/pull/3".to_owned()),
1875 version: Some("1.2.5".to_owned()),
1876 automerge_enabled: false,
1877 merged_directly: false,
1878 local: false,
1879 release: None,
1880 problem: Some("checks red".to_owned()),
1881 action_required: Some("look at the PR".to_owned()),
1882 });
1883
1884 let mut blocked_without_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1886 blocked_without_pr.release_bump = Some(ReleaseBump {
1887 pr_url: None,
1888 version: Some("1.2.6".to_owned()),
1889 automerge_enabled: false,
1890 merged_directly: false,
1891 local: false,
1892 release: None,
1893 problem: Some("gh pr create failed".to_owned()),
1894 action_required: Some("open the PR by hand".to_owned()),
1895 });
1896
1897 let stats = collect(&[
1898 unrecorded,
1899 automerged,
1900 merged_directly,
1901 blocked_with_pr,
1902 blocked_without_pr,
1903 ]);
1904 let b = &stats.release_bumps;
1905 assert_eq!(b.merged, 5);
1906 assert_eq!(b.recorded, 4);
1907 assert_eq!(b.pr_opened, 3);
1908 assert_eq!(b.automerge_enabled, 1);
1909 assert_eq!(b.merged_directly, 1);
1910 assert_eq!(b.needs_attention, 2);
1911 assert_eq!(b.clean(), 2);
1912 assert_eq!(b.clean() + b.needs_attention, b.recorded);
1914 assert_eq!(b.coverage_rate(), 80.0);
1915 assert!((b.automerge_rate() - 33.333_333_333_333_336).abs() < 1e-9);
1916 assert_eq!(b.attention_rate(), 50.0);
1917 }
1918
1919 #[test]
1920 fn release_bump_ignores_runs_that_are_not_merged() {
1921 use crate::run::ReleaseBump;
1922
1923 let mut blocked = state_with(Vec::new(), 'A', RunStatus::Blocked);
1924 blocked.release_bump = Some(ReleaseBump {
1925 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
1926 version: Some("9.9.9".to_owned()),
1927 automerge_enabled: true,
1928 merged_directly: false,
1929 local: false,
1930 release: None,
1931 problem: None,
1932 action_required: None,
1933 });
1934
1935 let stats = collect(&[blocked]);
1936 let b = &stats.release_bumps;
1937 assert_eq!(b.merged, 0);
1938 assert_eq!(b.recorded, 0);
1939 assert_eq!(b.pr_opened, 0);
1940 }
1941
1942 #[test]
1943 fn release_bump_stats_are_zero_on_merged_runs_with_no_bump_or_no_runs() {
1944 let stats = collect(&[state_with(Vec::new(), 'A', RunStatus::Merged)]);
1945 let b = &stats.release_bumps;
1946 assert_eq!(b.merged, 1);
1947 assert_eq!(b.recorded, 0);
1948 assert_eq!(b.coverage_rate(), 0.0);
1949 assert_eq!(b.automerge_rate(), 0.0);
1950 assert_eq!(b.attention_rate(), 0.0);
1951 assert_eq!(b.clean(), 0);
1952
1953 let empty = collect(&[]);
1954 let b = &empty.release_bumps;
1955 assert_eq!(b.merged, 0);
1956 assert_eq!(b.coverage_rate(), 0.0);
1957 assert_eq!(b.automerge_rate(), 0.0);
1958 assert_eq!(b.attention_rate(), 0.0);
1959 }
1960
1961 fn event(node: &str, at_secs: i64, message: &str) -> crate::run::Event {
1962 crate::run::Event {
1963 at: jiff::Timestamp::from_second(at_secs).unwrap(),
1964 node: node.to_owned(),
1965 message: message.to_owned(),
1966 }
1967 }
1968
1969 fn state_with_events(events: Vec<crate::run::Event>) -> RunState {
1970 let mut s = RunState::new(
1971 PathBuf::from("/repo"),
1972 "main".to_owned(),
1973 "abcdef".to_owned(),
1974 "task".to_owned(),
1975 Config::default(),
1976 );
1977 s.events = events;
1978 s
1979 }
1980
1981 #[test]
1982 fn a_node_with_multiple_events_spans_first_to_last() {
1983 let s = state_with_events(vec![
1984 event("implement", 1_000, "start"),
1985 event("implement", 1_030, "still running"),
1986 event("implement", 1_090, "done"),
1987 ]);
1988 let nodes = node_durations(&[s]);
1989 assert_eq!(nodes.len(), 1);
1990 assert_eq!(nodes[0].node, "implement");
1991 assert_eq!(nodes[0].runs, 1);
1992 assert_eq!(nodes[0].total_secs, 90);
1993 assert_eq!(nodes[0].max_secs, 90);
1994 assert_eq!(nodes[0].single, 0);
1995 assert_eq!(nodes[0].mean_secs(), 90.0);
1996 }
1997
1998 #[test]
1999 fn a_node_with_a_single_event_is_unmeasured_not_zero() {
2000 let s = state_with_events(vec![event("gate", 2_000, "ran once")]);
2001 let nodes = node_durations(&[s]);
2002 assert_eq!(nodes.len(), 1);
2003 assert_eq!(nodes[0].node, "gate");
2004 assert_eq!(nodes[0].runs, 0);
2005 assert_eq!(nodes[0].total_secs, 0);
2006 assert_eq!(nodes[0].single, 1);
2007 assert_eq!(nodes[0].mean_secs(), 0.0);
2010 }
2011
2012 #[test]
2013 fn a_run_with_no_events_produces_no_node_rows() {
2014 let s = state_with_events(vec![]);
2015 let nodes = node_durations(&[s]);
2016 assert!(nodes.is_empty());
2017 }
2018
2019 #[test]
2020 fn multiple_runs_aggregate_the_same_node() {
2021 let a = state_with_events(vec![event("judge", 0, "start"), event("judge", 60, "done")]);
2022 let b = state_with_events(vec![
2023 event("judge", 0, "start"),
2024 event("judge", 200, "done"),
2025 ]);
2026 let c = state_with_events(vec![event("judge", 5, "start")]);
2029 let nodes = node_durations(&[a, b, c]);
2030 assert_eq!(nodes.len(), 1);
2031 let judge = &nodes[0];
2032 assert_eq!(judge.node, "judge");
2033 assert_eq!(judge.runs, 2);
2034 assert_eq!(judge.total_secs, 260);
2035 assert_eq!(judge.max_secs, 200);
2036 assert_eq!(judge.single, 1);
2037 assert_eq!(judge.mean_secs(), 130.0);
2038 }
2039
2040 #[test]
2041 fn by_repo_splits_states_and_group_totals_sum_to_the_whole() {
2042 let states = vec![
2043 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
2044 state_with_repo(Vec::new(), 'A', RunStatus::Blocked, "/repos/a"),
2045 state_with_repo(Vec::new(), 'B', RunStatus::Merged, "/repos/b"),
2046 ];
2047 let groups = by_repo(&states);
2048 assert_eq!(groups.len(), 2);
2049
2050 let total_runs: usize = groups.iter().map(|g| g.stats.totals.runs).sum();
2051 assert_eq!(total_runs, collect(&states).totals.runs);
2052 let total_merged: usize = groups.iter().map(|g| g.stats.totals.merged).sum();
2053 assert_eq!(total_merged, collect(&states).totals.merged);
2054
2055 assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
2057 assert_eq!(groups[0].name, "a");
2058 assert_eq!(groups[0].stats.totals.runs, 2);
2059 assert_eq!(groups[1].repo, PathBuf::from("/repos/b"));
2060 assert_eq!(groups[1].name, "b");
2061 assert_eq!(groups[1].stats.totals.runs, 1);
2062 }
2063
2064 #[test]
2065 fn by_repo_breaks_a_run_count_tie_by_path() {
2066 let states = vec![
2067 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/z"),
2068 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
2069 ];
2070 let groups = by_repo(&states);
2071 assert_eq!(groups.len(), 2);
2072 assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
2073 assert_eq!(groups[1].repo, PathBuf::from("/repos/z"));
2074 }
2075
2076 #[test]
2077 fn by_repo_on_empty_input_yields_no_groups() {
2078 assert!(by_repo(&[]).is_empty());
2079 }
2080
2081 #[test]
2082 fn filter_repo_matches_the_full_path_exactly() {
2083 let states = vec![
2084 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
2085 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/ab"),
2086 ];
2087 let hits = filter_repo(&states, Path::new("/repos/a"));
2088 assert_eq!(hits.len(), 1);
2089 assert_eq!(hits[0].repo, PathBuf::from("/repos/a"));
2090 }
2091
2092 #[test]
2093 fn filter_repo_returns_nothing_for_an_unknown_repo_or_empty_input() {
2094 let states = vec![state_with_repo(
2095 Vec::new(),
2096 'A',
2097 RunStatus::Merged,
2098 "/repos/a",
2099 )];
2100 assert!(filter_repo(&states, Path::new("/repos/nope")).is_empty());
2101 assert!(filter_repo(&[], Path::new("/repos/a")).is_empty());
2102 }
2103
2104 #[test]
2105 fn daily_cuts_by_local_date_zero_fills_and_ignores_the_window_outside() {
2106 let tz = jiff::tz::TimeZone::fixed(jiff::tz::offset(9));
2107 let today = jiff::civil::date(2026, 3, 2);
2108 let at = |ts: &str, status| {
2109 let mut s = state_with(Vec::new(), 'A', status);
2110 s.created_at = ts.parse().unwrap();
2111 s
2112 };
2113 let states = vec![
2114 at("2026-03-01T23:30:00Z", RunStatus::Merged),
2116 at("2026-03-02T01:00:00Z", RunStatus::Ready),
2117 at("2026-03-02T02:00:00Z", RunStatus::Implementing),
2118 at("2026-03-02T03:00:00Z", RunStatus::Failed),
2119 at("2026-02-01T00:00:00Z", RunStatus::Merged),
2121 at("2026-01-01T00:00:00Z", RunStatus::Merged),
2123 at("2026-03-05T00:00:00Z", RunStatus::Merged),
2124 ];
2125 let d = daily(&states, today, &tz, 30);
2126 assert_eq!(d.len(), 30);
2127 assert_eq!(d.last().unwrap().date, today);
2128 assert_eq!(d[0].date, jiff::civil::date(2026, 2, 1));
2129 assert!(d.windows(2).all(|w| w[0].date < w[1].date));
2130 let last = d.last().unwrap();
2131 assert_eq!(
2132 (last.runs, last.merged, last.ready, last.other),
2133 (4, 1, 1, 2)
2134 );
2135 assert_eq!(d[0].runs, 1);
2136 assert_eq!(d.iter().map(|b| b.runs).sum::<usize>(), 5);
2137 assert!(d.iter().all(|b| b.merged + b.ready + b.other == b.runs));
2138 }
2139
2140 #[test]
2141 fn daily_survives_a_dst_boundary() {
2142 let tz = jiff::tz::TimeZone::get("America/New_York").unwrap();
2143 let today = jiff::civil::date(2026, 3, 9);
2144 let d = daily(&[], today, &tz, 30);
2145 assert_eq!(d.len(), 30);
2146 assert!(
2147 d.windows(2)
2148 .all(|w| w[0].date.tomorrow().unwrap() == w[1].date)
2149 );
2150 }
2151}