This commit is contained in:
aaron-jack-manning 2022-06-25 18:43:38 +10:00
parent 1a640d4056
commit fc4063c13b
14 changed files with 930 additions and 488 deletions

328
Cargo.lock generated Normal file
View File

@ -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"

14
Cargo.toml Normal file
View File

@ -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"

View File

@ -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 <CSV_PATH> <THRESHOLD> 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] <PATH>
ARGS:
<PATH> Path to the CSV containing the ballots
OPTIONS:
-h, --help Print help information
--report Generate report of counting
-t, --threshold <THRESHOLD> Threshold to win [default: 0.5]
-V, --version Print version information
```
explaining each argument and how to use it.
## Ballot File ## Ballot File
Here is a sample ballot file: The ballot file should be a `csv` formatted as below:
| Peter | Mia | Hannah | Lee | | Peter | Mia | Hannah | Lee | Fred | Julia |
| ----- | --- | ------ | --- | | ----- | --- | ------ | --- | ---- | ----- |
| 1 | 2 | | 3 | | | 2 | 1 | | | 3 |
| 2 | 4 | 3 | 1 | | 1 | | 2 | 3 | 4 | |
| | | 1 | | | 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. 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 | For example, the following are not valid:
| ----- | --- | ------ | --- |
| 1 | 1 | | 3 |
- 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 | However the following are valid:
| ----- | --- | ------ | --- |
| 3 | 2 | 1 | 5 |
To be warned about invalid ballots, run in debug mode. | Peter | Mia | Hannah | Lee | Fred | Julia |
| ----- | --- | ------ | --- | ---- | ----- |
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. | 0 | 1 | 2 | | | 3 |
| | | 1 | 4 | | |
## Permitted Votes | 2 | | 5 | | | 1 |
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.
Negative numbers are simply ignored.

2
makefile Normal file
View File

@ -0,0 +1,2 @@
all:
cargo run --release sample.csv --threshold 0.5

View File

@ -1,9 +1,19 @@
Peter,Mia,Hannah,Lee Peter,Mia,Hannah,Lee,Fred,Julia
3,,1,2 1,2,3,4,5,6
2,1,,3 ,4,3,,1,2
,1,, ,1,2,,5,
,2,3,1 3,6,,1,,
4,3,1,2 ,1,1,2,,
1,2,3,4 ,,1,,2,3
1,2,,4 ,,,,,
,,,1 ,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

1 Peter Mia Hannah Lee Fred Julia
2 3 1 2 1 3 2 4 5 6
3 2 1 4 3 3 1 2
4 1 2 5
5 3 2 6 3 1
6 4 3 1 1 2
7 1 2 3 1 4 2 3
8 1 2 4
9 3 2 1
10 1 2
11 1
12 3 1 2
13 1 2 8
14 5 2 0
15 1 2 7
16 3 4 7
17 1
18 3 2 1
19 3 2 1

72
src/ballot.rs Normal file
View File

@ -0,0 +1,72 @@
use std::collections::HashSet;
/// Represents a ballot paper.
#[derive(Debug, Clone)]
pub struct Ballot(Vec<usize>);
impl Ballot {
/// Creates a new ballot from the a `Vec<usize> where the values within the `Vec` are
/// candidates and the order within the `Vec` expresses preference.
pub fn new(ballot : Vec<usize>) -> Ballot {
Ballot(ballot)
}
/// Creates an iterator over the undelying `Vec<usize>` 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<Ballot> {
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<Option<usize>>) -> Result<Ballot, Vec<Option<usize>>> {
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))
}
}
}
}

296
src/ballot_box.rs Normal file
View File

@ -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<usize>),
Runoff(Vec<usize>),
}
#[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<Option<BallotBoxNode>>,
}
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<bool>,
total_votes : u32,
nodes : Vec<Option<BallotBoxNode>>,
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<BallotBox, csv::Error> {
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<String> =
headers
.into_iter()
.map(|x| (*x).parse::<String>())
.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::<usize>().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<usize> {
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<u32> =
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<usize>) {
self.runoff_or_promote(to_promote, false);
}
/// Eliminates the provided candidates and distributes their votes.
pub fn runoff(&mut self, to_eliminate : Vec<usize>) {
self.runoff_or_promote(to_eliminate, true);
}
fn runoff_or_promote(&mut self, to_promote_or_eliminate : Vec<usize>, 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<usize> = 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<usize>, 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));
}
}
}

20
src/candidates.rs Normal file
View File

@ -0,0 +1,20 @@
/// Collection of candidates, in the same order as the `csv`.
#[derive(Debug, Clone)]
pub struct Candidates(Vec<String>);
impl Candidates {
/// Creates a new instance of `Candidates` from a `Vec<String>`.
pub fn new(candidates : Vec<String>) -> 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()
}
}

61
src/main.rs Normal file
View File

@ -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);
}
}
}

62
src/reporting.rs Normal file
View File

@ -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<usize>], 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::<Vec<String>>().join(", ");
println!("{} {}", "Eliminating:".bright_magenta(), candidates);
},
Promotion(to_promote) => {
let candidates = to_promote.iter().map(|c| candidates.get(*c).unwrap().clone()).collect::<Vec<String>>().join(", ");
println!("Resolving tie between: {}", candidates.bright_cyan());
},
_ => (),
}
}
}
/// Displays the winner.
pub fn winner(winner : Option<usize>, candidates : &Candidates) {
match winner {
Some(winner) => println!("{} {}", "Winner:".bright_blue(), candidates.get(winner).unwrap()),
None => println!("{}", "The election was a tie".bright_blue()),
}
}

View File

@ -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",
]

View File

@ -1,7 +0,0 @@
[package]
name = "vote-counter"
version = "0.1.0"
edition = "2021"
[dependencies]
csv = "1.1.6"

View File

@ -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<usize>),
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<String>);
impl Candidates {
/// Returns the candidates read from the header of a CSV file.
fn from(path : &path::PathBuf) -> Result<Candidates, csv::Error> {
let mut reader = csv::Reader::from_path(path)?;
let headers = reader.headers()?;
let result : Vec<String> =
headers
.into_iter()
.map(|x| (*x).parse::<String>())
.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<usize>);
impl Ballot {
/// Creates a new ballot from the underlying vector.
fn new(ballot : Vec<usize>) -> Ballot {
Ballot(ballot)
}
// This processes a ballot as it appears in the provided CSV (as a Vec<Option<usize>> 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<Option<usize>>, num_candidates : usize) -> Option<Ballot> {
// 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<Option<BallotBox>>,
}
impl BallotBox {
/// Creates a new ballot box.
fn new(total : u32, endings : u32, children : Vec<Option<BallotBox>>) -> 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<BallotBox, csv::Error> {
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::<usize>().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<u32>) -> Vec<usize> {
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<usize>, 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<u32>) -> 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::<f64>().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 <CSV_PATH> <THRESHOLD>");
}
}