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 totals.tallied += 1;
203 if !t.unanimous_initial {
204 totals.split += 1;
205 }
206 if t.deliberated {
207 totals.deliberated += 1;
208 if t.changed_votes > 0 {
209 totals.minds_changed += 1;
210 }
211 if t.unanimous_final {
212 totals.converged += 1;
213 }
214 }
215 if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
216 agents
217 .entry(w.agent.clone())
218 .or_insert_with(|| AgentStats {
219 agent: w.agent.clone(),
220 ..AgentStats::default()
221 })
222 .wins += 1;
223 }
224 }
225
226 for round in &state.reviews {
227 totals.review_rounds += 1;
228 let adopted: Vec<&String> = round
229 .fix
230 .as_ref()
231 .map(|f| f.addressed.iter().collect())
232 .unwrap_or_default();
233
234 for rec in &round.reviews {
235 let entry = reviewers
236 .entry(rec.agent.clone())
237 .or_insert_with(|| ReviewerStats {
238 agent: rec.agent.clone(),
239 ..ReviewerStats::default()
240 });
241 entry.rounds += 1;
242 entry.submitted += rec.findings.len();
243 for f in &rec.findings {
244 if adopted.iter().any(|a| **a == f.id) {
245 entry.adopted += 1;
246 }
247 let overlapped = round
248 .reviews
249 .iter()
250 .filter(|other| other.reviewer != rec.reviewer)
251 .flat_map(|other| other.findings.iter())
252 .any(|g| same_defect(f, g));
253 if !overlapped {
254 entry.unique += 1;
255 }
256 }
257 }
258
259 if !round.e2e.is_empty() {
260 e2e.rounds += 1;
261 if round.e2e.iter().any(|o| !o.ok()) {
262 e2e.failures += 1;
263 if round.blocking == 0 {
264 e2e.sole_detections += 1;
265 }
266 }
267 }
268 }
269 }
270
271 let mut agents: Vec<AgentStats> = agents.into_values().collect();
272 agents.sort_by(|a, b| {
273 b.win_rate()
274 .total_cmp(&a.win_rate())
275 .then(b.entered.cmp(&a.entered))
276 });
277 let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
278 reviewers.sort_by(|a, b| {
279 b.adopted_per_round()
280 .total_cmp(&a.adopted_per_round())
281 .then(b.rounds.cmp(&a.rounds))
282 });
283
284 Stats {
285 totals,
286 agents,
287 reviewers,
288 e2e,
289 }
290}
291
292fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
298 if normalize(&a.title) == normalize(&b.title) {
299 return true;
300 }
301 match (&a.file, &b.file) {
302 (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
303 (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
304 _ => false,
305 },
306 _ => false,
307 }
308}
309
310fn normalize(title: &str) -> String {
311 title
312 .chars()
313 .filter(|c| c.is_alphanumeric())
314 .map(|c| c.to_ascii_lowercase())
315 .collect()
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::config::Config;
322 use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
323 use crate::verdict::{Finding, Severity};
324 use std::path::PathBuf;
325
326 fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
327 Finding {
328 id: id.to_owned(),
329 severity: sev,
330 file: Some(file.to_owned()),
331 line: Some(line),
332 title: title.to_owned(),
333 detail: String::new(),
334 }
335 }
336
337 fn candidate(label: char, agent: &str) -> Candidate {
338 Candidate {
339 index: 0,
340 label,
341 agent: agent.to_owned(),
342 branch: format!("magi/x/{label}"),
343 worktree: PathBuf::from("/w"),
344 summary: String::new(),
345 stat: String::new(),
346 files: 1,
347 commits: 1,
348 empty: false,
349 failed: None,
350 duration_ms: 0,
351 folded: false,
352 }
353 }
354
355 fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
356 let mut s = RunState::new(
357 PathBuf::from("/repo"),
358 "main".to_owned(),
359 "abcdef".to_owned(),
360 "task".to_owned(),
361 Config::default(),
362 );
363 s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
364 s.tally = Some(Tally {
365 first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
366 borda: BTreeMap::new(),
367 winner,
368 rankings: 3,
369 unanimous_initial: false,
370 deliberated: true,
371 changed_votes: 1,
372 unanimous_final: true,
373 tie_break: None,
374 judges: 3,
375 present: 3,
376 quorum: 2,
377 met_quorum: true,
378 });
379 s.reviews = reviews;
380 s.status = status;
381 s
382 }
383
384 #[test]
385 fn win_rates_and_completion_are_counted_per_agent() {
386 let states = vec![
387 state_with(Vec::new(), 'B', RunStatus::Merged),
388 state_with(Vec::new(), 'A', RunStatus::Blocked),
389 ];
390 let stats = collect(&states);
391 assert_eq!(stats.totals.runs, 2);
392 assert_eq!(stats.totals.merged, 1);
393 assert_eq!(stats.totals.blocked, 1);
394 assert_eq!(stats.totals.completion_rate(), 50.0);
395 assert_eq!(stats.totals.split, 2);
396 assert_eq!(stats.totals.minds_changed, 2);
397 assert_eq!(stats.totals.converged, 2);
398
399 let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
400 assert_eq!(beta.entered, 2);
401 assert_eq!(beta.wins, 1);
402 assert_eq!(beta.win_rate(), 50.0);
403 }
404
405 #[test]
406 fn reviewer_precision_and_uniqueness() {
407 let round = ReviewRound {
408 round: 1,
409 head: "h".to_owned(),
410 reviews: vec![
411 ReviewRecord {
412 reviewer: 1,
413 agent: "alpha".to_owned(),
414 summary: String::new(),
415 findings: vec![
416 finding(
417 "R1-1-1",
418 "src/a.rs",
419 10,
420 "panics on empty",
421 Severity::Blocker,
422 ),
423 finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
424 ],
425 failed: None,
426 duration_ms: 0,
427 },
428 ReviewRecord {
429 reviewer: 2,
430 agent: "beta".to_owned(),
431 summary: String::new(),
432 findings: vec![finding(
434 "R1-2-1",
435 "src/a.rs",
436 13,
437 "empty input panic",
438 Severity::Blocker,
439 )],
440 failed: None,
441 duration_ms: 0,
442 },
443 ],
444 e2e: Vec::new(),
445 fix: Some(FixRecord {
446 agent: "alpha".to_owned(),
447 addressed: vec!["R1-1-1".to_owned()],
448 rejected: Vec::new(),
449 notes: String::new(),
450 committed: true,
451 failed: None,
452 duration_ms: 0,
453 }),
454 blocking: 3,
455 clean: false,
456 };
457 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
458 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
459 assert_eq!(alpha.submitted, 2);
460 assert_eq!(alpha.adopted, 1);
461 assert_eq!(alpha.precision(), 50.0);
462 assert_eq!(alpha.adopted_per_round(), 1.0);
463 assert_eq!(alpha.unique, 1);
465
466 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
467 assert_eq!(beta.submitted, 1);
468 assert_eq!(beta.adopted, 0);
469 assert_eq!(beta.unique, 0);
470 }
471
472 #[test]
473 fn e2e_sole_detection_needs_a_clean_static_review() {
474 let fail = CommandOutcome {
475 command: "cargo test".to_owned(),
476 code: Some(101),
477 output_tail: "boom".to_owned(),
478 duration_ms: 1,
479 };
480 let sole = ReviewRound {
481 round: 1,
482 head: "h".to_owned(),
483 reviews: Vec::new(),
484 e2e: vec![fail.clone()],
485 fix: None,
486 blocking: 0,
487 clean: false,
488 };
489 let alongside = ReviewRound {
490 round: 2,
491 head: "h".to_owned(),
492 reviews: Vec::new(),
493 e2e: vec![fail],
494 fix: None,
495 blocking: 2,
496 clean: false,
497 };
498 let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
499 assert_eq!(stats.e2e.rounds, 2);
500 assert_eq!(stats.e2e.failures, 2);
501 assert_eq!(stats.e2e.sole_detections, 1);
502 assert_eq!(stats.e2e.sole_rate(), 50.0);
503 }
504
505 #[test]
506 fn empty_input_yields_zeroed_rates_not_nan() {
507 let stats = collect(&[]);
508 assert_eq!(stats.totals.completion_rate(), 0.0);
509 assert_eq!(stats.totals.split_rate(), 0.0);
510 assert_eq!(stats.e2e.sole_rate(), 0.0);
511 assert!(stats.agents.is_empty());
512 }
513
514 #[test]
515 fn same_defect_matches_titles_across_files() {
516 let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
517 let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
518 assert!(same_defect(&a, &b));
519 let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
520 assert!(!same_defect(&a, &c));
521 }
522}