//! 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, // 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 = options.iter().cloned().collect(); let mut eliminated: Vec = vec![]; let mut rounds: Vec = 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 = 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) { if let Some(c) = vote_counts.get_mut(opt) { *c += 1; } 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 = 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) for &opt in eliminated.iter().rev() { 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 = sorted.iter() .enumerate() .map(|(i, (id, count))| RankedOption { option_id: *id, rank: i + 1, score: *count as f64, }) .collect(); for &opt in eliminated.iter().rev() { 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)); } }