use crate::verifier::Gap;
#[derive(Debug, Clone)]
pub struct Ballot {
pub member: String,
pub complete: bool,
pub confidence: f64,
pub gaps: Vec<Gap>,
}
#[derive(Debug, Clone)]
pub struct MergeOutcome {
pub complete: bool,
pub gaps: Vec<Gap>,
pub merged_confidence: f64,
pub agreement_ratio: f64,
pub tie_broken: bool,
}
#[must_use]
pub fn merge(ballots: &[Ballot]) -> MergeOutcome {
let total = ballots.len();
if total == 0 {
return MergeOutcome {
complete: false,
gaps: Vec::new(),
merged_confidence: 0.0,
agreement_ratio: 0.0,
tie_broken: false,
};
}
let votes_true = ballots.iter().filter(|b| b.complete).count();
let votes_false = total - votes_true;
let (winner, tie_broken) = if votes_true > total / 2 {
(true, false)
} else if votes_false > total / 2 {
(false, false)
} else {
(false, true)
};
let winning: Vec<&Ballot> = ballots.iter().filter(|b| b.complete == winner).collect();
let winning_count = winning.len();
let gaps: Vec<Gap> = winning
.iter()
.flat_map(|b| b.gaps.iter().cloned())
.collect();
#[allow(clippy::cast_precision_loss)]
let merged_confidence = if winning_count == 0 {
0.0
} else {
winning.iter().map(|b| b.confidence).sum::<f64>() / winning_count as f64
};
#[allow(clippy::cast_precision_loss)]
let agreement_ratio = winning_count as f64 / total as f64;
MergeOutcome {
complete: winner,
gaps,
merged_confidence,
agreement_ratio,
tie_broken,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::verifier::GapSeverity;
fn ballot(member: &str, complete: bool, confidence: f64, gaps: Vec<Gap>) -> Ballot {
Ballot {
member: member.to_string(),
complete,
confidence,
gaps,
}
}
fn gap(desc: &str, severity: GapSeverity) -> Gap {
Gap {
description: desc.to_string(),
severity,
}
}
#[test]
fn unanimous_complete() {
let ballots = vec![
ballot("a", true, 0.9, vec![]),
ballot("b", true, 0.8, vec![]),
ballot("c", true, 0.95, vec![]),
];
let outcome = merge(&ballots);
assert!(outcome.complete);
assert!(outcome.gaps.is_empty());
assert!((outcome.merged_confidence - 0.883_333_333_333_333_3).abs() < 1e-9);
assert!((outcome.agreement_ratio - 1.0).abs() < 1e-9);
assert!(!outcome.tie_broken);
}
#[test]
fn unanimous_incomplete() {
let g = gap("missing tests", GapSeverity::Critical);
let ballots = vec![
ballot("a", false, 0.5, vec![g.clone()]),
ballot("b", false, 0.6, vec![g.clone()]),
ballot("c", false, 0.4, vec![g]),
];
let outcome = merge(&ballots);
assert!(!outcome.complete);
assert_eq!(outcome.gaps.len(), 3);
assert!((outcome.merged_confidence - 0.5).abs() < 1e-9);
assert!((outcome.agreement_ratio - 1.0).abs() < 1e-9);
}
#[test]
fn two_of_three_split_complete_wins() {
let ballots = vec![
ballot("a", true, 0.9, vec![]),
ballot("b", true, 0.7, vec![]),
ballot("c", false, 0.3, vec![gap("gap", GapSeverity::Minor)]),
];
let outcome = merge(&ballots);
assert!(outcome.complete);
assert!(outcome.gaps.is_empty(), "loser's gaps must not appear");
assert!((outcome.merged_confidence - 0.8).abs() < 1e-9);
assert!((outcome.agreement_ratio - 2.0 / 3.0).abs() < 1e-9);
}
#[test]
fn two_of_three_split_incomplete_wins() {
let g = gap("critical gap", GapSeverity::Critical);
let ballots = vec![
ballot("a", false, 0.4, vec![g.clone()]),
ballot("b", false, 0.6, vec![g]),
ballot("c", true, 0.95, vec![]),
];
let outcome = merge(&ballots);
assert!(!outcome.complete);
assert_eq!(outcome.gaps.len(), 2);
assert!((outcome.merged_confidence - 0.5).abs() < 1e-9);
assert!((outcome.agreement_ratio - 2.0 / 3.0).abs() < 1e-9);
}
#[test]
fn exact_tie_is_fail_safe_incomplete() {
let ballots = vec![
ballot("a", true, 0.9, vec![]),
ballot("b", false, 0.5, vec![]),
];
let outcome = merge(&ballots);
assert!(!outcome.complete, "tie must fail-safe to complete=false");
assert!(outcome.tie_broken);
assert!((outcome.merged_confidence - 0.5).abs() < 1e-9);
assert!((outcome.agreement_ratio - 0.5).abs() < 1e-9);
}
#[test]
fn single_ballot_edge_case() {
let ballots = vec![ballot("a", true, 0.77, vec![])];
let outcome = merge(&ballots);
assert!(outcome.complete);
assert!((outcome.merged_confidence - 0.77).abs() < 1e-9);
assert!((outcome.agreement_ratio - 1.0).abs() < 1e-9);
assert!(!outcome.tie_broken);
}
#[test]
fn empty_ballots_edge_case() {
let outcome = merge(&[]);
assert!(!outcome.complete);
assert!(outcome.gaps.is_empty());
assert!((outcome.merged_confidence).abs() < 1e-9);
assert!((outcome.agreement_ratio).abs() < 1e-9);
assert!(!outcome.tie_broken);
}
#[test]
fn s4_regression_unanimous_vs_split_confidence_not_agreement_ratio() {
let critical_gap = gap("critical gap", GapSeverity::Critical);
let unanimous = vec![
ballot("a", false, 0.3, vec![critical_gap.clone()]),
ballot("b", false, 0.35, vec![critical_gap.clone()]),
ballot("c", false, 0.25, vec![critical_gap.clone()]),
];
let unanimous_outcome = merge(&unanimous);
assert!((unanimous_outcome.agreement_ratio - 1.0).abs() < 1e-9);
assert!((unanimous_outcome.merged_confidence - 0.3).abs() < 1e-9);
assert!(unanimous_outcome.merged_confidence < unanimous_outcome.agreement_ratio);
let threshold = 0.7_f64;
let should_replan_unanimous = !unanimous_outcome.complete
&& unanimous_outcome.merged_confidence < threshold
&& unanimous_outcome
.gaps
.iter()
.any(|g| g.severity == GapSeverity::Critical);
assert!(
should_replan_unanimous,
"unanimous low-confidence incomplete verdict must trigger replan, not be \
suppressed by a high agreement_ratio"
);
let split = vec![
ballot("a", false, 0.3, vec![critical_gap.clone()]),
ballot("b", false, 0.35, vec![critical_gap]),
ballot("c", true, 0.9, vec![]),
];
let split_outcome = merge(&split);
assert!((split_outcome.agreement_ratio - 2.0 / 3.0).abs() < 1e-9);
assert!((split_outcome.merged_confidence - 0.325).abs() < 1e-9);
}
#[test]
fn m6_regression_excluded_member_not_zero_padded() {
let ballots = vec![
ballot("a", true, 0.9, vec![]),
ballot("b", true, 0.8, vec![]),
];
let outcome = merge(&ballots);
assert!(outcome.complete);
assert!((outcome.merged_confidence - 0.85).abs() < 1e-9);
}
}