diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40b79c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +.DS_Store +.vscode/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5bd2c9b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,75 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "getrandom" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "libc" +version = "0.2.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" + +[[package]] +name = "pairwise-voting" +version = "0.1.0" +dependencies = [ + "rand", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..93ef3fd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "pairwise-voting" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rand = "0.8.4" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a8d122 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Ranked Pairs Voting + +## Definition + +This is a Rust implementation of T.N. Tideman's Ranked Pairs algorithm, with test cases provided by the example given in the [Wikipedia article](https://en.wikipedia.org/wiki/Ranked_pairs). The goal of the ranked pair election/ballot is to select a winner amongst a list of preferences. It generates a sorted list of pairs and ensures that at least one candidate wins +amongst all the possible choices. The procedure first tallies and compares each pair of candidates (choices) and determines a winner. Then these majorities are sorted, in descending +order of magnitude from greatest to least. A "lock-in" graph is created, where the largest magnitude majority is considered first to create a DAG in which the source (i.e. the node with no incoming edges) is the winner. In the possiblity of a tie, where there is one or more source nodes, a winner is selected at random amongst them. + +## Citations + +- Tideman, T.N. Independence of clones as a criterion for voting rules. Soc Choice Welfare 4, 185–206 (1987). https://doi.org/10.1007/BF00433944 +- Zavist, T.M., Tideman, T.N. Complete independence of clones in the ranked pairs rule. Soc Choice Welfare 6, 167–173 (1989). https://doi.org/10.1007/BF00303170 + +## Quickstart + +`cargo build` +`cargo run` to see full working example with announced winner. +`cargo test` to run the example for each discrete procedure step. + diff --git a/src/ballot.rs b/src/ballot.rs new file mode 100644 index 0000000..8f0a9b8 --- /dev/null +++ b/src/ballot.rs @@ -0,0 +1,52 @@ +pub type Candidate = String; +pub type Ballot = [Candidate; 4]; + +pub fn create_wikipedia_ballots() -> Vec<[String; 4]> { + // Example election provided by: https://en.wikipedia.org/wiki/Ranked_pairs + let mut ballots: Vec = Vec::new(); + + for _i in 0..7 { + let ballot: [String; 4] = [String::from("w"),String::from("x"), String::from("z"), String::from("y")]; + + ballots.push(ballot); + } + assert_eq!(ballots.len(), 7); + + for _i in 0..2 { + let ballot = [String::from("w"),String::from("y"), String::from("x"), String::from("z")]; + + ballots.push(ballot); + } + assert_eq!(ballots.len(), 9); + + for _i in 0..4 { + let ballot = [String::from("x"),String::from("y"), String::from("z"), String::from("w")]; + + ballots.push(ballot); + } + assert_eq!(ballots.len(), 13); + + for _i in 0..5 { + let ballot = [String::from("x"),String::from("z"), String::from("w"), String::from("y")]; + + ballots.push(ballot); + } + assert_eq!(ballots.len(), 18); + + for _i in 0..1 { + let ballot = [String::from("y"),String::from("w"), String::from("x"), String::from("z")]; + + ballots.push(ballot); + } + assert_eq!(ballots.len(), 19); + + for _i in 0..8 { + let ballot = [String::from("y"),String::from("z"), String::from("w"), String::from("x")]; + + ballots.push(ballot); + } + assert_eq!(ballots.len(), 27); + + return ballots; +} + diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ca1fe7e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +pub mod ballot; +pub mod tally; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b52a2d1 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,21 @@ +use pairwise_voting::ballot::create_wikipedia_ballots; +use pairwise_voting::tally::*; +use rand::thread_rng; +use rand::seq::SliceRandom; + +fn main() { + let candidates = vec![String::from("w"),String::from("x"), String::from("z"), String::from("y")]; + let ballots = create_wikipedia_ballots(); + // Execute ranked pairs procedure + let results = tally(ballots); + let majorities = sort_majorities(&results); + let sources = lock(&candidates, majorities); + + if sources.len() == 1 { + let winner = sources.get(0).unwrap(); + println!("The winner is {}", winner); + } else { + println!("There is a tie between candidates: {:?}", sources); + println!("Randomly selecting winner: {:?}", sources.choose(&mut thread_rng()).unwrap()); + } +} diff --git a/src/tally.rs b/src/tally.rs new file mode 100644 index 0000000..06ee942 --- /dev/null +++ b/src/tally.rs @@ -0,0 +1,130 @@ +use crate::ballot::*; +use std::collections::HashMap; + +type Pair = (Candidate, Candidate); +type Score = i32; + +pub fn tally(ballots: Vec) -> HashMap { + let mut scores = HashMap::new(); + + for b in ballots { + for (rank, candidate) in b.iter().enumerate() { + for i in 0..b.len() { + if candidate == &b[i] { + scores.insert((candidate.to_owned(), b[i].to_owned()), 0); + } + else { + let count = scores.entry((candidate.to_owned(), b[i].to_owned())).or_insert(0); + if rank < i { + *count += 1; + } + else { + *count -= 1; + } + } + } + } + } + + return scores; +} + +pub fn sort_majorities(scores: &HashMap) -> Vec<(&Pair, &Score)> { + let mut majorities: Vec<(&Pair, &Score)> = scores.iter().collect(); + majorities.retain(|m| m.1 > &0); + majorities.sort_by(|a, b| b.1.cmp(a.1)); + + return majorities; +} + +fn reachable(graph: &HashMap<&String, Vec<&String>>, from: &String, to: &String) -> bool { + let edges = graph.get(from).unwrap(); + for &e in edges { + return &e == &to || reachable(graph, &e, to); + } + + return false; +} + +pub fn lock<'a>(candidates: &'a Vec, majorities: Vec<(&Pair, &Score)>) -> Vec<&'a Candidate> { + let mut graph = HashMap::new(); + + for c in candidates.iter() { + let edges: Vec<&String> = Vec::new(); + graph.insert(c, edges); + } + + for m in majorities.iter() { + let candidate_x = &m.0.0; + let candidate_y = &m.0.1; + + if !reachable(&graph, candidate_y, candidate_x) { + let edges = graph.entry(&candidate_x).or_insert(vec![]); + edges.push(candidate_y); + } + } + + let sources = candidates.into_iter().filter( |c| { + return candidates.into_iter().all(|n| { + let adj = graph.get(&n).unwrap(); + return !adj.contains(&c); + }); + }).collect::>(); + + return sources; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tally_count() { + let ballots = create_wikipedia_ballots(); + let scores = tally(ballots); + + assert_eq!(scores.get(&(String::from("w"), String::from("w"))).unwrap(), &0); + assert_eq!(scores.get(&(String::from("w"), String::from("x"))).unwrap(), &9); + assert_eq!(scores.get(&(String::from("w"), String::from("y"))).unwrap(), &1); + assert_eq!(scores.get(&(String::from("w"), String::from("z"))).unwrap(), &-7); + + assert_eq!(scores.get(&(String::from("x"), String::from("w"))).unwrap(), &-9); + assert_eq!(scores.get(&(String::from("x"), String::from("x"))).unwrap(), &0); + assert_eq!(scores.get(&(String::from("x"), String::from("y"))).unwrap(), &5); + assert_eq!(scores.get(&(String::from("x"), String::from("z"))).unwrap(), &11); + + assert_eq!(scores.get(&(String::from("y"), String::from("w"))).unwrap(), &-1); + assert_eq!(scores.get(&(String::from("y"), String::from("x"))).unwrap(), &-5); + assert_eq!(scores.get(&(String::from("y"), String::from("y"))).unwrap(), &0); + assert_eq!(scores.get(&(String::from("y"), String::from("z"))).unwrap(), &3); + + assert_eq!(scores.get(&(String::from("z"), String::from("w"))).unwrap(), &7); + assert_eq!(scores.get(&(String::from("z"), String::from("x"))).unwrap(), &-11); + assert_eq!(scores.get(&(String::from("z"), String::from("y"))).unwrap(), &-3); + assert_eq!(scores.get(&(String::from("z"), String::from("z"))).unwrap(), &0); + } + + #[test] + fn majorities_sorted_correctly() { + let ballots = create_wikipedia_ballots(); + let scores = tally(ballots); + let majorities = sort_majorities(&scores); + let correct_ordered_scores = vec![11,9,7,5,3,1]; + + for (i, m) in majorities.into_iter().enumerate() { + let score = m.1; + assert_eq!(score, correct_ordered_scores.get(i).unwrap()) + } + } + + #[test] + fn locked_graph_no_cycles() { + let candidates = vec![String::from("w"),String::from("x"), String::from("z"), String::from("y")]; + let ballots = create_wikipedia_ballots(); + let results = tally(ballots); + let majorities = sort_majorities(&results); + let sources = lock(&candidates, majorities); + assert_eq!(sources.get(0).unwrap(), &candidates.get(0).unwrap()); + } +} +