2026-01-27 16:21:58 +00:00
|
|
|
//! Ranked Choice / Instant Runoff Voting Implementation
|
|
|
|
|
//!
|
|
|
|
|
//! Voters rank options in order of preference. If no option has a majority,
|
|
|
|
|
//! the lowest-ranked option is eliminated and votes are redistributed
|
|
|
|
|
//! until one option achieves majority.
|
|
|
|
|
|
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
use super::{RankedOption, RoundResult, VotingDetails, VotingResult};
|
|
|
|
|
|
|
|
|
|
/// A ranked ballot (voter's preference order)
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct RankedBallot {
|
|
|
|
|
pub rankings: Vec<Uuid>, // Ordered list: index 0 = first choice
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Calculate voting results using Ranked Choice / Instant Runoff
|
|
|
|
|
pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
|
|
|
|
if options.is_empty() || ballots.is_empty() {
|
|
|
|
|
return VotingResult {
|
|
|
|
|
winner: None,
|
|
|
|
|
ranking: vec![],
|
|
|
|
|
details: VotingDetails::RankedChoice {
|
|
|
|
|
rounds: vec![],
|
|
|
|
|
eliminated: vec![],
|
|
|
|
|
},
|
|
|
|
|
total_ballots: ballots.len(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut active_options: HashSet<Uuid> = options.iter().cloned().collect();
|
|
|
|
|
let mut eliminated: Vec<Uuid> = vec![];
|
|
|
|
|
let mut rounds: Vec<RoundResult> = vec![];
|
|
|
|
|
let majority_threshold = (ballots.len() as f64 / 2.0).floor() as i64 + 1;
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
// Count first-choice votes among active options
|
|
|
|
|
let mut vote_counts: HashMap<Uuid, i64> = active_options.iter()
|
|
|
|
|
.map(|&id| (id, 0))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
for ballot in ballots {
|
|
|
|
|
// Find first choice among active options
|
|
|
|
|
for opt in &ballot.rankings {
|
|
|
|
|
if active_options.contains(opt) {
|
2026-01-28 23:46:43 +00:00
|
|
|
if let Some(c) = vote_counts.get_mut(opt) {
|
|
|
|
|
*c += 1;
|
|
|
|
|
}
|
2026-01-27 16:21:58 +00:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort by vote count
|
|
|
|
|
let mut sorted: Vec<(Uuid, i64)> = vote_counts.iter()
|
|
|
|
|
.map(|(&id, &count)| (id, count))
|
|
|
|
|
.collect();
|
|
|
|
|
sorted.sort_by(|a, b| b.1.cmp(&a.1));
|
|
|
|
|
|
|
|
|
|
let round_num = rounds.len() + 1;
|
|
|
|
|
|
|
|
|
|
// Check for majority winner
|
|
|
|
|
if let Some((winner, count)) = sorted.first() {
|
|
|
|
|
if *count >= majority_threshold {
|
|
|
|
|
rounds.push(RoundResult {
|
|
|
|
|
round: round_num,
|
|
|
|
|
vote_counts: sorted.clone(),
|
|
|
|
|
eliminated: None,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Build final ranking
|
|
|
|
|
let mut final_ranking: Vec<RankedOption> = sorted.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(i, (id, count))| RankedOption {
|
|
|
|
|
option_id: *id,
|
|
|
|
|
rank: i + 1,
|
|
|
|
|
score: *count as f64,
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// Add eliminated options at the end (in reverse elimination order)
|
2026-01-28 23:46:43 +00:00
|
|
|
for &opt in eliminated.iter().rev() {
|
2026-01-27 16:21:58 +00:00
|
|
|
final_ranking.push(RankedOption {
|
|
|
|
|
option_id: opt,
|
|
|
|
|
rank: final_ranking.len() + 1,
|
|
|
|
|
score: 0.0,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return VotingResult {
|
|
|
|
|
winner: Some(*winner),
|
|
|
|
|
ranking: final_ranking,
|
|
|
|
|
details: VotingDetails::RankedChoice {
|
|
|
|
|
rounds,
|
|
|
|
|
eliminated,
|
|
|
|
|
},
|
|
|
|
|
total_ballots: ballots.len(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only one option left - it wins
|
|
|
|
|
if active_options.len() <= 1 {
|
|
|
|
|
let winner = active_options.iter().next().cloned();
|
|
|
|
|
|
|
|
|
|
rounds.push(RoundResult {
|
|
|
|
|
round: round_num,
|
|
|
|
|
vote_counts: sorted.clone(),
|
|
|
|
|
eliminated: None,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let mut final_ranking: Vec<RankedOption> = sorted.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(i, (id, count))| RankedOption {
|
|
|
|
|
option_id: *id,
|
|
|
|
|
rank: i + 1,
|
|
|
|
|
score: *count as f64,
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
2026-01-28 23:46:43 +00:00
|
|
|
for &opt in eliminated.iter().rev() {
|
2026-01-27 16:21:58 +00:00
|
|
|
final_ranking.push(RankedOption {
|
|
|
|
|
option_id: opt,
|
|
|
|
|
rank: final_ranking.len() + 1,
|
|
|
|
|
score: 0.0,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return VotingResult {
|
|
|
|
|
winner,
|
|
|
|
|
ranking: final_ranking,
|
|
|
|
|
details: VotingDetails::RankedChoice {
|
|
|
|
|
rounds,
|
|
|
|
|
eliminated,
|
|
|
|
|
},
|
|
|
|
|
total_ballots: ballots.len(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Eliminate lowest-ranked option
|
|
|
|
|
if let Some((loser, _)) = sorted.last() {
|
|
|
|
|
let loser_id = *loser;
|
|
|
|
|
|
|
|
|
|
rounds.push(RoundResult {
|
|
|
|
|
round: round_num,
|
|
|
|
|
vote_counts: sorted,
|
|
|
|
|
eliminated: Some(loser_id),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
active_options.remove(&loser_id);
|
|
|
|
|
eliminated.push(loser_id);
|
|
|
|
|
} else {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback (shouldn't reach here)
|
|
|
|
|
VotingResult {
|
|
|
|
|
winner: None,
|
|
|
|
|
ranking: vec![],
|
|
|
|
|
details: VotingDetails::RankedChoice {
|
|
|
|
|
rounds,
|
|
|
|
|
eliminated,
|
|
|
|
|
},
|
|
|
|
|
total_ballots: ballots.len(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_first_round_majority() {
|
|
|
|
|
let a = Uuid::new_v4();
|
|
|
|
|
let b = Uuid::new_v4();
|
|
|
|
|
let c = Uuid::new_v4();
|
|
|
|
|
let options = vec![a, b, c];
|
|
|
|
|
|
|
|
|
|
// A has clear majority
|
|
|
|
|
let ballots = vec![
|
|
|
|
|
RankedBallot { rankings: vec![a, b, c] },
|
|
|
|
|
RankedBallot { rankings: vec![a, b, c] },
|
|
|
|
|
RankedBallot { rankings: vec![a, c, b] },
|
|
|
|
|
RankedBallot { rankings: vec![b, a, c] },
|
|
|
|
|
RankedBallot { rankings: vec![c, b, a] },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let result = calculate(&options, &ballots);
|
|
|
|
|
assert_eq!(result.winner, Some(a)); // 3/5 = 60% majority
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_runoff_needed() {
|
|
|
|
|
let a = Uuid::new_v4();
|
|
|
|
|
let b = Uuid::new_v4();
|
|
|
|
|
let c = Uuid::new_v4();
|
|
|
|
|
let options = vec![a, b, c];
|
|
|
|
|
|
|
|
|
|
// No first-round majority, C eliminated, B wins
|
|
|
|
|
let ballots = vec![
|
|
|
|
|
RankedBallot { rankings: vec![a, b, c] },
|
|
|
|
|
RankedBallot { rankings: vec![a, b, c] },
|
|
|
|
|
RankedBallot { rankings: vec![b, a, c] },
|
|
|
|
|
RankedBallot { rankings: vec![b, c, a] },
|
|
|
|
|
RankedBallot { rankings: vec![c, b, a] }, // C's vote goes to B
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let result = calculate(&options, &ballots);
|
|
|
|
|
|
|
|
|
|
// Round 1: A=2, B=2, C=1 -> C eliminated
|
|
|
|
|
// Round 2: A=2, B=3 -> B wins with majority
|
|
|
|
|
assert_eq!(result.winner, Some(b));
|
|
|
|
|
|
|
|
|
|
if let VotingDetails::RankedChoice { rounds, eliminated } = &result.details {
|
|
|
|
|
assert_eq!(rounds.len(), 2);
|
|
|
|
|
assert_eq!(eliminated, &vec![c]);
|
|
|
|
|
} else {
|
|
|
|
|
panic!("Wrong voting details type");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_spoiler_effect_eliminated() {
|
|
|
|
|
// Classic example: spoiler candidate doesn't affect outcome
|
|
|
|
|
let a = Uuid::new_v4();
|
|
|
|
|
let b = Uuid::new_v4();
|
|
|
|
|
let spoiler = Uuid::new_v4();
|
|
|
|
|
let options = vec![a, b, spoiler];
|
|
|
|
|
|
|
|
|
|
let ballots = vec![
|
|
|
|
|
RankedBallot { rankings: vec![a, spoiler, b] },
|
|
|
|
|
RankedBallot { rankings: vec![a, spoiler, b] },
|
|
|
|
|
RankedBallot { rankings: vec![spoiler, a, b] }, // Spoiler fans prefer A
|
|
|
|
|
RankedBallot { rankings: vec![b, a, spoiler] },
|
|
|
|
|
RankedBallot { rankings: vec![b, a, spoiler] },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let result = calculate(&options, &ballots);
|
|
|
|
|
|
|
|
|
|
// Without RCV, spoiler might split A's vote
|
|
|
|
|
// With RCV, spoiler eliminated, vote goes to A
|
|
|
|
|
// Round 1: A=2, B=2, Spoiler=1 -> Spoiler eliminated
|
|
|
|
|
// Round 2: A=3, B=2 -> A wins
|
|
|
|
|
assert_eq!(result.winner, Some(a));
|
|
|
|
|
}
|
|
|
|
|
}
|