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