likwid/backend/src/voting/ranked_choice.rs

249 lines
7.9 KiB
Rust
Raw Normal View History

//! 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) {
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<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)
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<RankedOption> = 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));
}
}