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,
43 pub submitted: usize,
45 pub adopted: usize,
47 pub unique: usize,
49}
50
51impl ReviewerStats {
52 pub fn adopted_per_round(&self) -> f64 {
54 if self.rounds == 0 {
55 0.0
56 } else {
57 self.adopted as f64 / self.rounds as f64
58 }
59 }
60
61 pub fn precision(&self) -> f64 {
63 if self.submitted == 0 {
64 0.0
65 } else {
66 100.0 * self.adopted as f64 / self.submitted as f64
67 }
68 }
69
70 pub fn unique_rate(&self) -> f64 {
72 if self.submitted == 0 {
73 0.0
74 } else {
75 100.0 * self.unique as f64 / self.submitted as f64
76 }
77 }
78}
79
80#[derive(Debug, Clone, Default)]
82pub struct E2eStats {
83 pub rounds: usize,
85 pub failures: usize,
87 pub sole_detections: usize,
90}
91
92impl E2eStats {
93 pub fn sole_rate(&self) -> f64 {
95 if self.failures == 0 {
96 0.0
97 } else {
98 100.0 * self.sole_detections as f64 / self.failures as f64
99 }
100 }
101}
102
103#[derive(Debug, Clone, Default)]
105pub struct Totals {
106 pub runs: usize,
108 pub merged: usize,
110 pub ready: usize,
112 pub blocked: usize,
114 pub failed: usize,
116 pub tallied: usize,
118 pub split: usize,
120 pub deliberated: usize,
122 pub minds_changed: usize,
124 pub converged: usize,
126 pub review_rounds: usize,
128}
129
130impl Totals {
131 pub fn completion_rate(&self) -> f64 {
133 if self.runs == 0 {
134 0.0
135 } else {
136 100.0 * (self.merged + self.ready) as f64 / self.runs as f64
137 }
138 }
139
140 pub fn split_rate(&self) -> f64 {
142 if self.tallied == 0 {
143 0.0
144 } else {
145 100.0 * self.split as f64 / self.tallied as f64
146 }
147 }
148}
149
150#[derive(Debug, Clone, Default)]
152pub struct Stats {
153 pub totals: Totals,
155 pub agents: Vec<AgentStats>,
157 pub reviewers: Vec<ReviewerStats>,
159 pub e2e: E2eStats,
161}
162
163pub fn load_all() -> Vec<RunState> {
165 list_ids()
166 .into_iter()
167 .filter_map(|id| RunState::load(&id).ok())
168 .collect()
169}
170
171pub fn collect(states: &[RunState]) -> Stats {
173 let mut totals = Totals::default();
174 let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
175 let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
176 let mut e2e = E2eStats::default();
177
178 for state in states {
179 totals.runs += 1;
180 match state.status {
181 RunStatus::Merged => totals.merged += 1,
182 RunStatus::Ready => totals.ready += 1,
183 RunStatus::Blocked => totals.blocked += 1,
184 RunStatus::Failed => totals.failed += 1,
185 _ => {}
186 }
187
188 for c in &state.candidates {
189 let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
190 agent: c.agent.clone(),
191 ..AgentStats::default()
192 });
193 if c.empty {
194 entry.empty += 1;
195 }
196 if c.viable() {
197 entry.entered += 1;
198 }
199 }
200
201 if let Some(t) = &state.tally {
202 if t.uncontested.is_none() {
209 totals.tallied += 1;
210 if !t.unanimous_initial {
211 totals.split += 1;
212 }
213 if t.deliberated {
214 totals.deliberated += 1;
215 if t.changed_votes > 0 {
216 totals.minds_changed += 1;
217 }
218 if t.unanimous_final {
219 totals.converged += 1;
220 }
221 }
222 }
223 if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
224 agents
225 .entry(w.agent.clone())
226 .or_insert_with(|| AgentStats {
227 agent: w.agent.clone(),
228 ..AgentStats::default()
229 })
230 .wins += 1;
231 }
232 }
233
234 for round in &state.reviews {
235 totals.review_rounds += 1;
236
237 let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
244 if !report_lost {
245 let adopted: Vec<&String> = round
246 .fix
247 .as_ref()
248 .map(|f| f.addressed.iter().collect())
249 .unwrap_or_default();
250
251 for rec in &round.reviews {
252 let entry =
253 reviewers
254 .entry(rec.agent.clone())
255 .or_insert_with(|| ReviewerStats {
256 agent: rec.agent.clone(),
257 ..ReviewerStats::default()
258 });
259 entry.rounds += 1;
260 entry.submitted += rec.findings.len();
261 for f in &rec.findings {
262 if adopted.iter().any(|a| **a == f.id) {
263 entry.adopted += 1;
264 }
265 let overlapped = round
266 .reviews
267 .iter()
268 .filter(|other| other.reviewer != rec.reviewer)
269 .flat_map(|other| other.findings.iter())
270 .any(|g| same_defect(f, g));
271 if !overlapped {
272 entry.unique += 1;
273 }
274 }
275 }
276 }
277
278 if !round.e2e.is_empty() {
279 e2e.rounds += 1;
280 if round.e2e.iter().any(|o| !o.ok()) {
281 e2e.failures += 1;
282 if round.blocking == 0 {
283 e2e.sole_detections += 1;
284 }
285 }
286 }
287 }
288 }
289
290 let mut agents: Vec<AgentStats> = agents.into_values().collect();
291 agents.sort_by(|a, b| {
292 b.win_rate()
293 .total_cmp(&a.win_rate())
294 .then(b.entered.cmp(&a.entered))
295 });
296 let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
297 reviewers.sort_by(|a, b| {
298 b.adopted_per_round()
299 .total_cmp(&a.adopted_per_round())
300 .then(b.rounds.cmp(&a.rounds))
301 });
302
303 Stats {
304 totals,
305 agents,
306 reviewers,
307 e2e,
308 }
309}
310
311fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
317 if normalize(&a.title) == normalize(&b.title) {
318 return true;
319 }
320 match (&a.file, &b.file) {
321 (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
322 (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
323 _ => false,
324 },
325 _ => false,
326 }
327}
328
329fn normalize(title: &str) -> String {
330 title
331 .chars()
332 .filter(|c| c.is_alphanumeric())
333 .map(|c| c.to_ascii_lowercase())
334 .collect()
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::config::Config;
341 use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
342 use crate::verdict::{Finding, Severity};
343 use std::path::PathBuf;
344
345 fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
346 Finding {
347 id: id.to_owned(),
348 severity: sev,
349 file: Some(file.to_owned()),
350 line: Some(line),
351 title: title.to_owned(),
352 detail: String::new(),
353 }
354 }
355
356 fn candidate(label: char, agent: &str) -> Candidate {
357 Candidate {
358 index: 0,
359 label,
360 agent: agent.to_owned(),
361 branch: format!("magi/x/{label}"),
362 worktree: PathBuf::from("/w"),
363 summary: String::new(),
364 stat: String::new(),
365 files: 1,
366 commits: 1,
367 empty: false,
368 failed: None,
369 duration_ms: 0,
370 folded: false,
371 }
372 }
373
374 fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
375 let mut s = RunState::new(
376 PathBuf::from("/repo"),
377 "main".to_owned(),
378 "abcdef".to_owned(),
379 "task".to_owned(),
380 Config::default(),
381 );
382 s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
383 s.tally = Some(Tally {
384 first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
385 borda: BTreeMap::new(),
386 winner,
387 rankings: 3,
388 unanimous_initial: false,
389 deliberated: true,
390 changed_votes: 1,
391 unanimous_final: true,
392 tie_break: None,
393 judges: 3,
394 present: 3,
395 quorum: 2,
396 met_quorum: true,
397 uncontested: None,
398 });
399 s.reviews = reviews;
400 s.status = status;
401 s
402 }
403
404 #[test]
405 fn win_rates_and_completion_are_counted_per_agent() {
406 let states = vec![
407 state_with(Vec::new(), 'B', RunStatus::Merged),
408 state_with(Vec::new(), 'A', RunStatus::Blocked),
409 ];
410 let stats = collect(&states);
411 assert_eq!(stats.totals.runs, 2);
412 assert_eq!(stats.totals.merged, 1);
413 assert_eq!(stats.totals.blocked, 1);
414 assert_eq!(stats.totals.completion_rate(), 50.0);
415 assert_eq!(stats.totals.split, 2);
416 assert_eq!(stats.totals.minds_changed, 2);
417 assert_eq!(stats.totals.converged, 2);
418
419 let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
420 assert_eq!(beta.entered, 2);
421 assert_eq!(beta.wins, 1);
422 assert_eq!(beta.win_rate(), 50.0);
423 }
424
425 #[test]
426 fn reviewer_precision_and_uniqueness() {
427 let round = ReviewRound {
428 round: 1,
429 head: "h".to_owned(),
430 reviews: vec![
431 ReviewRecord {
432 reviewer: 1,
433 agent: "alpha".to_owned(),
434 summary: String::new(),
435 findings: vec![
436 finding(
437 "R1-1-1",
438 "src/a.rs",
439 10,
440 "panics on empty",
441 Severity::Blocker,
442 ),
443 finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
444 ],
445 failed: None,
446 duration_ms: 0,
447 },
448 ReviewRecord {
449 reviewer: 2,
450 agent: "beta".to_owned(),
451 summary: String::new(),
452 findings: vec![finding(
454 "R1-2-1",
455 "src/a.rs",
456 13,
457 "empty input panic",
458 Severity::Blocker,
459 )],
460 failed: None,
461 duration_ms: 0,
462 },
463 ],
464 e2e: Vec::new(),
465 verify_retried: false,
466 fix: Some(FixRecord {
467 agent: "alpha".to_owned(),
468 addressed: vec!["R1-1-1".to_owned()],
469 rejected: Vec::new(),
470 notes: String::new(),
471 committed: true,
472 failed: None,
473 duration_ms: 0,
474 }),
475 blocking: 3,
476 clean: false,
477 };
478 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
479 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
480 assert_eq!(alpha.submitted, 2);
481 assert_eq!(alpha.adopted, 1);
482 assert_eq!(alpha.precision(), 50.0);
483 assert_eq!(alpha.adopted_per_round(), 1.0);
484 assert_eq!(alpha.unique, 1);
486
487 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
488 assert_eq!(beta.submitted, 1);
489 assert_eq!(beta.adopted, 0);
490 assert_eq!(beta.unique, 0);
491 }
492
493 #[test]
494 fn a_lost_fix_report_does_not_count_as_zero_adoption() {
495 let submitted = ReviewRound {
496 round: 1,
497 head: "h".to_owned(),
498 reviews: vec![ReviewRecord {
499 reviewer: 1,
500 agent: "alpha".to_owned(),
501 summary: String::new(),
502 findings: vec![finding(
503 "R1-1-1",
504 "src/a.rs",
505 10,
506 "panics on empty",
507 Severity::Blocker,
508 )],
509 failed: None,
510 duration_ms: 0,
511 }],
512 e2e: Vec::new(),
513 verify_retried: false,
514 fix: Some(FixRecord {
517 agent: "alpha".to_owned(),
518 addressed: Vec::new(),
519 rejected: Vec::new(),
520 notes: String::new(),
521 committed: true,
522 failed: Some("unparsable fix report".to_owned()),
523 duration_ms: 0,
524 }),
525 blocking: 4,
526 clean: false,
527 };
528 let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
529 assert!(
530 stats.reviewers.is_empty(),
531 "a round with no adoption signal must not enter any reviewer's \
532 denominator: {:?}",
533 stats.reviewers
534 );
535 }
536
537 #[test]
538 fn e2e_sole_detection_needs_a_clean_static_review() {
539 let fail = CommandOutcome {
540 command: "cargo test".to_owned(),
541 code: Some(101),
542 output_tail: "boom".to_owned(),
543 duration_ms: 1,
544 };
545 let sole = ReviewRound {
546 round: 1,
547 head: "h".to_owned(),
548 reviews: Vec::new(),
549 e2e: vec![fail.clone()],
550 verify_retried: false,
551 fix: None,
552 blocking: 0,
553 clean: false,
554 };
555 let alongside = ReviewRound {
556 round: 2,
557 head: "h".to_owned(),
558 reviews: Vec::new(),
559 e2e: vec![fail],
560 verify_retried: false,
561 fix: None,
562 blocking: 2,
563 clean: false,
564 };
565 let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
566 assert_eq!(stats.e2e.rounds, 2);
567 assert_eq!(stats.e2e.failures, 2);
568 assert_eq!(stats.e2e.sole_detections, 1);
569 assert_eq!(stats.e2e.sole_rate(), 50.0);
570 }
571
572 #[test]
573 fn empty_input_yields_zeroed_rates_not_nan() {
574 let stats = collect(&[]);
575 assert_eq!(stats.totals.completion_rate(), 0.0);
576 assert_eq!(stats.totals.split_rate(), 0.0);
577 assert_eq!(stats.e2e.sole_rate(), 0.0);
578 assert!(stats.agents.is_empty());
579 }
580
581 #[test]
582 fn same_defect_matches_titles_across_files() {
583 let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
584 let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
585 assert!(same_defect(&a, &b));
586 let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
587 assert!(!same_defect(&a, &c));
588 }
589}