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