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
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
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.

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

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