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)]
110pub struct AdvisorStats {
111 pub agent: String,
113 pub seated: usize,
115 pub proposed: usize,
117 pub absent: usize,
125 pub faint: usize,
128 pub strong: usize,
131}
132
133impl AdvisorStats {
134 pub fn reflection_rate(&self) -> f64 {
143 if self.proposed == 0 {
144 0.0
145 } else {
146 100.0 * self.strong as f64 / self.proposed as f64
147 }
148 }
149}
150
151#[derive(Debug, Clone, Default)]
153pub struct E2eStats {
154 pub rounds: usize,
156 pub failures: usize,
158 pub sole_detections: usize,
161 pub deferred: usize,
168}
169
170impl E2eStats {
171 pub fn sole_rate(&self) -> f64 {
173 if self.failures == 0 {
174 0.0
175 } else {
176 100.0 * self.sole_detections as f64 / self.failures as f64
177 }
178 }
179}
180
181#[derive(Debug, Clone, Default)]
183pub struct Totals {
184 pub runs: usize,
186 pub merged: usize,
188 pub ready: usize,
190 pub blocked: usize,
192 pub failed: usize,
194 pub stalled: usize,
199 pub verified_noop: usize,
202 pub superseded: usize,
205 pub in_progress: usize,
210 pub tallied: usize,
212 pub split: usize,
214 pub deliberated: usize,
216 pub minds_changed: usize,
218 pub converged: usize,
220 pub review_rounds: usize,
222}
223
224impl Totals {
225 pub fn completion_rate(&self) -> f64 {
227 if self.runs == 0 {
228 0.0
229 } else {
230 100.0 * (self.merged + self.ready) as f64 / self.runs as f64
231 }
232 }
233
234 pub fn split_rate(&self) -> f64 {
236 if self.tallied == 0 {
237 0.0
238 } else {
239 100.0 * self.split as f64 / self.tallied as f64
240 }
241 }
242}
243
244#[derive(Debug, Clone, Default, PartialEq)]
252pub struct NodeDuration {
253 pub node: String,
255 pub runs: usize,
257 pub total_secs: i64,
259 pub max_secs: i64,
261 pub single: usize,
266}
267
268impl NodeDuration {
269 pub fn mean_secs(&self) -> f64 {
273 if self.runs == 0 {
274 0.0
275 } else {
276 self.total_secs as f64 / self.runs as f64
277 }
278 }
279}
280
281pub fn node_durations(states: &[RunState]) -> Vec<NodeDuration> {
289 let mut nodes: BTreeMap<String, NodeDuration> = BTreeMap::new();
290
291 for state in states {
292 let mut spans: BTreeMap<&str, (jiff::Timestamp, jiff::Timestamp, usize)> = BTreeMap::new();
293 for e in &state.events {
294 spans
295 .entry(e.node.as_str())
296 .and_modify(|(min, max, count)| {
297 if e.at < *min {
298 *min = e.at;
299 }
300 if e.at > *max {
301 *max = e.at;
302 }
303 *count += 1;
304 })
305 .or_insert((e.at, e.at, 1));
306 }
307
308 for (node, (min, max, count)) in spans {
309 let entry = nodes
310 .entry(node.to_owned())
311 .or_insert_with(|| NodeDuration {
312 node: node.to_owned(),
313 ..NodeDuration::default()
314 });
315 if count < 2 {
316 entry.single += 1;
317 continue;
318 }
319 let span_secs = (max - min).get_seconds();
323 entry.runs += 1;
324 entry.total_secs += span_secs;
325 if span_secs > entry.max_secs {
326 entry.max_secs = span_secs;
327 }
328 }
329 }
330
331 let mut nodes: Vec<NodeDuration> = nodes.into_values().collect();
332 nodes.sort_by(|a, b| b.total_secs.cmp(&a.total_secs).then(a.node.cmp(&b.node)));
333 nodes
334}
335
336#[derive(Debug, Clone, Default)]
338pub struct Stats {
339 pub totals: Totals,
341 pub agents: Vec<AgentStats>,
343 pub reviewers: Vec<ReviewerStats>,
345 pub advisors: Vec<AdvisorStats>,
351 pub e2e: E2eStats,
353 pub nodes: Vec<NodeDuration>,
355}
356
357pub fn load_all() -> Vec<RunState> {
359 list_ids()
360 .into_iter()
361 .filter_map(|id| RunState::load(&id).ok())
362 .collect()
363}
364
365pub fn collect(states: &[RunState]) -> Stats {
367 let mut totals = Totals::default();
368 let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
369 let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
370 let mut advisors: BTreeMap<String, AdvisorStats> = BTreeMap::new();
371 let mut e2e = E2eStats::default();
372
373 for state in states {
374 totals.runs += 1;
375 match state.status {
376 RunStatus::Merged => totals.merged += 1,
377 RunStatus::Ready => totals.ready += 1,
378 RunStatus::Blocked => totals.blocked += 1,
379 RunStatus::Failed => totals.failed += 1,
380 RunStatus::Stalled => totals.stalled += 1,
381 RunStatus::VerifiedNoop => totals.verified_noop += 1,
382 RunStatus::Superseded => totals.superseded += 1,
388 RunStatus::Prep
389 | RunStatus::Implementing
390 | RunStatus::Judging
391 | RunStatus::Deliberating
392 | RunStatus::Voting
393 | RunStatus::Reviewing
394 | RunStatus::Gating
395 | RunStatus::Landing => totals.in_progress += 1,
396 }
397
398 for c in &state.candidates {
399 let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
400 agent: c.agent.clone(),
401 ..AgentStats::default()
402 });
403 if c.empty && c.verified_noop.is_none() {
408 entry.empty += 1;
409 }
410 if c.viable() {
411 entry.entered += 1;
412 }
413 }
414
415 if let Some(t) = &state.tally {
416 if t.uncontested.is_none() {
423 totals.tallied += 1;
424 if !t.unanimous_initial {
425 totals.split += 1;
426 }
427 if t.deliberated {
428 totals.deliberated += 1;
429 if t.changed_votes > 0 {
430 totals.minds_changed += 1;
431 }
432 if t.unanimous_final {
433 totals.converged += 1;
434 }
435 }
436 }
437 if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
438 agents
439 .entry(w.agent.clone())
440 .or_insert_with(|| AgentStats {
441 agent: w.agent.clone(),
442 ..AgentStats::default()
443 })
444 .wins += 1;
445 }
446 }
447
448 if let Some(advice) = &state.advice {
449 for rec in &advice.records {
450 let entry = advisors
451 .entry(rec.agent.clone())
452 .or_insert_with(|| AdvisorStats {
453 agent: rec.agent.clone(),
454 ..AdvisorStats::default()
455 });
456 entry.seated += 1;
457 if rec.proposal.is_none() {
458 entry.absent += 1;
459 continue;
460 }
461 entry.proposed += 1;
462 match rec.reflection {
463 crate::advise::Reflection::Strong => entry.strong += 1,
464 crate::advise::Reflection::Faint => entry.faint += 1,
465 crate::advise::Reflection::Absent => entry.faint += 1,
476 }
477 }
478 }
479
480 for round in &state.reviews {
481 totals.review_rounds += 1;
482
483 let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
490 let adopted: Vec<&String> = round
491 .fix
492 .as_ref()
493 .map(|f| f.addressed.iter().collect())
494 .unwrap_or_default();
495
496 for rec in &round.reviews {
497 let entry = reviewers
498 .entry(rec.agent.clone())
499 .or_insert_with(|| ReviewerStats {
500 agent: rec.agent.clone(),
501 ..ReviewerStats::default()
502 });
503 entry.seated += 1;
509 if rec.failed.is_some() {
510 entry.timeouts += 1;
511 continue;
512 }
513 if report_lost {
514 continue;
515 }
516 entry.rounds += 1;
517 entry.submitted += rec.findings.len();
518 for f in &rec.findings {
519 if adopted.iter().any(|a| **a == f.id) {
520 entry.adopted += 1;
521 }
522 let overlapped = round
523 .reviews
524 .iter()
525 .filter(|other| other.reviewer != rec.reviewer)
526 .flat_map(|other| other.findings.iter())
527 .any(|g| same_defect(f, g));
528 if !overlapped {
529 entry.unique += 1;
530 }
531 }
532 }
533
534 if round.e2e_deferred {
535 e2e.deferred += 1;
536 } else if !round.e2e.is_empty() {
537 e2e.rounds += 1;
538 if round.e2e.iter().any(|o| !o.ok()) {
539 e2e.failures += 1;
540 if round.blocking == 0 {
541 e2e.sole_detections += 1;
542 }
543 }
544 }
545 }
546 }
547
548 let mut agents: Vec<AgentStats> = agents.into_values().collect();
549 agents.sort_by(|a, b| {
550 b.win_rate()
551 .total_cmp(&a.win_rate())
552 .then(b.entered.cmp(&a.entered))
553 });
554 let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
555 reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
560 reviewers.sort_by(|a, b| {
561 b.adopted_per_round()
562 .total_cmp(&a.adopted_per_round())
563 .then(b.rounds.cmp(&a.rounds))
564 });
565
566 let mut advisors: Vec<AdvisorStats> = advisors.into_values().collect();
567 advisors.sort_by(|a, b| {
568 b.reflection_rate()
569 .total_cmp(&a.reflection_rate())
570 .then(b.proposed.cmp(&a.proposed))
571 });
572
573 let nodes = node_durations(states);
574
575 Stats {
576 totals,
577 agents,
578 reviewers,
579 advisors,
580 e2e,
581 nodes,
582 }
583}
584
585fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
591 if normalize(&a.title) == normalize(&b.title) {
592 return true;
593 }
594 match (&a.file, &b.file) {
595 (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
596 (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
597 _ => false,
598 },
599 _ => false,
600 }
601}
602
603fn normalize(title: &str) -> String {
604 title
605 .chars()
606 .filter(|c| c.is_alphanumeric())
607 .map(|c| c.to_ascii_lowercase())
608 .collect()
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use crate::config::Config;
615 use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
616 use crate::verdict::{Finding, Severity};
617 use std::path::PathBuf;
618
619 fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
620 Finding {
621 id: id.to_owned(),
622 severity: sev,
623 file: Some(file.to_owned()),
624 line: Some(line),
625 title: title.to_owned(),
626 detail: String::new(),
627 }
628 }
629
630 fn candidate(label: char, agent: &str) -> Candidate {
631 Candidate {
632 index: 0,
633 label,
634 agent: agent.to_owned(),
635 branch: format!("magi/x/{label}"),
636 worktree: PathBuf::from("/w"),
637 summary: String::new(),
638 stat: String::new(),
639 files: 1,
640 commits: 1,
641 empty: false,
642 failed: None,
643 verified_noop: None,
644 duration_ms: 0,
645 folded: false,
646 }
647 }
648
649 fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
650 let mut s = RunState::new(
651 PathBuf::from("/repo"),
652 "main".to_owned(),
653 "abcdef".to_owned(),
654 "task".to_owned(),
655 Config::default(),
656 );
657 s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
658 s.tally = Some(Tally {
659 first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
660 borda: BTreeMap::new(),
661 winner,
662 rankings: 3,
663 unanimous_initial: false,
664 deliberated: true,
665 changed_votes: 1,
666 unanimous_final: true,
667 tie_break: None,
668 judges: 3,
669 present: 3,
670 quorum: 2,
671 met_quorum: true,
672 uncontested: None,
673 });
674 s.reviews = reviews;
675 s.status = status;
676 s
677 }
678
679 #[test]
680 fn win_rates_and_completion_are_counted_per_agent() {
681 let states = vec![
682 state_with(Vec::new(), 'B', RunStatus::Merged),
683 state_with(Vec::new(), 'A', RunStatus::Blocked),
684 ];
685 let stats = collect(&states);
686 assert_eq!(stats.totals.runs, 2);
687 assert_eq!(stats.totals.merged, 1);
688 assert_eq!(stats.totals.blocked, 1);
689 assert_eq!(stats.totals.completion_rate(), 50.0);
690 assert_eq!(stats.totals.split, 2);
691 assert_eq!(stats.totals.minds_changed, 2);
692 assert_eq!(stats.totals.converged, 2);
693
694 let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
695 assert_eq!(beta.entered, 2);
696 assert_eq!(beta.wins, 1);
697 assert_eq!(beta.win_rate(), 50.0);
698 }
699
700 #[test]
701 fn reviewer_precision_and_uniqueness() {
702 let round = ReviewRound {
703 round: 1,
704 head: "h".to_owned(),
705 verified_head: None,
706 verified_at: None,
707 reviews: vec![
708 ReviewRecord {
709 attempts: 0,
710 reviewer: 1,
711 agent: "alpha".to_owned(),
712 summary: String::new(),
713 findings: vec![
714 finding(
715 "R1-1-1",
716 "src/a.rs",
717 10,
718 "panics on empty",
719 Severity::Blocker,
720 ),
721 finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
722 ],
723 vote: None,
724 failed: None,
725 duration_ms: 0,
726 },
727 ReviewRecord {
728 attempts: 0,
729 reviewer: 2,
730 agent: "beta".to_owned(),
731 summary: String::new(),
732 findings: vec![finding(
734 "R1-2-1",
735 "src/a.rs",
736 13,
737 "empty input panic",
738 Severity::Blocker,
739 )],
740 vote: None,
741 failed: None,
742 duration_ms: 0,
743 },
744 ],
745 e2e: Vec::new(),
746 verify_retried: false,
747 e2e_deferred: false,
748 e2e_defer_reason: None,
749 fix: Some(FixRecord {
750 agent: "alpha".to_owned(),
751 addressed: vec!["R1-1-1".to_owned()],
752 rejected: Vec::new(),
753 notes: String::new(),
754 committed: true,
755 failed: None,
756 duration_ms: 0,
757 continuation: None,
758 }),
759 blocking: 3,
760 answered: 2,
761 expected: 2,
762 clean: false,
763 progressed: true,
764 vote_split: false,
765 reconsideration: Vec::new(),
766 verdict: None,
767 };
768 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
769 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
770 assert_eq!(alpha.submitted, 2);
771 assert_eq!(alpha.adopted, 1);
772 assert_eq!(alpha.precision(), 50.0);
773 assert_eq!(alpha.adopted_per_round(), 1.0);
774 assert_eq!(alpha.unique, 1);
776
777 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
778 assert_eq!(beta.submitted, 1);
779 assert_eq!(beta.adopted, 0);
780 assert_eq!(beta.unique, 0);
781 }
782
783 #[test]
784 fn a_lost_fix_report_does_not_count_as_zero_adoption() {
785 let submitted = ReviewRound {
786 round: 1,
787 head: "h".to_owned(),
788 verified_head: None,
789 verified_at: None,
790 reviews: vec![ReviewRecord {
791 attempts: 0,
792 reviewer: 1,
793 agent: "alpha".to_owned(),
794 summary: String::new(),
795 findings: vec![finding(
796 "R1-1-1",
797 "src/a.rs",
798 10,
799 "panics on empty",
800 Severity::Blocker,
801 )],
802 vote: None,
803 failed: None,
804 duration_ms: 0,
805 }],
806 e2e: Vec::new(),
807 verify_retried: false,
808 e2e_deferred: false,
809 e2e_defer_reason: None,
810 fix: Some(FixRecord {
813 agent: "alpha".to_owned(),
814 addressed: Vec::new(),
815 rejected: Vec::new(),
816 notes: String::new(),
817 committed: true,
818 failed: Some("unparsable fix report".to_owned()),
819 duration_ms: 0,
820 continuation: None,
821 }),
822 blocking: 4,
823 answered: 1,
824 expected: 1,
825 clean: false,
826 progressed: false,
827 vote_split: false,
828 reconsideration: Vec::new(),
829 verdict: None,
830 };
831 let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
832 assert!(
833 stats.reviewers.is_empty(),
834 "a round with no adoption signal must not enter any reviewer's \
835 denominator: {:?}",
836 stats.reviewers
837 );
838 }
839
840 #[test]
841 fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
842 let round = ReviewRound {
843 round: 1,
844 head: "h".to_owned(),
845 verified_head: None,
846 verified_at: None,
847 reviews: vec![
848 ReviewRecord {
849 attempts: 0,
850 reviewer: 1,
851 agent: "alpha".to_owned(),
852 summary: String::new(),
853 findings: Vec::new(),
854 vote: None,
855 failed: None,
856 duration_ms: 0,
857 },
858 ReviewRecord {
859 attempts: 0,
860 reviewer: 2,
861 agent: "beta".to_owned(),
862 summary: String::new(),
863 findings: Vec::new(),
864 vote: None,
865 failed: Some("agent timed out".to_owned()),
866 duration_ms: 0,
867 },
868 ],
869 e2e: Vec::new(),
870 verify_retried: false,
871 e2e_deferred: false,
872 e2e_defer_reason: None,
873 fix: None,
874 blocking: 0,
875 answered: 1,
876 expected: 2,
877 clean: false,
878 progressed: false,
879 vote_split: false,
880 reconsideration: Vec::new(),
881 verdict: None,
882 };
883 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
884
885 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
886 assert_eq!(alpha.seated, 1);
887 assert_eq!(alpha.rounds, 1);
888 assert_eq!(alpha.timeouts, 0);
889 assert_eq!(alpha.submitted, 0);
890
891 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
892 assert_eq!(beta.seated, 1);
893 assert_eq!(beta.timeouts, 1);
894 assert_eq!(beta.submitted, 0);
895 assert_eq!(beta.rounds, 0);
899 assert_eq!(beta.timeout_rate(), 100.0);
900 }
901
902 #[test]
903 fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
904 let round = ReviewRound {
910 round: 1,
911 head: "h".to_owned(),
912 verified_head: None,
913 verified_at: None,
914 reviews: vec![
915 ReviewRecord {
916 attempts: 0,
917 reviewer: 1,
918 agent: "alpha".to_owned(),
919 summary: String::new(),
920 findings: vec![finding(
921 "R1-1-1",
922 "src/a.rs",
923 10,
924 "panics on empty",
925 Severity::Blocker,
926 )],
927 vote: None,
928 failed: None,
929 duration_ms: 0,
930 },
931 ReviewRecord {
932 attempts: 0,
933 reviewer: 2,
934 agent: "beta".to_owned(),
935 summary: String::new(),
936 findings: Vec::new(),
937 vote: None,
938 failed: Some("agent timed out".to_owned()),
939 duration_ms: 0,
940 },
941 ],
942 e2e: Vec::new(),
943 verify_retried: false,
944 e2e_deferred: false,
945 e2e_defer_reason: None,
946 fix: Some(FixRecord {
947 agent: "alpha".to_owned(),
948 addressed: Vec::new(),
949 rejected: Vec::new(),
950 notes: String::new(),
951 committed: true,
952 failed: Some("unparsable fix report".to_owned()),
953 duration_ms: 0,
954 continuation: None,
955 }),
956 blocking: 1,
957 answered: 1,
958 expected: 2,
959 clean: false,
960 progressed: false,
961 vote_split: false,
962 reconsideration: Vec::new(),
963 verdict: None,
964 };
965 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
966
967 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
968 assert_eq!(beta.timeouts, 1);
969 assert_eq!(beta.timeout_rate(), 100.0);
970 assert!(
973 !stats.reviewers.iter().any(|r| r.agent == "alpha"),
974 "{:?}",
975 stats.reviewers
976 );
977 }
978
979 #[test]
980 fn e2e_sole_detection_needs_a_clean_static_review() {
981 let fail = CommandOutcome {
982 command: "cargo test".to_owned(),
983 code: Some(101),
984 output_tail: "boom".to_owned(),
985 duration_ms: 1,
986 resource_blocked: false,
987 };
988 let sole = ReviewRound {
989 round: 1,
990 head: "h".to_owned(),
991 verified_head: None,
992 verified_at: None,
993 reviews: Vec::new(),
994 e2e: vec![fail.clone()],
995 verify_retried: false,
996 e2e_deferred: false,
997 e2e_defer_reason: None,
998 fix: None,
999 blocking: 0,
1000 answered: 0,
1001 expected: 0,
1002 clean: false,
1003 progressed: false,
1004 vote_split: false,
1005 reconsideration: Vec::new(),
1006 verdict: None,
1007 };
1008 let alongside = ReviewRound {
1009 round: 2,
1010 head: "h".to_owned(),
1011 verified_head: None,
1012 verified_at: None,
1013 reviews: Vec::new(),
1014 e2e: vec![fail],
1015 verify_retried: false,
1016 e2e_deferred: false,
1017 e2e_defer_reason: None,
1018 fix: None,
1019 blocking: 2,
1020 answered: 0,
1021 expected: 0,
1022 clean: false,
1023 progressed: false,
1024 vote_split: false,
1025 reconsideration: Vec::new(),
1026 verdict: None,
1027 };
1028 let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
1029 assert_eq!(stats.e2e.rounds, 2);
1030 assert_eq!(stats.e2e.failures, 2);
1031 assert_eq!(stats.e2e.sole_detections, 1);
1032 assert_eq!(stats.e2e.sole_rate(), 50.0);
1033 }
1034
1035 #[test]
1036 fn every_run_status_lands_in_exactly_one_breakdown_bucket() {
1037 let states = vec![
1038 state_with(Vec::new(), 'A', RunStatus::Merged),
1039 state_with(Vec::new(), 'A', RunStatus::Ready),
1040 state_with(Vec::new(), 'A', RunStatus::Blocked),
1041 state_with(Vec::new(), 'A', RunStatus::Failed),
1042 state_with(Vec::new(), 'A', RunStatus::Stalled),
1043 state_with(Vec::new(), 'A', RunStatus::VerifiedNoop),
1044 state_with(Vec::new(), 'A', RunStatus::Superseded),
1045 state_with(Vec::new(), 'A', RunStatus::Implementing),
1046 state_with(Vec::new(), 'A', RunStatus::Landing),
1047 ];
1048 let stats = collect(&states);
1049 let t = &stats.totals;
1050 assert_eq!(t.runs, 9);
1051 assert_eq!(t.merged, 1);
1052 assert_eq!(t.ready, 1);
1053 assert_eq!(t.blocked, 1);
1054 assert_eq!(t.failed, 1);
1055 assert_eq!(t.stalled, 1);
1056 assert_eq!(t.verified_noop, 1);
1057 assert_eq!(t.superseded, 1);
1058 assert_eq!(t.in_progress, 2);
1061 assert_eq!(
1062 t.merged
1063 + t.ready
1064 + t.blocked
1065 + t.failed
1066 + t.stalled
1067 + t.verified_noop
1068 + t.superseded
1069 + t.in_progress,
1070 t.runs,
1071 "every run must land in exactly one bucket of the breakdown"
1072 );
1073 }
1074
1075 #[test]
1076 fn empty_input_yields_zeroed_rates_not_nan() {
1077 let stats = collect(&[]);
1078 assert_eq!(stats.totals.completion_rate(), 0.0);
1079 assert_eq!(stats.totals.split_rate(), 0.0);
1080 assert_eq!(stats.e2e.sole_rate(), 0.0);
1081 assert!(stats.agents.is_empty());
1082 assert!(stats.advisors.is_empty());
1083 assert_eq!(AdvisorStats::default().reflection_rate(), 0.0);
1084 }
1085
1086 fn advisor_record(
1087 seat: &str,
1088 agent: &str,
1089 proposal: Option<crate::verdict::Proposal>,
1090 reflection: crate::advise::Reflection,
1091 ) -> crate::advise::AdvisorRecord {
1092 crate::advise::AdvisorRecord {
1093 seat: seat.to_owned(),
1094 agent: agent.to_owned(),
1095 proposal,
1096 error: None,
1097 duration_ms: 0,
1098 reflection,
1099 }
1100 }
1101
1102 fn a_proposal() -> crate::verdict::Proposal {
1103 crate::verdict::Proposal {
1104 approach: "do the thing".to_owned(),
1105 key_tradeoff: "speed over memory".to_owned(),
1106 risks: Vec::new(),
1107 touches: Vec::new(),
1108 why_not_naive: "the naive version breaks under load".to_owned(),
1109 }
1110 }
1111
1112 #[test]
1113 fn advisor_stats_count_proposed_absent_and_reflection_split() {
1114 use crate::advise::{Advice, Reflection};
1115
1116 let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1117 s.advice = Some(Advice {
1118 records: vec![
1119 advisor_record("advisor-1", "alpha", Some(a_proposal()), Reflection::Strong),
1120 advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1121 advisor_record("advisor-3", "alpha", None, Reflection::Absent),
1122 ],
1123 synthesis: Some("blended brief".to_owned()),
1124 });
1125
1126 let stats = collect(&[s]);
1127 let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1128 assert_eq!(alpha.seated, 3);
1129 assert_eq!(alpha.proposed, 2);
1130 assert_eq!(alpha.absent, 1);
1131 assert_eq!(alpha.strong, 1);
1132 assert_eq!(alpha.faint, 1);
1133 assert_eq!(alpha.reflection_rate(), 50.0);
1134 }
1135
1136 #[test]
1137 fn advisor_stats_count_absent_from_the_proposal_not_the_reflection_default() {
1138 use crate::advise::{Advice, Reflection};
1150
1151 let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1152 s.advice = Some(Advice {
1153 records: vec![
1154 advisor_record("advisor-1", "alpha", None, Reflection::Absent),
1155 advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1156 advisor_record("advisor-3", "alpha", Some(a_proposal()), Reflection::Absent),
1157 ],
1158 synthesis: None,
1159 });
1160
1161 let stats = collect(&[s]);
1162 let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1163 assert_eq!(alpha.seated, 3);
1164 assert_eq!(alpha.proposed, 2);
1165 assert_eq!(alpha.absent, 1);
1166 assert_eq!(alpha.faint, 2);
1167 assert_eq!(alpha.strong, 0);
1168 }
1169
1170 #[test]
1171 fn advisor_stats_ignore_runs_with_advise_off() {
1172 let s = state_with(Vec::new(), 'A', RunStatus::Merged);
1173 assert!(s.advice.is_none());
1174 let stats = collect(&[s]);
1175 assert!(stats.advisors.is_empty());
1176 }
1177
1178 #[test]
1179 fn same_defect_matches_titles_across_files() {
1180 let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
1181 let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
1182 assert!(same_defect(&a, &b));
1183 let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
1184 assert!(!same_defect(&a, &c));
1185 }
1186
1187 fn event(node: &str, at_secs: i64, message: &str) -> crate::run::Event {
1188 crate::run::Event {
1189 at: jiff::Timestamp::from_second(at_secs).unwrap(),
1190 node: node.to_owned(),
1191 message: message.to_owned(),
1192 }
1193 }
1194
1195 fn state_with_events(events: Vec<crate::run::Event>) -> RunState {
1196 let mut s = RunState::new(
1197 PathBuf::from("/repo"),
1198 "main".to_owned(),
1199 "abcdef".to_owned(),
1200 "task".to_owned(),
1201 Config::default(),
1202 );
1203 s.events = events;
1204 s
1205 }
1206
1207 #[test]
1208 fn a_node_with_multiple_events_spans_first_to_last() {
1209 let s = state_with_events(vec![
1210 event("implement", 1_000, "start"),
1211 event("implement", 1_030, "still running"),
1212 event("implement", 1_090, "done"),
1213 ]);
1214 let nodes = node_durations(&[s]);
1215 assert_eq!(nodes.len(), 1);
1216 assert_eq!(nodes[0].node, "implement");
1217 assert_eq!(nodes[0].runs, 1);
1218 assert_eq!(nodes[0].total_secs, 90);
1219 assert_eq!(nodes[0].max_secs, 90);
1220 assert_eq!(nodes[0].single, 0);
1221 assert_eq!(nodes[0].mean_secs(), 90.0);
1222 }
1223
1224 #[test]
1225 fn a_node_with_a_single_event_is_unmeasured_not_zero() {
1226 let s = state_with_events(vec![event("gate", 2_000, "ran once")]);
1227 let nodes = node_durations(&[s]);
1228 assert_eq!(nodes.len(), 1);
1229 assert_eq!(nodes[0].node, "gate");
1230 assert_eq!(nodes[0].runs, 0);
1231 assert_eq!(nodes[0].total_secs, 0);
1232 assert_eq!(nodes[0].single, 1);
1233 assert_eq!(nodes[0].mean_secs(), 0.0);
1236 }
1237
1238 #[test]
1239 fn a_run_with_no_events_produces_no_node_rows() {
1240 let s = state_with_events(vec![]);
1241 let nodes = node_durations(&[s]);
1242 assert!(nodes.is_empty());
1243 }
1244
1245 #[test]
1246 fn multiple_runs_aggregate_the_same_node() {
1247 let a = state_with_events(vec![event("judge", 0, "start"), event("judge", 60, "done")]);
1248 let b = state_with_events(vec![
1249 event("judge", 0, "start"),
1250 event("judge", 200, "done"),
1251 ]);
1252 let c = state_with_events(vec![event("judge", 5, "start")]);
1255 let nodes = node_durations(&[a, b, c]);
1256 assert_eq!(nodes.len(), 1);
1257 let judge = &nodes[0];
1258 assert_eq!(judge.node, "judge");
1259 assert_eq!(judge.runs, 2);
1260 assert_eq!(judge.total_secs, 260);
1261 assert_eq!(judge.max_secs, 200);
1262 assert_eq!(judge.single, 1);
1263 assert_eq!(judge.mean_secs(), 130.0);
1264 }
1265}