From fc4063c13bfcdd45dd2e551b23f39458d2f9b420 Mon Sep 17 00:00:00 2001 From: aaron-jack-manning Date: Sat, 25 Jun 2022 18:43:38 +1000 Subject: [PATCH] 0.1.0 --- vote-counter/.gitignore => .gitignore | 0 Cargo.lock | 328 ++++++++++++++++++++++++ Cargo.toml | 14 + README.md | 93 ++++--- makefile | 2 + sample.csv | 28 +- src/ballot.rs | 72 ++++++ src/ballot_box.rs | 296 +++++++++++++++++++++ src/candidates.rs | 20 ++ src/main.rs | 61 +++++ src/reporting.rs | 62 +++++ vote-counter/Cargo.lock | 80 ------ vote-counter/Cargo.toml | 7 - vote-counter/src/main.rs | 355 -------------------------- 14 files changed, 930 insertions(+), 488 deletions(-) rename vote-counter/.gitignore => .gitignore (100%) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 makefile create mode 100644 src/ballot.rs create mode 100644 src/ballot_box.rs create mode 100644 src/candidates.rs create mode 100644 src/main.rs create mode 100644 src/reporting.rs delete mode 100644 vote-counter/Cargo.lock delete mode 100644 vote-counter/Cargo.toml delete mode 100644 vote-counter/src/main.rs diff --git a/vote-counter/.gitignore b/.gitignore similarity index 100% rename from vote-counter/.gitignore rename to .gitignore diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..368ac2a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,328 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "clap" +version = "3.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53da17d37dba964b9b3ecb5c5a1f193a2762c700e6829201e645b9381c99dc7" +dependencies = [ + "atty", + "bitflags", + "clap_derive", + "clap_lex", + "indexmap", + "once_cell", + "strsim", + "termcolor", + "textwrap", +] + +[[package]] +name = "clap_derive" +version = "3.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11d40217d16aee8508cc8e5fde8b4ff24639758608e5374e731b53f85749fb9" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5538cd660450ebeb4234cfecf8f2284b844ffc4c50531e66d584ad5b91293613" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "colored" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd" +dependencies = [ + "atty", + "lazy_static", + "winapi", +] + +[[package]] +name = "csv" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" +dependencies = [ + "bstr", + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +dependencies = [ + "memchr", +] + +[[package]] +name = "exitcode" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de853764b47027c2e862a995c34978ffa63c1501f2e15f987ba11bd4f9bba193" + +[[package]] +name = "hashbrown" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3" + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "indexmap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c6392766afd7964e2531940894cffe4bd8d7d17dbc3c1c4857040fd4b33bdb3" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "once_cell" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225" + +[[package]] +name = "os_str_bytes" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21326818e99cfe6ce1e524c2a805c189a99b5ae555a35d19f9a284b427d86afa" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" + +[[package]] +name = "ryu" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" + +[[package]] +name = "serde" +version = "1.0.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" + +[[package]] +name = "unicode-ident" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "vote-counter" +version = "0.1.0" +dependencies = [ + "clap", + "colored", + "csv", + "exitcode", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2afe982 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "vote-counter" +version = "0.1.0" +edition = "2021" +description = "An opinionated single transferrable vote counter for the command line." +license = "MIT OR Apache-2.0" +authors = ["Aaron Manning"] +repository = "https://github.com/aaron-jack-manning/vote-counter" + +[dependencies] +csv = "1.1.6" +clap = { version = "3.2.5", features = ["derive"] } +exitcode = "1.1.2" +colored = "2" diff --git a/README.md b/README.md index 0c25c16..03a798c 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,77 @@ -# Vote Counter +# Vote Counter -This is just a program that I wrote to count instant runoff votes, according to a certain specification. +An opinionated single transferrable vote counter for the command line. -## Usage +## Installation -It's usage is as follows: +Installation can be done via cargo by running: ``` -vote-counter +cargo install vote-counter ``` -The first command line arguments is the path to the `.csv` file containing the ballot papers, and the second is the threshold required to win, where 0.5 corresponds to a simple majority. +## Demo + +A sample `csv` file in the appropriate format is provided at the top level of this repository. Run: + +``` +vote-counter sample.csv --report +``` + +to count the votes from that file. + +## Arguments + +Running `vote-counter --help` will output the following: + +``` +USAGE: + vote-counter [OPTIONS] + +ARGS: + Path to the CSV containing the ballots + +OPTIONS: + -h, --help Print help information + --report Generate report of counting + -t, --threshold Threshold to win [default: 0.5] + -V, --version Print version information +``` + +explaining each argument and how to use it. ## Ballot File -Here is a sample ballot file: +The ballot file should be a `csv` formatted as below: -| Peter | Mia | Hannah | Lee | -| ----- | --- | ------ | --- | -| 1 | 2 | | 3 | -| 2 | 4 | 3 | 1 | -| | | 1 | | +| Peter | Mia | Hannah | Lee | Fred | Julia | +| ----- | --- | ------ | --- | ---- | ----- | +| | 2 | 1 | | | 3 | +| 1 | | 2 | 3 | 4 | | +| 5 | 4 | 3 | 1 | 2 | 6 | Each row represents a ballot paper, where preferenced are expressed starting at 1, and continuing until the voter no longer has a preference. -## Invalid Votes +## Validity of Votes -The following votes are considered invalid: +This program is generally permissive in the votes that are considered valid. If a ballot includes any number of non-negative preference numbers, none of which are repeating, the ballot is valid. -- Multiple occurances of the same preference, for example: +An invalid ballot occurs when the same preference is expressed twice. -| Peter | Mia | Hannah | Lee | -| ----- | --- | ------ | --- | -| 1 | 1 | | 3 | +For example, the following are not valid: -- A preference number which exceeds the number of candidates, for example: +| Peter | Mia | Hannah | Lee | Fred | Julia | +| ----- | --- | ------ | --- | ---- | ----- | +| 1 | 1 | | 3 | | | +| 0 | 1 | | 4 | | 4 | +| 2 | | 2 | | 1 | | -| Peter | Mia | Hannah | Lee | -| ----- | --- | ------ | --- | -| 3 | 2 | 1 | 5 | +However the following are valid: -To be warned about invalid ballots, run in debug mode. - -The code has been internally documented reasonably thoroughly so if you want to fork the repo and change the logic surrounding invalid votes I hope I have made that reasonably easy. - -## Permitted Votes - -Aside from the obviously valid votes, which number candidates 1 to a given preference as far as the voter may chose, votes which skip a preference are also considered valid, for example: - -| Peter | Mia | Hannah | Lee | -| ----- | --- | ------ | --- | -| | | 3 | 1 | - -where preferences are shuffled down such that in the above example, Hannah is considered to be the second preference. - -A sample ballots file called `sample.csv` is provided which includes only valid ballots. +| Peter | Mia | Hannah | Lee | Fred | Julia | +| ----- | --- | ------ | --- | ---- | ----- | +| 0 | 1 | 2 | | | 3 | +| | | 1 | 4 | | | +| 2 | | 5 | | | 1 | +Negative numbers are simply ignored. diff --git a/makefile b/makefile new file mode 100644 index 0000000..3fb86a7 --- /dev/null +++ b/makefile @@ -0,0 +1,2 @@ +all: + cargo run --release sample.csv --threshold 0.5 diff --git a/sample.csv b/sample.csv index 49f17fa..23187d6 100644 --- a/sample.csv +++ b/sample.csv @@ -1,9 +1,19 @@ -Peter,Mia,Hannah,Lee -3,,1,2 -2,1,,3 -,1,, -,2,3,1 -4,3,1,2 -1,2,3,4 -1,2,,4 -,,,1 +Peter,Mia,Hannah,Lee,Fred,Julia +1,2,3,4,5,6 +,4,3,,1,2 +,1,2,,5, +3,6,,1,, +,1,1,2,, +,,1,,2,3 +,,,,, +,3,2,1,, +1,2,,,, +,,1,,, +,,3,1,2, +,1,2,8,, +,5,2,,0, +1,2,,7,, +,,3,4,,7 +,,,,1, +,,,3,2,1 +,3,2,,,1 diff --git a/src/ballot.rs b/src/ballot.rs new file mode 100644 index 0000000..9dac494 --- /dev/null +++ b/src/ballot.rs @@ -0,0 +1,72 @@ +use std::collections::HashSet; + +/// Represents a ballot paper. +#[derive(Debug, Clone)] +pub struct Ballot(Vec); + +impl Ballot { + /// Creates a new ballot from the a `Vec where the values within the `Vec` are + /// candidates and the order within the `Vec` expresses preference. + pub fn new(ballot : Vec) -> Ballot { + Ballot(ballot) + } + + /// Creates an iterator over the undelying `Vec` within the `Ballot`. + pub fn iter(&self) -> std::slice::Iter<'_, usize> { + self.0.iter() + } + + /// Removes the specified candidates from the ballot. + pub fn remove_candidates(ballot : Ballot, to_remove : &[usize]) -> Option { + let new_ballot: Vec<_> = + ballot.0 + .into_iter() + .filter(|c| !to_remove.contains(c)) + .collect(); + + match new_ballot.len() { + 0 => None, + _ => Some(Ballot::new(new_ballot)) + } + } + + /// Returns the highest preference candidate. + pub fn first_pref(&self) -> usize { + self.0[0] + } + + /// Creates a ballot from the representation read from the file. + pub fn from_raw_ballot(raw_ballot : Vec>) -> Result>> { + let mut pref_pairs = Vec::with_capacity(raw_ballot.len()); + + let mut preference_set = HashSet::with_capacity(raw_ballot.len()); + + for (candidate, preference) in raw_ballot.iter().enumerate() { + if let Some(preference) = preference { + if !preference_set.insert(preference) { + // Value already existed in set, which means preference was expressed twice. + return Err(raw_ballot); + } + pref_pairs.push((preference, candidate)); + } + } + + match pref_pairs.len() { + // No preference was expressed at all. + 0 => Err(raw_ballot), + _ => { + // Sort the ballot by order of preference. + pref_pairs.sort_by(|(p1, _), (p2, _)| p1.cmp(p2)); + + // Resolve the preference-candidate pairs to just the candidate. + let ballot = + pref_pairs + .into_iter() + .map(|(_, c)| c) + .collect(); + + Ok(Ballot(ballot)) + } + } + } +} diff --git a/src/ballot_box.rs b/src/ballot_box.rs new file mode 100644 index 0000000..1a923c1 --- /dev/null +++ b/src/ballot_box.rs @@ -0,0 +1,296 @@ +use std::mem; +use std::path; + +use crate::candidates::Candidates; +use crate::reporting; +use crate::ballot::Ballot; + +/// Represents the current status of the count, and how to proceed counting. +#[derive(Clone, Debug)] +pub enum CountStatus { + Winner(usize), + Tie, + Promotion(Vec), + Runoff(Vec), +} + +#[derive(Debug, Clone)] +/// Node of trie like structure representing the votes. This stores ballots with common starting +/// preference, using the endings value to count how many votes expressed the same preference from +/// the top to that node. Each 'level' of the structure represents a preference, with each +/// candidate appearing in the `children` field's vector in order. +struct BallotBoxNode { + total_beneath : u32, + endings : u32, + children : Vec>, +} + +impl BallotBoxNode { + /// Creates a new, empty ballot box node. + fn new(children : usize) -> Self { + BallotBoxNode { + total_beneath : 0, + endings : 0, + children : vec![None; children], + } + } +} + +/// Stores list of candidates, total number of votes, the candidates which have been eliminated and +/// the votes themselves using a `BallotBoxNode`s. +#[derive(Debug, Clone)] +pub struct BallotBox { + eliminated : Vec, + total_votes : u32, + nodes : Vec>, + pub candidates : Candidates, +} + +impl BallotBox { + /// Creates a new, empty ballot box. + fn new(candidates : Candidates) -> Self { + BallotBox { + eliminated : vec![true; candidates.len()], + total_votes : 0, + nodes : vec![None; candidates.len()], + candidates, + } + } + + /// Reads and fills the ballot box from a file. + pub fn from_file(path : &path::PathBuf, report : bool) -> Result { + + let mut reader = + csv::ReaderBuilder::new() + .has_headers(true) + .from_path(path)?; + + // Read the headers and create the candidates. + let headers = reader.headers()?; + + let candidates : Vec = + headers + .into_iter() + .map(|x| (*x).parse::()) + .map(|x| x.unwrap()) + .collect(); + + let candidates = Candidates::new(candidates); + + let mut ballot_box = BallotBox::new(candidates); + + let mut counter = 1; + for result in reader.records() { + let mut raw_ballot = Vec::new(); + counter += 1; + + for value in result?.iter() { + raw_ballot.push(value.parse::().ok()) + } + + match Ballot::from_raw_ballot(raw_ballot) { + Ok(ballot) => ballot_box.push(ballot, 1), + Err(raw_ballot) => reporting::invalid_ballot(counter, &raw_ballot, report), + } + } + + Ok(ballot_box) + } + + /// Returns a collection of all eliminated candidates. + fn eliminated(&self) -> Vec { + let mut eliminated = Vec::new(); + + for i in 0..self.candidates.len() { + if self.eliminated[i] { + eliminated.push(i) + } + } + + eliminated + } + + /// Returns the number of remaining candidates which have yet to be eliminated. + fn remaining(&self) -> usize { + self + .eliminated + .iter() + .filter(|b| !*b) + .count() + } + + /// Adds the provided ballot to the `BallotBox` `quantity` times. + fn push(&mut self, ballot : Ballot, quantity : u32) { + + // All candidates are marked as eliminated at the start, so this may need to change as each + // new ballot is added in. + self.eliminated[ballot.first_pref()] = false; + + // Update the total number of votes at the top level. + self.total_votes += quantity; + + let mut current_node : Option<&mut BallotBoxNode> = None; + + for (_, &candidate) in ballot.iter().enumerate() { + + // Traverse down the trie appropriately depending on if it is currently at the top + // level or not. + current_node = match current_node { + None => { + if self.nodes[candidate].is_none() { + self.nodes[candidate] = Some(BallotBoxNode::new(self.candidates.len())); + } + + let children = &mut self.nodes; + Some(children[candidate].as_mut().unwrap()) + }, + Some(current_node) => { + if current_node.children[candidate].is_none() { + current_node.children[candidate] = Some(BallotBoxNode::new(self.candidates.len())); + } + + let children = &mut current_node.children; + Some(children[candidate].as_mut().unwrap()) + } + }; + + // Update the total number of votes under the current node. + current_node.as_mut().unwrap().total_beneath += quantity; + } + + // Update the endings count on the last node. + current_node.unwrap().endings += quantity; + } + + + // Gives the current status of the count, and indicates who needs to be eliminated in a runoff + // if necessary. + pub fn status(&self, threshold : f64, report : bool) -> CountStatus { + let totals : Vec = + self + .nodes + .iter() + .map(|n| match n { + None => 0, + Some(node) => node.total_beneath, + }) + .collect(); + + let max = *totals.iter().max().unwrap(); + let min = *totals.iter().filter(|x| x != &&0).min().unwrap(); + + let winners = + totals + .iter() + .enumerate() + .fold(Vec::new(), |mut winners, (candidate, total)| { + if total == &max { + winners.push(candidate); + }; + + winners + }); + + let losers = + totals + .iter() + .enumerate() + .fold(Vec::new(), |mut losers, (candidate, total)| { + if total == &min { + losers.push(candidate); + }; + + losers + }); + + reporting::current_count(totals.iter().enumerate().map(|(a, b)| (a, *b)).collect(), &self.candidates, report); + + // All votes have been reduced to 0. + let status = if max == 0 { + CountStatus::Tie + } + // A unique winner has been determined. + else if winners.len() == 1 && f64::try_from(max).unwrap() >= (threshold * f64::try_from(self.total_votes).unwrap()) { + CountStatus::Winner(winners[0]) + } + // All remaining candidates are on equal votes. + else if winners.len() == self.remaining() { + CountStatus::Promotion(winners) + } + // Distribute the votes of all losers. + else { + CountStatus::Runoff(losers) + }; + + reporting::status(&status, &self.candidates, report); + + status + } + + /// Promotes lower preference votes of the provided candidates. + pub fn promote(&mut self, to_promote : Vec) { + self.runoff_or_promote(to_promote, false); + } + + /// Eliminates the provided candidates and distributes their votes. + pub fn runoff(&mut self, to_eliminate : Vec) { + self.runoff_or_promote(to_eliminate, true); + } + + fn runoff_or_promote(&mut self, to_promote_or_eliminate : Vec, runoff : bool) { + // Vector of ballots and the quantity to redistribute. + let mut adjusted_votes : Vec<(Ballot, u32)> = Vec::new(); + + for candidate in to_promote_or_eliminate { + // Swap the votes to distribute out. + let mut to_distribute = None; + mem::swap(&mut self.nodes[candidate], &mut to_distribute); + let to_distribute = to_distribute.unwrap(); + + // Update the top level total. + self.total_votes -= to_distribute.total_beneath; + + BallotBox::distribute(&to_distribute, Vec::new(), &mut adjusted_votes); + + // Update the array of eliminated candidates. + if runoff { + self.eliminated[candidate] = true; + } + } + + // Determine all previously eliminated candidates (including in this round). + let eliminated_candidates : Vec = self.eliminated(); + + for (vote, qty) in adjusted_votes { + // Remove any preferences expressed for the candidates which have already been + // eliminated, and add the remaining ballot if it is non-empty. + if let Some(vote) = Ballot::remove_candidates(vote, &eliminated_candidates) { + self.push(vote, qty); + } + } + } + + /// Helper function for `runoff_or_promote` which handles the calculating of votes that need to + /// be distributed. + fn distribute(to_distribute : &BallotBoxNode, current_ballot : Vec, adjusted_votes : &mut Vec<(Ballot, u32)>) { + for (candidate, child) in to_distribute.children.iter().enumerate() { + if let Some(node) = child { + // Clone the current ballot so that new values can be added as passed down. + let mut next_ballot = current_ballot.clone(); + // Add the current candidate to the ballot. + next_ballot.push(candidate); + + BallotBox::distribute(node, next_ballot, adjusted_votes); + } + } + + // Add the current ballot to the collection with the corresponding count. + // This will intentionally ignore ballots at the top level, which are being distributed + // anyway. + if to_distribute.endings > 0 { + adjusted_votes.push((Ballot::new(current_ballot), to_distribute.endings)); + } + } +} + + diff --git a/src/candidates.rs b/src/candidates.rs new file mode 100644 index 0000000..6b245d5 --- /dev/null +++ b/src/candidates.rs @@ -0,0 +1,20 @@ +/// Collection of candidates, in the same order as the `csv`. +#[derive(Debug, Clone)] +pub struct Candidates(Vec); + +impl Candidates { + /// Creates a new instance of `Candidates` from a `Vec`. + pub fn new(candidates : Vec) -> Self { + Candidates(candidates) + } + + /// Gets a candidate's name based on their index. + pub fn get(&self, candidate : usize) -> Option<&String> { + self.0.get(candidate) + } + + /// Returns the number of candidates. + pub fn len(&self) -> usize { + self.0.len() + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9e110a9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,61 @@ +mod ballot_box; +mod reporting; +mod candidates; +mod ballot; + +use ballot_box::BallotBox; +use ballot_box::CountStatus::*; + +use std::path; +use std::process; + +use clap::Parser; + +#[derive(Parser, Debug)] +#[clap(author, version)] +/// Stores the command line arguments. +struct Args { + /// Path to the CSV containing the ballots. + #[clap()] + path : path::PathBuf, + + /// Threshold to win. + #[clap(long, short, default_value = "0.5")] + threshold : f64, + + /// Generate report of counting. + #[clap(long, takes_value = false)] + report : bool, +} + +/// Primary entry point to vote counting algorithms. +fn count(args : Args) -> Result<(), csv::Error> { + + let mut ballot_box = BallotBox::from_file(&args.path, args.report)?; + + let winner = loop { + match ballot_box.status(args.threshold, args.report) { + Winner(winner) => break Some(winner), + Tie => break None, + Runoff(to_eliminated) => ballot_box.runoff(to_eliminated), + Promotion(to_promote) => ballot_box.promote(to_promote), + } + }; + + reporting::winner(winner, &ballot_box.candidates); + + Ok(()) +} + +fn main() { + let args = Args::parse(); + match count(args) { + Ok(_) => { + process::exit(exitcode::OK); + }, + Err(error) => { + println!("An error occured reading the CSV data: {}", error); + process::exit(exitcode::DATAERR); + } + } +} diff --git a/src/reporting.rs b/src/reporting.rs new file mode 100644 index 0000000..76728d2 --- /dev/null +++ b/src/reporting.rs @@ -0,0 +1,62 @@ +use colored::*; + +use crate::ballot_box::{ + CountStatus, + CountStatus::* +}; +use crate::candidates::Candidates; + +/// Displays the invalid ballot provided. +pub fn invalid_ballot(number : u32, ballot : &[Option], report : bool) { + if report { + let segments : Vec<_> = + ballot + .iter() + .map(|op| { + match op { + None => String::from("_"), + Some(pref) => pref.to_string(), + } + }) + .collect(); + + let formatted = segments.join(","); + println!("{} {} (line: {})", "Invalid Ballot:".bright_green().bold(), formatted, number); + } +} + +/// Displays the current count of top preference votes. +pub fn current_count(count : Vec<(usize, u32)>, candidates : &Candidates, report : bool) { + if report { + println!("{}", "Current Count:".bright_yellow().bold()); + + for (candidate, votes) in count { + println!(" {} : {}", candidates.get(candidate).unwrap(), votes); + } + } +} + +/// Displays a `CountStatus` and associated data if it is a `Runoff` or `Promotion`. +pub fn status(status : &CountStatus, candidates : &Candidates, report : bool) { + if report { + match status { + Runoff(to_distribute) => { + let candidates = to_distribute.iter().map(|c| candidates.get(*c).unwrap().clone()).collect::>().join(", "); + println!("{} {}", "Eliminating:".bright_magenta(), candidates); + }, + Promotion(to_promote) => { + let candidates = to_promote.iter().map(|c| candidates.get(*c).unwrap().clone()).collect::>().join(", "); + println!("Resolving tie between: {}", candidates.bright_cyan()); + }, + _ => (), + } + } +} + +/// Displays the winner. +pub fn winner(winner : Option, candidates : &Candidates) { + match winner { + Some(winner) => println!("{} {}", "Winner:".bright_blue(), candidates.get(winner).unwrap()), + None => println!("{}", "The election was a tie".bright_blue()), + } +} diff --git a/vote-counter/Cargo.lock b/vote-counter/Cargo.lock deleted file mode 100644 index 569f239..0000000 --- a/vote-counter/Cargo.lock +++ /dev/null @@ -1,80 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "bstr" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" -dependencies = [ - "lazy_static", - "memchr", - "regex-automata", - "serde", -] - -[[package]] -name = "csv" -version = "1.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" -dependencies = [ - "bstr", - "csv-core", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "csv-core" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" -dependencies = [ - "memchr", -] - -[[package]] -name = "itoa" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - -[[package]] -name = "memchr" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" - -[[package]] -name = "ryu" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" - -[[package]] -name = "serde" -version = "1.0.137" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" - -[[package]] -name = "vote-counter" -version = "0.1.0" -dependencies = [ - "csv", -] diff --git a/vote-counter/Cargo.toml b/vote-counter/Cargo.toml deleted file mode 100644 index f1eeec8..0000000 --- a/vote-counter/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "vote-counter" -version = "0.1.0" -edition = "2021" - -[dependencies] -csv = "1.1.6" diff --git a/vote-counter/src/main.rs b/vote-counter/src/main.rs deleted file mode 100644 index f937ce8..0000000 --- a/vote-counter/src/main.rs +++ /dev/null @@ -1,355 +0,0 @@ -use std::path; -use std::mem; -use std::env; - -/// Represents the result of an election. -enum ElecResult { - Winner(usize), - Tie(Vec), - StillCounting, -} - -/// Collection of candidates, where the index is in the same order as the provided CSV, used -/// throughout the program to identify a candidate. -#[derive(Debug)] -struct Candidates(Vec); - -impl Candidates { - /// Returns the candidates read from the header of a CSV file. - fn from(path : &path::PathBuf) -> Result { - let mut reader = csv::Reader::from_path(path)?; - let headers = reader.headers()?; - - let result : Vec = - headers - .into_iter() - .map(|x| (*x).parse::()) - .map(|x| x.unwrap()) - .collect(); - - Ok(Candidates(result)) - } - - /// Gets a candidate's name based on their index. - fn get(&self, candidate : usize) -> Option<&String> { - self.0.get(candidate) - } - - /// Returns the number of candidates. - fn qty(&self) -> usize { - self.0.len() - } -} - -/// The index of the inner vector is the preference, with the value stored at that index -/// representing the candidate. Preferences start at 0, and are adjusted at file read. -#[derive(Debug)] -struct Ballot(Vec); - -impl Ballot { - /// Creates a new ballot from the underlying vector. - fn new(ballot : Vec) -> Ballot { - Ballot(ballot) - } - - // This processes a ballot as it appears in the provided CSV (as a Vec> where - // None means expressing no preference). - // As such, this function takes a vector indexed by candidate, which yields the preference, and - // returns a trimmed vector indexed by preference which yields the candidate. - fn from_csv_row(csv_row : Vec>, num_candidates : usize) -> Option { - // Populate the ballot paper with None before mutating, so that it can be done at arbitrary - // index. - let mut ballot = Vec::with_capacity(num_candidates); - for _i in 0..csv_row.len() { - ballot.push(None); - } - - for (candidate, preference) in csv_row.iter().enumerate() { - // If the preference was expressed. - if let Some(preference) = preference { - if preference >= &num_candidates || ballot[*preference].is_some() { - // Preference was specified which was greater than the number of candidates or - // duplicate preferences were expressed, both of which lead to a discounted - // vote. - return None; - } - else { - // No errors, so store the candidate at the index by preference. - ballot[*preference] = Some(candidate); - } - } - } - - let ballot = - ballot - .iter() - // Filter out any candidates which were not filled out. - .filter_map(|x| *x) - .collect(); - - Some(Ballot::new(ballot)) - } -} - - - - - -/// Trie like structure, which stores ballots with common starting preferences, using the endings -/// value to represent how many votes expressed the preferences from that node to the top. -/// Each 'level' of ballot box represents a preference, from 1 (or 0 internally) down, with each -/// candidate appearing in the children. -#[derive(Debug, Clone)] -struct BallotBox { - total : u32, - endings : u32, - children : Vec>, -} - -impl BallotBox { - /// Creates a new ballot box. - fn new(total : u32, endings : u32, children : Vec>) -> BallotBox { - BallotBox { - total, - endings, - children - } - } - - /// Creates an empty ballot box. - fn empty(children : usize) -> BallotBox { - BallotBox::new(0, 0, vec![None; children]) - } - - /// Fills the ballot box from a csv. - fn fill(path : &path::PathBuf, num_candidates : usize) -> Result { - let mut ballot_box = BallotBox::empty(num_candidates); - let mut reader = - csv::ReaderBuilder::new() - .has_headers(true) - .from_path(path)?; - - for result in reader.records() { - let mut row = Vec::new(); - for value in result?.iter() { - // The use of ok() and map() convert the result into an option, so that not - // expressing preference is none, and then proceed to reduce all by 1 so that the - // data structures can be 0 indexed even when the preferences start at 1 on the - // papers themselves. - row.push(value.parse::().ok().map(|x| x - 1)) - } - - #[cfg(debug_assertions)] - let raw_ballot = row.clone(); - - // If the ballot can be created from the csv row without error, it should be added to - // the ballot box. - if let Some(ballot) = Ballot::from_csv_row(row.clone(), num_candidates) { - ballot_box.push(ballot, num_candidates); - } - else { - #[cfg(debug_assertions)] - { - println!("INVALID BALLOT: {:?}", raw_ballot); - } - } - } - - Ok(ballot_box) - } - - /// Wrapper around push many which adds a single ballot to the ballot_box. - fn push(&mut self, ballot : Ballot, num_candidates : usize) { - self.push_many(ballot, num_candidates, 1); - } - - /// Pushes quantity many instances of the provided ballot to the ballot box. The ballot is - /// consumed and freed. - fn push_many(&mut self, ballot : Ballot, num_candidates : usize, quantity : u32) { - - self.total += quantity; - - let mut curr = self; - - for (preference, candidate) in ballot.0.iter().enumerate() { - - // If the next candidate is none in the ballot box, that sub box needs to be created. - if curr.children[*candidate].is_none() { - curr.children[*candidate] = Some(BallotBox::empty(num_candidates)); - } - - // Traverse the box downwards by entering the new ballot box. - let children = &mut curr.children; - curr = children[*candidate].as_mut().unwrap(); - - // Update the totals on the current node. - curr.total += quantity; - - // Reached the end of the ballot paper so need to mark the ending. - if preference == ballot.0.len() - 1 { - curr.endings += quantity; - } - } - } - - /// Assuming no winner exists, performs the runoff, redistributing votes of the least popular - /// candidate. - fn runoff(&mut self, num_candidates : usize) { - - fn all_min(vec : &Vec) -> Vec { - let mut min = std::u32::MAX; - let mut minimums = Vec::new(); - for (i, element) in vec.iter().enumerate() { - if element == &min { - minimums.push(i); - } - else if element < &min { - min = *element; - minimums.clear(); - minimums.push(i); - } - } - minimums - } - - // Total top preference votes for each candidate. - let totals = - self - .children - .iter() - .map(|b| b.as_ref().map(|x| x.total).unwrap_or(std::u32::MAX)) - .collect(); - - let to_eliminate = all_min(&totals); - - // Collection of all adjusted votes (with first preference removed) to be distributed. - // These need to be collected across each candidate which is eliminated to prevent later - // preferences of votes moved within this round from being counted as top level votes. - let mut adjusted_votes = Vec::new(); - - println!("About to eliminate: {:?}", to_eliminate); - // Loop over each candidate and eliminate accordingly. - for candidate in to_eliminate { - // Separate the ballot box which needs to be distributed from the original so that - // the mutable and immutable ref can exist at the same time. - let mut to_distribute = None; - mem::swap(&mut self.children[candidate], &mut to_distribute); - - // Remove the votes to distribute from the top level total as they will be re-added - // later. - self.total -= to_distribute.as_ref().unwrap().total; - BallotBox::runoff_helper(self, &to_distribute.unwrap(), Vec::with_capacity(num_candidates), num_candidates, &mut adjusted_votes); - } - - for vote in adjusted_votes { - let (ballot, quantity) = vote; - self.push_many(ballot, num_candidates, quantity); - } - } - - /// Recursively redistributes votes from to_distribute into original_box. - fn runoff_helper(original_box : &mut BallotBox, to_distribute : &BallotBox, ballot : Vec, num_candidates : usize, to_add : &mut Vec<(Ballot, u32)>) { - - for (candidate, child) in to_distribute.children.iter().enumerate() { - // Only need to process valid ballot box children. - if let Some(ballot_box) = child { - // Clone the ballot to pass down so that the vote corresponding with the endings is - // known. - let mut new_ballot = ballot.clone(); - new_ballot.push(candidate); - - // Redistribute all children. - BallotBox::runoff_helper(original_box, ballot_box, new_ballot, num_candidates, to_add); - } - } - - - // Add the ballots accordingly. - if to_distribute.endings != 0 { - //original_box.push_many(Ballot::new(ballot), num_candidates, to_distribute.endings); - to_add.push((Ballot::new(ballot), to_distribute.endings)); - } - } - - /// Checks if any candidates have reached the required threshold of first preference votes to - /// win. - fn winner(&self, threshold : f64) -> ElecResult { - - fn all_max(vec : &Vec) -> Vec<(usize, u32)> { - let mut max = std::u32::MIN; - let mut maximums = Vec::new(); - for (i, element) in vec.iter().enumerate() { - if element == &max { - maximums.push((i, *element)); - } - else if element > &max { - max = *element; - maximums.clear(); - maximums.push((i, *element)); - } - } - maximums - } - - // If there is a tie a winner cannot be cal - let totals = - self - .children - .iter() - .map(|b| b.as_ref().map(|x| x.total).unwrap_or(std::u32::MIN)) - .collect(); - - let winners = all_max(&totals); - - let (_, biggest_count) = winners[0]; - - let total_votes : u32 = totals.iter().sum(); - - if f64::from(biggest_count) / f64::from(total_votes) >= threshold { - if winners.len() > 1 { - ElecResult::Tie(winners.iter().map(|x| x.0).collect()) - } - else { - ElecResult::Winner(winners[0].0) - } - } - else { - ElecResult::StillCounting - } - } -} - -fn main() { - if env::args().len() == 3 { - let path = path::PathBuf::from(env::args().nth(1).unwrap()); - let threshold = env::args().nth(2).unwrap().parse::().unwrap(); - - let candidates = Candidates::from(&path).unwrap(); - let mut ballots = BallotBox::fill(&path, candidates.qty()).unwrap(); - - let winners = loop { - match ballots.winner(threshold) { - ElecResult::Winner(winner) => break vec![winner], - ElecResult::Tie(winners) => break winners, - ElecResult::StillCounting => ballots.runoff(candidates.qty()), - } - }; - - if winners.len() == 1 { - let winner = candidates.get(winners[0]).unwrap(); - println!("{} is the elected candidate.", winner); - } - else { - let winners : Vec<&String> = - winners - .iter() - .map(|x| candidates.get(*x).unwrap()) - .collect(); - - println!("{:?} are the elected candidates.", winners); - } - } - else { - println!("Usage: vote-counter "); - } -}