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 }),
527 blocking: 3,
528 answered: 2,
529 expected: 2,
530 clean: false,
531 progressed: true,
532 vote_split: false,
533 reconsideration: Vec::new(),
534 verdict: None,
535 };
536 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
537 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
538 assert_eq!(alpha.submitted, 2);
539 assert_eq!(alpha.adopted, 1);
540 assert_eq!(alpha.precision(), 50.0);
541 assert_eq!(alpha.adopted_per_round(), 1.0);
542 assert_eq!(alpha.unique, 1);
544
545 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
546 assert_eq!(beta.submitted, 1);
547 assert_eq!(beta.adopted, 0);
548 assert_eq!(beta.unique, 0);
549 }
550
551 #[test]
552 fn a_lost_fix_report_does_not_count_as_zero_adoption() {
553 let submitted = ReviewRound {
554 round: 1,
555 head: "h".to_owned(),
556 verified_head: None,
557 reviews: vec![ReviewRecord {
558 reviewer: 1,
559 agent: "alpha".to_owned(),
560 summary: String::new(),
561 findings: vec![finding(
562 "R1-1-1",
563 "src/a.rs",
564 10,
565 "panics on empty",
566 Severity::Blocker,
567 )],
568 vote: None,
569 failed: None,
570 duration_ms: 0,
571 }],
572 e2e: Vec::new(),
573 verify_retried: false,
574 e2e_deferred: false,
575 e2e_defer_reason: None,
576 fix: Some(FixRecord {
579 agent: "alpha".to_owned(),
580 addressed: Vec::new(),
581 rejected: Vec::new(),
582 notes: String::new(),
583 committed: true,
584 failed: Some("unparsable fix report".to_owned()),
585 duration_ms: 0,
586 }),
587 blocking: 4,
588 answered: 1,
589 expected: 1,
590 clean: false,
591 progressed: false,
592 vote_split: false,
593 reconsideration: Vec::new(),
594 verdict: None,
595 };
596 let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
597 assert!(
598 stats.reviewers.is_empty(),
599 "a round with no adoption signal must not enter any reviewer's \
600 denominator: {:?}",
601 stats.reviewers
602 );
603 }
604
605 #[test]
606 fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
607 let round = ReviewRound {
608 round: 1,
609 head: "h".to_owned(),
610 verified_head: None,
611 reviews: vec![
612 ReviewRecord {
613 reviewer: 1,
614 agent: "alpha".to_owned(),
615 summary: String::new(),
616 findings: Vec::new(),
617 vote: None,
618 failed: None,
619 duration_ms: 0,
620 },
621 ReviewRecord {
622 reviewer: 2,
623 agent: "beta".to_owned(),
624 summary: String::new(),
625 findings: Vec::new(),
626 vote: None,
627 failed: Some("agent timed out".to_owned()),
628 duration_ms: 0,
629 },
630 ],
631 e2e: Vec::new(),
632 verify_retried: false,
633 e2e_deferred: false,
634 e2e_defer_reason: None,
635 fix: None,
636 blocking: 0,
637 answered: 1,
638 expected: 2,
639 clean: false,
640 progressed: false,
641 vote_split: false,
642 reconsideration: Vec::new(),
643 verdict: None,
644 };
645 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
646
647 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
648 assert_eq!(alpha.seated, 1);
649 assert_eq!(alpha.rounds, 1);
650 assert_eq!(alpha.timeouts, 0);
651 assert_eq!(alpha.submitted, 0);
652
653 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
654 assert_eq!(beta.seated, 1);
655 assert_eq!(beta.timeouts, 1);
656 assert_eq!(beta.submitted, 0);
657 assert_eq!(beta.rounds, 0);
661 assert_eq!(beta.timeout_rate(), 100.0);
662 }
663
664 #[test]
665 fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
666 let round = ReviewRound {
672 round: 1,
673 head: "h".to_owned(),
674 verified_head: None,
675 reviews: vec![
676 ReviewRecord {
677 reviewer: 1,
678 agent: "alpha".to_owned(),
679 summary: String::new(),
680 findings: vec![finding(
681 "R1-1-1",
682 "src/a.rs",
683 10,
684 "panics on empty",
685 Severity::Blocker,
686 )],
687 vote: None,
688 failed: None,
689 duration_ms: 0,
690 },
691 ReviewRecord {
692 reviewer: 2,
693 agent: "beta".to_owned(),
694 summary: String::new(),
695 findings: Vec::new(),
696 vote: None,
697 failed: Some("agent timed out".to_owned()),
698 duration_ms: 0,
699 },
700 ],
701 e2e: Vec::new(),
702 verify_retried: false,
703 e2e_deferred: false,
704 e2e_defer_reason: None,
705 fix: Some(FixRecord {
706 agent: "alpha".to_owned(),
707 addressed: Vec::new(),
708 rejected: Vec::new(),
709 notes: String::new(),
710 committed: true,
711 failed: Some("unparsable fix report".to_owned()),
712 duration_ms: 0,
713 }),
714 blocking: 1,
715 answered: 1,
716 expected: 2,
717 clean: false,
718 progressed: false,
719 vote_split: false,
720 reconsideration: Vec::new(),
721 verdict: None,
722 };
723 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
724
725 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
726 assert_eq!(beta.timeouts, 1);
727 assert_eq!(beta.timeout_rate(), 100.0);
728 assert!(
731 !stats.reviewers.iter().any(|r| r.agent == "alpha"),
732 "{:?}",
733 stats.reviewers
734 );
735 }
736
737 #[test]
738 fn e2e_sole_detection_needs_a_clean_static_review() {
739 let fail = CommandOutcome {
740 command: "cargo test".to_owned(),
741 code: Some(101),
742 output_tail: "boom".to_owned(),
743 duration_ms: 1,
744 };
745 let sole = ReviewRound {
746 round: 1,
747 head: "h".to_owned(),
748 verified_head: None,
749 reviews: Vec::new(),
750 e2e: vec![fail.clone()],
751 verify_retried: false,
752 e2e_deferred: false,
753 e2e_defer_reason: None,
754 fix: None,
755 blocking: 0,
756 answered: 0,
757 expected: 0,
758 clean: false,
759 progressed: false,
760 vote_split: false,
761 reconsideration: Vec::new(),
762 verdict: None,
763 };
764 let alongside = ReviewRound {
765 round: 2,
766 head: "h".to_owned(),
767 verified_head: None,
768 reviews: Vec::new(),
769 e2e: vec![fail],
770 verify_retried: false,
771 e2e_deferred: false,
772 e2e_defer_reason: None,
773 fix: None,
774 blocking: 2,
775 answered: 0,
776 expected: 0,
777 clean: false,
778 progressed: false,
779 vote_split: false,
780 reconsideration: Vec::new(),
781 verdict: None,
782 };
783 let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
784 assert_eq!(stats.e2e.rounds, 2);
785 assert_eq!(stats.e2e.failures, 2);
786 assert_eq!(stats.e2e.sole_detections, 1);
787 assert_eq!(stats.e2e.sole_rate(), 50.0);
788 }
789
790 #[test]
791 fn empty_input_yields_zeroed_rates_not_nan() {
792 let stats = collect(&[]);
793 assert_eq!(stats.totals.completion_rate(), 0.0);
794 assert_eq!(stats.totals.split_rate(), 0.0);
795 assert_eq!(stats.e2e.sole_rate(), 0.0);
796 assert!(stats.agents.is_empty());
797 }
798
799 #[test]
800 fn same_defect_matches_titles_across_files() {
801 let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
802 let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
803 assert!(same_defect(&a, &b));
804 let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
805 assert!(!same_defect(&a, &c));
806 }
807}