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 failed: None,
484 duration_ms: 0,
485 },
486 ReviewRecord {
487 reviewer: 2,
488 agent: "beta".to_owned(),
489 summary: String::new(),
490 findings: vec![finding(
492 "R1-2-1",
493 "src/a.rs",
494 13,
495 "empty input panic",
496 Severity::Blocker,
497 )],
498 failed: None,
499 duration_ms: 0,
500 },
501 ],
502 e2e: Vec::new(),
503 verify_retried: false,
504 fix: Some(FixRecord {
505 agent: "alpha".to_owned(),
506 addressed: vec!["R1-1-1".to_owned()],
507 rejected: Vec::new(),
508 notes: String::new(),
509 committed: true,
510 failed: None,
511 duration_ms: 0,
512 }),
513 blocking: 3,
514 answered: 2,
515 expected: 2,
516 clean: false,
517 };
518 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
519 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
520 assert_eq!(alpha.submitted, 2);
521 assert_eq!(alpha.adopted, 1);
522 assert_eq!(alpha.precision(), 50.0);
523 assert_eq!(alpha.adopted_per_round(), 1.0);
524 assert_eq!(alpha.unique, 1);
526
527 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
528 assert_eq!(beta.submitted, 1);
529 assert_eq!(beta.adopted, 0);
530 assert_eq!(beta.unique, 0);
531 }
532
533 #[test]
534 fn a_lost_fix_report_does_not_count_as_zero_adoption() {
535 let submitted = ReviewRound {
536 round: 1,
537 head: "h".to_owned(),
538 reviews: vec![ReviewRecord {
539 reviewer: 1,
540 agent: "alpha".to_owned(),
541 summary: String::new(),
542 findings: vec![finding(
543 "R1-1-1",
544 "src/a.rs",
545 10,
546 "panics on empty",
547 Severity::Blocker,
548 )],
549 failed: None,
550 duration_ms: 0,
551 }],
552 e2e: Vec::new(),
553 verify_retried: false,
554 fix: Some(FixRecord {
557 agent: "alpha".to_owned(),
558 addressed: Vec::new(),
559 rejected: Vec::new(),
560 notes: String::new(),
561 committed: true,
562 failed: Some("unparsable fix report".to_owned()),
563 duration_ms: 0,
564 }),
565 blocking: 4,
566 answered: 1,
567 expected: 1,
568 clean: false,
569 };
570 let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
571 assert!(
572 stats.reviewers.is_empty(),
573 "a round with no adoption signal must not enter any reviewer's \
574 denominator: {:?}",
575 stats.reviewers
576 );
577 }
578
579 #[test]
580 fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
581 let round = ReviewRound {
582 round: 1,
583 head: "h".to_owned(),
584 reviews: vec![
585 ReviewRecord {
586 reviewer: 1,
587 agent: "alpha".to_owned(),
588 summary: String::new(),
589 findings: Vec::new(),
590 failed: None,
591 duration_ms: 0,
592 },
593 ReviewRecord {
594 reviewer: 2,
595 agent: "beta".to_owned(),
596 summary: String::new(),
597 findings: Vec::new(),
598 failed: Some("agent timed out".to_owned()),
599 duration_ms: 0,
600 },
601 ],
602 e2e: Vec::new(),
603 verify_retried: false,
604 fix: None,
605 blocking: 0,
606 answered: 1,
607 expected: 2,
608 clean: false,
609 };
610 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
611
612 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
613 assert_eq!(alpha.seated, 1);
614 assert_eq!(alpha.rounds, 1);
615 assert_eq!(alpha.timeouts, 0);
616 assert_eq!(alpha.submitted, 0);
617
618 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
619 assert_eq!(beta.seated, 1);
620 assert_eq!(beta.timeouts, 1);
621 assert_eq!(beta.submitted, 0);
622 assert_eq!(beta.rounds, 0);
626 assert_eq!(beta.timeout_rate(), 100.0);
627 }
628
629 #[test]
630 fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
631 let round = ReviewRound {
637 round: 1,
638 head: "h".to_owned(),
639 reviews: vec![
640 ReviewRecord {
641 reviewer: 1,
642 agent: "alpha".to_owned(),
643 summary: String::new(),
644 findings: vec![finding(
645 "R1-1-1",
646 "src/a.rs",
647 10,
648 "panics on empty",
649 Severity::Blocker,
650 )],
651 failed: None,
652 duration_ms: 0,
653 },
654 ReviewRecord {
655 reviewer: 2,
656 agent: "beta".to_owned(),
657 summary: String::new(),
658 findings: Vec::new(),
659 failed: Some("agent timed out".to_owned()),
660 duration_ms: 0,
661 },
662 ],
663 e2e: Vec::new(),
664 verify_retried: false,
665 fix: Some(FixRecord {
666 agent: "alpha".to_owned(),
667 addressed: Vec::new(),
668 rejected: Vec::new(),
669 notes: String::new(),
670 committed: true,
671 failed: Some("unparsable fix report".to_owned()),
672 duration_ms: 0,
673 }),
674 blocking: 1,
675 answered: 1,
676 expected: 2,
677 clean: false,
678 };
679 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
680
681 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
682 assert_eq!(beta.timeouts, 1);
683 assert_eq!(beta.timeout_rate(), 100.0);
684 assert!(
687 !stats.reviewers.iter().any(|r| r.agent == "alpha"),
688 "{:?}",
689 stats.reviewers
690 );
691 }
692
693 #[test]
694 fn e2e_sole_detection_needs_a_clean_static_review() {
695 let fail = CommandOutcome {
696 command: "cargo test".to_owned(),
697 code: Some(101),
698 output_tail: "boom".to_owned(),
699 duration_ms: 1,
700 };
701 let sole = ReviewRound {
702 round: 1,
703 head: "h".to_owned(),
704 reviews: Vec::new(),
705 e2e: vec![fail.clone()],
706 verify_retried: false,
707 fix: None,
708 blocking: 0,
709 answered: 0,
710 expected: 0,
711 clean: false,
712 };
713 let alongside = ReviewRound {
714 round: 2,
715 head: "h".to_owned(),
716 reviews: Vec::new(),
717 e2e: vec![fail],
718 verify_retried: false,
719 fix: None,
720 blocking: 2,
721 answered: 0,
722 expected: 0,
723 clean: false,
724 };
725 let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
726 assert_eq!(stats.e2e.rounds, 2);
727 assert_eq!(stats.e2e.failures, 2);
728 assert_eq!(stats.e2e.sole_detections, 1);
729 assert_eq!(stats.e2e.sole_rate(), 50.0);
730 }
731
732 #[test]
733 fn empty_input_yields_zeroed_rates_not_nan() {
734 let stats = collect(&[]);
735 assert_eq!(stats.totals.completion_rate(), 0.0);
736 assert_eq!(stats.totals.split_rate(), 0.0);
737 assert_eq!(stats.e2e.sole_rate(), 0.0);
738 assert!(stats.agents.is_empty());
739 }
740
741 #[test]
742 fn same_defect_matches_titles_across_files() {
743 let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
744 let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
745 assert!(same_defect(&a, &b));
746 let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
747 assert!(!same_defect(&a, &c));
748 }
749}