first commit
This commit is contained in:
commit
1a640d4056
58
README.md
Normal file
58
README.md
Normal 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
9
sample.csv
Normal 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
vote-counter/.gitignore
vendored
Normal file
1
vote-counter/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
80
vote-counter/Cargo.lock
generated
Normal file
80
vote-counter/Cargo.lock
generated
Normal 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
7
vote-counter/Cargo.toml
Normal 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
355
vote-counter/src/main.rs
Normal 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>");
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user