From be96ddf6b3930c31a82ad5466f1408432f535c3f Mon Sep 17 00:00:00 2001 From: milselarch Date: Sun, 12 May 2024 10:41:08 +0800 Subject: [PATCH] add condorcet ranked pairs --- Cargo.toml | 2 +- README.md | 26 ++++++++-- src/lib.rs | 104 ++++++++++++++++++++++++++++++-------- tests/integration_test.rs | 63 +++++++++++++++++++++++ 4 files changed, 168 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f962a54..6ffa8ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trie_rcv" -version = "1.1.4" +version = "1.2.0" edition = "2021" description = "Ranked Choice Voting implementation using Tries in Rust" license = "Apache-2.0" diff --git a/README.md b/README.md index be0bc26..05aa79f 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,13 @@ fn main() { ### Elimination Strategies Technically the RCV algorithm specification doesn't state what to do in the situation that there are multiple candidates who all have the same, lowest number of votes in some round during -RCV. The `elimination_strategy` setting handles how to deal with this edge case: +RCV. + +The `elimination_strategy` setting handles which candidates to eliminate each round. +Technically the RCV algorithm specification doesn't state what to do in the situation that +there are multiple candidates who all have the same, lowest number of votes in some round during +RCV - `EliminationStrategies::ElimnateAll`, `EliminationStrategies::DowdallScoring`, +and `EliminationStrategies::RankedPairs` offer different ways to resolve that edge case. 1. `EliminationStrategies::ElimnateAll` Removes all candidates with the lowest number of votes each round. @@ -112,10 +118,12 @@ towards the dowdall score. 3. `EliminationStrategies::RankedPairs` Among multiple candidates with the lowest number of votes each round, attempt to construct a directed acyclic graph establishing a pecking order between -these candidates via [ranked-pair](https://en.wikipedia.org/wiki/Ranked_pairs) +candidate preferences via [ranked-pair](https://en.wikipedia.org/wiki/Ranked_pairs) comparisons, whereby candidate A is said to be better than candidate B -if there are more votes that rank A higher than B -than vice versa. +if there are more votes that rank A higher than B and vice versa, and eliminate +the candidate(s) that are at the bottom to the pecking order (i.e. there are no other +candidates that it is "better" than the pecking order, and there is at least +1 candidate that can be said to be "better" in the pecking order) 1. Note that if a ranked vote ranks A explicitly but not B, then that is counted as ranking A higher than B as well 2. In the event that a directed acyclic preference graph cannot be established @@ -123,6 +131,16 @@ than vice versa. behavior will default to eliminating all candidates with the same, lowest number of votes each round i.e. it will fall back to the behavior of `EliminationStrategies::ElimnateAll` +4. `EliminationStrategies::CondorcetRankedPairs` +(Implementation of the majority rule according to +[this](https://scholar.harvard.edu/files/maskin/files/how_to_improve_ranked-choice_voting_and_capitalism_and_society_e._maskin.pdf) paper) +Between the candidates with the lowest *and* second-lowest number of votes each +round, attempt to construct a directed acyclic graph to establish a pecking +order between candidate preferences via [ranked-pair](https://en.wikipedia.org/wiki/Ranked_pairs) +comparisons, and eliminate the candidate(s) that are at the bottom to the pecking order. +This ensures that the winning candidate is a Condorcet winner if one exists in the poll +results, and will revert to `EliminationStrategies::ElimnateAll` if the preference graph cannot +be constructed. ## Build instructions Build crate using `cargo build`, run integration tests with `cargo test` diff --git a/src/lib.rs b/src/lib.rs index 77dda2b..db67c76 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -use std::cmp::{min, Ordering}; +use std::cmp::{min, Ordering, PartialEq}; use std::collections::{HashMap, HashSet}; use petgraph::graph::{DiGraph, NodeIndex}; use itertools::{iproduct, Itertools}; @@ -64,7 +64,7 @@ struct VoteTransferChanges<'a> { } // strategies for how to eliminate candidates each round -#[derive(Clone, PartialEq)] +#[derive(Copy, Clone, PartialEq)] pub enum EliminationStrategies { // removes all candidates with the lowest number of votes each round EliminateAll, @@ -74,7 +74,11 @@ pub enum EliminationStrategies { // eliminate the candidate(s) with both the lowest number of votes // and who lose against other candidates with the same number of votes // in a head-to-head comparison - RankedPairs + RankedPairs, + // compare the candidate(s) that have the lowest and second-lowest number + // of votes each round and eliminate the candidate(s) who lose to + // to the other candidates in this group in a head-to-head comparison + CondorcetRankedPairs } fn is_graph_acyclic(graph: &DiGraph) -> bool { @@ -267,13 +271,60 @@ impl RankedChoiceVoteTrie { transfer_changes } + fn find_condorcet_ranked_pairs_weakest( + &self, candidate_vote_counts: &HashMap, + ranked_pairs_map: &HashMap<(u16, u16), u64>, + lowest_vote_candidates: Vec + ) -> Vec { + println!("CC_PRE_RANK_FILTER {:?}", candidate_vote_counts); + println!("CC_PAIRS_MAP {:?}", ranked_pairs_map); + let mut vote_counts: Vec = + candidate_vote_counts.values().cloned().collect(); + vote_counts.sort(); + + // get the second-lowest number of effective votes, or the lowest + // number of votes if the second-lowest number of effective votes + // is not available + let vote_threshold = match vote_counts.get(1) { + Some(second_lowest_votes) => { *second_lowest_votes } + None => { + match vote_counts.get(0) { + Some(lowest_votes) => { *lowest_votes } + None => { return vec![] } + } + } + }; + + // find candidates with less than or equal to the + // second-lowest number of effective votes + let mut weak_candidates: Vec = Vec::new(); + for (candidate, num_votes) in candidate_vote_counts { + if *num_votes <= vote_threshold { + weak_candidates.push(*candidate); + } + } + + let pairs_result = self.find_ranked_pairs_weakest( + weak_candidates, ranked_pairs_map + ); + + if pairs_result.1 == false { + return lowest_vote_candidates; + } else { + return pairs_result.0; + } + } + fn find_ranked_pairs_weakest( &self, candidates: Vec, ranked_pairs_map: &HashMap<(u16, u16), u64> - ) -> Vec { - // println!("\n----------------"); - // println!("PRE_RANK_FILTER {:?}", candidates); - // println!("PAIRS_MAP {:?}", ranked_pairs_map); + ) -> (Vec, bool) { + /* + Finds the candidates that perform the worst in pairwise + head-to-head comparison. + Returns the worst performing candidates, and whether it was possible + to construct a preference graph + */ let mut graph = DiGraph::::new(); let mut node_map = HashMap::::new(); @@ -372,7 +423,7 @@ impl RankedChoiceVoteTrie { is_graph_weakly_connected(&graph)) ); */ - return candidates.clone(); + return (candidates.clone(), false); } let has_no_outgoing_edges = |&node: &NodeIndex| -> bool { @@ -387,7 +438,7 @@ impl RankedChoiceVoteTrie { .iter().map(|&index| graph[index]).collect(); // println!("POST_NODES {:?}", weakest_nodes); // println!("POST_RANK_FILTER {:?}", weakest_candidates); - weakest_candidates + (weakest_candidates, true) } fn find_dowdall_weakest(&self, candidates: Vec) -> Vec { @@ -514,7 +565,11 @@ impl RankedChoiceVoteTrie { } let mut ranked_pairs_map: HashMap<(u16, u16), u64> = HashMap::new(); - if self.elimination_strategy == EliminationStrategies::RankedPairs { + let strategy = self.elimination_strategy; + if + (strategy == EliminationStrategies::RankedPairs) || + (strategy == EliminationStrategies::CondorcetRankedPairs) + { Self::build_ranked_pairs_map( &self.root, &mut Vec::new(), &mut ranked_pairs_map, &self.unique_candidates @@ -522,7 +577,6 @@ impl RankedChoiceVoteTrie { } while !candidate_vote_counts.is_empty() { - // println!("COUNTS {:?}", candidate_vote_counts); let mut min_candidate_votes: u64 = u64::MAX; // impossible for any candidate to win as sum of // candidate votes is under the total number of votes cast @@ -539,28 +593,34 @@ impl RankedChoiceVoteTrie { } // find candidates with the lowest number of effective votes - let mut weakest_candidates: Vec = Vec::new(); + let mut lowest_vote_candidates: Vec = Vec::new(); for (candidate, num_votes) in &candidate_vote_counts { if *num_votes == min_candidate_votes { - weakest_candidates.push(*candidate); + lowest_vote_candidates.push(*candidate); } } // further filter down candidates to eliminate using // specified elimination strategy - match self.elimination_strategy { - EliminationStrategies::EliminateAll => {}, + let weakest_candidates = match self.elimination_strategy { + EliminationStrategies::EliminateAll => { + lowest_vote_candidates + }, EliminationStrategies::DowdallScoring => { - weakest_candidates = self.find_dowdall_weakest( - weakest_candidates - ); + self.find_dowdall_weakest(lowest_vote_candidates) }, EliminationStrategies::RankedPairs => { - weakest_candidates = self.find_ranked_pairs_weakest( - weakest_candidates, &ranked_pairs_map - ); + self.find_ranked_pairs_weakest( + lowest_vote_candidates, &ranked_pairs_map + ).0 + }, + EliminationStrategies::CondorcetRankedPairs => { + self.find_condorcet_ranked_pairs_weakest( + &candidate_vote_counts, &ranked_pairs_map, + lowest_vote_candidates + ) } - } + }; // find all candidates, nodes, and vote counts to transfer to let mut all_vote_transfers: Vec = Vec::new(); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index a7caf5b..4292d64 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,3 +1,4 @@ +use itertools::all; use trie_rcv; use trie_rcv::{EliminationStrategies, RankedChoiceVoteTrie}; use trie_rcv::vote::{SpecialVotes, RankedVote}; @@ -154,4 +155,66 @@ fn test_all_elimination() { let winner = rcv.run_election(votes); println!("WINNER = {:?}", winner); assert_eq!(winner, Some(1)); +} + +#[test] +fn test_spoiler_vote() { + const T: i32 = 3; + const S: i32 = 2; + const B: i32 = 1; + + let rcv_vote_type1 = vec![vec![S, B, T]]; + let rcv_vote_type2 = vec![vec![B, S, T]]; + let rcv_vote_type3 = vec![vec![B, T, S]]; + let rcv_vote_type4 = vec![vec![T, B, S]]; + + fn repeat(num_votes: u64, vote_type: Vec>) -> Vec> { + return (0..num_votes) + .flat_map(|_| vote_type.clone()) + .collect::>(); + } + + let mut raw_votes: Vec> = vec![]; + raw_votes.extend(repeat(35, rcv_vote_type1)); + raw_votes.extend(repeat(10, rcv_vote_type2)); + raw_votes.extend(repeat(10, rcv_vote_type3)); + raw_votes.extend(repeat(45, rcv_vote_type4)); + + let votes = RankedVote::from_vectors(&raw_votes).unwrap(); + let mut rcv = RankedChoiceVoteTrie::new(); + rcv.set_elimination_strategy(EliminationStrategies::RankedPairs); + let winner = rcv.run_election(votes); + println!("WINNER = {:?}", winner); + assert_eq!(winner, Some(T as u16)); +} + +#[test] +fn test_condorcet_vote() { + const T: i32 = 3; + const S: i32 = 2; + const B: i32 = 1; + + let rcv_vote_type1 = vec![vec![S, B, T]]; + let rcv_vote_type2 = vec![vec![B, S, T]]; + let rcv_vote_type3 = vec![vec![B, T, S]]; + let rcv_vote_type4 = vec![vec![T, B, S]]; + + fn repeat(num_votes: u64, vote_type: Vec>) -> Vec> { + return (0..num_votes) + .flat_map(|_| vote_type.clone()) + .collect::>(); + } + + let mut raw_votes: Vec> = vec![]; + raw_votes.extend(repeat(35, rcv_vote_type1)); + raw_votes.extend(repeat(10, rcv_vote_type2)); + raw_votes.extend(repeat(10, rcv_vote_type3)); + raw_votes.extend(repeat(45, rcv_vote_type4)); + + let votes = RankedVote::from_vectors(&raw_votes).unwrap(); + let mut rcv = RankedChoiceVoteTrie::new(); + rcv.set_elimination_strategy(EliminationStrategies::CondorcetRankedPairs); + let winner = rcv.run_election(votes); + println!("WINNER = {:?}", winner); + assert_eq!(winner, Some(B as u16)); } \ No newline at end of file