1use std::collections::BTreeMap;
9
10use crate::run::{RunState, RunStatus, list_ids};
11
12#[derive(Debug, Clone, Default)]
14pub struct AgentStats {
15 pub agent: String,
17 pub entered: usize,
19 pub wins: usize,
21 pub empty: usize,
23}
24
25impl AgentStats {
26 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#[derive(Debug, Clone, Default)]
38pub struct ReviewerStats {
39 pub agent: String,
41 pub rounds: usize,
46 pub seated: usize,
51 pub submitted: usize,
53 pub adopted: usize,
55 pub unique: usize,
57 pub timeouts: usize,
61}
62
63impl ReviewerStats {
64 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 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 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 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#[derive(Debug, Clone, Default)]
110pub struct AdvisorStats {
111 pub agent: String,
113 pub seated: usize,
115 pub proposed: usize,
117 pub absent: usize,
125 pub faint: usize,
128 pub strong: usize,
131}
132
133impl AdvisorStats {
134 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#[derive(Debug, Clone, Default)]
153pub struct E2eStats {
154 pub rounds: usize,
156 pub failures: usize,
158 pub sole_detections: usize,
161 pub deferred: usize,
168}
169
170impl E2eStats {
171 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#[derive(Debug, Clone, Default)]
183pub struct Totals {
184 pub runs: usize,
186 pub merged: usize,
188 pub ready: usize,
190 pub blocked: usize,
192 pub failed: usize,
194 pub stalled: usize,
199 pub verified_noop: usize,
202 pub superseded: usize,
205 pub in_progress: usize,
210 pub tallied: usize,
212 pub split: usize,
214 pub deliberated: usize,
216 pub minds_changed: usize,
218 pub converged: usize,
220 pub review_rounds: usize,
222}
223
224impl Totals {
225 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 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#[derive(Debug, Clone, Default, PartialEq)]
252pub struct NodeDuration {
253 pub node: String,
255 pub runs: usize,
257 pub total_secs: i64,
259 pub max_secs: i64,
261 pub single: usize,
266}
267
268impl NodeDuration {
269 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
281pub 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 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#[derive(Debug, Clone, Default)]
345pub struct ReleaseBumpStats {
346 pub merged: usize,
348 pub recorded: usize,
350 pub pr_opened: usize,
352 pub automerge_enabled: usize,
358 pub merged_directly: usize,
365 pub needs_attention: usize,
368}
369
370impl ReleaseBumpStats {
371 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 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 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 pub fn clean(&self) -> usize {
409 self.recorded.saturating_sub(self.needs_attention)
410 }
411}
412
413#[derive(Debug, Clone, Default)]
415pub struct Stats {
416 pub totals: Totals,
418 pub agents: Vec<AgentStats>,
420 pub reviewers: Vec<ReviewerStats>,
422 pub advisors: Vec<AdvisorStats>,
428 pub e2e: E2eStats,
430 pub nodes: Vec<NodeDuration>,
432 pub release_bumps: ReleaseBumpStats,
434}
435
436pub fn load_all() -> Vec<RunState> {
438 list_ids()
439 .into_iter()
440 .filter_map(|id| RunState::load(&id).ok())
441 .collect()
442}
443
444pub 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 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 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 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 crate::advise::Reflection::Absent => entry.faint += 1,
574 }
575 }
576 }
577
578 for round in &state.reviews {
579 totals.review_rounds += 1;
580
581 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 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 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
684fn 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 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 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 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 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 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 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 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 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 let unrecorded = state_with(Vec::new(), 'A', RunStatus::Merged);
1292
1293 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 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 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 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 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 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 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}