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 stalled: usize,
151 pub verified_noop: usize,
154 pub superseded: usize,
157 pub in_progress: usize,
162 pub tallied: usize,
164 pub split: usize,
166 pub deliberated: usize,
168 pub minds_changed: usize,
170 pub converged: usize,
172 pub review_rounds: usize,
174}
175
176impl Totals {
177 pub fn completion_rate(&self) -> f64 {
179 if self.runs == 0 {
180 0.0
181 } else {
182 100.0 * (self.merged + self.ready) as f64 / self.runs as f64
183 }
184 }
185
186 pub fn split_rate(&self) -> f64 {
188 if self.tallied == 0 {
189 0.0
190 } else {
191 100.0 * self.split as f64 / self.tallied as f64
192 }
193 }
194}
195
196#[derive(Debug, Clone, Default, PartialEq)]
204pub struct NodeDuration {
205 pub node: String,
207 pub runs: usize,
209 pub total_secs: i64,
211 pub max_secs: i64,
213 pub single: usize,
218}
219
220impl NodeDuration {
221 pub fn mean_secs(&self) -> f64 {
225 if self.runs == 0 {
226 0.0
227 } else {
228 self.total_secs as f64 / self.runs as f64
229 }
230 }
231}
232
233pub fn node_durations(states: &[RunState]) -> Vec<NodeDuration> {
241 let mut nodes: BTreeMap<String, NodeDuration> = BTreeMap::new();
242
243 for state in states {
244 let mut spans: BTreeMap<&str, (jiff::Timestamp, jiff::Timestamp, usize)> = BTreeMap::new();
245 for e in &state.events {
246 spans
247 .entry(e.node.as_str())
248 .and_modify(|(min, max, count)| {
249 if e.at < *min {
250 *min = e.at;
251 }
252 if e.at > *max {
253 *max = e.at;
254 }
255 *count += 1;
256 })
257 .or_insert((e.at, e.at, 1));
258 }
259
260 for (node, (min, max, count)) in spans {
261 let entry = nodes
262 .entry(node.to_owned())
263 .or_insert_with(|| NodeDuration {
264 node: node.to_owned(),
265 ..NodeDuration::default()
266 });
267 if count < 2 {
268 entry.single += 1;
269 continue;
270 }
271 let span_secs = (max - min).get_seconds();
275 entry.runs += 1;
276 entry.total_secs += span_secs;
277 if span_secs > entry.max_secs {
278 entry.max_secs = span_secs;
279 }
280 }
281 }
282
283 let mut nodes: Vec<NodeDuration> = nodes.into_values().collect();
284 nodes.sort_by(|a, b| b.total_secs.cmp(&a.total_secs).then(a.node.cmp(&b.node)));
285 nodes
286}
287
288#[derive(Debug, Clone, Default)]
290pub struct Stats {
291 pub totals: Totals,
293 pub agents: Vec<AgentStats>,
295 pub reviewers: Vec<ReviewerStats>,
297 pub e2e: E2eStats,
299 pub nodes: Vec<NodeDuration>,
301}
302
303pub fn load_all() -> Vec<RunState> {
305 list_ids()
306 .into_iter()
307 .filter_map(|id| RunState::load(&id).ok())
308 .collect()
309}
310
311pub fn collect(states: &[RunState]) -> Stats {
313 let mut totals = Totals::default();
314 let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
315 let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
316 let mut e2e = E2eStats::default();
317
318 for state in states {
319 totals.runs += 1;
320 match state.status {
321 RunStatus::Merged => totals.merged += 1,
322 RunStatus::Ready => totals.ready += 1,
323 RunStatus::Blocked => totals.blocked += 1,
324 RunStatus::Failed => totals.failed += 1,
325 RunStatus::Stalled => totals.stalled += 1,
326 RunStatus::VerifiedNoop => totals.verified_noop += 1,
327 RunStatus::Superseded => totals.superseded += 1,
333 RunStatus::Prep
334 | RunStatus::Implementing
335 | RunStatus::Judging
336 | RunStatus::Deliberating
337 | RunStatus::Voting
338 | RunStatus::Reviewing
339 | RunStatus::Gating
340 | RunStatus::Landing => totals.in_progress += 1,
341 }
342
343 for c in &state.candidates {
344 let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
345 agent: c.agent.clone(),
346 ..AgentStats::default()
347 });
348 if c.empty && c.verified_noop.is_none() {
353 entry.empty += 1;
354 }
355 if c.viable() {
356 entry.entered += 1;
357 }
358 }
359
360 if let Some(t) = &state.tally {
361 if t.uncontested.is_none() {
368 totals.tallied += 1;
369 if !t.unanimous_initial {
370 totals.split += 1;
371 }
372 if t.deliberated {
373 totals.deliberated += 1;
374 if t.changed_votes > 0 {
375 totals.minds_changed += 1;
376 }
377 if t.unanimous_final {
378 totals.converged += 1;
379 }
380 }
381 }
382 if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
383 agents
384 .entry(w.agent.clone())
385 .or_insert_with(|| AgentStats {
386 agent: w.agent.clone(),
387 ..AgentStats::default()
388 })
389 .wins += 1;
390 }
391 }
392
393 for round in &state.reviews {
394 totals.review_rounds += 1;
395
396 let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
403 let adopted: Vec<&String> = round
404 .fix
405 .as_ref()
406 .map(|f| f.addressed.iter().collect())
407 .unwrap_or_default();
408
409 for rec in &round.reviews {
410 let entry = reviewers
411 .entry(rec.agent.clone())
412 .or_insert_with(|| ReviewerStats {
413 agent: rec.agent.clone(),
414 ..ReviewerStats::default()
415 });
416 entry.seated += 1;
422 if rec.failed.is_some() {
423 entry.timeouts += 1;
424 continue;
425 }
426 if report_lost {
427 continue;
428 }
429 entry.rounds += 1;
430 entry.submitted += rec.findings.len();
431 for f in &rec.findings {
432 if adopted.iter().any(|a| **a == f.id) {
433 entry.adopted += 1;
434 }
435 let overlapped = round
436 .reviews
437 .iter()
438 .filter(|other| other.reviewer != rec.reviewer)
439 .flat_map(|other| other.findings.iter())
440 .any(|g| same_defect(f, g));
441 if !overlapped {
442 entry.unique += 1;
443 }
444 }
445 }
446
447 if round.e2e_deferred {
448 e2e.deferred += 1;
449 } else if !round.e2e.is_empty() {
450 e2e.rounds += 1;
451 if round.e2e.iter().any(|o| !o.ok()) {
452 e2e.failures += 1;
453 if round.blocking == 0 {
454 e2e.sole_detections += 1;
455 }
456 }
457 }
458 }
459 }
460
461 let mut agents: Vec<AgentStats> = agents.into_values().collect();
462 agents.sort_by(|a, b| {
463 b.win_rate()
464 .total_cmp(&a.win_rate())
465 .then(b.entered.cmp(&a.entered))
466 });
467 let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
468 reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
473 reviewers.sort_by(|a, b| {
474 b.adopted_per_round()
475 .total_cmp(&a.adopted_per_round())
476 .then(b.rounds.cmp(&a.rounds))
477 });
478
479 let nodes = node_durations(states);
480
481 Stats {
482 totals,
483 agents,
484 reviewers,
485 e2e,
486 nodes,
487 }
488}
489
490fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
496 if normalize(&a.title) == normalize(&b.title) {
497 return true;
498 }
499 match (&a.file, &b.file) {
500 (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
501 (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
502 _ => false,
503 },
504 _ => false,
505 }
506}
507
508fn normalize(title: &str) -> String {
509 title
510 .chars()
511 .filter(|c| c.is_alphanumeric())
512 .map(|c| c.to_ascii_lowercase())
513 .collect()
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use crate::config::Config;
520 use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
521 use crate::verdict::{Finding, Severity};
522 use std::path::PathBuf;
523
524 fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
525 Finding {
526 id: id.to_owned(),
527 severity: sev,
528 file: Some(file.to_owned()),
529 line: Some(line),
530 title: title.to_owned(),
531 detail: String::new(),
532 }
533 }
534
535 fn candidate(label: char, agent: &str) -> Candidate {
536 Candidate {
537 index: 0,
538 label,
539 agent: agent.to_owned(),
540 branch: format!("magi/x/{label}"),
541 worktree: PathBuf::from("/w"),
542 summary: String::new(),
543 stat: String::new(),
544 files: 1,
545 commits: 1,
546 empty: false,
547 failed: None,
548 verified_noop: None,
549 duration_ms: 0,
550 folded: false,
551 }
552 }
553
554 fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
555 let mut s = RunState::new(
556 PathBuf::from("/repo"),
557 "main".to_owned(),
558 "abcdef".to_owned(),
559 "task".to_owned(),
560 Config::default(),
561 );
562 s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
563 s.tally = Some(Tally {
564 first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
565 borda: BTreeMap::new(),
566 winner,
567 rankings: 3,
568 unanimous_initial: false,
569 deliberated: true,
570 changed_votes: 1,
571 unanimous_final: true,
572 tie_break: None,
573 judges: 3,
574 present: 3,
575 quorum: 2,
576 met_quorum: true,
577 uncontested: None,
578 });
579 s.reviews = reviews;
580 s.status = status;
581 s
582 }
583
584 #[test]
585 fn win_rates_and_completion_are_counted_per_agent() {
586 let states = vec![
587 state_with(Vec::new(), 'B', RunStatus::Merged),
588 state_with(Vec::new(), 'A', RunStatus::Blocked),
589 ];
590 let stats = collect(&states);
591 assert_eq!(stats.totals.runs, 2);
592 assert_eq!(stats.totals.merged, 1);
593 assert_eq!(stats.totals.blocked, 1);
594 assert_eq!(stats.totals.completion_rate(), 50.0);
595 assert_eq!(stats.totals.split, 2);
596 assert_eq!(stats.totals.minds_changed, 2);
597 assert_eq!(stats.totals.converged, 2);
598
599 let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
600 assert_eq!(beta.entered, 2);
601 assert_eq!(beta.wins, 1);
602 assert_eq!(beta.win_rate(), 50.0);
603 }
604
605 #[test]
606 fn reviewer_precision_and_uniqueness() {
607 let round = ReviewRound {
608 round: 1,
609 head: "h".to_owned(),
610 verified_head: None,
611 verified_at: None,
612 reviews: vec![
613 ReviewRecord {
614 attempts: 0,
615 reviewer: 1,
616 agent: "alpha".to_owned(),
617 summary: String::new(),
618 findings: vec![
619 finding(
620 "R1-1-1",
621 "src/a.rs",
622 10,
623 "panics on empty",
624 Severity::Blocker,
625 ),
626 finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
627 ],
628 vote: None,
629 failed: None,
630 duration_ms: 0,
631 },
632 ReviewRecord {
633 attempts: 0,
634 reviewer: 2,
635 agent: "beta".to_owned(),
636 summary: String::new(),
637 findings: vec![finding(
639 "R1-2-1",
640 "src/a.rs",
641 13,
642 "empty input panic",
643 Severity::Blocker,
644 )],
645 vote: None,
646 failed: None,
647 duration_ms: 0,
648 },
649 ],
650 e2e: Vec::new(),
651 verify_retried: false,
652 e2e_deferred: false,
653 e2e_defer_reason: None,
654 fix: Some(FixRecord {
655 agent: "alpha".to_owned(),
656 addressed: vec!["R1-1-1".to_owned()],
657 rejected: Vec::new(),
658 notes: String::new(),
659 committed: true,
660 failed: None,
661 duration_ms: 0,
662 continuation: None,
663 }),
664 blocking: 3,
665 answered: 2,
666 expected: 2,
667 clean: false,
668 progressed: true,
669 vote_split: false,
670 reconsideration: Vec::new(),
671 verdict: None,
672 };
673 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
674 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
675 assert_eq!(alpha.submitted, 2);
676 assert_eq!(alpha.adopted, 1);
677 assert_eq!(alpha.precision(), 50.0);
678 assert_eq!(alpha.adopted_per_round(), 1.0);
679 assert_eq!(alpha.unique, 1);
681
682 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
683 assert_eq!(beta.submitted, 1);
684 assert_eq!(beta.adopted, 0);
685 assert_eq!(beta.unique, 0);
686 }
687
688 #[test]
689 fn a_lost_fix_report_does_not_count_as_zero_adoption() {
690 let submitted = ReviewRound {
691 round: 1,
692 head: "h".to_owned(),
693 verified_head: None,
694 verified_at: None,
695 reviews: vec![ReviewRecord {
696 attempts: 0,
697 reviewer: 1,
698 agent: "alpha".to_owned(),
699 summary: String::new(),
700 findings: vec![finding(
701 "R1-1-1",
702 "src/a.rs",
703 10,
704 "panics on empty",
705 Severity::Blocker,
706 )],
707 vote: None,
708 failed: None,
709 duration_ms: 0,
710 }],
711 e2e: Vec::new(),
712 verify_retried: false,
713 e2e_deferred: false,
714 e2e_defer_reason: None,
715 fix: Some(FixRecord {
718 agent: "alpha".to_owned(),
719 addressed: Vec::new(),
720 rejected: Vec::new(),
721 notes: String::new(),
722 committed: true,
723 failed: Some("unparsable fix report".to_owned()),
724 duration_ms: 0,
725 continuation: None,
726 }),
727 blocking: 4,
728 answered: 1,
729 expected: 1,
730 clean: false,
731 progressed: false,
732 vote_split: false,
733 reconsideration: Vec::new(),
734 verdict: None,
735 };
736 let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
737 assert!(
738 stats.reviewers.is_empty(),
739 "a round with no adoption signal must not enter any reviewer's \
740 denominator: {:?}",
741 stats.reviewers
742 );
743 }
744
745 #[test]
746 fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
747 let round = ReviewRound {
748 round: 1,
749 head: "h".to_owned(),
750 verified_head: None,
751 verified_at: None,
752 reviews: vec![
753 ReviewRecord {
754 attempts: 0,
755 reviewer: 1,
756 agent: "alpha".to_owned(),
757 summary: String::new(),
758 findings: Vec::new(),
759 vote: None,
760 failed: None,
761 duration_ms: 0,
762 },
763 ReviewRecord {
764 attempts: 0,
765 reviewer: 2,
766 agent: "beta".to_owned(),
767 summary: String::new(),
768 findings: Vec::new(),
769 vote: None,
770 failed: Some("agent timed out".to_owned()),
771 duration_ms: 0,
772 },
773 ],
774 e2e: Vec::new(),
775 verify_retried: false,
776 e2e_deferred: false,
777 e2e_defer_reason: None,
778 fix: None,
779 blocking: 0,
780 answered: 1,
781 expected: 2,
782 clean: false,
783 progressed: false,
784 vote_split: false,
785 reconsideration: Vec::new(),
786 verdict: None,
787 };
788 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
789
790 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
791 assert_eq!(alpha.seated, 1);
792 assert_eq!(alpha.rounds, 1);
793 assert_eq!(alpha.timeouts, 0);
794 assert_eq!(alpha.submitted, 0);
795
796 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
797 assert_eq!(beta.seated, 1);
798 assert_eq!(beta.timeouts, 1);
799 assert_eq!(beta.submitted, 0);
800 assert_eq!(beta.rounds, 0);
804 assert_eq!(beta.timeout_rate(), 100.0);
805 }
806
807 #[test]
808 fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
809 let round = ReviewRound {
815 round: 1,
816 head: "h".to_owned(),
817 verified_head: None,
818 verified_at: None,
819 reviews: vec![
820 ReviewRecord {
821 attempts: 0,
822 reviewer: 1,
823 agent: "alpha".to_owned(),
824 summary: String::new(),
825 findings: vec![finding(
826 "R1-1-1",
827 "src/a.rs",
828 10,
829 "panics on empty",
830 Severity::Blocker,
831 )],
832 vote: None,
833 failed: None,
834 duration_ms: 0,
835 },
836 ReviewRecord {
837 attempts: 0,
838 reviewer: 2,
839 agent: "beta".to_owned(),
840 summary: String::new(),
841 findings: Vec::new(),
842 vote: None,
843 failed: Some("agent timed out".to_owned()),
844 duration_ms: 0,
845 },
846 ],
847 e2e: Vec::new(),
848 verify_retried: false,
849 e2e_deferred: false,
850 e2e_defer_reason: None,
851 fix: Some(FixRecord {
852 agent: "alpha".to_owned(),
853 addressed: Vec::new(),
854 rejected: Vec::new(),
855 notes: String::new(),
856 committed: true,
857 failed: Some("unparsable fix report".to_owned()),
858 duration_ms: 0,
859 continuation: None,
860 }),
861 blocking: 1,
862 answered: 1,
863 expected: 2,
864 clean: false,
865 progressed: false,
866 vote_split: false,
867 reconsideration: Vec::new(),
868 verdict: None,
869 };
870 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
871
872 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
873 assert_eq!(beta.timeouts, 1);
874 assert_eq!(beta.timeout_rate(), 100.0);
875 assert!(
878 !stats.reviewers.iter().any(|r| r.agent == "alpha"),
879 "{:?}",
880 stats.reviewers
881 );
882 }
883
884 #[test]
885 fn e2e_sole_detection_needs_a_clean_static_review() {
886 let fail = CommandOutcome {
887 command: "cargo test".to_owned(),
888 code: Some(101),
889 output_tail: "boom".to_owned(),
890 duration_ms: 1,
891 resource_blocked: false,
892 };
893 let sole = ReviewRound {
894 round: 1,
895 head: "h".to_owned(),
896 verified_head: None,
897 verified_at: None,
898 reviews: Vec::new(),
899 e2e: vec![fail.clone()],
900 verify_retried: false,
901 e2e_deferred: false,
902 e2e_defer_reason: None,
903 fix: None,
904 blocking: 0,
905 answered: 0,
906 expected: 0,
907 clean: false,
908 progressed: false,
909 vote_split: false,
910 reconsideration: Vec::new(),
911 verdict: None,
912 };
913 let alongside = ReviewRound {
914 round: 2,
915 head: "h".to_owned(),
916 verified_head: None,
917 verified_at: None,
918 reviews: Vec::new(),
919 e2e: vec![fail],
920 verify_retried: false,
921 e2e_deferred: false,
922 e2e_defer_reason: None,
923 fix: None,
924 blocking: 2,
925 answered: 0,
926 expected: 0,
927 clean: false,
928 progressed: false,
929 vote_split: false,
930 reconsideration: Vec::new(),
931 verdict: None,
932 };
933 let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
934 assert_eq!(stats.e2e.rounds, 2);
935 assert_eq!(stats.e2e.failures, 2);
936 assert_eq!(stats.e2e.sole_detections, 1);
937 assert_eq!(stats.e2e.sole_rate(), 50.0);
938 }
939
940 #[test]
941 fn every_run_status_lands_in_exactly_one_breakdown_bucket() {
942 let states = vec![
943 state_with(Vec::new(), 'A', RunStatus::Merged),
944 state_with(Vec::new(), 'A', RunStatus::Ready),
945 state_with(Vec::new(), 'A', RunStatus::Blocked),
946 state_with(Vec::new(), 'A', RunStatus::Failed),
947 state_with(Vec::new(), 'A', RunStatus::Stalled),
948 state_with(Vec::new(), 'A', RunStatus::VerifiedNoop),
949 state_with(Vec::new(), 'A', RunStatus::Superseded),
950 state_with(Vec::new(), 'A', RunStatus::Implementing),
951 state_with(Vec::new(), 'A', RunStatus::Landing),
952 ];
953 let stats = collect(&states);
954 let t = &stats.totals;
955 assert_eq!(t.runs, 9);
956 assert_eq!(t.merged, 1);
957 assert_eq!(t.ready, 1);
958 assert_eq!(t.blocked, 1);
959 assert_eq!(t.failed, 1);
960 assert_eq!(t.stalled, 1);
961 assert_eq!(t.verified_noop, 1);
962 assert_eq!(t.superseded, 1);
963 assert_eq!(t.in_progress, 2);
966 assert_eq!(
967 t.merged
968 + t.ready
969 + t.blocked
970 + t.failed
971 + t.stalled
972 + t.verified_noop
973 + t.superseded
974 + t.in_progress,
975 t.runs,
976 "every run must land in exactly one bucket of the breakdown"
977 );
978 }
979
980 #[test]
981 fn empty_input_yields_zeroed_rates_not_nan() {
982 let stats = collect(&[]);
983 assert_eq!(stats.totals.completion_rate(), 0.0);
984 assert_eq!(stats.totals.split_rate(), 0.0);
985 assert_eq!(stats.e2e.sole_rate(), 0.0);
986 assert!(stats.agents.is_empty());
987 }
988
989 #[test]
990 fn same_defect_matches_titles_across_files() {
991 let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
992 let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
993 assert!(same_defect(&a, &b));
994 let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
995 assert!(!same_defect(&a, &c));
996 }
997
998 fn event(node: &str, at_secs: i64, message: &str) -> crate::run::Event {
999 crate::run::Event {
1000 at: jiff::Timestamp::from_second(at_secs).unwrap(),
1001 node: node.to_owned(),
1002 message: message.to_owned(),
1003 }
1004 }
1005
1006 fn state_with_events(events: Vec<crate::run::Event>) -> RunState {
1007 let mut s = RunState::new(
1008 PathBuf::from("/repo"),
1009 "main".to_owned(),
1010 "abcdef".to_owned(),
1011 "task".to_owned(),
1012 Config::default(),
1013 );
1014 s.events = events;
1015 s
1016 }
1017
1018 #[test]
1019 fn a_node_with_multiple_events_spans_first_to_last() {
1020 let s = state_with_events(vec![
1021 event("implement", 1_000, "start"),
1022 event("implement", 1_030, "still running"),
1023 event("implement", 1_090, "done"),
1024 ]);
1025 let nodes = node_durations(&[s]);
1026 assert_eq!(nodes.len(), 1);
1027 assert_eq!(nodes[0].node, "implement");
1028 assert_eq!(nodes[0].runs, 1);
1029 assert_eq!(nodes[0].total_secs, 90);
1030 assert_eq!(nodes[0].max_secs, 90);
1031 assert_eq!(nodes[0].single, 0);
1032 assert_eq!(nodes[0].mean_secs(), 90.0);
1033 }
1034
1035 #[test]
1036 fn a_node_with_a_single_event_is_unmeasured_not_zero() {
1037 let s = state_with_events(vec![event("gate", 2_000, "ran once")]);
1038 let nodes = node_durations(&[s]);
1039 assert_eq!(nodes.len(), 1);
1040 assert_eq!(nodes[0].node, "gate");
1041 assert_eq!(nodes[0].runs, 0);
1042 assert_eq!(nodes[0].total_secs, 0);
1043 assert_eq!(nodes[0].single, 1);
1044 assert_eq!(nodes[0].mean_secs(), 0.0);
1047 }
1048
1049 #[test]
1050 fn a_run_with_no_events_produces_no_node_rows() {
1051 let s = state_with_events(vec![]);
1052 let nodes = node_durations(&[s]);
1053 assert!(nodes.is_empty());
1054 }
1055
1056 #[test]
1057 fn multiple_runs_aggregate_the_same_node() {
1058 let a = state_with_events(vec![event("judge", 0, "start"), event("judge", 60, "done")]);
1059 let b = state_with_events(vec![
1060 event("judge", 0, "start"),
1061 event("judge", 200, "done"),
1062 ]);
1063 let c = state_with_events(vec![event("judge", 5, "start")]);
1066 let nodes = node_durations(&[a, b, c]);
1067 assert_eq!(nodes.len(), 1);
1068 let judge = &nodes[0];
1069 assert_eq!(judge.node, "judge");
1070 assert_eq!(judge.runs, 2);
1071 assert_eq!(judge.total_secs, 260);
1072 assert_eq!(judge.max_secs, 200);
1073 assert_eq!(judge.single, 1);
1074 assert_eq!(judge.mean_secs(), 130.0);
1075 }
1076}