Skip to content

Commit

Permalink
add condorcet ranked pairs
Browse files Browse the repository at this point in the history
  • Loading branch information
milselarch committed May 12, 2024
1 parent 2319b8a commit be96ddf
Show file tree
Hide file tree
Showing 4 changed files with 168 additions and 27 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -112,17 +118,29 @@ 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
(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`
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`
104 changes: 82 additions & 22 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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,
Expand All @@ -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<u16, u64>) -> bool {
Expand Down Expand Up @@ -267,13 +271,60 @@ impl RankedChoiceVoteTrie {
transfer_changes
}

fn find_condorcet_ranked_pairs_weakest(
&self, candidate_vote_counts: &HashMap<u16, u64>,
ranked_pairs_map: &HashMap<(u16, u16), u64>,
lowest_vote_candidates: Vec<u16>
) -> Vec<u16> {
println!("CC_PRE_RANK_FILTER {:?}", candidate_vote_counts);
println!("CC_PAIRS_MAP {:?}", ranked_pairs_map);
let mut vote_counts: Vec<u64> =
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<u16> = 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<u16>,
ranked_pairs_map: &HashMap<(u16, u16), u64>
) -> Vec<u16> {
// println!("\n----------------");
// println!("PRE_RANK_FILTER {:?}", candidates);
// println!("PAIRS_MAP {:?}", ranked_pairs_map);
) -> (Vec<u16>, 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::<u16, u64>::new();
let mut node_map = HashMap::<u16, NodeIndex>::new();

Expand Down Expand Up @@ -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 {
Expand All @@ -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<u16>) -> Vec<u16> {
Expand Down Expand Up @@ -514,15 +565,18 @@ 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
);
}

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
Expand All @@ -539,28 +593,34 @@ impl RankedChoiceVoteTrie {
}

// find candidates with the lowest number of effective votes
let mut weakest_candidates: Vec<u16> = Vec::new();
let mut lowest_vote_candidates: Vec<u16> = 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<VoteTransfer> = Vec::new();
Expand Down
63 changes: 63 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use itertools::all;
use trie_rcv;
use trie_rcv::{EliminationStrategies, RankedChoiceVoteTrie};
use trie_rcv::vote::{SpecialVotes, RankedVote};
Expand Down Expand Up @@ -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<i32>>) -> Vec<Vec<i32>> {
return (0..num_votes)
.flat_map(|_| vote_type.clone())
.collect::<Vec<_>>();
}

let mut raw_votes: Vec<Vec<i32>> = 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<i32>>) -> Vec<Vec<i32>> {
return (0..num_votes)
.flat_map(|_| vote_type.clone())
.collect::<Vec<_>>();
}

let mut raw_votes: Vec<Vec<i32>> = 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));
}

0 comments on commit be96ddf

Please sign in to comment.