first commit

This commit is contained in:
aaron-jack-manning 2022-05-24 22:25:23 +10:00
commit 1a640d4056
6 changed files with 510 additions and 0 deletions

58
README.md Normal file
View File

@ -0,0 +1,58 @@
# Vote Counter
This is just a program that I wrote to count instant runoff votes, according to a certain specification.
## Usage
It's usage is as follows:
```
vote-counter <CSV_PATH> <THRESHOLD>
```
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.
## Ballot File
Here is a sample ballot file:
| Peter | Mia | Hannah | Lee |
| ----- | --- | ------ | --- |
| 1 | 2 | | 3 |
| 2 | 4 | 3 | 1 |
| | | 1 | |
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
The following votes are considered invalid:
- Multiple occurances of the same preference, for example:
| Peter | Mia | Hannah | Lee |
| ----- | --- | ------ | --- |
| 1 | 1 | | 3 |
- A preference number which exceeds the number of candidates, for example:
| Peter | Mia | Hannah | Lee |
| ----- | --- | ------ | --- |
| 3 | 2 | 1 | 5 |
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.

9
sample.csv Normal file
View File

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

1
vote-counter/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

80
vote-counter/Cargo.lock generated Normal file
View File

@ -0,0 +1,80 @@
# 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",
]

7
vote-counter/Cargo.toml Normal file
View File

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

355
vote-counter/src/main.rs Normal file
View File

@ -0,0 +1,355 @@
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>");
}
}