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