From 72f2b7fd212e2262aa70d218472964dad04c490f Mon Sep 17 00:00:00 2001 From: milselarch Date: Sat, 4 May 2024 17:44:00 +0800 Subject: [PATCH 1/7] build ranked pairs map --- README.md | 1 + src/lib.rs | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c2ca905..1e95461 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # trie_rcv [https://crates.io/crates/trie_rcv](https://crates.io/crates/trie_rcv) Ranked Choice Voting (RCV) implementation using Tries in Rust. + RCV differs from normal first past the post voting in that voters are allowed to rank candidates from most to least preferable. To determine the winner of an RCV election, the least votes for the least popular candidate(s) are transferred to their next choice until diff --git a/src/lib.rs b/src/lib.rs index bdc665c..180361c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,9 +51,9 @@ struct VoteTransferChanges<'a> { vote_transfers: Vec> } -#[derive(Clone)] +#[derive(Clone, PartialEq)] pub enum EliminationStrategies { - EliminateAll, DowdallScoring + EliminateAll, DowdallScoring, RankedPairs } impl RankedChoiceVoteTrie { @@ -183,6 +183,31 @@ impl RankedChoiceVoteTrie { return rcv.determine_winner(); } + pub fn build_ranked_pairs_map( + &self, node: &TrieNode, search_path: &mut Vec, + ranked_pairs_map: &mut HashMap<(u16, u16), u16> + ) { + let kv_pairs_vec: Vec<(&VoteValues, &TrieNode)> = + node.children.iter().collect(); + for (vote_value, child) in kv_pairs_vec { + let candidate = match vote_value { + VoteValues::SpecialVote(_) => { continue } + VoteValues::Candidate(candidate) => { candidate } + }; + + for preferred_candidate in search_path.iter() { + let ranked_pair = (*preferred_candidate, *candidate); + let pairwise_votes = + ranked_pairs_map.entry(ranked_pair).or_insert(0); + *pairwise_votes += 1; + } + + search_path.push(*candidate); + self.build_ranked_pairs_map(child, search_path, ranked_pairs_map); + search_path.pop(); + }; + } + pub fn determine_winner<'a>(&self) -> Option { // println!("RUN_ELECTION_START"); let mut candidate_vote_counts: HashMap = HashMap::new(); @@ -212,6 +237,13 @@ impl RankedChoiceVoteTrie { }; } + let mut ranked_pairs_map: HashMap<(u16, u16), u16> = HashMap::new(); + if self.elimination_strategy == EliminationStrategies::RankedPairs { + self.build_ranked_pairs_map( + &self.root, &mut Vec::new(), &mut ranked_pairs_map + ); + } + while candidate_vote_counts.len() > 0 { // println!("COUNTS {:?}", candidate_vote_counts); let mut min_candidate_votes: u64 = u64::MAX; @@ -243,6 +275,9 @@ impl RankedChoiceVoteTrie { EliminationStrategies::EliminateAll => {}, EliminationStrategies::DowdallScoring => { weakest_candidates = self.find_dowdall_weakest(weakest_candidates); + }, + EliminationStrategies::RankedPairs => { + todo!(); } } From bfcb1f833214d3a50e1327bb7da4563e8bcdae27 Mon Sep 17 00:00:00 2001 From: milselarch Date: Sun, 5 May 2024 02:29:48 +0800 Subject: [PATCH 2/7] get pecking order --- Cargo.toml | 3 +- src/lib.rs | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 153 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 98f1296..4171317 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trie_rcv" -version = "1.0.1" +version = "1.1.0" edition = "2021" description = "Ranked Choice Voting implementation using Tries in Rust" license = "Apache-2.0" @@ -10,3 +10,4 @@ repository = "https://github.com/milselarch/trie_rcv" [dependencies] itertools = "0.12.1" +petgraph = "0.6.4" diff --git a/src/lib.rs b/src/lib.rs index 180361c..73e9663 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,19 @@ use std::cmp::min; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use petgraph::graph::{DiGraph, NodeIndex}; +use itertools::iproduct; +use std::collections::VecDeque; +use petgraph::prelude::EdgeRef; + pub use vote::*; pub mod vote; +#[derive(PartialEq)] +pub enum PairPreferences { + PreferredOver, Inconclusive, PreferredAgainst +} + #[derive(Default)] struct TrieNode { children: HashMap, @@ -51,9 +61,69 @@ struct VoteTransferChanges<'a> { vote_transfers: Vec> } +// strategies for how to eliminate candidates each round #[derive(Clone, PartialEq)] pub enum EliminationStrategies { - EliminateAll, DowdallScoring, RankedPairs + // eliminate all candidates with the lowest number of votes + EliminateAll, + // eliminate the candidate(s) with both the lowest number of votes + // followed by the lowest dowdall score + DowdallScoring, + // 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 +} + +fn is_graph_acyclic(graph: &DiGraph) -> bool { + todo!() +} + +fn pecking_order_in_graph(graph: &DiGraph) -> bool { + /* + Checks if: + 1. there is a set of nodes where there are no other nodes outside + that set that are preferred over it in a head-to-head comparison + 2. there is a set of nodes where there are no other nodes outside + that set that are preferred against it in a head-to-head comparison + 3. graph is weakly connected i.e. every node is able to each every other + node if all the edges are converted from directed to undirected + 4. graph is acyclic i.e. there doesn't exist any path of directed edges + from some edge in the graph back to itself + */ + if !is_graph_acyclic(graph) { return false; } + let check_is_outgoing = |&node| { + graph.edges_directed(node, petgraph::Direction::Incoming).count() == 0 + }; + + let outgoing_only_nodes: Vec = graph + .node_indices().filter(check_is_outgoing).collect(); + let mut explored_nodes = HashSet::::new(); + let mut queue = VecDeque::::new(); + queue.extend(outgoing_only_nodes); + + loop { + let node_idx_option = queue.pop_front(); + let node_idx = match node_idx_option { + None => { break } + Some(node_idx) => { node_idx } + }; + + if explored_nodes.contains(&node_idx) { continue } + explored_nodes.insert(node_idx); + + let outgoing_neighbors: Vec = graph + .edges_directed(node_idx, petgraph::Direction::Outgoing) + .map(|edge| edge.target()) + .collect(); + + for neighbor in outgoing_neighbors { + if explored_nodes.contains(&neighbor) { continue } + queue.push_back(neighbor); + } + } + + return graph.node_count() == explored_nodes.len() } impl RankedChoiceVoteTrie { @@ -148,6 +218,73 @@ impl RankedChoiceVoteTrie { frontier_nodes.entry(*candidate).or_insert(Vec::new()) } + fn find_ranked_pairs_weakest( + &self, candidates: Vec, + ranked_pairs_map: &HashMap<(u16, u16), u64> + ) -> Vec { + let mut weakest_candidates: Vec = Vec::new(); + let mut graph = DiGraph::::new(); + let mut node_map = HashMap::::new(); + + /* + Determines whether candidate1 is preferred over candidate2 overall, + or vice versa, or there is no net preference between the two. + Also returns the net number of votes along said overall preference + */ + let get_preference = | + candidate1: u16, candidate2: u16 + | -> (PairPreferences, u64) { + let preferred_over_votes = + ranked_pairs_map.get(&(candidate1, candidate2)) + .unwrap_or(&0); + let preferred_against_votes = + ranked_pairs_map.get(&(candidate2, candidate1)) + .unwrap_or(&0); + + return if preferred_over_votes > preferred_against_votes { + let strength = preferred_over_votes - preferred_against_votes; + (PairPreferences::PreferredOver, strength) + } else if preferred_over_votes == preferred_against_votes { + (PairPreferences::Inconclusive, 0) + } else { + let strength = preferred_against_votes - preferred_over_votes; + (PairPreferences::PreferredAgainst, strength) + } + }; + + let get_or_create_node = |candidate: u16| -> NodeIndex { + if let Some(&existing_index) = node_map.get(&candidate) { + existing_index + } else { + let index = graph.add_node(candidate); + node_map.insert(candidate, index); + index + } + }; + + // construct preference strength graph between candidates + for (candidate1, candidate2) in iproduct!(&candidates, &candidates) { + if candidate1 == candidate2 { continue } + let (preference, strength) = + get_preference(*candidate1, *candidate2); + + match preference { + PairPreferences::PreferredAgainst => { continue } + PairPreferences::Inconclusive => { continue } + PairPreferences::PreferredOver => {} + } + + assert!(preference == PairPreferences::PreferredOver); + let node1_idx = get_or_create_node(*candidate1); + let node2_idx = get_or_create_node(*candidate2); + if !graph.contains_edge(node1_idx, node2_idx) { + graph.add_edge(node1_idx, node2_idx, strength); + } + } + + return weakest_candidates; + } + fn find_dowdall_weakest(&self, candidates: Vec) -> Vec { /* returns the subset of candidates from the input candidates vector @@ -185,11 +322,17 @@ impl RankedChoiceVoteTrie { pub fn build_ranked_pairs_map( &self, node: &TrieNode, search_path: &mut Vec, - ranked_pairs_map: &mut HashMap<(u16, u16), u16> + ranked_pairs_map: &mut HashMap<(u16, u16), u64> ) { - let kv_pairs_vec: Vec<(&VoteValues, &TrieNode)> = - node.children.iter().collect(); - for (vote_value, child) in kv_pairs_vec { + let kv_pairs_vec: Vec<(Box<&VoteValues>, Box<&TrieNode>)> = + node.children.iter().map(|(vote_value, node)| { + (Box::new(vote_value), Box::new(node)) + }).collect(); + + for (boxed_vote_value, boxed_child) in kv_pairs_vec { + let vote_value = *boxed_vote_value; + let child = *boxed_child; + let candidate = match vote_value { VoteValues::SpecialVote(_) => { continue } VoteValues::Candidate(candidate) => { candidate } @@ -199,7 +342,7 @@ impl RankedChoiceVoteTrie { let ranked_pair = (*preferred_candidate, *candidate); let pairwise_votes = ranked_pairs_map.entry(ranked_pair).or_insert(0); - *pairwise_votes += 1; + *pairwise_votes += child.num_votes; } search_path.push(*candidate); @@ -237,7 +380,7 @@ impl RankedChoiceVoteTrie { }; } - let mut ranked_pairs_map: HashMap<(u16, u16), u16> = HashMap::new(); + let mut ranked_pairs_map: HashMap<(u16, u16), u64> = HashMap::new(); if self.elimination_strategy == EliminationStrategies::RankedPairs { self.build_ranked_pairs_map( &self.root, &mut Vec::new(), &mut ranked_pairs_map From bbb9ee5b9b37816d91977d22d5f195f16f173bc5 Mon Sep 17 00:00:00 2001 From: milselarch Date: Sun, 5 May 2024 13:53:04 +0800 Subject: [PATCH 3/7] complete cycle check in ranked pairs preference graph --- src/lib.rs | 158 ++++++++++++++++++++++++-------------- tests/integration_test.rs | 17 ++++ 2 files changed, 117 insertions(+), 58 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 73e9663..066d355 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,9 @@ use std::cmp::min; use std::collections::{HashMap, HashSet}; use petgraph::graph::{DiGraph, NodeIndex}; -use itertools::iproduct; +use itertools::{iproduct, Itertools}; use std::collections::VecDeque; +use petgraph::Direction; use petgraph::prelude::EdgeRef; pub use vote::*; @@ -76,54 +77,84 @@ pub enum EliminationStrategies { } fn is_graph_acyclic(graph: &DiGraph) -> bool { - todo!() + /* + checks if there doesn't exist any path of directed edges + from some edge in the graph back to itself + */ + if graph.node_count() == 0 { return true } + let nodes: Vec = graph.node_indices().collect(); + let mut all_explored_nodes = HashSet::::new(); + + fn dfs_find_cycle( + node: &NodeIndex, path: &mut Vec, + explored: &mut HashSet::, graph: &DiGraph + ) -> bool { + // use DFS to see if a cycle can be created from paths starting from node + explored.insert(*node); + + // get neighbors of node where there is an + // outgoing edge from node to neighbor + let directed_neighbors: Vec = graph + .edges_directed(*node, petgraph::Direction::Outgoing) + .map(|edge| { edge.target()} ) + .collect(); + + for neighbor in directed_neighbors { + if path.contains(&neighbor) { return true } + path.push(neighbor); + let has_cycle = dfs_find_cycle(&neighbor, path, explored, graph); + path.pop(); + + if has_cycle { return true } + } + + return false; + } + + for node in nodes { + if all_explored_nodes.contains(&node) { continue } + let mut dfs_explored_nodes = HashSet::::new(); + let has_cycle = dfs_find_cycle( + &node, &mut Vec::::new(), &mut dfs_explored_nodes, graph + ); + + if has_cycle { return false } + all_explored_nodes.extend(dfs_explored_nodes.iter().collect_vec()); + } + + return true; } -fn pecking_order_in_graph(graph: &DiGraph) -> bool { +fn is_graph_weakly_connected(graph: &DiGraph) -> bool { /* - Checks if: - 1. there is a set of nodes where there are no other nodes outside - that set that are preferred over it in a head-to-head comparison - 2. there is a set of nodes where there are no other nodes outside - that set that are preferred against it in a head-to-head comparison - 3. graph is weakly connected i.e. every node is able to each every other - node if all the edges are converted from directed to undirected - 4. graph is acyclic i.e. there doesn't exist any path of directed edges - from some edge in the graph back to itself + checks if there is a path from every node to every other + node when all the edges are converted from directed to undirected */ - if !is_graph_acyclic(graph) { return false; } - let check_is_outgoing = |&node| { - graph.edges_directed(node, petgraph::Direction::Incoming).count() == 0 - }; - - let outgoing_only_nodes: Vec = graph - .node_indices().filter(check_is_outgoing).collect(); - let mut explored_nodes = HashSet::::new(); + if graph.node_count() == 0 { return true } let mut queue = VecDeque::::new(); - queue.extend(outgoing_only_nodes); + let mut explored_nodes = HashSet::::new(); + let nodes: Vec = graph.node_indices().collect(); + let start_node = nodes[0]; + queue.push_back(start_node); + // do a DFS search to see if all nodes are reachable from start_node loop { - let node_idx_option = queue.pop_front(); - let node_idx = match node_idx_option { - None => { break } - Some(node_idx) => { node_idx } + let node = match queue.pop_back() { + None => { break; } + Some(node) => node }; - if explored_nodes.contains(&node_idx) { continue } - explored_nodes.insert(node_idx); + if explored_nodes.contains(&node) { continue } + explored_nodes.insert(node); - let outgoing_neighbors: Vec = graph - .edges_directed(node_idx, petgraph::Direction::Outgoing) - .map(|edge| edge.target()) - .collect(); - - for neighbor in outgoing_neighbors { - if explored_nodes.contains(&neighbor) { continue } - queue.push_back(neighbor); + // collects nodes that share incoming or outgoing edges + let neighbors: Vec = graph.neighbors(node).collect(); + for neighbor in neighbors { + queue.push_back(neighbor) } } - return graph.node_count() == explored_nodes.len() + return explored_nodes.len() == graph.node_count() } impl RankedChoiceVoteTrie { @@ -211,18 +242,10 @@ impl RankedChoiceVoteTrie { return transfer_changes; } - fn get_or_create_nodes<'a>( - &'a self, candidate: &u16, - frontier_nodes: &'a mut HashMap> - ) -> &mut Vec<&TrieNode> { - frontier_nodes.entry(*candidate).or_insert(Vec::new()) - } - fn find_ranked_pairs_weakest( &self, candidates: Vec, ranked_pairs_map: &HashMap<(u16, u16), u64> ) -> Vec { - let mut weakest_candidates: Vec = Vec::new(); let mut graph = DiGraph::::new(); let mut node_map = HashMap::::new(); @@ -252,15 +275,13 @@ impl RankedChoiceVoteTrie { } }; - let get_or_create_node = |candidate: u16| -> NodeIndex { - if let Some(&existing_index) = node_map.get(&candidate) { - existing_index - } else { - let index = graph.add_node(candidate); - node_map.insert(candidate, index); - index - } - }; + fn get_or_create_node ( + graph: &mut DiGraph, + node_map: &mut HashMap, + candidate: u16 + ) -> NodeIndex { + *node_map.get(&candidate).unwrap_or(&graph.add_node(candidate)) + } // construct preference strength graph between candidates for (candidate1, candidate2) in iproduct!(&candidates, &candidates) { @@ -275,13 +296,30 @@ impl RankedChoiceVoteTrie { } assert!(preference == PairPreferences::PreferredOver); - let node1_idx = get_or_create_node(*candidate1); - let node2_idx = get_or_create_node(*candidate2); + let node1_idx = + get_or_create_node(&mut graph, &mut node_map, *candidate1); + let node2_idx = + get_or_create_node(&mut graph, &mut node_map, *candidate2); if !graph.contains_edge(node1_idx, node2_idx) { graph.add_edge(node1_idx, node2_idx, strength); } } + // unable to establish pecking order among candidates + if !(is_graph_acyclic(&graph) && is_graph_weakly_connected(&graph)) { + return candidates.clone(); + } + + let has_no_outgoing_edges = |&node: &NodeIndex| -> bool { + graph.neighbors_directed(node, Direction::Outgoing).count() == 0 + }; + let weakest_nodes: Vec = graph + .node_indices() + .filter(has_no_outgoing_edges) + .collect(); + + let weakest_candidates = weakest_nodes + .iter().map(|&index| graph[index]).collect(); return weakest_candidates; } @@ -320,7 +358,7 @@ impl RankedChoiceVoteTrie { return rcv.determine_winner(); } - pub fn build_ranked_pairs_map( + fn build_ranked_pairs_map( &self, node: &TrieNode, search_path: &mut Vec, ranked_pairs_map: &mut HashMap<(u16, u16), u64> ) { @@ -417,10 +455,14 @@ impl RankedChoiceVoteTrie { match self.elimination_strategy { EliminationStrategies::EliminateAll => {}, EliminationStrategies::DowdallScoring => { - weakest_candidates = self.find_dowdall_weakest(weakest_candidates); + weakest_candidates = self.find_dowdall_weakest( + weakest_candidates + ); }, EliminationStrategies::RankedPairs => { - todo!(); + weakest_candidates = self.find_ranked_pairs_weakest( + weakest_candidates, &ranked_pairs_map + ); } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index e412d39..b94c459 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -133,4 +133,21 @@ fn test_all_elimination() { let winner = rcv.run_election(votes); println!("WINNER = {:?}", winner); assert_eq!(winner, Some(1)); +} + +#[test] +fn test_ranked_pairs_elimination() { + let votes = RankedVote::from_vectors(&vec![ + vec![1, 6, 15], + vec![1, 2, 6, 15, 5, 4, 7, 3, 11], + vec![6, 15, 1, 11, 10, 16, 17, 8, 2, 3, 5, 7], + vec![9, 8, 6, 11, 13, 3, 1], + vec![13, 14, 16, 6, 3, 4, 5, 2, 1, 8, 9] + ]).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(6)); } \ No newline at end of file From f9df78ab8666326311c21497b7f36cb97e9af7ac Mon Sep 17 00:00:00 2001 From: milselarch Date: Sun, 5 May 2024 14:56:38 +0800 Subject: [PATCH 4/7] renames variables in from_vector, update README --- README.md | 20 +++++++++++++---- src/vote.rs | 47 ++++++++++++++++++++++++++------------- tests/integration_test.rs | 18 +++++++++++++++ 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1e95461..9a2a37d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,19 @@ use trie_rcv::RankedChoiceVoteTrie; use trie_rcv::vote::RankedVote; fn main() { + let mut rcv = RankedChoiceVoteTrie::new(); + rcv.set_elimination_strategy(EliminationStrategies::EliminateAll); + + rcv.insert_vote(RankedVote::from_vector(&vec![1, 2, 3, 4]).unwrap()); + rcv.insert_vote(RankedVote::from_vector(&vec![1, 2, 3]).unwrap()); + rcv.insert_vote(RankedVote::from_vector(&vec![3]).unwrap()); + rcv.insert_vote(RankedVote::from_vector(&vec![3, 2, 4]).unwrap()); + rcv.insert_vote(RankedVote::from_vector(&vec![4, 1]).unwrap()); + let winner = rcv.determine_winner(); + println!("WINNER = {:?}", winner); + assert_eq!(winner, Some(1),); + + // alternatively: let votes = RankedVote::from_vectors(&vec![ vec![1, 2, 3, 4], vec![1, 2, 3], @@ -22,10 +35,9 @@ fn main() { vec![4, 1] ]).unwrap(); - let rcv = RankedChoiceVoteTrie::new(); - let winner = rcv.run_election(votes); - println!("WINNER = {:?}", winner); - assert_eq!(winner, Some(1)); + let winner2 = rcv.run_election(votes); + println!("WINNER = {:?}", winner2); + assert_eq!(winner2, Some(1)); } ``` diff --git a/src/vote.rs b/src/vote.rs index 3b96231..d3a50e2 100644 --- a/src/vote.rs +++ b/src/vote.rs @@ -109,45 +109,62 @@ impl RankedVote { return Ok(votes); } - pub fn from_vector(raw_rankings: &Vec) -> Result { + pub fn from_candidates( + candidates: &Vec + ) -> Result { + return Self::from_vector( + &candidates.iter().map(|x| *x as i32).collect() + ) + } + + pub fn from_vector( + raw_ranked_vote: &Vec + ) -> Result { // println!("INSERT {:?}", raw_rankings); - let mut rankings: Vec = Vec::new(); - let mut special_vote: Option = None; + let mut candidates: Vec = Vec::new(); + let mut special_vote_value: Option = None; let mut unique_values = HashSet::new(); - let length = raw_rankings.len(); + let length = raw_ranked_vote.len(); let last_index = length - 1; - for (k, raw_ranking) in raw_rankings.iter().enumerate() { + for (k, raw_ranked_vote_value) in raw_ranked_vote.iter().enumerate() { let is_last_index = k == last_index; - if unique_values.contains(raw_ranking) { + if unique_values.contains(raw_ranked_vote_value) { return Err(VoteErrors::DuplicateVotes); } else { - unique_values.insert(*raw_ranking); + unique_values.insert(*raw_ranked_vote_value); } - if raw_ranking.is_negative() { + if raw_ranked_vote_value.is_negative() { if !is_last_index { return Err(VoteErrors::NonFinalSpecialVote); } assert!(is_last_index); - let cast_result = SpecialVotes::from_int(*raw_ranking); + let cast_result = + SpecialVotes::from_int(*raw_ranked_vote_value); match cast_result { Err(cast_error) => { return Err(cast_error); }, - Ok(cast_value) => { special_vote = Some(cast_value) } + Ok(cast_value) => { + special_vote_value = Some(cast_value) + } } } else { - assert!(raw_ranking.is_positive()); - let cast_result = u16::try_from(*raw_ranking); + assert!(raw_ranked_vote_value.is_positive()); + let cast_result = u16::try_from(*raw_ranked_vote_value); match cast_result { - Err(_) => { return Err(VoteErrors::InvalidCastToSpecialVote); }, - Ok(choice) => { rankings.push(choice) } + Ok(candidate) => { candidates.push(candidate) } + Err(_) => { + return Err(VoteErrors::InvalidCastToSpecialVote); + }, } } } // println!("INSERT_END {:?}", raw_rankings); - return Ok(RankedVote { rankings, special_vote }) + return Ok(RankedVote { + rankings: candidates, special_vote: special_vote_value + }) } pub fn to_vector(&self) -> Vec { diff --git a/tests/integration_test.rs b/tests/integration_test.rs index b94c459..34a7462 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -24,6 +24,24 @@ fn test_basic_scenario() { ); } +#[test] +fn test_vote_insert() { + let mut rcv = RankedChoiceVoteTrie::new(); + rcv.set_elimination_strategy(EliminationStrategies::EliminateAll); + + rcv.insert_vote(RankedVote::from_vector(&vec![1, 2, 3, 4]).unwrap()); + rcv.insert_vote(RankedVote::from_vector(&vec![1, 2, 3]).unwrap()); + rcv.insert_vote(RankedVote::from_vector(&vec![3]).unwrap()); + rcv.insert_vote(RankedVote::from_vector(&vec![3, 2, 4]).unwrap()); + rcv.insert_vote(RankedVote::from_vector(&vec![4, 1]).unwrap()); + let winner = rcv.determine_winner(); + println!("WINNER = {:?}", winner); + assert_eq!( + winner, Some(1), + "Vote 4 > 1 should go to 1, leading to Candidate 1 winning" + ); +} + #[test] fn test_simple_majority() { let votes = RankedVote::from_vectors(&vec![ From b28be14eed4fbfe5adc9f85ae405f3116e91b99f Mon Sep 17 00:00:00 2001 From: milselarch Date: Sun, 5 May 2024 14:57:44 +0800 Subject: [PATCH 5/7] update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a2a37d..a06e202 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ fn main() { rcv.insert_vote(RankedVote::from_vector(&vec![4, 1]).unwrap()); let winner = rcv.determine_winner(); println!("WINNER = {:?}", winner); - assert_eq!(winner, Some(1),); + assert_eq!(winner, Some(1)); // alternatively: let votes = RankedVote::from_vectors(&vec![ From 43b164f633c5d9cc0e86c2f1a741b962eb80222c Mon Sep 17 00:00:00 2001 From: milselarch Date: Sun, 5 May 2024 18:22:20 +0800 Subject: [PATCH 6/7] bgufix for ranked pairs elimination --- src/lib.rs | 102 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 066d355..8e4ad36 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,7 +47,8 @@ impl TrieNode { pub struct RankedChoiceVoteTrie { root: TrieNode, dowdall_score_map: HashMap, - elimination_strategy: EliminationStrategies + elimination_strategy: EliminationStrategies, + unique_candidates: HashSet } struct VoteTransfer<'a> { @@ -137,6 +138,13 @@ fn is_graph_weakly_connected(graph: &DiGraph) -> bool { let start_node = nodes[0]; queue.push_back(start_node); + let get_undirected_neighbors = |node: NodeIndex| { + let mut neighbors = Vec::::new(); + neighbors.extend(graph.neighbors_directed(node, Direction::Incoming)); + neighbors.extend(graph.neighbors_directed(node, Direction::Outgoing)); + return neighbors; + }; + // do a DFS search to see if all nodes are reachable from start_node loop { let node = match queue.pop_back() { @@ -147,8 +155,8 @@ fn is_graph_weakly_connected(graph: &DiGraph) -> bool { if explored_nodes.contains(&node) { continue } explored_nodes.insert(node); - // collects nodes that share incoming or outgoing edges - let neighbors: Vec = graph.neighbors(node).collect(); + let neighbors: Vec = get_undirected_neighbors(node); + // println!("DFS {:?}", (node, &neighbors)); for neighbor in neighbors { queue.push_back(neighbor) } @@ -163,6 +171,7 @@ impl RankedChoiceVoteTrie { root: TrieNode::new(), dowdall_score_map: Default::default(), elimination_strategy: EliminationStrategies::DowdallScoring, + unique_candidates: Default::default(), } } @@ -179,12 +188,14 @@ impl RankedChoiceVoteTrie { pub fn insert_vote<'a>(&mut self, vote: RankedVote) { let mut current = &mut self.root; let vote_items = vote.iter().enumerate(); + current.num_votes += 1; for (ranking, vote_value) in vote_items { // println!("ITEM {}", ranking); match vote_value { VoteValues::SpecialVote(_) => {} VoteValues::Candidate(candidate) => { + self.unique_candidates.insert(candidate); let score = *self.dowdall_score_map .entry(candidate).or_insert(0f32); let new_score = score + 1.0 / (ranking + 1) as f32; @@ -246,6 +257,9 @@ impl RankedChoiceVoteTrie { &self, candidates: Vec, ranked_pairs_map: &HashMap<(u16, u16), u64> ) -> Vec { + // println!("\n----------------"); + // println!("PRE_RANK_FILTER {:?}", candidates); + // println!("PAIRS_MAP {:?}", ranked_pairs_map); let mut graph = DiGraph::::new(); let mut node_map = HashMap::::new(); @@ -264,6 +278,13 @@ impl RankedChoiceVoteTrie { ranked_pairs_map.get(&(candidate2, candidate1)) .unwrap_or(&0); + /* + println!("C_PAIR {:?}", ( + (candidate1, candidate2), + (preferred_over_votes, preferred_against_votes) + )); + */ + return if preferred_over_votes > preferred_against_votes { let strength = preferred_over_votes - preferred_against_votes; (PairPreferences::PreferredOver, strength) @@ -280,7 +301,23 @@ impl RankedChoiceVoteTrie { node_map: &mut HashMap, candidate: u16 ) -> NodeIndex { - *node_map.get(&candidate).unwrap_or(&graph.add_node(candidate)) + // println!("NODE_MAP_PRE {:?}", (candidate, &node_map, &graph)); + let node = match node_map.get(&candidate) { + Some(node) => { *node } + None => { + let node = graph.add_node(candidate); + node_map.insert(candidate, node); + node + } + }; + + // println!("NODE_MAP_POST {:?}", (candidate, &node_map, &graph)); + return node; + } + + // initialize all the nodes in the graph + for candidate in &candidates { + get_or_create_node(&mut graph, &mut node_map, *candidate); } // construct preference strength graph between candidates @@ -301,12 +338,20 @@ impl RankedChoiceVoteTrie { let node2_idx = get_or_create_node(&mut graph, &mut node_map, *candidate2); if !graph.contains_edge(node1_idx, node2_idx) { + // println!("ADD_EDGE {:?}", (node1_idx, node2_idx)); graph.add_edge(node1_idx, node2_idx, strength); } } + // println!("GRAPH {:?}", graph); // unable to establish pecking order among candidates if !(is_graph_acyclic(&graph) && is_graph_weakly_connected(&graph)) { + /* + println!("POST_RANK_FILTER {:?}", ( + &candidates, is_graph_acyclic(&graph), + is_graph_weakly_connected(&graph)) + ); + */ return candidates.clone(); } @@ -320,6 +365,8 @@ impl RankedChoiceVoteTrie { let weakest_candidates = weakest_nodes .iter().map(|&index| graph[index]).collect(); + // println!("POST_NODES {:?}", weakest_nodes); + // println!("POST_RANK_FILTER {:?}", weakest_candidates); return weakest_candidates; } @@ -352,7 +399,8 @@ impl RankedChoiceVoteTrie { let mut rcv = RankedChoiceVoteTrie { root: Default::default(), dowdall_score_map: Default::default(), - elimination_strategy: self.elimination_strategy.clone() + elimination_strategy: self.elimination_strategy.clone(), + unique_candidates: Default::default() }; rcv.insert_votes(votes); return rcv.determine_winner(); @@ -360,33 +408,65 @@ impl RankedChoiceVoteTrie { fn build_ranked_pairs_map( &self, node: &TrieNode, search_path: &mut Vec, - ranked_pairs_map: &mut HashMap<(u16, u16), u64> + ranked_pairs_map: &mut HashMap<(u16, u16), u64>, + unique_candidates: &HashSet ) { let kv_pairs_vec: Vec<(Box<&VoteValues>, Box<&TrieNode>)> = node.children.iter().map(|(vote_value, node)| { (Box::new(vote_value), Box::new(node)) }).collect(); + // number of votes that terminate at node + let mut terminating_votes: u64 = node.num_votes; + // println!("NODE_VOTES {:?}", node.num_votes); + for (boxed_vote_value, boxed_child) in kv_pairs_vec { let vote_value = *boxed_vote_value; let child = *boxed_child; + // println!("CHILD_VOTES {:?}", child.num_votes); + assert!(terminating_votes >= child.num_votes); + terminating_votes -= child.num_votes; + let candidate = match vote_value { VoteValues::SpecialVote(_) => { continue } VoteValues::Candidate(candidate) => { candidate } }; - for preferred_candidate in search_path.iter() { - let ranked_pair = (*preferred_candidate, *candidate); + for preferable_candidate in search_path.iter() { + let ranked_pair = (*preferable_candidate, *candidate); let pairwise_votes = ranked_pairs_map.entry(ranked_pair).or_insert(0); *pairwise_votes += child.num_votes; } search_path.push(*candidate); - self.build_ranked_pairs_map(child, search_path, ranked_pairs_map); + self.build_ranked_pairs_map( + child, search_path, ranked_pairs_map, unique_candidates + ); search_path.pop(); }; + + if terminating_votes > 0 { + // println!("UNIQUE {:?}", unique_candidates); + // println!("TERMINATE {:?}", (&search_path, terminating_votes)); + // candidates who weren't explicitly listed in current vote path + let search_path: &Vec = search_path.as_ref(); + let mut unspecified_candidates = unique_candidates.clone(); + for candidate in search_path { + unspecified_candidates.remove(candidate); + } + + for preferable_candidate in search_path { + for candidate in &unspecified_candidates { + let ranked_pair = (*preferable_candidate, *candidate); + let pairwise_votes = + ranked_pairs_map.entry(ranked_pair).or_insert(0); + // println!("INSERT {:?}", (ranked_pair, terminating_votes)); + *pairwise_votes += terminating_votes; + } + } + } } pub fn determine_winner<'a>(&self) -> Option { @@ -403,6 +483,7 @@ impl RankedChoiceVoteTrie { let kv_pairs_vec: Vec<(&VoteValues, &TrieNode)> = self.root.children.iter().collect(); + for (vote_value, node) in kv_pairs_vec { match vote_value { VoteValues::SpecialVote(SpecialVotes::ABSTAIN) => {} @@ -421,7 +502,8 @@ impl RankedChoiceVoteTrie { let mut ranked_pairs_map: HashMap<(u16, u16), u64> = HashMap::new(); if self.elimination_strategy == EliminationStrategies::RankedPairs { self.build_ranked_pairs_map( - &self.root, &mut Vec::new(), &mut ranked_pairs_map + &self.root, &mut Vec::new(), &mut ranked_pairs_map, + &self.unique_candidates ); } From 714db216a215a0f532d1e80b47e08effff6a96d7 Mon Sep 17 00:00:00 2001 From: milselarch Date: Sun, 5 May 2024 18:58:57 +0800 Subject: [PATCH 7/7] update README --- README.md | 77 +++++++++++++++++++++++++++++++++------ src/lib.rs | 2 +- tests/integration_test.rs | 30 ++++++++------- 3 files changed, 82 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index a06e202..f0804ae 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Ranked Choice Voting (RCV) implementation using Tries in Rust. RCV differs from normal first past the post voting in that voters are allowed to rank candidates from most to least preferable. To determine the winner of an RCV election, the -least votes for the least popular candidate(s) are transferred to their next choice until -some candidate reaches a majority. +votes for the least popular candidate each round is transferred to the next candidate in the +respective ranked votes until some candidate achieves an effective majority. Example usage: ```rust @@ -15,8 +15,8 @@ use trie_rcv::vote::RankedVote; fn main() { let mut rcv = RankedChoiceVoteTrie::new(); + // remove all candidates with the lowest number of votes each round rcv.set_elimination_strategy(EliminationStrategies::EliminateAll); - rcv.insert_vote(RankedVote::from_vector(&vec![1, 2, 3, 4]).unwrap()); rcv.insert_vote(RankedVote::from_vector(&vec![1, 2, 3]).unwrap()); rcv.insert_vote(RankedVote::from_vector(&vec![3]).unwrap()); @@ -41,11 +41,19 @@ fn main() { } ``` -This implementation also supports votes containing `withhold` and `abstain` votes, -where the `withhold` vote allows the voter to declare for none of the candidates, and -the abstain vote allows the voter to voluntarily remove himself from the poll -(this is useful for improving the chances that the rest of the votes are able -to conclude with a winning candidate) +This implementation also supports ranked votes ending +with `SpecialVotes::WITHHOLD` and `SpecialVotes::ABSTAIN` values: +1. `SpecialVotes::WITHHOLD` +Allows the voter to explicitly declare for none of the candidates. +Qualitatively this allows a voter to declare a vote of no confidence. +Serializes to `-1` via `SpecialVotes::WITHHOLD.to_int()` +2. `SpecialVotes::ABSTAIN` +Allows the voter to explicitly declare for none of the candidates while also +voluntarily removing himself from the poll. +Qualitatively this allows a voter to indicate +that he wants one of the candidates to win but isn't able to decide for himself and +would thus want to delegate the decision to the rest of the electorate. +Serializes to `-2` via `SpecialVotes::ABSTAIN.to_int()` ```rust use trie_rcv; @@ -53,23 +61,68 @@ use trie_rcv::RankedChoiceVoteTrie; use trie_rcv::vote::{RankedVote, SpecialVotes}; fn main() { - let votes = RankedVote::from_vectors(&vec![ + let rcv = RankedChoiceVoteTrie::new(); + + let votes_round1 = RankedVote::from_vectors(&vec![ vec![1, SpecialVotes::WITHHOLD.to_int()], vec![2, 1], vec![3, 2], vec![3] ]).unwrap(); - let rcv = RankedChoiceVoteTrie::new(); - let winner = rcv.run_election(votes); + let winner_round1 = rcv.run_election(votes_round1); println!("WINNER = {:?}", winner); assert_eq!( - winner, None, concat![ + winner_round1, None, concat![ "Candidate 1's vote should not count after round 1, ", "no one should have majority" ]); + + let votes_round2 = RankedVote::from_vectors(&vec![ + vec![1, SpecialVotes::ABSTAIN.to_int()], + vec![2, 1], + vec![3, 2], + vec![3] + ]).unwrap(); + + let winner_round2 = rcv.run_election(votes_round2); + println!("WINNER = {:?}", winner_round2); + assert_eq!( + winner_round2, Some(3), concat![ + "First vote is ignored in round 2, candidate 3 wins" + ]); } ``` +### 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: + +1. `EliminationStrategies::ElimnateAll` +Removes all candidates with the lowest number of votes each round. +2. `EliminationStrategies::DowdallScoring` (default) +Among multiple candidates with the lowest number of votes each round, +sort the candidates by their dowdall score and eliminate the candidate(s) +with the lowest [Dowdall score](https://rdrr.io/cran/votesys/man/dowdall_method.html). +The Dowdall score for each candidate is calculated by +the sum of the inverse of the ranking (starting from 1) for each ranked vote. +If a ranked vote does not contain a candidate, then it does not count +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) +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. + 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 + (such as when there are cyclic preferences between candidates), then the elimination + 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` + ## Build instructions Build crate using `cargo build`, run integration tests with `cargo test` diff --git a/src/lib.rs b/src/lib.rs index 8e4ad36..7e2654f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,7 +66,7 @@ struct VoteTransferChanges<'a> { // strategies for how to eliminate candidates each round #[derive(Clone, PartialEq)] pub enum EliminationStrategies { - // eliminate all candidates with the lowest number of votes + // removes all candidates with the lowest number of votes each round EliminateAll, // eliminate the candidate(s) with both the lowest number of votes // followed by the lowest dowdall score diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 34a7462..2688580 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -72,7 +72,7 @@ fn test_tie_scenario() { } #[test] -fn test_zero_vote_end() { +fn test_withold_vote_end() { let votes = RankedVote::from_vectors(&vec![ vec![1, WITHOLD_VOTE_VAL], vec![2, 1], @@ -90,34 +90,36 @@ fn test_zero_vote_end() { ]); } -#[test] -fn test_zero_nil_votes_only() { +fn test_abstain_vote_end() { let votes = RankedVote::from_vectors(&vec![ - vec![WITHOLD_VOTE_VAL], - vec![WITHOLD_VOTE_VAL], - vec![WITHOLD_VOTE_VAL], - vec![ABSTAIN_VOTE_VAL] + vec![1, ABSTAIN_VOTE_VAL], + vec![2, 1], + vec![3, 2], + vec![3] ]).unwrap(); let rcv = RankedChoiceVoteTrie::new(); let winner = rcv.run_election(votes); println!("WINNER = {:?}", winner); - assert_eq!(winner, None); + assert_eq!( + winner, Some(3), concat![ + "First vote is ignored in round 2, candidate 3 wins" + ]); } #[test] -fn test_null_vote_end() { +fn test_withhold_votes_only() { let votes = RankedVote::from_vectors(&vec![ - vec![1, ABSTAIN_VOTE_VAL], - vec![2, 1], - vec![3, 2], - vec![3] + vec![WITHOLD_VOTE_VAL], + vec![WITHOLD_VOTE_VAL], + vec![WITHOLD_VOTE_VAL], + vec![ABSTAIN_VOTE_VAL] ]).unwrap(); let rcv = RankedChoiceVoteTrie::new(); let winner = rcv.run_election(votes); println!("WINNER = {:?}", winner); - assert_eq!(winner, Some(3)); + assert_eq!(winner, None); } #[test]