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 tallied: usize,
148 pub split: usize,
150 pub deliberated: usize,
152 pub minds_changed: usize,
154 pub converged: usize,
156 pub review_rounds: usize,
158}
159
160impl Totals {
161 pub fn completion_rate(&self) -> f64 {
163 if self.runs == 0 {
164 0.0
165 } else {
166 100.0 * (self.merged + self.ready) as f64 / self.runs as f64
167 }
168 }
169
170 pub fn split_rate(&self) -> f64 {
172 if self.tallied == 0 {
173 0.0
174 } else {
175 100.0 * self.split as f64 / self.tallied as f64
176 }
177 }
178}
179
180#[derive(Debug, Clone, Default)]
182pub struct Stats {
183 pub totals: Totals,
185 pub agents: Vec<AgentStats>,
187 pub reviewers: Vec<ReviewerStats>,
189 pub e2e: E2eStats,
191}
192
193pub fn load_all() -> Vec<RunState> {
195 list_ids()
196 .into_iter()
197 .filter_map(|id| RunState::load(&id).ok())
198 .collect()
199}
200
201pub fn collect(states: &[RunState]) -> Stats {
203 let mut totals = Totals::default();
204 let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
205 let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
206 let mut e2e = E2eStats::default();
207
208 for state in states {
209 totals.runs += 1;
210 match state.status {
211 RunStatus::Merged => totals.merged += 1,
212 RunStatus::Ready => totals.ready += 1,
213 RunStatus::Blocked => totals.blocked += 1,
214 RunStatus::Failed => totals.failed += 1,
215 _ => {}
216 }
217
218 for c in &state.candidates {
219 let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
220 agent: c.agent.clone(),
221 ..AgentStats::default()
222 });
223 if c.empty && c.verified_noop.is_none() {
228 entry.empty += 1;
229 }
230 if c.viable() {
231 entry.entered += 1;
232 }
233 }
234
235 if let Some(t) = &state.tally {
236 if t.uncontested.is_none() {
243 totals.tallied += 1;
244 if !t.unanimous_initial {
245 totals.split += 1;
246 }
247 if t.deliberated {
248 totals.deliberated += 1;
249 if t.changed_votes > 0 {
250 totals.minds_changed += 1;
251 }
252 if t.unanimous_final {
253 totals.converged += 1;
254 }
255 }
256 }
257 if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
258 agents
259 .entry(w.agent.clone())
260 .or_insert_with(|| AgentStats {
261 agent: w.agent.clone(),
262 ..AgentStats::default()
263 })
264 .wins += 1;
265 }
266 }
267
268 for round in &state.reviews {
269 totals.review_rounds += 1;
270
271 let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
278 let adopted: Vec<&String> = round
279 .fix
280 .as_ref()
281 .map(|f| f.addressed.iter().collect())
282 .unwrap_or_default();
283
284 for rec in &round.reviews {
285 let entry = reviewers
286 .entry(rec.agent.clone())
287 .or_insert_with(|| ReviewerStats {
288 agent: rec.agent.clone(),
289 ..ReviewerStats::default()
290 });
291 entry.seated += 1;
297 if rec.failed.is_some() {
298 entry.timeouts += 1;
299 continue;
300 }
301 if report_lost {
302 continue;
303 }
304 entry.rounds += 1;
305 entry.submitted += rec.findings.len();
306 for f in &rec.findings {
307 if adopted.iter().any(|a| **a == f.id) {
308 entry.adopted += 1;
309 }
310 let overlapped = round
311 .reviews
312 .iter()
313 .filter(|other| other.reviewer != rec.reviewer)
314 .flat_map(|other| other.findings.iter())
315 .any(|g| same_defect(f, g));
316 if !overlapped {
317 entry.unique += 1;
318 }
319 }
320 }
321
322 if round.e2e_deferred {
323 e2e.deferred += 1;
324 } else if !round.e2e.is_empty() {
325 e2e.rounds += 1;
326 if round.e2e.iter().any(|o| !o.ok()) {
327 e2e.failures += 1;
328 if round.blocking == 0 {
329 e2e.sole_detections += 1;
330 }
331 }
332 }
333 }
334 }
335
336 let mut agents: Vec<AgentStats> = agents.into_values().collect();
337 agents.sort_by(|a, b| {
338 b.win_rate()
339 .total_cmp(&a.win_rate())
340 .then(b.entered.cmp(&a.entered))
341 });
342 let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
343 reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
348 reviewers.sort_by(|a, b| {
349 b.adopted_per_round()
350 .total_cmp(&a.adopted_per_round())
351 .then(b.rounds.cmp(&a.rounds))
352 });
353
354 Stats {
355 totals,
356 agents,
357 reviewers,
358 e2e,
359 }
360}
361
362fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
368 if normalize(&a.title) == normalize(&b.title) {
369 return true;
370 }
371 match (&a.file, &b.file) {
372 (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
373 (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
374 _ => false,
375 },
376 _ => false,
377 }
378}
379
380fn normalize(title: &str) -> String {
381 title
382 .chars()
383 .filter(|c| c.is_alphanumeric())
384 .map(|c| c.to_ascii_lowercase())
385 .collect()
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::config::Config;
392 use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
393 use crate::verdict::{Finding, Severity};
394 use std::path::PathBuf;
395
396 fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
397 Finding {
398 id: id.to_owned(),
399 severity: sev,
400 file: Some(file.to_owned()),
401 line: Some(line),
402 title: title.to_owned(),
403 detail: String::new(),
404 }
405 }
406
407 fn candidate(label: char, agent: &str) -> Candidate {
408 Candidate {
409 index: 0,
410 label,
411 agent: agent.to_owned(),
412 branch: format!("magi/x/{label}"),
413 worktree: PathBuf::from("/w"),
414 summary: String::new(),
415 stat: String::new(),
416 files: 1,
417 commits: 1,
418 empty: false,
419 failed: None,
420 verified_noop: None,
421 duration_ms: 0,
422 folded: false,
423 }
424 }
425
426 fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
427 let mut s = RunState::new(
428 PathBuf::from("/repo"),
429 "main".to_owned(),
430 "abcdef".to_owned(),
431 "task".to_owned(),
432 Config::default(),
433 );
434 s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
435 s.tally = Some(Tally {
436 first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
437 borda: BTreeMap::new(),
438 winner,
439 rankings: 3,
440 unanimous_initial: false,
441 deliberated: true,
442 changed_votes: 1,
443 unanimous_final: true,
444 tie_break: None,
445 judges: 3,
446 present: 3,
447 quorum: 2,
448 met_quorum: true,
449 uncontested: None,
450 });
451 s.reviews = reviews;
452 s.status = status;
453 s
454 }
455
456 #[test]
457 fn win_rates_and_completion_are_counted_per_agent() {
458 let states = vec![
459 state_with(Vec::new(), 'B', RunStatus::Merged),
460 state_with(Vec::new(), 'A', RunStatus::Blocked),
461 ];
462 let stats = collect(&states);
463 assert_eq!(stats.totals.runs, 2);
464 assert_eq!(stats.totals.merged, 1);
465 assert_eq!(stats.totals.blocked, 1);
466 assert_eq!(stats.totals.completion_rate(), 50.0);
467 assert_eq!(stats.totals.split, 2);
468 assert_eq!(stats.totals.minds_changed, 2);
469 assert_eq!(stats.totals.converged, 2);
470
471 let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
472 assert_eq!(beta.entered, 2);
473 assert_eq!(beta.wins, 1);
474 assert_eq!(beta.win_rate(), 50.0);
475 }
476
477 #[test]
478 fn reviewer_precision_and_uniqueness() {
479 let round = ReviewRound {
480 round: 1,
481 head: "h".to_owned(),
482 verified_head: None,
483 verified_at: None,
484 reviews: vec![
485 ReviewRecord {
486 attempts: 0,
487 reviewer: 1,
488 agent: "alpha".to_owned(),
489 summary: String::new(),
490 findings: vec![
491 finding(
492 "R1-1-1",
493 "src/a.rs",
494 10,
495 "panics on empty",
496 Severity::Blocker,
497 ),
498 finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
499 ],
500 vote: None,
501 failed: None,
502 duration_ms: 0,
503 },
504 ReviewRecord {
505 attempts: 0,
506 reviewer: 2,
507 agent: "beta".to_owned(),
508 summary: String::new(),
509 findings: vec![finding(
511 "R1-2-1",
512 "src/a.rs",
513 13,
514 "empty input panic",
515 Severity::Blocker,
516 )],
517 vote: None,
518 failed: None,
519 duration_ms: 0,
520 },
521 ],
522 e2e: Vec::new(),
523 verify_retried: false,
524 e2e_deferred: false,
525 e2e_defer_reason: None,
526 fix: Some(FixRecord {
527 agent: "alpha".to_owned(),
528 addressed: vec!["R1-1-1".to_owned()],
529 rejected: Vec::new(),
530 notes: String::new(),
531 committed: true,
532 failed: None,
533 duration_ms: 0,
534 continuation: None,
535 }),
536 blocking: 3,
537 answered: 2,
538 expected: 2,
539 clean: false,
540 progressed: true,
541 vote_split: false,
542 reconsideration: Vec::new(),
543 verdict: None,
544 };
545 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
546 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
547 assert_eq!(alpha.submitted, 2);
548 assert_eq!(alpha.adopted, 1);
549 assert_eq!(alpha.precision(), 50.0);
550 assert_eq!(alpha.adopted_per_round(), 1.0);
551 assert_eq!(alpha.unique, 1);
553
554 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
555 assert_eq!(beta.submitted, 1);
556 assert_eq!(beta.adopted, 0);
557 assert_eq!(beta.unique, 0);
558 }
559
560 #[test]
561 fn a_lost_fix_report_does_not_count_as_zero_adoption() {
562 let submitted = ReviewRound {
563 round: 1,
564 head: "h".to_owned(),
565 verified_head: None,
566 verified_at: None,
567 reviews: vec![ReviewRecord {
568 attempts: 0,
569 reviewer: 1,
570 agent: "alpha".to_owned(),
571 summary: String::new(),
572 findings: vec![finding(
573 "R1-1-1",
574 "src/a.rs",
575 10,
576 "panics on empty",
577 Severity::Blocker,
578 )],
579 vote: None,
580 failed: None,
581 duration_ms: 0,
582 }],
583 e2e: Vec::new(),
584 verify_retried: false,
585 e2e_deferred: false,
586 e2e_defer_reason: None,
587 fix: Some(FixRecord {
590 agent: "alpha".to_owned(),
591 addressed: Vec::new(),
592 rejected: Vec::new(),
593 notes: String::new(),
594 committed: true,
595 failed: Some("unparsable fix report".to_owned()),
596 duration_ms: 0,
597 continuation: None,
598 }),
599 blocking: 4,
600 answered: 1,
601 expected: 1,
602 clean: false,
603 progressed: false,
604 vote_split: false,
605 reconsideration: Vec::new(),
606 verdict: None,
607 };
608 let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
609 assert!(
610 stats.reviewers.is_empty(),
611 "a round with no adoption signal must not enter any reviewer's \
612 denominator: {:?}",
613 stats.reviewers
614 );
615 }
616
617 #[test]
618 fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
619 let round = ReviewRound {
620 round: 1,
621 head: "h".to_owned(),
622 verified_head: None,
623 verified_at: None,
624 reviews: vec![
625 ReviewRecord {
626 attempts: 0,
627 reviewer: 1,
628 agent: "alpha".to_owned(),
629 summary: String::new(),
630 findings: Vec::new(),
631 vote: None,
632 failed: None,
633 duration_ms: 0,
634 },
635 ReviewRecord {
636 attempts: 0,
637 reviewer: 2,
638 agent: "beta".to_owned(),
639 summary: String::new(),
640 findings: Vec::new(),
641 vote: None,
642 failed: Some("agent timed out".to_owned()),
643 duration_ms: 0,
644 },
645 ],
646 e2e: Vec::new(),
647 verify_retried: false,
648 e2e_deferred: false,
649 e2e_defer_reason: None,
650 fix: None,
651 blocking: 0,
652 answered: 1,
653 expected: 2,
654 clean: false,
655 progressed: false,
656 vote_split: false,
657 reconsideration: Vec::new(),
658 verdict: None,
659 };
660 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
661
662 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
663 assert_eq!(alpha.seated, 1);
664 assert_eq!(alpha.rounds, 1);
665 assert_eq!(alpha.timeouts, 0);
666 assert_eq!(alpha.submitted, 0);
667
668 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
669 assert_eq!(beta.seated, 1);
670 assert_eq!(beta.timeouts, 1);
671 assert_eq!(beta.submitted, 0);
672 assert_eq!(beta.rounds, 0);
676 assert_eq!(beta.timeout_rate(), 100.0);
677 }
678
679 #[test]
680 fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
681 let round = ReviewRound {
687 round: 1,
688 head: "h".to_owned(),
689 verified_head: None,
690 verified_at: None,
691 reviews: vec![
692 ReviewRecord {
693 attempts: 0,
694 reviewer: 1,
695 agent: "alpha".to_owned(),
696 summary: String::new(),
697 findings: vec![finding(
698 "R1-1-1",
699 "src/a.rs",
700 10,
701 "panics on empty",
702 Severity::Blocker,
703 )],
704 vote: None,
705 failed: None,
706 duration_ms: 0,
707 },
708 ReviewRecord {
709 attempts: 0,
710 reviewer: 2,
711 agent: "beta".to_owned(),
712 summary: String::new(),
713 findings: Vec::new(),
714 vote: None,
715 failed: Some("agent timed out".to_owned()),
716 duration_ms: 0,
717 },
718 ],
719 e2e: Vec::new(),
720 verify_retried: false,
721 e2e_deferred: false,
722 e2e_defer_reason: None,
723 fix: Some(FixRecord {
724 agent: "alpha".to_owned(),
725 addressed: Vec::new(),
726 rejected: Vec::new(),
727 notes: String::new(),
728 committed: true,
729 failed: Some("unparsable fix report".to_owned()),
730 duration_ms: 0,
731 continuation: None,
732 }),
733 blocking: 1,
734 answered: 1,
735 expected: 2,
736 clean: false,
737 progressed: false,
738 vote_split: false,
739 reconsideration: Vec::new(),
740 verdict: None,
741 };
742 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
743
744 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
745 assert_eq!(beta.timeouts, 1);
746 assert_eq!(beta.timeout_rate(), 100.0);
747 assert!(
750 !stats.reviewers.iter().any(|r| r.agent == "alpha"),
751 "{:?}",
752 stats.reviewers
753 );
754 }
755
756 #[test]
757 fn e2e_sole_detection_needs_a_clean_static_review() {
758 let fail = CommandOutcome {
759 command: "cargo test".to_owned(),
760 code: Some(101),
761 output_tail: "boom".to_owned(),
762 duration_ms: 1,
763 resource_blocked: false,
764 };
765 let sole = ReviewRound {
766 round: 1,
767 head: "h".to_owned(),
768 verified_head: None,
769 verified_at: None,
770 reviews: Vec::new(),
771 e2e: vec![fail.clone()],
772 verify_retried: false,
773 e2e_deferred: false,
774 e2e_defer_reason: None,
775 fix: None,
776 blocking: 0,
777 answered: 0,
778 expected: 0,
779 clean: false,
780 progressed: false,
781 vote_split: false,
782 reconsideration: Vec::new(),
783 verdict: None,
784 };
785 let alongside = ReviewRound {
786 round: 2,
787 head: "h".to_owned(),
788 verified_head: None,
789 verified_at: None,
790 reviews: Vec::new(),
791 e2e: vec![fail],
792 verify_retried: false,
793 e2e_deferred: false,
794 e2e_defer_reason: None,
795 fix: None,
796 blocking: 2,
797 answered: 0,
798 expected: 0,
799 clean: false,
800 progressed: false,
801 vote_split: false,
802 reconsideration: Vec::new(),
803 verdict: None,
804 };
805 let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
806 assert_eq!(stats.e2e.rounds, 2);
807 assert_eq!(stats.e2e.failures, 2);
808 assert_eq!(stats.e2e.sole_detections, 1);
809 assert_eq!(stats.e2e.sole_rate(), 50.0);
810 }
811
812 #[test]
813 fn empty_input_yields_zeroed_rates_not_nan() {
814 let stats = collect(&[]);
815 assert_eq!(stats.totals.completion_rate(), 0.0);
816 assert_eq!(stats.totals.split_rate(), 0.0);
817 assert_eq!(stats.e2e.sole_rate(), 0.0);
818 assert!(stats.agents.is_empty());
819 }
820
821 #[test]
822 fn same_defect_matches_titles_across_files() {
823 let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
824 let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
825 assert!(same_defect(&a, &b));
826 let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
827 assert!(!same_defect(&a, &c));
828 }
829}