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)]
105pub struct E2eStats {
106 pub rounds: usize,
108 pub failures: usize,
110 pub sole_detections: usize,
113 pub deferred: usize,
120}
121
122impl E2eStats {
123 pub fn sole_rate(&self) -> f64 {
125 if self.failures == 0 {
126 0.0
127 } else {
128 100.0 * self.sole_detections as f64 / self.failures as f64
129 }
130 }
131}
132
133#[derive(Debug, Clone, Default)]
135pub struct Totals {
136 pub runs: usize,
138 pub merged: usize,
140 pub ready: usize,
142 pub blocked: usize,
144 pub failed: usize,
146 pub stalled: usize,
151 pub verified_noop: usize,
154 pub superseded: usize,
157 pub in_progress: usize,
162 pub tallied: usize,
164 pub split: usize,
166 pub deliberated: usize,
168 pub minds_changed: usize,
170 pub converged: usize,
172 pub review_rounds: usize,
174}
175
176impl Totals {
177 pub fn completion_rate(&self) -> f64 {
179 if self.runs == 0 {
180 0.0
181 } else {
182 100.0 * (self.merged + self.ready) as f64 / self.runs as f64
183 }
184 }
185
186 pub fn split_rate(&self) -> f64 {
188 if self.tallied == 0 {
189 0.0
190 } else {
191 100.0 * self.split as f64 / self.tallied as f64
192 }
193 }
194}
195
196#[derive(Debug, Clone, Default)]
198pub struct Stats {
199 pub totals: Totals,
201 pub agents: Vec<AgentStats>,
203 pub reviewers: Vec<ReviewerStats>,
205 pub e2e: E2eStats,
207}
208
209pub fn load_all() -> Vec<RunState> {
211 list_ids()
212 .into_iter()
213 .filter_map(|id| RunState::load(&id).ok())
214 .collect()
215}
216
217pub fn collect(states: &[RunState]) -> Stats {
219 let mut totals = Totals::default();
220 let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
221 let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
222 let mut e2e = E2eStats::default();
223
224 for state in states {
225 totals.runs += 1;
226 match state.status {
227 RunStatus::Merged => totals.merged += 1,
228 RunStatus::Ready => totals.ready += 1,
229 RunStatus::Blocked => totals.blocked += 1,
230 RunStatus::Failed => totals.failed += 1,
231 RunStatus::Stalled => totals.stalled += 1,
232 RunStatus::VerifiedNoop => totals.verified_noop += 1,
233 RunStatus::Superseded => totals.superseded += 1,
239 RunStatus::Prep
240 | RunStatus::Implementing
241 | RunStatus::Judging
242 | RunStatus::Deliberating
243 | RunStatus::Voting
244 | RunStatus::Reviewing
245 | RunStatus::Gating
246 | RunStatus::Landing => totals.in_progress += 1,
247 }
248
249 for c in &state.candidates {
250 let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
251 agent: c.agent.clone(),
252 ..AgentStats::default()
253 });
254 if c.empty && c.verified_noop.is_none() {
259 entry.empty += 1;
260 }
261 if c.viable() {
262 entry.entered += 1;
263 }
264 }
265
266 if let Some(t) = &state.tally {
267 if t.uncontested.is_none() {
274 totals.tallied += 1;
275 if !t.unanimous_initial {
276 totals.split += 1;
277 }
278 if t.deliberated {
279 totals.deliberated += 1;
280 if t.changed_votes > 0 {
281 totals.minds_changed += 1;
282 }
283 if t.unanimous_final {
284 totals.converged += 1;
285 }
286 }
287 }
288 if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
289 agents
290 .entry(w.agent.clone())
291 .or_insert_with(|| AgentStats {
292 agent: w.agent.clone(),
293 ..AgentStats::default()
294 })
295 .wins += 1;
296 }
297 }
298
299 for round in &state.reviews {
300 totals.review_rounds += 1;
301
302 let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
309 let adopted: Vec<&String> = round
310 .fix
311 .as_ref()
312 .map(|f| f.addressed.iter().collect())
313 .unwrap_or_default();
314
315 for rec in &round.reviews {
316 let entry = reviewers
317 .entry(rec.agent.clone())
318 .or_insert_with(|| ReviewerStats {
319 agent: rec.agent.clone(),
320 ..ReviewerStats::default()
321 });
322 entry.seated += 1;
328 if rec.failed.is_some() {
329 entry.timeouts += 1;
330 continue;
331 }
332 if report_lost {
333 continue;
334 }
335 entry.rounds += 1;
336 entry.submitted += rec.findings.len();
337 for f in &rec.findings {
338 if adopted.iter().any(|a| **a == f.id) {
339 entry.adopted += 1;
340 }
341 let overlapped = round
342 .reviews
343 .iter()
344 .filter(|other| other.reviewer != rec.reviewer)
345 .flat_map(|other| other.findings.iter())
346 .any(|g| same_defect(f, g));
347 if !overlapped {
348 entry.unique += 1;
349 }
350 }
351 }
352
353 if round.e2e_deferred {
354 e2e.deferred += 1;
355 } else if !round.e2e.is_empty() {
356 e2e.rounds += 1;
357 if round.e2e.iter().any(|o| !o.ok()) {
358 e2e.failures += 1;
359 if round.blocking == 0 {
360 e2e.sole_detections += 1;
361 }
362 }
363 }
364 }
365 }
366
367 let mut agents: Vec<AgentStats> = agents.into_values().collect();
368 agents.sort_by(|a, b| {
369 b.win_rate()
370 .total_cmp(&a.win_rate())
371 .then(b.entered.cmp(&a.entered))
372 });
373 let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
374 reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
379 reviewers.sort_by(|a, b| {
380 b.adopted_per_round()
381 .total_cmp(&a.adopted_per_round())
382 .then(b.rounds.cmp(&a.rounds))
383 });
384
385 Stats {
386 totals,
387 agents,
388 reviewers,
389 e2e,
390 }
391}
392
393fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
399 if normalize(&a.title) == normalize(&b.title) {
400 return true;
401 }
402 match (&a.file, &b.file) {
403 (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
404 (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
405 _ => false,
406 },
407 _ => false,
408 }
409}
410
411fn normalize(title: &str) -> String {
412 title
413 .chars()
414 .filter(|c| c.is_alphanumeric())
415 .map(|c| c.to_ascii_lowercase())
416 .collect()
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use crate::config::Config;
423 use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
424 use crate::verdict::{Finding, Severity};
425 use std::path::PathBuf;
426
427 fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
428 Finding {
429 id: id.to_owned(),
430 severity: sev,
431 file: Some(file.to_owned()),
432 line: Some(line),
433 title: title.to_owned(),
434 detail: String::new(),
435 }
436 }
437
438 fn candidate(label: char, agent: &str) -> Candidate {
439 Candidate {
440 index: 0,
441 label,
442 agent: agent.to_owned(),
443 branch: format!("magi/x/{label}"),
444 worktree: PathBuf::from("/w"),
445 summary: String::new(),
446 stat: String::new(),
447 files: 1,
448 commits: 1,
449 empty: false,
450 failed: None,
451 verified_noop: None,
452 duration_ms: 0,
453 folded: false,
454 }
455 }
456
457 fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
458 let mut s = RunState::new(
459 PathBuf::from("/repo"),
460 "main".to_owned(),
461 "abcdef".to_owned(),
462 "task".to_owned(),
463 Config::default(),
464 );
465 s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
466 s.tally = Some(Tally {
467 first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
468 borda: BTreeMap::new(),
469 winner,
470 rankings: 3,
471 unanimous_initial: false,
472 deliberated: true,
473 changed_votes: 1,
474 unanimous_final: true,
475 tie_break: None,
476 judges: 3,
477 present: 3,
478 quorum: 2,
479 met_quorum: true,
480 uncontested: None,
481 });
482 s.reviews = reviews;
483 s.status = status;
484 s
485 }
486
487 #[test]
488 fn win_rates_and_completion_are_counted_per_agent() {
489 let states = vec![
490 state_with(Vec::new(), 'B', RunStatus::Merged),
491 state_with(Vec::new(), 'A', RunStatus::Blocked),
492 ];
493 let stats = collect(&states);
494 assert_eq!(stats.totals.runs, 2);
495 assert_eq!(stats.totals.merged, 1);
496 assert_eq!(stats.totals.blocked, 1);
497 assert_eq!(stats.totals.completion_rate(), 50.0);
498 assert_eq!(stats.totals.split, 2);
499 assert_eq!(stats.totals.minds_changed, 2);
500 assert_eq!(stats.totals.converged, 2);
501
502 let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
503 assert_eq!(beta.entered, 2);
504 assert_eq!(beta.wins, 1);
505 assert_eq!(beta.win_rate(), 50.0);
506 }
507
508 #[test]
509 fn reviewer_precision_and_uniqueness() {
510 let round = ReviewRound {
511 round: 1,
512 head: "h".to_owned(),
513 verified_head: None,
514 verified_at: None,
515 reviews: vec![
516 ReviewRecord {
517 attempts: 0,
518 reviewer: 1,
519 agent: "alpha".to_owned(),
520 summary: String::new(),
521 findings: vec![
522 finding(
523 "R1-1-1",
524 "src/a.rs",
525 10,
526 "panics on empty",
527 Severity::Blocker,
528 ),
529 finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
530 ],
531 vote: None,
532 failed: None,
533 duration_ms: 0,
534 },
535 ReviewRecord {
536 attempts: 0,
537 reviewer: 2,
538 agent: "beta".to_owned(),
539 summary: String::new(),
540 findings: vec![finding(
542 "R1-2-1",
543 "src/a.rs",
544 13,
545 "empty input panic",
546 Severity::Blocker,
547 )],
548 vote: None,
549 failed: None,
550 duration_ms: 0,
551 },
552 ],
553 e2e: Vec::new(),
554 verify_retried: false,
555 e2e_deferred: false,
556 e2e_defer_reason: None,
557 fix: Some(FixRecord {
558 agent: "alpha".to_owned(),
559 addressed: vec!["R1-1-1".to_owned()],
560 rejected: Vec::new(),
561 notes: String::new(),
562 committed: true,
563 failed: None,
564 duration_ms: 0,
565 continuation: None,
566 }),
567 blocking: 3,
568 answered: 2,
569 expected: 2,
570 clean: false,
571 progressed: true,
572 vote_split: false,
573 reconsideration: Vec::new(),
574 verdict: None,
575 };
576 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
577 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
578 assert_eq!(alpha.submitted, 2);
579 assert_eq!(alpha.adopted, 1);
580 assert_eq!(alpha.precision(), 50.0);
581 assert_eq!(alpha.adopted_per_round(), 1.0);
582 assert_eq!(alpha.unique, 1);
584
585 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
586 assert_eq!(beta.submitted, 1);
587 assert_eq!(beta.adopted, 0);
588 assert_eq!(beta.unique, 0);
589 }
590
591 #[test]
592 fn a_lost_fix_report_does_not_count_as_zero_adoption() {
593 let submitted = ReviewRound {
594 round: 1,
595 head: "h".to_owned(),
596 verified_head: None,
597 verified_at: None,
598 reviews: vec![ReviewRecord {
599 attempts: 0,
600 reviewer: 1,
601 agent: "alpha".to_owned(),
602 summary: String::new(),
603 findings: vec![finding(
604 "R1-1-1",
605 "src/a.rs",
606 10,
607 "panics on empty",
608 Severity::Blocker,
609 )],
610 vote: None,
611 failed: None,
612 duration_ms: 0,
613 }],
614 e2e: Vec::new(),
615 verify_retried: false,
616 e2e_deferred: false,
617 e2e_defer_reason: None,
618 fix: Some(FixRecord {
621 agent: "alpha".to_owned(),
622 addressed: Vec::new(),
623 rejected: Vec::new(),
624 notes: String::new(),
625 committed: true,
626 failed: Some("unparsable fix report".to_owned()),
627 duration_ms: 0,
628 continuation: None,
629 }),
630 blocking: 4,
631 answered: 1,
632 expected: 1,
633 clean: false,
634 progressed: false,
635 vote_split: false,
636 reconsideration: Vec::new(),
637 verdict: None,
638 };
639 let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
640 assert!(
641 stats.reviewers.is_empty(),
642 "a round with no adoption signal must not enter any reviewer's \
643 denominator: {:?}",
644 stats.reviewers
645 );
646 }
647
648 #[test]
649 fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
650 let round = ReviewRound {
651 round: 1,
652 head: "h".to_owned(),
653 verified_head: None,
654 verified_at: None,
655 reviews: vec![
656 ReviewRecord {
657 attempts: 0,
658 reviewer: 1,
659 agent: "alpha".to_owned(),
660 summary: String::new(),
661 findings: Vec::new(),
662 vote: None,
663 failed: None,
664 duration_ms: 0,
665 },
666 ReviewRecord {
667 attempts: 0,
668 reviewer: 2,
669 agent: "beta".to_owned(),
670 summary: String::new(),
671 findings: Vec::new(),
672 vote: None,
673 failed: Some("agent timed out".to_owned()),
674 duration_ms: 0,
675 },
676 ],
677 e2e: Vec::new(),
678 verify_retried: false,
679 e2e_deferred: false,
680 e2e_defer_reason: None,
681 fix: None,
682 blocking: 0,
683 answered: 1,
684 expected: 2,
685 clean: false,
686 progressed: false,
687 vote_split: false,
688 reconsideration: Vec::new(),
689 verdict: None,
690 };
691 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
692
693 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
694 assert_eq!(alpha.seated, 1);
695 assert_eq!(alpha.rounds, 1);
696 assert_eq!(alpha.timeouts, 0);
697 assert_eq!(alpha.submitted, 0);
698
699 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
700 assert_eq!(beta.seated, 1);
701 assert_eq!(beta.timeouts, 1);
702 assert_eq!(beta.submitted, 0);
703 assert_eq!(beta.rounds, 0);
707 assert_eq!(beta.timeout_rate(), 100.0);
708 }
709
710 #[test]
711 fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
712 let round = ReviewRound {
718 round: 1,
719 head: "h".to_owned(),
720 verified_head: None,
721 verified_at: None,
722 reviews: vec![
723 ReviewRecord {
724 attempts: 0,
725 reviewer: 1,
726 agent: "alpha".to_owned(),
727 summary: String::new(),
728 findings: vec![finding(
729 "R1-1-1",
730 "src/a.rs",
731 10,
732 "panics on empty",
733 Severity::Blocker,
734 )],
735 vote: None,
736 failed: None,
737 duration_ms: 0,
738 },
739 ReviewRecord {
740 attempts: 0,
741 reviewer: 2,
742 agent: "beta".to_owned(),
743 summary: String::new(),
744 findings: Vec::new(),
745 vote: None,
746 failed: Some("agent timed out".to_owned()),
747 duration_ms: 0,
748 },
749 ],
750 e2e: Vec::new(),
751 verify_retried: false,
752 e2e_deferred: false,
753 e2e_defer_reason: None,
754 fix: Some(FixRecord {
755 agent: "alpha".to_owned(),
756 addressed: Vec::new(),
757 rejected: Vec::new(),
758 notes: String::new(),
759 committed: true,
760 failed: Some("unparsable fix report".to_owned()),
761 duration_ms: 0,
762 continuation: None,
763 }),
764 blocking: 1,
765 answered: 1,
766 expected: 2,
767 clean: false,
768 progressed: false,
769 vote_split: false,
770 reconsideration: Vec::new(),
771 verdict: None,
772 };
773 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
774
775 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
776 assert_eq!(beta.timeouts, 1);
777 assert_eq!(beta.timeout_rate(), 100.0);
778 assert!(
781 !stats.reviewers.iter().any(|r| r.agent == "alpha"),
782 "{:?}",
783 stats.reviewers
784 );
785 }
786
787 #[test]
788 fn e2e_sole_detection_needs_a_clean_static_review() {
789 let fail = CommandOutcome {
790 command: "cargo test".to_owned(),
791 code: Some(101),
792 output_tail: "boom".to_owned(),
793 duration_ms: 1,
794 resource_blocked: false,
795 };
796 let sole = ReviewRound {
797 round: 1,
798 head: "h".to_owned(),
799 verified_head: None,
800 verified_at: None,
801 reviews: Vec::new(),
802 e2e: vec![fail.clone()],
803 verify_retried: false,
804 e2e_deferred: false,
805 e2e_defer_reason: None,
806 fix: None,
807 blocking: 0,
808 answered: 0,
809 expected: 0,
810 clean: false,
811 progressed: false,
812 vote_split: false,
813 reconsideration: Vec::new(),
814 verdict: None,
815 };
816 let alongside = ReviewRound {
817 round: 2,
818 head: "h".to_owned(),
819 verified_head: None,
820 verified_at: None,
821 reviews: Vec::new(),
822 e2e: vec![fail],
823 verify_retried: false,
824 e2e_deferred: false,
825 e2e_defer_reason: None,
826 fix: None,
827 blocking: 2,
828 answered: 0,
829 expected: 0,
830 clean: false,
831 progressed: false,
832 vote_split: false,
833 reconsideration: Vec::new(),
834 verdict: None,
835 };
836 let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
837 assert_eq!(stats.e2e.rounds, 2);
838 assert_eq!(stats.e2e.failures, 2);
839 assert_eq!(stats.e2e.sole_detections, 1);
840 assert_eq!(stats.e2e.sole_rate(), 50.0);
841 }
842
843 #[test]
844 fn every_run_status_lands_in_exactly_one_breakdown_bucket() {
845 let states = vec![
846 state_with(Vec::new(), 'A', RunStatus::Merged),
847 state_with(Vec::new(), 'A', RunStatus::Ready),
848 state_with(Vec::new(), 'A', RunStatus::Blocked),
849 state_with(Vec::new(), 'A', RunStatus::Failed),
850 state_with(Vec::new(), 'A', RunStatus::Stalled),
851 state_with(Vec::new(), 'A', RunStatus::VerifiedNoop),
852 state_with(Vec::new(), 'A', RunStatus::Superseded),
853 state_with(Vec::new(), 'A', RunStatus::Implementing),
854 state_with(Vec::new(), 'A', RunStatus::Landing),
855 ];
856 let stats = collect(&states);
857 let t = &stats.totals;
858 assert_eq!(t.runs, 9);
859 assert_eq!(t.merged, 1);
860 assert_eq!(t.ready, 1);
861 assert_eq!(t.blocked, 1);
862 assert_eq!(t.failed, 1);
863 assert_eq!(t.stalled, 1);
864 assert_eq!(t.verified_noop, 1);
865 assert_eq!(t.superseded, 1);
866 assert_eq!(t.in_progress, 2);
869 assert_eq!(
870 t.merged
871 + t.ready
872 + t.blocked
873 + t.failed
874 + t.stalled
875 + t.verified_noop
876 + t.superseded
877 + t.in_progress,
878 t.runs,
879 "every run must land in exactly one bucket of the breakdown"
880 );
881 }
882
883 #[test]
884 fn empty_input_yields_zeroed_rates_not_nan() {
885 let stats = collect(&[]);
886 assert_eq!(stats.totals.completion_rate(), 0.0);
887 assert_eq!(stats.totals.split_rate(), 0.0);
888 assert_eq!(stats.e2e.sole_rate(), 0.0);
889 assert!(stats.agents.is_empty());
890 }
891
892 #[test]
893 fn same_defect_matches_titles_across_files() {
894 let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
895 let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
896 assert!(same_defect(&a, &b));
897 let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
898 assert!(!same_defect(&a, &c));
899 }
900}