use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::run::{RunState, RunStatus, list_ids};
#[derive(Debug, Clone, Default)]
pub struct AgentStats {
pub agent: String,
pub entered: usize,
pub wins: usize,
pub empty: usize,
}
impl AgentStats {
pub fn win_rate(&self) -> f64 {
if self.entered == 0 {
0.0
} else {
100.0 * self.wins as f64 / self.entered as f64
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ReviewerStats {
pub agent: String,
pub rounds: usize,
pub seated: usize,
pub submitted: usize,
pub adopted: usize,
pub unique: usize,
pub timeouts: usize,
}
impl ReviewerStats {
pub fn adopted_per_round(&self) -> f64 {
if self.rounds == 0 {
0.0
} else {
self.adopted as f64 / self.rounds as f64
}
}
pub fn precision(&self) -> f64 {
if self.submitted == 0 {
0.0
} else {
100.0 * self.adopted as f64 / self.submitted as f64
}
}
pub fn unique_rate(&self) -> f64 {
if self.submitted == 0 {
0.0
} else {
100.0 * self.unique as f64 / self.submitted as f64
}
}
pub fn timeout_rate(&self) -> f64 {
if self.seated == 0 {
0.0
} else {
100.0 * self.timeouts as f64 / self.seated as f64
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AdvisorStats {
pub agent: String,
pub seated: usize,
pub proposed: usize,
pub absent: usize,
pub faint: usize,
pub strong: usize,
}
impl AdvisorStats {
pub fn reflection_rate(&self) -> f64 {
if self.proposed == 0 {
0.0
} else {
100.0 * self.strong as f64 / self.proposed as f64
}
}
}
#[derive(Debug, Clone, Default)]
pub struct E2eStats {
pub rounds: usize,
pub failures: usize,
pub sole_detections: usize,
pub deferred: usize,
}
impl E2eStats {
pub fn sole_rate(&self) -> f64 {
if self.failures == 0 {
0.0
} else {
100.0 * self.sole_detections as f64 / self.failures as f64
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Totals {
pub runs: usize,
pub merged: usize,
pub ready: usize,
pub blocked: usize,
pub failed: usize,
pub stalled: usize,
pub verified_noop: usize,
pub superseded: usize,
pub in_progress: usize,
pub tallied: usize,
pub split: usize,
pub deliberated: usize,
pub minds_changed: usize,
pub converged: usize,
pub review_rounds: usize,
}
impl Totals {
pub fn completion_rate(&self) -> f64 {
if self.runs == 0 {
0.0
} else {
100.0 * (self.merged + self.ready) as f64 / self.runs as f64
}
}
pub fn split_rate(&self) -> f64 {
if self.tallied == 0 {
0.0
} else {
100.0 * self.split as f64 / self.tallied as f64
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NodeDuration {
pub node: String,
pub runs: usize,
pub total_secs: i64,
pub max_secs: i64,
pub single: usize,
}
impl NodeDuration {
pub fn mean_secs(&self) -> f64 {
if self.runs == 0 {
0.0
} else {
self.total_secs as f64 / self.runs as f64
}
}
}
pub fn node_durations<'a>(states: impl IntoIterator<Item = &'a RunState>) -> Vec<NodeDuration> {
let mut nodes: BTreeMap<String, NodeDuration> = BTreeMap::new();
for state in states {
let mut spans: BTreeMap<&str, (jiff::Timestamp, jiff::Timestamp, usize)> = BTreeMap::new();
for e in &state.events {
spans
.entry(e.node.as_str())
.and_modify(|(min, max, count)| {
if e.at < *min {
*min = e.at;
}
if e.at > *max {
*max = e.at;
}
*count += 1;
})
.or_insert((e.at, e.at, 1));
}
for (node, (min, max, count)) in spans {
let entry = nodes
.entry(node.to_owned())
.or_insert_with(|| NodeDuration {
node: node.to_owned(),
..NodeDuration::default()
});
if count < 2 {
entry.single += 1;
continue;
}
let span_secs = (max - min).get_seconds();
entry.runs += 1;
entry.total_secs += span_secs;
if span_secs > entry.max_secs {
entry.max_secs = span_secs;
}
}
}
let mut nodes: Vec<NodeDuration> = nodes.into_values().collect();
nodes.sort_by(|a, b| b.total_secs.cmp(&a.total_secs).then(a.node.cmp(&b.node)));
nodes
}
#[derive(Debug, Clone, Default)]
pub struct ReleaseBumpStats {
pub merged: usize,
pub recorded: usize,
pub pr_opened: usize,
pub automerge_enabled: usize,
pub merged_directly: usize,
pub needs_attention: usize,
}
impl ReleaseBumpStats {
pub fn coverage_rate(&self) -> f64 {
if self.merged == 0 {
0.0
} else {
100.0 * self.recorded as f64 / self.merged as f64
}
}
pub fn automerge_rate(&self) -> f64 {
if self.pr_opened == 0 {
0.0
} else {
100.0 * self.automerge_enabled as f64 / self.pr_opened as f64
}
}
pub fn attention_rate(&self) -> f64 {
if self.recorded == 0 {
0.0
} else {
100.0 * self.needs_attention as f64 / self.recorded as f64
}
}
pub fn clean(&self) -> usize {
self.recorded.saturating_sub(self.needs_attention)
}
}
#[derive(Debug, Clone, Default)]
pub struct Stats {
pub totals: Totals,
pub agents: Vec<AgentStats>,
pub reviewers: Vec<ReviewerStats>,
pub advisors: Vec<AdvisorStats>,
pub e2e: E2eStats,
pub nodes: Vec<NodeDuration>,
pub release_bumps: ReleaseBumpStats,
}
pub fn load_all() -> Vec<RunState> {
list_ids()
.into_iter()
.filter_map(|id| RunState::load(&id).ok())
.collect()
}
#[derive(Debug, Clone)]
pub struct RepoStats {
pub repo: PathBuf,
pub name: String,
pub stats: Stats,
}
pub fn by_repo(states: &[RunState]) -> Vec<RepoStats> {
let mut groups: BTreeMap<PathBuf, Vec<&RunState>> = BTreeMap::new();
for state in states {
groups.entry(state.repo.clone()).or_default().push(state);
}
let mut out: Vec<RepoStats> = groups
.into_iter()
.map(|(repo, group)| {
let stats = collect_refs(group);
let name = repo
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| repo.to_string_lossy().into_owned());
RepoStats { repo, name, stats }
})
.collect();
out.sort_by(|a, b| {
b.stats
.totals
.runs
.cmp(&a.stats.totals.runs)
.then(a.repo.cmp(&b.repo))
});
out
}
pub fn filter_repo<'a>(states: &'a [RunState], repo: &Path) -> Vec<&'a RunState> {
states.iter().filter(|s| s.repo == repo).collect()
}
pub fn collect(states: &[RunState]) -> Stats {
collect_refs(states)
}
pub fn collect_refs<'a>(states: impl IntoIterator<Item = &'a RunState>) -> Stats {
let states: Vec<&'a RunState> = states.into_iter().collect();
let mut totals = Totals::default();
let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
let mut advisors: BTreeMap<String, AdvisorStats> = BTreeMap::new();
let mut e2e = E2eStats::default();
let mut release_bumps = ReleaseBumpStats::default();
for state in &states {
totals.runs += 1;
if state.status == RunStatus::Merged {
release_bumps.merged += 1;
if let Some(b) = &state.release_bump {
release_bumps.recorded += 1;
if b.pr_url.is_some() {
release_bumps.pr_opened += 1;
}
if b.automerge_enabled {
release_bumps.automerge_enabled += 1;
}
if b.merged_directly {
release_bumps.merged_directly += 1;
}
}
if state.needs_attention() {
release_bumps.needs_attention += 1;
}
}
match state.status {
RunStatus::Merged => totals.merged += 1,
RunStatus::Ready => totals.ready += 1,
RunStatus::Blocked => totals.blocked += 1,
RunStatus::Failed => totals.failed += 1,
RunStatus::Stalled => totals.stalled += 1,
RunStatus::VerifiedNoop => totals.verified_noop += 1,
RunStatus::Superseded => totals.superseded += 1,
RunStatus::Prep
| RunStatus::Implementing
| RunStatus::Judging
| RunStatus::Deliberating
| RunStatus::Voting
| RunStatus::Reviewing
| RunStatus::Gating
| RunStatus::Landing => totals.in_progress += 1,
}
for c in &state.candidates {
let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
agent: c.agent.clone(),
..AgentStats::default()
});
if c.empty && c.verified_noop.is_none() {
entry.empty += 1;
}
if c.viable() {
entry.entered += 1;
}
}
if let Some(t) = &state.tally {
if t.uncontested.is_none() {
totals.tallied += 1;
if !t.unanimous_initial {
totals.split += 1;
}
if t.deliberated {
totals.deliberated += 1;
if t.changed_votes > 0 {
totals.minds_changed += 1;
}
if t.unanimous_final {
totals.converged += 1;
}
}
}
if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
agents
.entry(w.agent.clone())
.or_insert_with(|| AgentStats {
agent: w.agent.clone(),
..AgentStats::default()
})
.wins += 1;
}
}
if let Some(advice) = &state.advice {
for rec in &advice.records {
let entry = advisors
.entry(rec.agent.clone())
.or_insert_with(|| AdvisorStats {
agent: rec.agent.clone(),
..AdvisorStats::default()
});
entry.seated += 1;
if rec.proposal.is_none() {
entry.absent += 1;
continue;
}
entry.proposed += 1;
match rec.reflection {
crate::advise::Reflection::Strong => entry.strong += 1,
crate::advise::Reflection::Faint => entry.faint += 1,
crate::advise::Reflection::Absent => entry.faint += 1,
}
}
}
for round in &state.reviews {
totals.review_rounds += 1;
let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
let adopted: Vec<&String> = round
.fix
.as_ref()
.map(|f| f.addressed.iter().collect())
.unwrap_or_default();
for rec in &round.reviews {
let entry = reviewers
.entry(rec.agent.clone())
.or_insert_with(|| ReviewerStats {
agent: rec.agent.clone(),
..ReviewerStats::default()
});
entry.seated += 1;
if rec.failed.is_some() {
entry.timeouts += 1;
continue;
}
if report_lost {
continue;
}
entry.rounds += 1;
entry.submitted += rec.findings.len();
for f in &rec.findings {
if adopted.iter().any(|a| **a == f.id) {
entry.adopted += 1;
}
let overlapped = round
.reviews
.iter()
.filter(|other| other.reviewer != rec.reviewer)
.flat_map(|other| other.findings.iter())
.any(|g| same_defect(f, g));
if !overlapped {
entry.unique += 1;
}
}
}
if round.e2e_deferred {
e2e.deferred += 1;
} else if !round.e2e.is_empty() {
e2e.rounds += 1;
if round.e2e.iter().any(|o| !o.ok()) {
e2e.failures += 1;
if round.blocking == 0 {
e2e.sole_detections += 1;
}
}
}
}
}
let mut agents: Vec<AgentStats> = agents.into_values().collect();
agents.sort_by(|a, b| {
b.win_rate()
.total_cmp(&a.win_rate())
.then(b.entered.cmp(&a.entered))
});
let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
reviewers.sort_by(|a, b| {
b.adopted_per_round()
.total_cmp(&a.adopted_per_round())
.then(b.rounds.cmp(&a.rounds))
});
let mut advisors: Vec<AdvisorStats> = advisors.into_values().collect();
advisors.sort_by(|a, b| {
b.reflection_rate()
.total_cmp(&a.reflection_rate())
.then(b.proposed.cmp(&a.proposed))
});
let nodes = node_durations(states);
Stats {
totals,
agents,
reviewers,
advisors,
e2e,
nodes,
release_bumps,
}
}
fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
if normalize(&a.title) == normalize(&b.title) {
return true;
}
match (&a.file, &b.file) {
(Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
(Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
_ => false,
},
_ => false,
}
}
fn normalize(title: &str) -> String {
title
.chars()
.filter(|c| c.is_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
use crate::verdict::{Finding, Severity};
use std::path::PathBuf;
fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
Finding {
id: id.to_owned(),
severity: sev,
file: Some(file.to_owned()),
line: Some(line),
title: title.to_owned(),
detail: String::new(),
}
}
fn candidate(label: char, agent: &str) -> Candidate {
Candidate {
index: 0,
label,
agent: agent.to_owned(),
branch: format!("magi/x/{label}"),
worktree: PathBuf::from("/w"),
summary: String::new(),
stat: String::new(),
files: 1,
commits: 1,
empty: false,
failed: None,
verified_noop: None,
duration_ms: 0,
folded: false,
}
}
fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
let mut s = RunState::new(
PathBuf::from("/repo"),
"main".to_owned(),
"abcdef".to_owned(),
"task".to_owned(),
Config::default(),
);
s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
s.tally = Some(Tally {
first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
borda: BTreeMap::new(),
winner,
rankings: 3,
unanimous_initial: false,
deliberated: true,
changed_votes: 1,
unanimous_final: true,
tie_break: None,
judges: 3,
present: 3,
quorum: 2,
met_quorum: true,
uncontested: None,
});
s.reviews = reviews;
s.status = status;
s
}
fn state_with_repo(
reviews: Vec<ReviewRound>,
winner: char,
status: RunStatus,
repo: &str,
) -> RunState {
let mut s = state_with(reviews, winner, status);
s.repo = PathBuf::from(repo);
s
}
#[test]
fn win_rates_and_completion_are_counted_per_agent() {
let states = vec![
state_with(Vec::new(), 'B', RunStatus::Merged),
state_with(Vec::new(), 'A', RunStatus::Blocked),
];
let stats = collect(&states);
assert_eq!(stats.totals.runs, 2);
assert_eq!(stats.totals.merged, 1);
assert_eq!(stats.totals.blocked, 1);
assert_eq!(stats.totals.completion_rate(), 50.0);
assert_eq!(stats.totals.split, 2);
assert_eq!(stats.totals.minds_changed, 2);
assert_eq!(stats.totals.converged, 2);
let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
assert_eq!(beta.entered, 2);
assert_eq!(beta.wins, 1);
assert_eq!(beta.win_rate(), 50.0);
}
#[test]
fn reviewer_precision_and_uniqueness() {
let round = ReviewRound {
round: 1,
head: "h".to_owned(),
verified_head: None,
verified_at: None,
reviews: vec![
ReviewRecord {
attempts: 0,
reviewer: 1,
agent: "alpha".to_owned(),
summary: String::new(),
findings: vec![
finding(
"R1-1-1",
"src/a.rs",
10,
"panics on empty",
Severity::Blocker,
),
finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
],
vote: None,
failed: None,
duration_ms: 0,
},
ReviewRecord {
attempts: 0,
reviewer: 2,
agent: "beta".to_owned(),
summary: String::new(),
findings: vec![finding(
"R1-2-1",
"src/a.rs",
13,
"empty input panic",
Severity::Blocker,
)],
vote: None,
failed: None,
duration_ms: 0,
},
],
e2e: Vec::new(),
verify_retried: false,
e2e_deferred: false,
e2e_defer_reason: None,
fix: Some(FixRecord {
agent: "alpha".to_owned(),
addressed: vec!["R1-1-1".to_owned()],
rejected: Vec::new(),
notes: String::new(),
committed: true,
failed: None,
duration_ms: 0,
continuation: None,
}),
blocking: 3,
answered: 2,
expected: 2,
clean: false,
progressed: true,
vote_split: false,
reconsideration: Vec::new(),
verdict: None,
};
let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
assert_eq!(alpha.submitted, 2);
assert_eq!(alpha.adopted, 1);
assert_eq!(alpha.precision(), 50.0);
assert_eq!(alpha.adopted_per_round(), 1.0);
assert_eq!(alpha.unique, 1);
let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
assert_eq!(beta.submitted, 1);
assert_eq!(beta.adopted, 0);
assert_eq!(beta.unique, 0);
}
#[test]
fn a_lost_fix_report_does_not_count_as_zero_adoption() {
let submitted = ReviewRound {
round: 1,
head: "h".to_owned(),
verified_head: None,
verified_at: None,
reviews: vec![ReviewRecord {
attempts: 0,
reviewer: 1,
agent: "alpha".to_owned(),
summary: String::new(),
findings: vec![finding(
"R1-1-1",
"src/a.rs",
10,
"panics on empty",
Severity::Blocker,
)],
vote: None,
failed: None,
duration_ms: 0,
}],
e2e: Vec::new(),
verify_retried: false,
e2e_deferred: false,
e2e_defer_reason: None,
fix: Some(FixRecord {
agent: "alpha".to_owned(),
addressed: Vec::new(),
rejected: Vec::new(),
notes: String::new(),
committed: true,
failed: Some("unparsable fix report".to_owned()),
duration_ms: 0,
continuation: None,
}),
blocking: 4,
answered: 1,
expected: 1,
clean: false,
progressed: false,
vote_split: false,
reconsideration: Vec::new(),
verdict: None,
};
let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
assert!(
stats.reviewers.is_empty(),
"a round with no adoption signal must not enter any reviewer's \
denominator: {:?}",
stats.reviewers
);
}
#[test]
fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
let round = ReviewRound {
round: 1,
head: "h".to_owned(),
verified_head: None,
verified_at: None,
reviews: vec![
ReviewRecord {
attempts: 0,
reviewer: 1,
agent: "alpha".to_owned(),
summary: String::new(),
findings: Vec::new(),
vote: None,
failed: None,
duration_ms: 0,
},
ReviewRecord {
attempts: 0,
reviewer: 2,
agent: "beta".to_owned(),
summary: String::new(),
findings: Vec::new(),
vote: None,
failed: Some("agent timed out".to_owned()),
duration_ms: 0,
},
],
e2e: Vec::new(),
verify_retried: false,
e2e_deferred: false,
e2e_defer_reason: None,
fix: None,
blocking: 0,
answered: 1,
expected: 2,
clean: false,
progressed: false,
vote_split: false,
reconsideration: Vec::new(),
verdict: None,
};
let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
assert_eq!(alpha.seated, 1);
assert_eq!(alpha.rounds, 1);
assert_eq!(alpha.timeouts, 0);
assert_eq!(alpha.submitted, 0);
let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
assert_eq!(beta.seated, 1);
assert_eq!(beta.timeouts, 1);
assert_eq!(beta.submitted, 0);
assert_eq!(beta.rounds, 0);
assert_eq!(beta.timeout_rate(), 100.0);
}
#[test]
fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
let round = ReviewRound {
round: 1,
head: "h".to_owned(),
verified_head: None,
verified_at: None,
reviews: vec![
ReviewRecord {
attempts: 0,
reviewer: 1,
agent: "alpha".to_owned(),
summary: String::new(),
findings: vec![finding(
"R1-1-1",
"src/a.rs",
10,
"panics on empty",
Severity::Blocker,
)],
vote: None,
failed: None,
duration_ms: 0,
},
ReviewRecord {
attempts: 0,
reviewer: 2,
agent: "beta".to_owned(),
summary: String::new(),
findings: Vec::new(),
vote: None,
failed: Some("agent timed out".to_owned()),
duration_ms: 0,
},
],
e2e: Vec::new(),
verify_retried: false,
e2e_deferred: false,
e2e_defer_reason: None,
fix: Some(FixRecord {
agent: "alpha".to_owned(),
addressed: Vec::new(),
rejected: Vec::new(),
notes: String::new(),
committed: true,
failed: Some("unparsable fix report".to_owned()),
duration_ms: 0,
continuation: None,
}),
blocking: 1,
answered: 1,
expected: 2,
clean: false,
progressed: false,
vote_split: false,
reconsideration: Vec::new(),
verdict: None,
};
let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
assert_eq!(beta.timeouts, 1);
assert_eq!(beta.timeout_rate(), 100.0);
assert!(
!stats.reviewers.iter().any(|r| r.agent == "alpha"),
"{:?}",
stats.reviewers
);
}
#[test]
fn e2e_sole_detection_needs_a_clean_static_review() {
let fail = CommandOutcome {
command: "cargo test".to_owned(),
code: Some(101),
output_tail: "boom".to_owned(),
duration_ms: 1,
resource_blocked: false,
};
let sole = ReviewRound {
round: 1,
head: "h".to_owned(),
verified_head: None,
verified_at: None,
reviews: Vec::new(),
e2e: vec![fail.clone()],
verify_retried: false,
e2e_deferred: false,
e2e_defer_reason: None,
fix: None,
blocking: 0,
answered: 0,
expected: 0,
clean: false,
progressed: false,
vote_split: false,
reconsideration: Vec::new(),
verdict: None,
};
let alongside = ReviewRound {
round: 2,
head: "h".to_owned(),
verified_head: None,
verified_at: None,
reviews: Vec::new(),
e2e: vec![fail],
verify_retried: false,
e2e_deferred: false,
e2e_defer_reason: None,
fix: None,
blocking: 2,
answered: 0,
expected: 0,
clean: false,
progressed: false,
vote_split: false,
reconsideration: Vec::new(),
verdict: None,
};
let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
assert_eq!(stats.e2e.rounds, 2);
assert_eq!(stats.e2e.failures, 2);
assert_eq!(stats.e2e.sole_detections, 1);
assert_eq!(stats.e2e.sole_rate(), 50.0);
}
#[test]
fn every_run_status_lands_in_exactly_one_breakdown_bucket() {
let states = vec![
state_with(Vec::new(), 'A', RunStatus::Merged),
state_with(Vec::new(), 'A', RunStatus::Ready),
state_with(Vec::new(), 'A', RunStatus::Blocked),
state_with(Vec::new(), 'A', RunStatus::Failed),
state_with(Vec::new(), 'A', RunStatus::Stalled),
state_with(Vec::new(), 'A', RunStatus::VerifiedNoop),
state_with(Vec::new(), 'A', RunStatus::Superseded),
state_with(Vec::new(), 'A', RunStatus::Implementing),
state_with(Vec::new(), 'A', RunStatus::Landing),
];
let stats = collect(&states);
let t = &stats.totals;
assert_eq!(t.runs, 9);
assert_eq!(t.merged, 1);
assert_eq!(t.ready, 1);
assert_eq!(t.blocked, 1);
assert_eq!(t.failed, 1);
assert_eq!(t.stalled, 1);
assert_eq!(t.verified_noop, 1);
assert_eq!(t.superseded, 1);
assert_eq!(t.in_progress, 2);
assert_eq!(
t.merged
+ t.ready
+ t.blocked
+ t.failed
+ t.stalled
+ t.verified_noop
+ t.superseded
+ t.in_progress,
t.runs,
"every run must land in exactly one bucket of the breakdown"
);
}
#[test]
fn empty_input_yields_zeroed_rates_not_nan() {
let stats = collect(&[]);
assert_eq!(stats.totals.completion_rate(), 0.0);
assert_eq!(stats.totals.split_rate(), 0.0);
assert_eq!(stats.e2e.sole_rate(), 0.0);
assert!(stats.agents.is_empty());
assert!(stats.advisors.is_empty());
assert_eq!(AdvisorStats::default().reflection_rate(), 0.0);
}
fn advisor_record(
seat: &str,
agent: &str,
proposal: Option<crate::verdict::Proposal>,
reflection: crate::advise::Reflection,
) -> crate::advise::AdvisorRecord {
crate::advise::AdvisorRecord {
seat: seat.to_owned(),
agent: agent.to_owned(),
proposal,
error: None,
duration_ms: 0,
reflection,
}
}
fn a_proposal() -> crate::verdict::Proposal {
crate::verdict::Proposal {
approach: "do the thing".to_owned(),
key_tradeoff: "speed over memory".to_owned(),
risks: Vec::new(),
touches: Vec::new(),
why_not_naive: "the naive version breaks under load".to_owned(),
}
}
#[test]
fn advisor_stats_count_proposed_absent_and_reflection_split() {
use crate::advise::{Advice, Reflection};
let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
s.advice = Some(Advice {
records: vec![
advisor_record("advisor-1", "alpha", Some(a_proposal()), Reflection::Strong),
advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
advisor_record("advisor-3", "alpha", None, Reflection::Absent),
],
synthesis: Some("blended brief".to_owned()),
});
let stats = collect(&[s]);
let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
assert_eq!(alpha.seated, 3);
assert_eq!(alpha.proposed, 2);
assert_eq!(alpha.absent, 1);
assert_eq!(alpha.strong, 1);
assert_eq!(alpha.faint, 1);
assert_eq!(alpha.reflection_rate(), 50.0);
}
#[test]
fn advisor_stats_count_absent_from_the_proposal_not_the_reflection_default() {
use crate::advise::{Advice, Reflection};
let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
s.advice = Some(Advice {
records: vec![
advisor_record("advisor-1", "alpha", None, Reflection::Absent),
advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
advisor_record("advisor-3", "alpha", Some(a_proposal()), Reflection::Absent),
],
synthesis: None,
});
let stats = collect(&[s]);
let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
assert_eq!(alpha.seated, 3);
assert_eq!(alpha.proposed, 2);
assert_eq!(alpha.absent, 1);
assert_eq!(alpha.faint, 2);
assert_eq!(alpha.strong, 0);
}
#[test]
fn advisor_stats_ignore_runs_with_advise_off() {
let s = state_with(Vec::new(), 'A', RunStatus::Merged);
assert!(s.advice.is_none());
let stats = collect(&[s]);
assert!(stats.advisors.is_empty());
}
#[test]
fn same_defect_matches_titles_across_files() {
let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
assert!(same_defect(&a, &b));
let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
assert!(!same_defect(&a, &c));
}
#[test]
fn release_bump_stats_split_clean_from_attention_and_track_coverage() {
use crate::run::ReleaseBump;
let unrecorded = state_with(Vec::new(), 'A', RunStatus::Merged);
let mut automerged = state_with(Vec::new(), 'A', RunStatus::Merged);
automerged.release_bump = Some(ReleaseBump {
pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
version: Some("1.2.3".to_owned()),
automerge_enabled: true,
merged_directly: false,
problem: None,
action_required: None,
});
let mut merged_directly = state_with(Vec::new(), 'A', RunStatus::Merged);
merged_directly.release_bump = Some(ReleaseBump {
pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
version: Some("1.2.4".to_owned()),
automerge_enabled: false,
merged_directly: true,
problem: None,
action_required: None,
});
let mut blocked_with_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
blocked_with_pr.release_bump = Some(ReleaseBump {
pr_url: Some("https://github.com/o/r/pull/3".to_owned()),
version: Some("1.2.5".to_owned()),
automerge_enabled: false,
merged_directly: false,
problem: Some("checks red".to_owned()),
action_required: Some("look at the PR".to_owned()),
});
let mut blocked_without_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
blocked_without_pr.release_bump = Some(ReleaseBump {
pr_url: None,
version: Some("1.2.6".to_owned()),
automerge_enabled: false,
merged_directly: false,
problem: Some("gh pr create failed".to_owned()),
action_required: Some("open the PR by hand".to_owned()),
});
let stats = collect(&[
unrecorded,
automerged,
merged_directly,
blocked_with_pr,
blocked_without_pr,
]);
let b = &stats.release_bumps;
assert_eq!(b.merged, 5);
assert_eq!(b.recorded, 4);
assert_eq!(b.pr_opened, 3);
assert_eq!(b.automerge_enabled, 1);
assert_eq!(b.merged_directly, 1);
assert_eq!(b.needs_attention, 2);
assert_eq!(b.clean(), 2);
assert_eq!(b.clean() + b.needs_attention, b.recorded);
assert_eq!(b.coverage_rate(), 80.0);
assert!((b.automerge_rate() - 33.333_333_333_333_336).abs() < 1e-9);
assert_eq!(b.attention_rate(), 50.0);
}
#[test]
fn release_bump_ignores_runs_that_are_not_merged() {
use crate::run::ReleaseBump;
let mut blocked = state_with(Vec::new(), 'A', RunStatus::Blocked);
blocked.release_bump = Some(ReleaseBump {
pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
version: Some("9.9.9".to_owned()),
automerge_enabled: true,
merged_directly: false,
problem: None,
action_required: None,
});
let stats = collect(&[blocked]);
let b = &stats.release_bumps;
assert_eq!(b.merged, 0);
assert_eq!(b.recorded, 0);
assert_eq!(b.pr_opened, 0);
}
#[test]
fn release_bump_stats_are_zero_on_merged_runs_with_no_bump_or_no_runs() {
let stats = collect(&[state_with(Vec::new(), 'A', RunStatus::Merged)]);
let b = &stats.release_bumps;
assert_eq!(b.merged, 1);
assert_eq!(b.recorded, 0);
assert_eq!(b.coverage_rate(), 0.0);
assert_eq!(b.automerge_rate(), 0.0);
assert_eq!(b.attention_rate(), 0.0);
assert_eq!(b.clean(), 0);
let empty = collect(&[]);
let b = &empty.release_bumps;
assert_eq!(b.merged, 0);
assert_eq!(b.coverage_rate(), 0.0);
assert_eq!(b.automerge_rate(), 0.0);
assert_eq!(b.attention_rate(), 0.0);
}
fn event(node: &str, at_secs: i64, message: &str) -> crate::run::Event {
crate::run::Event {
at: jiff::Timestamp::from_second(at_secs).unwrap(),
node: node.to_owned(),
message: message.to_owned(),
}
}
fn state_with_events(events: Vec<crate::run::Event>) -> RunState {
let mut s = RunState::new(
PathBuf::from("/repo"),
"main".to_owned(),
"abcdef".to_owned(),
"task".to_owned(),
Config::default(),
);
s.events = events;
s
}
#[test]
fn a_node_with_multiple_events_spans_first_to_last() {
let s = state_with_events(vec![
event("implement", 1_000, "start"),
event("implement", 1_030, "still running"),
event("implement", 1_090, "done"),
]);
let nodes = node_durations(&[s]);
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].node, "implement");
assert_eq!(nodes[0].runs, 1);
assert_eq!(nodes[0].total_secs, 90);
assert_eq!(nodes[0].max_secs, 90);
assert_eq!(nodes[0].single, 0);
assert_eq!(nodes[0].mean_secs(), 90.0);
}
#[test]
fn a_node_with_a_single_event_is_unmeasured_not_zero() {
let s = state_with_events(vec![event("gate", 2_000, "ran once")]);
let nodes = node_durations(&[s]);
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].node, "gate");
assert_eq!(nodes[0].runs, 0);
assert_eq!(nodes[0].total_secs, 0);
assert_eq!(nodes[0].single, 1);
assert_eq!(nodes[0].mean_secs(), 0.0);
}
#[test]
fn a_run_with_no_events_produces_no_node_rows() {
let s = state_with_events(vec![]);
let nodes = node_durations(&[s]);
assert!(nodes.is_empty());
}
#[test]
fn multiple_runs_aggregate_the_same_node() {
let a = state_with_events(vec![event("judge", 0, "start"), event("judge", 60, "done")]);
let b = state_with_events(vec![
event("judge", 0, "start"),
event("judge", 200, "done"),
]);
let c = state_with_events(vec![event("judge", 5, "start")]);
let nodes = node_durations(&[a, b, c]);
assert_eq!(nodes.len(), 1);
let judge = &nodes[0];
assert_eq!(judge.node, "judge");
assert_eq!(judge.runs, 2);
assert_eq!(judge.total_secs, 260);
assert_eq!(judge.max_secs, 200);
assert_eq!(judge.single, 1);
assert_eq!(judge.mean_secs(), 130.0);
}
#[test]
fn by_repo_splits_states_and_group_totals_sum_to_the_whole() {
let states = vec![
state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
state_with_repo(Vec::new(), 'A', RunStatus::Blocked, "/repos/a"),
state_with_repo(Vec::new(), 'B', RunStatus::Merged, "/repos/b"),
];
let groups = by_repo(&states);
assert_eq!(groups.len(), 2);
let total_runs: usize = groups.iter().map(|g| g.stats.totals.runs).sum();
assert_eq!(total_runs, collect(&states).totals.runs);
let total_merged: usize = groups.iter().map(|g| g.stats.totals.merged).sum();
assert_eq!(total_merged, collect(&states).totals.merged);
assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
assert_eq!(groups[0].name, "a");
assert_eq!(groups[0].stats.totals.runs, 2);
assert_eq!(groups[1].repo, PathBuf::from("/repos/b"));
assert_eq!(groups[1].name, "b");
assert_eq!(groups[1].stats.totals.runs, 1);
}
#[test]
fn by_repo_breaks_a_run_count_tie_by_path() {
let states = vec![
state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/z"),
state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
];
let groups = by_repo(&states);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
assert_eq!(groups[1].repo, PathBuf::from("/repos/z"));
}
#[test]
fn by_repo_on_empty_input_yields_no_groups() {
assert!(by_repo(&[]).is_empty());
}
#[test]
fn filter_repo_matches_the_full_path_exactly() {
let states = vec![
state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/ab"),
];
let hits = filter_repo(&states, Path::new("/repos/a"));
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].repo, PathBuf::from("/repos/a"));
}
#[test]
fn filter_repo_returns_nothing_for_an_unknown_repo_or_empty_input() {
let states = vec![state_with_repo(
Vec::new(),
'A',
RunStatus::Merged,
"/repos/a",
)];
assert!(filter_repo(&states, Path::new("/repos/nope")).is_empty());
assert!(filter_repo(&[], Path::new("/repos/a")).is_empty());
}
}