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