1use std::collections::BTreeMap;
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}
25
26impl AgentStats {
27 pub fn win_rate(&self) -> f64 {
29 if self.entered == 0 {
30 0.0
31 } else {
32 100.0 * self.wins as f64 / self.entered as f64
33 }
34 }
35}
36
37#[derive(Debug, Clone, Default)]
39pub struct ReviewerStats {
40 pub agent: String,
42 pub rounds: usize,
47 pub seated: usize,
52 pub submitted: usize,
54 pub adopted: usize,
56 pub unique: usize,
58 pub timeouts: usize,
62}
63
64impl ReviewerStats {
65 pub fn adopted_per_round(&self) -> f64 {
67 if self.rounds == 0 {
68 0.0
69 } else {
70 self.adopted as f64 / self.rounds as f64
71 }
72 }
73
74 pub fn precision(&self) -> f64 {
78 if self.submitted == 0 {
79 0.0
80 } else {
81 100.0 * self.adopted as f64 / self.submitted as f64
82 }
83 }
84
85 pub fn unique_rate(&self) -> f64 {
87 if self.submitted == 0 {
88 0.0
89 } else {
90 100.0 * self.unique as f64 / self.submitted as f64
91 }
92 }
93
94 pub fn timeout_rate(&self) -> f64 {
96 if self.seated == 0 {
97 0.0
98 } else {
99 100.0 * self.timeouts as f64 / self.seated as f64
100 }
101 }
102}
103
104#[derive(Debug, Clone, Default)]
111pub struct AdvisorStats {
112 pub agent: String,
114 pub seated: usize,
116 pub proposed: usize,
118 pub absent: usize,
126 pub faint: usize,
129 pub strong: usize,
132}
133
134impl AdvisorStats {
135 pub fn reflection_rate(&self) -> f64 {
144 if self.proposed == 0 {
145 0.0
146 } else {
147 100.0 * self.strong as f64 / self.proposed as f64
148 }
149 }
150}
151
152#[derive(Debug, Clone, Default)]
154pub struct E2eStats {
155 pub rounds: usize,
157 pub failures: usize,
159 pub sole_detections: usize,
162 pub deferred: usize,
169}
170
171impl E2eStats {
172 pub fn sole_rate(&self) -> f64 {
174 if self.failures == 0 {
175 0.0
176 } else {
177 100.0 * self.sole_detections as f64 / self.failures as f64
178 }
179 }
180}
181
182#[derive(Debug, Clone, Default)]
184pub struct Totals {
185 pub runs: usize,
187 pub merged: usize,
189 pub ready: usize,
191 pub blocked: usize,
193 pub failed: usize,
195 pub stalled: usize,
200 pub verified_noop: usize,
203 pub superseded: usize,
206 pub in_progress: usize,
211 pub tallied: usize,
213 pub split: usize,
215 pub deliberated: usize,
217 pub minds_changed: usize,
219 pub converged: usize,
221 pub review_rounds: usize,
223}
224
225impl Totals {
226 pub fn completion_rate(&self) -> f64 {
228 if self.runs == 0 {
229 0.0
230 } else {
231 100.0 * (self.merged + self.ready) as f64 / self.runs as f64
232 }
233 }
234
235 pub fn split_rate(&self) -> f64 {
237 if self.tallied == 0 {
238 0.0
239 } else {
240 100.0 * self.split as f64 / self.tallied as f64
241 }
242 }
243}
244
245#[derive(Debug, Clone, Default, PartialEq)]
253pub struct NodeDuration {
254 pub node: String,
256 pub runs: usize,
258 pub total_secs: i64,
260 pub max_secs: i64,
262 pub single: usize,
267}
268
269impl NodeDuration {
270 pub fn mean_secs(&self) -> f64 {
274 if self.runs == 0 {
275 0.0
276 } else {
277 self.total_secs as f64 / self.runs as f64
278 }
279 }
280}
281
282pub fn node_durations<'a>(states: impl IntoIterator<Item = &'a RunState>) -> Vec<NodeDuration> {
290 let mut nodes: BTreeMap<String, NodeDuration> = BTreeMap::new();
291
292 for state in states {
293 let mut spans: BTreeMap<&str, (jiff::Timestamp, jiff::Timestamp, usize)> = BTreeMap::new();
294 for e in &state.events {
295 spans
296 .entry(e.node.as_str())
297 .and_modify(|(min, max, count)| {
298 if e.at < *min {
299 *min = e.at;
300 }
301 if e.at > *max {
302 *max = e.at;
303 }
304 *count += 1;
305 })
306 .or_insert((e.at, e.at, 1));
307 }
308
309 for (node, (min, max, count)) in spans {
310 let entry = nodes
311 .entry(node.to_owned())
312 .or_insert_with(|| NodeDuration {
313 node: node.to_owned(),
314 ..NodeDuration::default()
315 });
316 if count < 2 {
317 entry.single += 1;
318 continue;
319 }
320 let span_secs = (max - min).get_seconds();
324 entry.runs += 1;
325 entry.total_secs += span_secs;
326 if span_secs > entry.max_secs {
327 entry.max_secs = span_secs;
328 }
329 }
330 }
331
332 let mut nodes: Vec<NodeDuration> = nodes.into_values().collect();
333 nodes.sort_by(|a, b| b.total_secs.cmp(&a.total_secs).then(a.node.cmp(&b.node)));
334 nodes
335}
336
337#[derive(Debug, Clone, Default)]
346pub struct ReleaseBumpStats {
347 pub merged: usize,
349 pub recorded: usize,
351 pub pr_opened: usize,
353 pub automerge_enabled: usize,
359 pub merged_directly: usize,
366 pub needs_attention: usize,
369}
370
371impl ReleaseBumpStats {
372 pub fn coverage_rate(&self) -> f64 {
374 if self.merged == 0 {
375 0.0
376 } else {
377 100.0 * self.recorded as f64 / self.merged as f64
378 }
379 }
380
381 pub fn automerge_rate(&self) -> f64 {
383 if self.pr_opened == 0 {
384 0.0
385 } else {
386 100.0 * self.automerge_enabled as f64 / self.pr_opened as f64
387 }
388 }
389
390 pub fn attention_rate(&self) -> f64 {
392 if self.recorded == 0 {
393 0.0
394 } else {
395 100.0 * self.needs_attention as f64 / self.recorded as f64
396 }
397 }
398
399 pub fn clean(&self) -> usize {
410 self.recorded.saturating_sub(self.needs_attention)
411 }
412}
413
414#[derive(Debug, Clone, Default)]
416pub struct Stats {
417 pub totals: Totals,
419 pub agents: Vec<AgentStats>,
421 pub reviewers: Vec<ReviewerStats>,
423 pub advisors: Vec<AdvisorStats>,
429 pub e2e: E2eStats,
431 pub nodes: Vec<NodeDuration>,
433 pub release_bumps: ReleaseBumpStats,
435}
436
437pub fn load_all() -> Vec<RunState> {
439 list_ids()
440 .into_iter()
441 .filter_map(|id| RunState::load(&id).ok())
442 .collect()
443}
444
445#[derive(Debug, Clone)]
447pub struct RepoStats {
448 pub repo: PathBuf,
454 pub name: String,
458 pub stats: Stats,
461}
462
463pub fn by_repo(states: &[RunState]) -> Vec<RepoStats> {
470 let mut groups: BTreeMap<PathBuf, Vec<&RunState>> = BTreeMap::new();
471 for state in states {
472 groups.entry(state.repo.clone()).or_default().push(state);
473 }
474
475 let mut out: Vec<RepoStats> = groups
476 .into_iter()
477 .map(|(repo, group)| {
478 let stats = collect_refs(group);
479 let name = repo
480 .file_name()
481 .map(|n| n.to_string_lossy().into_owned())
482 .unwrap_or_else(|| repo.to_string_lossy().into_owned());
483 RepoStats { repo, name, stats }
484 })
485 .collect();
486 out.sort_by(|a, b| {
487 b.stats
488 .totals
489 .runs
490 .cmp(&a.stats.totals.runs)
491 .then(a.repo.cmp(&b.repo))
492 });
493 out
494}
495
496pub fn filter_repo<'a>(states: &'a [RunState], repo: &Path) -> Vec<&'a RunState> {
505 states.iter().filter(|s| s.repo == repo).collect()
506}
507
508pub fn collect(states: &[RunState]) -> Stats {
510 collect_refs(states)
511}
512
513pub fn collect_refs<'a>(states: impl IntoIterator<Item = &'a RunState>) -> Stats {
518 let states: Vec<&'a RunState> = states.into_iter().collect();
519 let mut totals = Totals::default();
520 let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
521 let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
522 let mut advisors: BTreeMap<String, AdvisorStats> = BTreeMap::new();
523 let mut e2e = E2eStats::default();
524 let mut release_bumps = ReleaseBumpStats::default();
525
526 for state in &states {
527 totals.runs += 1;
528 if state.status == RunStatus::Merged {
529 release_bumps.merged += 1;
530 if let Some(b) = &state.release_bump {
531 release_bumps.recorded += 1;
532 if b.pr_url.is_some() {
533 release_bumps.pr_opened += 1;
534 }
535 if b.automerge_enabled {
536 release_bumps.automerge_enabled += 1;
537 }
538 if b.merged_directly {
539 release_bumps.merged_directly += 1;
540 }
541 }
542 if state.needs_attention() {
543 release_bumps.needs_attention += 1;
544 }
545 }
546 match state.status {
547 RunStatus::Merged => totals.merged += 1,
548 RunStatus::Ready => totals.ready += 1,
549 RunStatus::Blocked => totals.blocked += 1,
550 RunStatus::Failed => totals.failed += 1,
551 RunStatus::Stalled => totals.stalled += 1,
552 RunStatus::VerifiedNoop => totals.verified_noop += 1,
553 RunStatus::Superseded | RunStatus::AlreadyInBase => totals.superseded += 1,
563 RunStatus::Prep
564 | RunStatus::Implementing
565 | RunStatus::Judging
566 | RunStatus::Deliberating
567 | RunStatus::Voting
568 | RunStatus::Reviewing
569 | RunStatus::Gating
570 | RunStatus::Landing => totals.in_progress += 1,
571 }
572
573 for c in &state.candidates {
574 let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
575 agent: c.agent.clone(),
576 ..AgentStats::default()
577 });
578 if c.empty && c.verified_noop.is_none() {
583 entry.empty += 1;
584 }
585 if c.viable() {
586 entry.entered += 1;
587 }
588 }
589
590 if let Some(t) = &state.tally {
591 if t.uncontested.is_none() {
598 totals.tallied += 1;
599 if !t.unanimous_initial {
600 totals.split += 1;
601 }
602 if t.deliberated {
603 totals.deliberated += 1;
604 if t.changed_votes > 0 {
605 totals.minds_changed += 1;
606 }
607 if t.unanimous_final {
608 totals.converged += 1;
609 }
610 }
611 }
612 if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
613 agents
614 .entry(w.agent.clone())
615 .or_insert_with(|| AgentStats {
616 agent: w.agent.clone(),
617 ..AgentStats::default()
618 })
619 .wins += 1;
620 }
621 }
622
623 if let Some(advice) = &state.advice {
624 for rec in &advice.records {
625 let entry = advisors
626 .entry(rec.agent.clone())
627 .or_insert_with(|| AdvisorStats {
628 agent: rec.agent.clone(),
629 ..AdvisorStats::default()
630 });
631 entry.seated += 1;
632 if rec.proposal.is_none() {
633 entry.absent += 1;
634 continue;
635 }
636 entry.proposed += 1;
637 match rec.reflection {
638 crate::advise::Reflection::Strong => entry.strong += 1,
639 crate::advise::Reflection::Faint => entry.faint += 1,
640 crate::advise::Reflection::Absent => entry.faint += 1,
651 }
652 }
653 }
654
655 for round in &state.reviews {
656 totals.review_rounds += 1;
657
658 let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
665 let adopted: Vec<&String> = round
666 .fix
667 .as_ref()
668 .map(|f| f.addressed.iter().collect())
669 .unwrap_or_default();
670
671 for rec in &round.reviews {
672 let entry = reviewers
673 .entry(rec.agent.clone())
674 .or_insert_with(|| ReviewerStats {
675 agent: rec.agent.clone(),
676 ..ReviewerStats::default()
677 });
678 entry.seated += 1;
684 if rec.failed.is_some() {
685 entry.timeouts += 1;
686 continue;
687 }
688 if report_lost {
689 continue;
690 }
691 entry.rounds += 1;
692 entry.submitted += rec.findings.len();
693 for f in &rec.findings {
694 if adopted.iter().any(|a| **a == f.id) {
695 entry.adopted += 1;
696 }
697 let overlapped = round
698 .reviews
699 .iter()
700 .filter(|other| other.reviewer != rec.reviewer)
701 .flat_map(|other| other.findings.iter())
702 .any(|g| same_defect(f, g));
703 if !overlapped {
704 entry.unique += 1;
705 }
706 }
707 }
708
709 if round.e2e_deferred {
710 e2e.deferred += 1;
711 } else if !round.e2e.is_empty() {
712 e2e.rounds += 1;
713 if round.e2e.iter().any(|o| !o.ok()) {
714 e2e.failures += 1;
715 if round.blocking == 0 {
716 e2e.sole_detections += 1;
717 }
718 }
719 }
720 }
721 }
722
723 let mut agents: Vec<AgentStats> = agents.into_values().collect();
724 agents.sort_by(|a, b| {
725 b.win_rate()
726 .total_cmp(&a.win_rate())
727 .then(b.entered.cmp(&a.entered))
728 });
729 let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
730 reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
735 reviewers.sort_by(|a, b| {
736 b.adopted_per_round()
737 .total_cmp(&a.adopted_per_round())
738 .then(b.rounds.cmp(&a.rounds))
739 });
740
741 let mut advisors: Vec<AdvisorStats> = advisors.into_values().collect();
742 advisors.sort_by(|a, b| {
743 b.reflection_rate()
744 .total_cmp(&a.reflection_rate())
745 .then(b.proposed.cmp(&a.proposed))
746 });
747
748 let nodes = node_durations(states);
749
750 Stats {
751 totals,
752 agents,
753 reviewers,
754 advisors,
755 e2e,
756 nodes,
757 release_bumps,
758 }
759}
760
761fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
767 if normalize(&a.title) == normalize(&b.title) {
768 return true;
769 }
770 match (&a.file, &b.file) {
771 (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
772 (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
773 _ => false,
774 },
775 _ => false,
776 }
777}
778
779fn normalize(title: &str) -> String {
780 title
781 .chars()
782 .filter(|c| c.is_alphanumeric())
783 .map(|c| c.to_ascii_lowercase())
784 .collect()
785}
786
787#[derive(Debug, Clone, PartialEq, Eq)]
789pub struct DayBucket {
790 pub date: jiff::civil::Date,
792 pub runs: usize,
794 pub merged: usize,
796 pub ready: usize,
798 pub other: usize,
800}
801
802pub fn daily<'a>(
811 states: impl IntoIterator<Item = &'a RunState>,
812 today: jiff::civil::Date,
813 tz: &jiff::tz::TimeZone,
814 days: usize,
815) -> Vec<DayBucket> {
816 let mut buckets: Vec<DayBucket> = (0..days)
817 .rev()
818 .filter_map(|back| {
819 let span = jiff::Span::new().days(i64::try_from(back).ok()?);
820 today.checked_sub(span).ok()
821 })
822 .map(|date| DayBucket {
823 date,
824 runs: 0,
825 merged: 0,
826 ready: 0,
827 other: 0,
828 })
829 .collect();
830 for state in states {
831 let date = state.created_at.to_zoned(tz.clone()).date();
832 let Some(bucket) = buckets.iter_mut().find(|b| b.date == date) else {
833 continue;
834 };
835 bucket.runs += 1;
836 match state.status {
837 RunStatus::Merged => bucket.merged += 1,
838 RunStatus::Ready => bucket.ready += 1,
839 _ => bucket.other += 1,
840 }
841 }
842 buckets
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848 use crate::config::Config;
849 use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
850 use crate::verdict::{Finding, Severity};
851 use std::path::PathBuf;
852
853 fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
854 Finding {
855 id: id.to_owned(),
856 severity: sev,
857 file: Some(file.to_owned()),
858 line: Some(line),
859 title: title.to_owned(),
860 detail: String::new(),
861 }
862 }
863
864 fn candidate(label: char, agent: &str) -> Candidate {
865 Candidate {
866 index: 0,
867 label,
868 agent: agent.to_owned(),
869 branch: format!("magi/x/{label}"),
870 worktree: PathBuf::from("/w"),
871 summary: String::new(),
872 stat: String::new(),
873 files: 1,
874 commits: 1,
875 empty: false,
876 failed: None,
877 verified_noop: None,
878 duration_ms: 0,
879 folded: false,
880 }
881 }
882
883 fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
884 let mut s = RunState::new(
885 PathBuf::from("/repo"),
886 "main".to_owned(),
887 "abcdef".to_owned(),
888 "task".to_owned(),
889 Config::default(),
890 );
891 s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
892 s.tally = Some(Tally {
893 first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
894 borda: BTreeMap::new(),
895 winner,
896 rankings: 3,
897 unanimous_initial: false,
898 deliberated: true,
899 changed_votes: 1,
900 unanimous_final: true,
901 tie_break: None,
902 judges: 3,
903 present: 3,
904 quorum: 2,
905 met_quorum: true,
906 uncontested: None,
907 });
908 s.reviews = reviews;
909 s.status = status;
910 s
911 }
912
913 fn state_with_repo(
914 reviews: Vec<ReviewRound>,
915 winner: char,
916 status: RunStatus,
917 repo: &str,
918 ) -> RunState {
919 let mut s = state_with(reviews, winner, status);
920 s.repo = PathBuf::from(repo);
921 s
922 }
923
924 #[test]
925 fn win_rates_and_completion_are_counted_per_agent() {
926 let states = vec![
927 state_with(Vec::new(), 'B', RunStatus::Merged),
928 state_with(Vec::new(), 'A', RunStatus::Blocked),
929 ];
930 let stats = collect(&states);
931 assert_eq!(stats.totals.runs, 2);
932 assert_eq!(stats.totals.merged, 1);
933 assert_eq!(stats.totals.blocked, 1);
934 assert_eq!(stats.totals.completion_rate(), 50.0);
935 assert_eq!(stats.totals.split, 2);
936 assert_eq!(stats.totals.minds_changed, 2);
937 assert_eq!(stats.totals.converged, 2);
938
939 let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
940 assert_eq!(beta.entered, 2);
941 assert_eq!(beta.wins, 1);
942 assert_eq!(beta.win_rate(), 50.0);
943 }
944
945 #[test]
946 fn reviewer_precision_and_uniqueness() {
947 let round = ReviewRound {
948 round: 1,
949 head: "h".to_owned(),
950 verified_head: None,
951 verified_at: None,
952 reviews: vec![
953 ReviewRecord {
954 attempts: 0,
955 reviewer: 1,
956 agent: "alpha".to_owned(),
957 summary: String::new(),
958 findings: vec![
959 finding(
960 "R1-1-1",
961 "src/a.rs",
962 10,
963 "panics on empty",
964 Severity::Blocker,
965 ),
966 finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
967 ],
968 vote: None,
969 failed: None,
970 duration_ms: 0,
971 },
972 ReviewRecord {
973 attempts: 0,
974 reviewer: 2,
975 agent: "beta".to_owned(),
976 summary: String::new(),
977 findings: vec![finding(
979 "R1-2-1",
980 "src/a.rs",
981 13,
982 "empty input panic",
983 Severity::Blocker,
984 )],
985 vote: None,
986 failed: None,
987 duration_ms: 0,
988 },
989 ],
990 e2e: Vec::new(),
991 verify_retried: false,
992 e2e_deferred: false,
993 e2e_defer_reason: None,
994 fix: Some(FixRecord {
995 agent: "alpha".to_owned(),
996 addressed: vec!["R1-1-1".to_owned()],
997 rejected: Vec::new(),
998 notes: String::new(),
999 committed: true,
1000 failed: None,
1001 duration_ms: 0,
1002 continuation: None,
1003 }),
1004 blocking: 3,
1005 answered: 2,
1006 expected: 2,
1007 clean: false,
1008 progressed: true,
1009 vote_split: false,
1010 reconsideration: Vec::new(),
1011 verdict: None,
1012 };
1013 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
1014 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
1015 assert_eq!(alpha.submitted, 2);
1016 assert_eq!(alpha.adopted, 1);
1017 assert_eq!(alpha.precision(), 50.0);
1018 assert_eq!(alpha.adopted_per_round(), 1.0);
1019 assert_eq!(alpha.unique, 1);
1021
1022 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1023 assert_eq!(beta.submitted, 1);
1024 assert_eq!(beta.adopted, 0);
1025 assert_eq!(beta.unique, 0);
1026 }
1027
1028 #[test]
1029 fn a_lost_fix_report_does_not_count_as_zero_adoption() {
1030 let submitted = ReviewRound {
1031 round: 1,
1032 head: "h".to_owned(),
1033 verified_head: None,
1034 verified_at: None,
1035 reviews: vec![ReviewRecord {
1036 attempts: 0,
1037 reviewer: 1,
1038 agent: "alpha".to_owned(),
1039 summary: String::new(),
1040 findings: vec![finding(
1041 "R1-1-1",
1042 "src/a.rs",
1043 10,
1044 "panics on empty",
1045 Severity::Blocker,
1046 )],
1047 vote: None,
1048 failed: None,
1049 duration_ms: 0,
1050 }],
1051 e2e: Vec::new(),
1052 verify_retried: false,
1053 e2e_deferred: false,
1054 e2e_defer_reason: None,
1055 fix: Some(FixRecord {
1058 agent: "alpha".to_owned(),
1059 addressed: Vec::new(),
1060 rejected: Vec::new(),
1061 notes: String::new(),
1062 committed: true,
1063 failed: Some("unparsable fix report".to_owned()),
1064 duration_ms: 0,
1065 continuation: None,
1066 }),
1067 blocking: 4,
1068 answered: 1,
1069 expected: 1,
1070 clean: false,
1071 progressed: false,
1072 vote_split: false,
1073 reconsideration: Vec::new(),
1074 verdict: None,
1075 };
1076 let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
1077 assert!(
1078 stats.reviewers.is_empty(),
1079 "a round with no adoption signal must not enter any reviewer's \
1080 denominator: {:?}",
1081 stats.reviewers
1082 );
1083 }
1084
1085 #[test]
1086 fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
1087 let round = ReviewRound {
1088 round: 1,
1089 head: "h".to_owned(),
1090 verified_head: None,
1091 verified_at: None,
1092 reviews: vec![
1093 ReviewRecord {
1094 attempts: 0,
1095 reviewer: 1,
1096 agent: "alpha".to_owned(),
1097 summary: String::new(),
1098 findings: Vec::new(),
1099 vote: None,
1100 failed: None,
1101 duration_ms: 0,
1102 },
1103 ReviewRecord {
1104 attempts: 0,
1105 reviewer: 2,
1106 agent: "beta".to_owned(),
1107 summary: String::new(),
1108 findings: Vec::new(),
1109 vote: None,
1110 failed: Some("agent timed out".to_owned()),
1111 duration_ms: 0,
1112 },
1113 ],
1114 e2e: Vec::new(),
1115 verify_retried: false,
1116 e2e_deferred: false,
1117 e2e_defer_reason: None,
1118 fix: None,
1119 blocking: 0,
1120 answered: 1,
1121 expected: 2,
1122 clean: false,
1123 progressed: false,
1124 vote_split: false,
1125 reconsideration: Vec::new(),
1126 verdict: None,
1127 };
1128 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
1129
1130 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
1131 assert_eq!(alpha.seated, 1);
1132 assert_eq!(alpha.rounds, 1);
1133 assert_eq!(alpha.timeouts, 0);
1134 assert_eq!(alpha.submitted, 0);
1135
1136 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1137 assert_eq!(beta.seated, 1);
1138 assert_eq!(beta.timeouts, 1);
1139 assert_eq!(beta.submitted, 0);
1140 assert_eq!(beta.rounds, 0);
1144 assert_eq!(beta.timeout_rate(), 100.0);
1145 }
1146
1147 #[test]
1148 fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
1149 let round = ReviewRound {
1155 round: 1,
1156 head: "h".to_owned(),
1157 verified_head: None,
1158 verified_at: None,
1159 reviews: vec![
1160 ReviewRecord {
1161 attempts: 0,
1162 reviewer: 1,
1163 agent: "alpha".to_owned(),
1164 summary: String::new(),
1165 findings: vec![finding(
1166 "R1-1-1",
1167 "src/a.rs",
1168 10,
1169 "panics on empty",
1170 Severity::Blocker,
1171 )],
1172 vote: None,
1173 failed: None,
1174 duration_ms: 0,
1175 },
1176 ReviewRecord {
1177 attempts: 0,
1178 reviewer: 2,
1179 agent: "beta".to_owned(),
1180 summary: String::new(),
1181 findings: Vec::new(),
1182 vote: None,
1183 failed: Some("agent timed out".to_owned()),
1184 duration_ms: 0,
1185 },
1186 ],
1187 e2e: Vec::new(),
1188 verify_retried: false,
1189 e2e_deferred: false,
1190 e2e_defer_reason: None,
1191 fix: Some(FixRecord {
1192 agent: "alpha".to_owned(),
1193 addressed: Vec::new(),
1194 rejected: Vec::new(),
1195 notes: String::new(),
1196 committed: true,
1197 failed: Some("unparsable fix report".to_owned()),
1198 duration_ms: 0,
1199 continuation: None,
1200 }),
1201 blocking: 1,
1202 answered: 1,
1203 expected: 2,
1204 clean: false,
1205 progressed: false,
1206 vote_split: false,
1207 reconsideration: Vec::new(),
1208 verdict: None,
1209 };
1210 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
1211
1212 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1213 assert_eq!(beta.timeouts, 1);
1214 assert_eq!(beta.timeout_rate(), 100.0);
1215 assert!(
1218 !stats.reviewers.iter().any(|r| r.agent == "alpha"),
1219 "{:?}",
1220 stats.reviewers
1221 );
1222 }
1223
1224 #[test]
1225 fn e2e_sole_detection_needs_a_clean_static_review() {
1226 let fail = CommandOutcome {
1227 command: "cargo test".to_owned(),
1228 code: Some(101),
1229 output_tail: "boom".to_owned(),
1230 duration_ms: 1,
1231 resource_blocked: false,
1232 };
1233 let sole = ReviewRound {
1234 round: 1,
1235 head: "h".to_owned(),
1236 verified_head: None,
1237 verified_at: None,
1238 reviews: Vec::new(),
1239 e2e: vec![fail.clone()],
1240 verify_retried: false,
1241 e2e_deferred: false,
1242 e2e_defer_reason: None,
1243 fix: None,
1244 blocking: 0,
1245 answered: 0,
1246 expected: 0,
1247 clean: false,
1248 progressed: false,
1249 vote_split: false,
1250 reconsideration: Vec::new(),
1251 verdict: None,
1252 };
1253 let alongside = ReviewRound {
1254 round: 2,
1255 head: "h".to_owned(),
1256 verified_head: None,
1257 verified_at: None,
1258 reviews: Vec::new(),
1259 e2e: vec![fail],
1260 verify_retried: false,
1261 e2e_deferred: false,
1262 e2e_defer_reason: None,
1263 fix: None,
1264 blocking: 2,
1265 answered: 0,
1266 expected: 0,
1267 clean: false,
1268 progressed: false,
1269 vote_split: false,
1270 reconsideration: Vec::new(),
1271 verdict: None,
1272 };
1273 let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
1274 assert_eq!(stats.e2e.rounds, 2);
1275 assert_eq!(stats.e2e.failures, 2);
1276 assert_eq!(stats.e2e.sole_detections, 1);
1277 assert_eq!(stats.e2e.sole_rate(), 50.0);
1278 }
1279
1280 #[test]
1281 fn every_run_status_lands_in_exactly_one_breakdown_bucket() {
1282 let states = vec![
1283 state_with(Vec::new(), 'A', RunStatus::Merged),
1284 state_with(Vec::new(), 'A', RunStatus::Ready),
1285 state_with(Vec::new(), 'A', RunStatus::Blocked),
1286 state_with(Vec::new(), 'A', RunStatus::Failed),
1287 state_with(Vec::new(), 'A', RunStatus::Stalled),
1288 state_with(Vec::new(), 'A', RunStatus::VerifiedNoop),
1289 state_with(Vec::new(), 'A', RunStatus::Superseded),
1290 state_with(Vec::new(), 'A', RunStatus::Implementing),
1291 state_with(Vec::new(), 'A', RunStatus::Landing),
1292 ];
1293 let stats = collect(&states);
1294 let t = &stats.totals;
1295 assert_eq!(t.runs, 9);
1296 assert_eq!(t.merged, 1);
1297 assert_eq!(t.ready, 1);
1298 assert_eq!(t.blocked, 1);
1299 assert_eq!(t.failed, 1);
1300 assert_eq!(t.stalled, 1);
1301 assert_eq!(t.verified_noop, 1);
1302 assert_eq!(t.superseded, 1);
1303 assert_eq!(t.in_progress, 2);
1306 assert_eq!(
1307 t.merged
1308 + t.ready
1309 + t.blocked
1310 + t.failed
1311 + t.stalled
1312 + t.verified_noop
1313 + t.superseded
1314 + t.in_progress,
1315 t.runs,
1316 "every run must land in exactly one bucket of the breakdown"
1317 );
1318 }
1319
1320 #[test]
1321 fn empty_input_yields_zeroed_rates_not_nan() {
1322 let stats = collect(&[]);
1323 assert_eq!(stats.totals.completion_rate(), 0.0);
1324 assert_eq!(stats.totals.split_rate(), 0.0);
1325 assert_eq!(stats.e2e.sole_rate(), 0.0);
1326 assert!(stats.agents.is_empty());
1327 assert!(stats.advisors.is_empty());
1328 assert_eq!(AdvisorStats::default().reflection_rate(), 0.0);
1329 }
1330
1331 fn advisor_record(
1332 seat: &str,
1333 agent: &str,
1334 proposal: Option<crate::verdict::Proposal>,
1335 reflection: crate::advise::Reflection,
1336 ) -> crate::advise::AdvisorRecord {
1337 crate::advise::AdvisorRecord {
1338 seat: seat.to_owned(),
1339 agent: agent.to_owned(),
1340 proposal,
1341 error: None,
1342 duration_ms: 0,
1343 reflection,
1344 }
1345 }
1346
1347 fn a_proposal() -> crate::verdict::Proposal {
1348 crate::verdict::Proposal {
1349 approach: "do the thing".to_owned(),
1350 key_tradeoff: "speed over memory".to_owned(),
1351 risks: Vec::new(),
1352 touches: Vec::new(),
1353 why_not_naive: "the naive version breaks under load".to_owned(),
1354 }
1355 }
1356
1357 #[test]
1358 fn advisor_stats_count_proposed_absent_and_reflection_split() {
1359 use crate::advise::{Advice, Reflection};
1360
1361 let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1362 s.advice = Some(Advice {
1363 records: vec![
1364 advisor_record("advisor-1", "alpha", Some(a_proposal()), Reflection::Strong),
1365 advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1366 advisor_record("advisor-3", "alpha", None, Reflection::Absent),
1367 ],
1368 synthesis: Some("blended brief".to_owned()),
1369 });
1370
1371 let stats = collect(&[s]);
1372 let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1373 assert_eq!(alpha.seated, 3);
1374 assert_eq!(alpha.proposed, 2);
1375 assert_eq!(alpha.absent, 1);
1376 assert_eq!(alpha.strong, 1);
1377 assert_eq!(alpha.faint, 1);
1378 assert_eq!(alpha.reflection_rate(), 50.0);
1379 }
1380
1381 #[test]
1382 fn advisor_stats_count_absent_from_the_proposal_not_the_reflection_default() {
1383 use crate::advise::{Advice, Reflection};
1395
1396 let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1397 s.advice = Some(Advice {
1398 records: vec![
1399 advisor_record("advisor-1", "alpha", None, Reflection::Absent),
1400 advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1401 advisor_record("advisor-3", "alpha", Some(a_proposal()), Reflection::Absent),
1402 ],
1403 synthesis: None,
1404 });
1405
1406 let stats = collect(&[s]);
1407 let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1408 assert_eq!(alpha.seated, 3);
1409 assert_eq!(alpha.proposed, 2);
1410 assert_eq!(alpha.absent, 1);
1411 assert_eq!(alpha.faint, 2);
1412 assert_eq!(alpha.strong, 0);
1413 }
1414
1415 #[test]
1416 fn advisor_stats_ignore_runs_with_advise_off() {
1417 let s = state_with(Vec::new(), 'A', RunStatus::Merged);
1418 assert!(s.advice.is_none());
1419 let stats = collect(&[s]);
1420 assert!(stats.advisors.is_empty());
1421 }
1422
1423 #[test]
1424 fn same_defect_matches_titles_across_files() {
1425 let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
1426 let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
1427 assert!(same_defect(&a, &b));
1428 let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
1429 assert!(!same_defect(&a, &c));
1430 }
1431
1432 #[test]
1433 fn release_bump_stats_split_clean_from_attention_and_track_coverage() {
1434 use crate::run::ReleaseBump;
1435
1436 let unrecorded = state_with(Vec::new(), 'A', RunStatus::Merged);
1438
1439 let mut automerged = state_with(Vec::new(), 'A', RunStatus::Merged);
1441 automerged.release_bump = Some(ReleaseBump {
1442 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
1443 version: Some("1.2.3".to_owned()),
1444 automerge_enabled: true,
1445 merged_directly: false,
1446 local: false,
1447 release: None,
1448 problem: None,
1449 action_required: None,
1450 });
1451
1452 let mut merged_directly = state_with(Vec::new(), 'A', RunStatus::Merged);
1456 merged_directly.release_bump = Some(ReleaseBump {
1457 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
1458 version: Some("1.2.4".to_owned()),
1459 automerge_enabled: false,
1460 merged_directly: true,
1461 local: false,
1462 release: None,
1463 problem: None,
1464 action_required: None,
1465 });
1466
1467 let mut blocked_with_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1469 blocked_with_pr.release_bump = Some(ReleaseBump {
1470 pr_url: Some("https://github.com/o/r/pull/3".to_owned()),
1471 version: Some("1.2.5".to_owned()),
1472 automerge_enabled: false,
1473 merged_directly: false,
1474 local: false,
1475 release: None,
1476 problem: Some("checks red".to_owned()),
1477 action_required: Some("look at the PR".to_owned()),
1478 });
1479
1480 let mut blocked_without_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1482 blocked_without_pr.release_bump = Some(ReleaseBump {
1483 pr_url: None,
1484 version: Some("1.2.6".to_owned()),
1485 automerge_enabled: false,
1486 merged_directly: false,
1487 local: false,
1488 release: None,
1489 problem: Some("gh pr create failed".to_owned()),
1490 action_required: Some("open the PR by hand".to_owned()),
1491 });
1492
1493 let stats = collect(&[
1494 unrecorded,
1495 automerged,
1496 merged_directly,
1497 blocked_with_pr,
1498 blocked_without_pr,
1499 ]);
1500 let b = &stats.release_bumps;
1501 assert_eq!(b.merged, 5);
1502 assert_eq!(b.recorded, 4);
1503 assert_eq!(b.pr_opened, 3);
1504 assert_eq!(b.automerge_enabled, 1);
1505 assert_eq!(b.merged_directly, 1);
1506 assert_eq!(b.needs_attention, 2);
1507 assert_eq!(b.clean(), 2);
1508 assert_eq!(b.clean() + b.needs_attention, b.recorded);
1510 assert_eq!(b.coverage_rate(), 80.0);
1511 assert!((b.automerge_rate() - 33.333_333_333_333_336).abs() < 1e-9);
1512 assert_eq!(b.attention_rate(), 50.0);
1513 }
1514
1515 #[test]
1516 fn release_bump_ignores_runs_that_are_not_merged() {
1517 use crate::run::ReleaseBump;
1518
1519 let mut blocked = state_with(Vec::new(), 'A', RunStatus::Blocked);
1520 blocked.release_bump = Some(ReleaseBump {
1521 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
1522 version: Some("9.9.9".to_owned()),
1523 automerge_enabled: true,
1524 merged_directly: false,
1525 local: false,
1526 release: None,
1527 problem: None,
1528 action_required: None,
1529 });
1530
1531 let stats = collect(&[blocked]);
1532 let b = &stats.release_bumps;
1533 assert_eq!(b.merged, 0);
1534 assert_eq!(b.recorded, 0);
1535 assert_eq!(b.pr_opened, 0);
1536 }
1537
1538 #[test]
1539 fn release_bump_stats_are_zero_on_merged_runs_with_no_bump_or_no_runs() {
1540 let stats = collect(&[state_with(Vec::new(), 'A', RunStatus::Merged)]);
1541 let b = &stats.release_bumps;
1542 assert_eq!(b.merged, 1);
1543 assert_eq!(b.recorded, 0);
1544 assert_eq!(b.coverage_rate(), 0.0);
1545 assert_eq!(b.automerge_rate(), 0.0);
1546 assert_eq!(b.attention_rate(), 0.0);
1547 assert_eq!(b.clean(), 0);
1548
1549 let empty = collect(&[]);
1550 let b = &empty.release_bumps;
1551 assert_eq!(b.merged, 0);
1552 assert_eq!(b.coverage_rate(), 0.0);
1553 assert_eq!(b.automerge_rate(), 0.0);
1554 assert_eq!(b.attention_rate(), 0.0);
1555 }
1556
1557 fn event(node: &str, at_secs: i64, message: &str) -> crate::run::Event {
1558 crate::run::Event {
1559 at: jiff::Timestamp::from_second(at_secs).unwrap(),
1560 node: node.to_owned(),
1561 message: message.to_owned(),
1562 }
1563 }
1564
1565 fn state_with_events(events: Vec<crate::run::Event>) -> RunState {
1566 let mut s = RunState::new(
1567 PathBuf::from("/repo"),
1568 "main".to_owned(),
1569 "abcdef".to_owned(),
1570 "task".to_owned(),
1571 Config::default(),
1572 );
1573 s.events = events;
1574 s
1575 }
1576
1577 #[test]
1578 fn a_node_with_multiple_events_spans_first_to_last() {
1579 let s = state_with_events(vec![
1580 event("implement", 1_000, "start"),
1581 event("implement", 1_030, "still running"),
1582 event("implement", 1_090, "done"),
1583 ]);
1584 let nodes = node_durations(&[s]);
1585 assert_eq!(nodes.len(), 1);
1586 assert_eq!(nodes[0].node, "implement");
1587 assert_eq!(nodes[0].runs, 1);
1588 assert_eq!(nodes[0].total_secs, 90);
1589 assert_eq!(nodes[0].max_secs, 90);
1590 assert_eq!(nodes[0].single, 0);
1591 assert_eq!(nodes[0].mean_secs(), 90.0);
1592 }
1593
1594 #[test]
1595 fn a_node_with_a_single_event_is_unmeasured_not_zero() {
1596 let s = state_with_events(vec![event("gate", 2_000, "ran once")]);
1597 let nodes = node_durations(&[s]);
1598 assert_eq!(nodes.len(), 1);
1599 assert_eq!(nodes[0].node, "gate");
1600 assert_eq!(nodes[0].runs, 0);
1601 assert_eq!(nodes[0].total_secs, 0);
1602 assert_eq!(nodes[0].single, 1);
1603 assert_eq!(nodes[0].mean_secs(), 0.0);
1606 }
1607
1608 #[test]
1609 fn a_run_with_no_events_produces_no_node_rows() {
1610 let s = state_with_events(vec![]);
1611 let nodes = node_durations(&[s]);
1612 assert!(nodes.is_empty());
1613 }
1614
1615 #[test]
1616 fn multiple_runs_aggregate_the_same_node() {
1617 let a = state_with_events(vec![event("judge", 0, "start"), event("judge", 60, "done")]);
1618 let b = state_with_events(vec![
1619 event("judge", 0, "start"),
1620 event("judge", 200, "done"),
1621 ]);
1622 let c = state_with_events(vec![event("judge", 5, "start")]);
1625 let nodes = node_durations(&[a, b, c]);
1626 assert_eq!(nodes.len(), 1);
1627 let judge = &nodes[0];
1628 assert_eq!(judge.node, "judge");
1629 assert_eq!(judge.runs, 2);
1630 assert_eq!(judge.total_secs, 260);
1631 assert_eq!(judge.max_secs, 200);
1632 assert_eq!(judge.single, 1);
1633 assert_eq!(judge.mean_secs(), 130.0);
1634 }
1635
1636 #[test]
1637 fn by_repo_splits_states_and_group_totals_sum_to_the_whole() {
1638 let states = vec![
1639 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1640 state_with_repo(Vec::new(), 'A', RunStatus::Blocked, "/repos/a"),
1641 state_with_repo(Vec::new(), 'B', RunStatus::Merged, "/repos/b"),
1642 ];
1643 let groups = by_repo(&states);
1644 assert_eq!(groups.len(), 2);
1645
1646 let total_runs: usize = groups.iter().map(|g| g.stats.totals.runs).sum();
1647 assert_eq!(total_runs, collect(&states).totals.runs);
1648 let total_merged: usize = groups.iter().map(|g| g.stats.totals.merged).sum();
1649 assert_eq!(total_merged, collect(&states).totals.merged);
1650
1651 assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
1653 assert_eq!(groups[0].name, "a");
1654 assert_eq!(groups[0].stats.totals.runs, 2);
1655 assert_eq!(groups[1].repo, PathBuf::from("/repos/b"));
1656 assert_eq!(groups[1].name, "b");
1657 assert_eq!(groups[1].stats.totals.runs, 1);
1658 }
1659
1660 #[test]
1661 fn by_repo_breaks_a_run_count_tie_by_path() {
1662 let states = vec![
1663 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/z"),
1664 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1665 ];
1666 let groups = by_repo(&states);
1667 assert_eq!(groups.len(), 2);
1668 assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
1669 assert_eq!(groups[1].repo, PathBuf::from("/repos/z"));
1670 }
1671
1672 #[test]
1673 fn by_repo_on_empty_input_yields_no_groups() {
1674 assert!(by_repo(&[]).is_empty());
1675 }
1676
1677 #[test]
1678 fn filter_repo_matches_the_full_path_exactly() {
1679 let states = vec![
1680 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1681 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/ab"),
1682 ];
1683 let hits = filter_repo(&states, Path::new("/repos/a"));
1684 assert_eq!(hits.len(), 1);
1685 assert_eq!(hits[0].repo, PathBuf::from("/repos/a"));
1686 }
1687
1688 #[test]
1689 fn filter_repo_returns_nothing_for_an_unknown_repo_or_empty_input() {
1690 let states = vec![state_with_repo(
1691 Vec::new(),
1692 'A',
1693 RunStatus::Merged,
1694 "/repos/a",
1695 )];
1696 assert!(filter_repo(&states, Path::new("/repos/nope")).is_empty());
1697 assert!(filter_repo(&[], Path::new("/repos/a")).is_empty());
1698 }
1699
1700 #[test]
1701 fn daily_cuts_by_local_date_zero_fills_and_ignores_the_window_outside() {
1702 let tz = jiff::tz::TimeZone::fixed(jiff::tz::offset(9));
1703 let today = jiff::civil::date(2026, 3, 2);
1704 let at = |ts: &str, status| {
1705 let mut s = state_with(Vec::new(), 'A', status);
1706 s.created_at = ts.parse().unwrap();
1707 s
1708 };
1709 let states = vec![
1710 at("2026-03-01T23:30:00Z", RunStatus::Merged),
1712 at("2026-03-02T01:00:00Z", RunStatus::Ready),
1713 at("2026-03-02T02:00:00Z", RunStatus::Implementing),
1714 at("2026-03-02T03:00:00Z", RunStatus::Failed),
1715 at("2026-02-01T00:00:00Z", RunStatus::Merged),
1717 at("2026-01-01T00:00:00Z", RunStatus::Merged),
1719 at("2026-03-05T00:00:00Z", RunStatus::Merged),
1720 ];
1721 let d = daily(&states, today, &tz, 30);
1722 assert_eq!(d.len(), 30);
1723 assert_eq!(d.last().unwrap().date, today);
1724 assert_eq!(d[0].date, jiff::civil::date(2026, 2, 1));
1725 assert!(d.windows(2).all(|w| w[0].date < w[1].date));
1726 let last = d.last().unwrap();
1727 assert_eq!(
1728 (last.runs, last.merged, last.ready, last.other),
1729 (4, 1, 1, 2)
1730 );
1731 assert_eq!(d[0].runs, 1);
1732 assert_eq!(d.iter().map(|b| b.runs).sum::<usize>(), 5);
1733 assert!(d.iter().all(|b| b.merged + b.ready + b.other == b.runs));
1734 }
1735
1736 #[test]
1737 fn daily_survives_a_dst_boundary() {
1738 let tz = jiff::tz::TimeZone::get("America/New_York").unwrap();
1739 let today = jiff::civil::date(2026, 3, 9);
1740 let d = daily(&[], today, &tz, 30);
1741 assert_eq!(d.len(), 30);
1742 assert!(
1743 d.windows(2)
1744 .all(|w| w[0].date.tomorrow().unwrap() == w[1].date)
1745 );
1746 }
1747}