From 3bbef933bb416ecbbdc9de09471f0ed3328a58da Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Mon, 14 Jun 2021 20:43:36 +1000 Subject: [PATCH] Refactor and add documentation --- src/election.rs | 23 ++- src/lib.rs | 8 + src/logger.rs | 13 ++ src/numbers/fixed.rs | 3 +- src/numbers/mod.rs | 18 +- src/numbers/native.rs | 3 +- src/numbers/rational_num.rs | 2 +- src/numbers/rational_rug.rs | 3 +- src/sharandom.rs | 3 + src/stv/gregory.rs | 313 ++++++++++++++++++++++++++++++++++ src/stv/mod.rs | 325 ++++++------------------------------ src/stv/wasm.rs | 53 ++++-- src/ties.rs | 9 + tests/ers97.rs | 2 +- tests/scotland.rs | 2 +- 15 files changed, 482 insertions(+), 298 deletions(-) create mode 100644 src/stv/gregory.rs diff --git a/src/election.rs b/src/election.rs index 0467afc..08b8541 100644 --- a/src/election.rs +++ b/src/election.rs @@ -21,6 +21,7 @@ use crate::sharandom::SHARandom; use std::collections::HashMap; +/// An election to be counted pub struct Election { pub name: String, pub seats: usize, @@ -30,6 +31,7 @@ pub struct Election { } impl Election { + /// Parse the given BLT file and return an [Election] pub fn from_blt>(mut lines: I) -> Self { // Read first line let line = lines.next().expect("Unexpected EOF"); @@ -101,6 +103,7 @@ impl Election { return election; } + /// Convert ballots with weight >1 to multiple ballots of weight 1 pub fn normalise_ballots(&mut self) { let mut normalised_ballots = Vec::new(); for ballot in self.ballots.iter() { @@ -119,11 +122,13 @@ impl Election { } } +/// A candidate in an [Election] #[derive(PartialEq, Eq, Hash)] pub struct Candidate { pub name: String, } +/// The current state of counting an [Election] #[derive(Clone)] pub struct CountState<'a, N> { pub election: &'a Election, @@ -148,6 +153,7 @@ pub struct CountState<'a, N> { } impl<'a, N: Number> CountState<'a, N> { + /// Construct a new blank [CountState] for the given [Election] pub fn new(election: &'a Election) -> Self { let mut state = CountState { election: &election, @@ -177,6 +183,7 @@ impl<'a, N: Number> CountState<'a, N> { return state; } + /// [Step](CountCard::step) every [CountCard] to prepare for the next stage pub fn step_all(&mut self) { for (_, count_card) in self.candidates.iter_mut() { count_card.step(); @@ -186,6 +193,7 @@ impl<'a, N: Number> CountState<'a, N> { } } +/// Represents either a reference to a [CountState] or a clone #[allow(dead_code)] pub enum CountStateOrRef<'a, N> { State(CountState<'a, N>), // NYI: May be used e.g. for tie-breaking or rollback-based constraints @@ -193,10 +201,12 @@ pub enum CountStateOrRef<'a, N> { } impl<'a, N> CountStateOrRef<'a, N> { + /// Construct a [CountStateOrRef] as a reference to a [CountState] pub fn from(state: &'a CountState) -> Self { return Self::Ref(state); } + /// Return a reference to the underlying [CountState] pub fn as_ref(&self) -> &CountState { match self { CountStateOrRef::State(state) => &state, @@ -205,6 +215,7 @@ impl<'a, N> CountStateOrRef<'a, N> { } } +/// Result of a stage of counting pub struct StageResult<'a, N> { pub kind: Option<&'a str>, pub title: &'a String, @@ -212,6 +223,7 @@ pub struct StageResult<'a, N> { pub state: CountStateOrRef<'a, N>, } +/// Current state of a [Candidate] during an election count #[derive(Clone)] pub struct CountCard<'a, N> { pub state: CandidateState, @@ -225,6 +237,7 @@ pub struct CountCard<'a, N> { } impl<'a, N: Number> CountCard<'a, N> { + /// Returns a new blank [CountCard] pub fn new() -> Self { return CountCard { state: CandidateState::Hopeful, @@ -236,23 +249,23 @@ impl<'a, N: Number> CountCard<'a, N> { }; } - //pub fn votes(&'a self) -> N { - // return self.orig_votes.clone() + &self.transfers; - //} - + /// Transfer the given number of votes to this [CountCard], incrementing [transfers](CountCard::transfers) and [votes](CountCard::votes) pub fn transfer(&mut self, transfer: &'_ N) { self.transfers += transfer; self.votes += transfer; } + /// Set [orig_votes](CountCard::orig_votes) to [votes](CountCard::votes), and set [transfers](CountCard::transfers) to 0 pub fn step(&mut self) { self.orig_votes = self.votes.clone(); self.transfers = N::new(); } } +/// Parcel of [Vote]s during a count pub type Parcel<'a, N> = Vec>; +/// Represents a [Ballot] with an associated value #[derive(Clone)] pub struct Vote<'a, N> { pub ballot: &'a Ballot, @@ -260,11 +273,13 @@ pub struct Vote<'a, N> { pub up_to_pref: usize, } +/// A record of a voter's preferences pub struct Ballot { pub orig_value: N, pub preferences: Vec, } +/// State of a [Candidate] during a count #[allow(dead_code)] #[derive(PartialEq)] #[derive(Clone)] diff --git a/src/lib.rs b/src/lib.rs index 098b1b5..3b08338 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,17 +15,25 @@ * along with this program. If not, see . */ +/// Data types for representing abstract elections pub mod election; +/// Smart logging framework pub mod logger; +/// Implementations of different numeric representations pub mod numbers; +/// Deterministic random number generation using SHA256 pub mod sharandom; +/// STV counting logic pub mod stv; +/// Tie-breaking methods pub mod ties; use git_version::git_version; use wasm_bindgen::prelude::wasm_bindgen; +/// The git revision of this OpenTally build pub const VERSION: &str = git_version!(args=["--always", "--dirty=-dev"], fallback="unknown"); +/// Get [VERSION] as a String (for WebAssembly) #[wasm_bindgen] pub fn version() -> String { VERSION.to_string() } diff --git a/src/logger.rs b/src/logger.rs index b4dd8ed..7dbd85e 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -15,12 +15,16 @@ * along with this program. If not, see . */ +/// Smart logger used in election counts #[derive(Clone)] pub struct Logger<'a> { pub entries: Vec>, } impl<'a> Logger<'a> { + /// Append a new entry to the log + /// + /// If consecutive smart log entries have the same templates, they will be merged pub fn log(&mut self, entry: LogEntry<'a>) { if let LogEntry::Smart(mut smart) = entry { if self.entries.len() > 0 { @@ -41,10 +45,14 @@ impl<'a> Logger<'a> { } } + /// Append a new literal log entry pub fn log_literal(&mut self, literal: String) { self.log(LogEntry::Literal(literal)); } + /// Append a new smart log entry + /// + /// If consecutive smart log entries have the same templates, they will be merged pub fn log_smart(&mut self, template1: &'a str, template2: &'a str, data: Vec<&'a str>) { self.log(LogEntry::Smart(SmartLogEntry { template1: template1, @@ -53,6 +61,7 @@ impl<'a> Logger<'a> { })); } + /// Render the log to a [String] pub fn render(&self) -> Vec { return self.entries.iter().map(|e| match e { LogEntry::Smart(smart) => smart.render(), @@ -61,12 +70,14 @@ impl<'a> Logger<'a> { } } +/// Represents either a literal or smart log entry #[derive(Clone)] pub enum LogEntry<'a> { Smart(SmartLogEntry<'a>), Literal(String) } +/// Smart log entry #[derive(Clone)] pub struct SmartLogEntry<'a> { template1: &'a str, @@ -75,6 +86,7 @@ pub struct SmartLogEntry<'a> { } impl<'a> SmartLogEntry<'a> { + /// Render the [SmartLogEntry] to a [String] pub fn render(&self) -> String { if self.data.len() == 0 { panic!("Attempted to format smart log entry with no data"); @@ -86,6 +98,7 @@ impl<'a> SmartLogEntry<'a> { } } +/// Join the given strings, with commas and terminal "and" pub fn smart_join(data: &Vec<&str>) -> String { return format!("{} and {}", data[0..data.len()-1].join(", "), data.last().unwrap()); } diff --git a/src/numbers/fixed.rs b/src/numbers/fixed.rs index 566ec08..af951e9 100644 --- a/src/numbers/fixed.rs +++ b/src/numbers/fixed.rs @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -use super::{Assign, From, Number}; +use super::{Assign, Number}; use ibig::{IBig, ops::Abs}; use num_traits::{Num, One, Zero}; @@ -37,6 +37,7 @@ fn get_factor() -> &'static IBig { unsafe { FACTOR.as_ref().unwrap() } } +/// Fixed-point number #[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] pub struct Fixed(IBig); diff --git a/src/numbers/mod.rs b/src/numbers/mod.rs index ba87026..e3893a5 100644 --- a/src/numbers/mod.rs +++ b/src/numbers/mod.rs @@ -15,12 +15,16 @@ * along with this program. If not, see . */ +/// Fixed-point arithmetic (using `ibig`) mod fixed; +/// Native 64-bit floating point arithmetic mod native; +/// Exact rational arithmetic (using `rug`/GMP) #[cfg(not(target_arch = "wasm32"))] mod rational_rug; +/// Exact rational arithmetic (using `num-bigint`) //#[cfg(target_arch = "wasm32")] mod rational_num; @@ -30,29 +34,35 @@ use std::cmp::Ord; use std::fmt; use std::ops; +/// Assign value, avoiding additional allocations pub trait Assign { + /// Set the value of `self` to the value of `src`, avoiding additional allocations fn assign(&mut self, src: Src); } -pub trait From { - fn from(n: T) -> Self; -} - +/// Trait for OpenTally numeric representations //pub trait Number: NumRef + NumAssignRef + PartialOrd + Assign + Clone + fmt::Display where for<'a> &'a Self: RefNum<&'a Self> { pub trait Number: NumRef + NumAssignRef + ops::Neg + Ord + Assign + From + From + Clone + fmt::Display where for<'a> Self: Assign<&'a Self> { + /// Return a new [Number] fn new() -> Self; + /// Convert to CLI argument representation fn describe() -> String; + /// Convert to CLI argument representation, returning an empty string if the default fn describe_opt() -> String { Self::describe() } + /// Exponentiate `self` to the `exponent` power fn pow_assign(&mut self, exponent: i32); + /// Round `self` down if necessary to `dps` decimal places fn floor_mut(&mut self, dps: usize); + /// Round `self` up if necessary to `dps` decimal places fn ceil_mut(&mut self, dps: usize); + /// Parse the given string into a [Number] fn parse(s: &str) -> Self { if let Ok(value) = Self::from_str_radix(s, 10) { return value; diff --git a/src/numbers/native.rs b/src/numbers/native.rs index e3d72ce..5e5fba4 100644 --- a/src/numbers/native.rs +++ b/src/numbers/native.rs @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -use super::{Assign, From, Number}; +use super::{Assign, Number}; use derive_more::Display; use num_traits::{Num, One, Zero}; @@ -26,6 +26,7 @@ use std::ops; type ImplType = f64; +/// Native 64-bit floating-point number #[derive(Clone, Display, PartialEq, PartialOrd)] pub struct NativeFloat64(ImplType); diff --git a/src/numbers/rational_num.rs b/src/numbers/rational_num.rs index 95e4638..dd58f03 100644 --- a/src/numbers/rational_num.rs +++ b/src/numbers/rational_num.rs @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -use super::{Assign, From, Number}; +use super::{Assign, Number}; use num_traits::{Num, One, Signed, Zero}; diff --git a/src/numbers/rational_rug.rs b/src/numbers/rational_rug.rs index d59570c..9c3e018 100644 --- a/src/numbers/rational_rug.rs +++ b/src/numbers/rational_rug.rs @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -use super::{Assign, From, Number}; +use super::{Assign, Number}; use num_traits::{Num, One, Zero}; use rug::{self, ops::Pow, ops::PowAssign, rational::ParseRationalError}; @@ -25,6 +25,7 @@ use std::cmp::{Ord, Ordering, PartialEq, PartialOrd}; use std::fmt; use std::ops; +/// Rational number #[derive(Clone, PartialEq, PartialOrd)] pub struct Rational(rug::Rational); diff --git a/src/sharandom.rs b/src/sharandom.rs index 3605685..940cc8e 100644 --- a/src/sharandom.rs +++ b/src/sharandom.rs @@ -18,6 +18,7 @@ use ibig::UBig; use sha2::{Digest, Sha256}; +/// Deterministic random number generator using SHA256 #[derive(Clone)] pub struct SHARandom<'r> { seed: &'r str, @@ -25,6 +26,7 @@ pub struct SHARandom<'r> { } impl<'r> SHARandom<'r> { + /// Return a new [SHARandom] with the given seed pub fn new(seed: &'r str) -> Self { Self { seed: seed, @@ -32,6 +34,7 @@ impl<'r> SHARandom<'r> { } } + /// Draw a random number *n*, such that 0 ≤ *n* < `max` pub fn next(&mut self, max: usize) -> usize { let mut hasher = Sha256::new(); hasher.update(format!("{},{}", self.seed, self.counter).as_bytes()); diff --git a/src/stv/gregory.rs b/src/stv/gregory.rs new file mode 100644 index 0000000..6678d76 --- /dev/null +++ b/src/stv/gregory.rs @@ -0,0 +1,313 @@ +/* OpenTally: Open-source election vote counting + * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +use super::{NextPreferencesEntry, NextPreferencesResult, STVError, STVOptions, SumSurplusTransfersMode, SurplusMethod, SurplusOrder}; + +use crate::election::{Candidate, CountCard, CountState, Parcel, Vote}; +use crate::numbers::Number; + +use itertools::Itertools; + +use std::cmp::max; +use std::ops; + +/// Distribute the largest surplus according to the Gregory method, based on [STVOptions::surplus] +pub fn distribute_surpluses(state: &mut CountState, opts: &STVOptions) -> Result +where + for<'r> &'r N: ops::Sub<&'r N, Output=N>, + for<'r> &'r N: ops::Div<&'r N, Output=N>, + for<'r> &'r N: ops::Neg +{ + let quota = state.quota.as_ref().unwrap(); + let mut has_surplus: Vec<(&Candidate, &CountCard)> = state.election.candidates.iter() // Present in order in case of tie + .map(|c| (c, state.candidates.get(c).unwrap())) + .filter(|(_, cc)| &cc.votes > quota) + .collect(); + let total_surpluses = has_surplus.iter() + .fold(N::new(), |acc, (_, cc)| acc + &cc.votes - quota); + + if !has_surplus.is_empty() { + // Determine if surplues can be deferred + if opts.defer_surpluses { + if super::can_defer_surpluses(state, opts, &total_surpluses) { + state.logger.log_literal(format!("Distribution of surpluses totalling {:.dps$} votes will be deferred.", total_surpluses, dps=opts.pp_decimals)); + return Ok(false); + } + } + + match opts.surplus_order { + SurplusOrder::BySize => { + // Compare b with a to sort high-low + has_surplus.sort_by(|a, b| b.1.votes.cmp(&a.1.votes)); + } + SurplusOrder::ByOrder => { + has_surplus.sort_by(|a, b| a.1.order_elected.cmp(&b.1.order_elected)); + } + } + + // Distribute top candidate's surplus + let elected_candidate; + + // Handle ties + if has_surplus.len() > 1 && has_surplus[0].1.votes == has_surplus[1].1.votes { + let max_votes = &has_surplus[0].1.votes; + let has_surplus = has_surplus.into_iter().filter_map(|(c, cc)| if &cc.votes == max_votes { Some(c) } else { None }).collect(); + elected_candidate = super::choose_highest(state, opts, has_surplus)?; + } else { + elected_candidate = has_surplus[0].0; + } + + distribute_surplus(state, &opts, elected_candidate); + + return Ok(true); + } + return Ok(false); +} + +/// Return the denominator of the transfer value +fn calculate_surplus_denom(surplus: &N, result: &NextPreferencesResult, transferable_votes: &N, weighted: bool, transferable_only: bool) -> Option +where + for<'r> &'r N: ops::Sub<&'r N, Output=N> +{ + if transferable_only { + let total_units = if weighted { &result.total_votes } else { &result.total_ballots }; + let exhausted_units = if weighted { &result.exhausted.num_votes } else { &result.exhausted.num_ballots }; + let transferable_units = total_units - exhausted_units; + + if transferable_votes > surplus { + return Some(transferable_units); + } else { + return None; + } + } else { + if weighted { + return Some(result.total_votes.clone()); + } else { + return Some(result.total_ballots.clone()); + } + } +} + +/// Return the reweighted value of the vote after being transferred +fn reweight_vote( + num_votes: &N, + num_ballots: &N, + surplus: &N, + weighted: bool, + surplus_fraction: &Option, + surplus_denom: &Option, + round_tvs: Option, + rounding: Option) -> N +{ + let mut result; + + match surplus_denom { + Some(v) => { + if let Some(_) = round_tvs { + // Rounding requested: use the rounded transfer value + if weighted { + result = num_votes.clone() * surplus_fraction.as_ref().unwrap(); + } else { + result = num_ballots.clone() * surplus_fraction.as_ref().unwrap(); + } + } else { + // Avoid unnecessary rounding error by first multiplying by the surplus + if weighted { + result = num_votes.clone() * surplus / v; + } else { + result = num_ballots.clone() * surplus / v; + } + } + } + None => { + result = num_votes.clone(); + } + } + + // Round down if requested + if let Some(dps) = rounding { + result.floor_mut(dps); + } + + return result; +} + +/// Compute the number of votes to credit to a continuing candidate during a surplus transfer, based on [STVOptions::sum_surplus_transfers] +fn sum_surplus_transfers(entry: &NextPreferencesEntry, surplus: &N, is_weighted: bool, surplus_fraction: &Option, surplus_denom: &Option, _state: &mut CountState, opts: &STVOptions) -> N +where + for<'r> &'r N: ops::Div<&'r N, Output=N>, +{ + match opts.sum_surplus_transfers { + SumSurplusTransfersMode::SingleStep => { + // Calculate transfer across all votes + //state.logger.log_literal(format!("Transferring {:.0} ballot papers, totalling {:.dps$} votes.", entry.num_ballots, entry.num_votes, dps=opts.pp_decimals)); + return reweight_vote(&entry.num_votes, &entry.num_ballots, surplus, is_weighted, surplus_fraction, surplus_denom, opts.round_tvs, opts.round_votes); + } + SumSurplusTransfersMode::ByValue => { + // Sum transfers by value + let mut result = N::new(); + + // Sort into parcels by value + let mut votes: Vec<&Vote> = entry.votes.iter().collect(); + votes.sort_unstable_by(|a, b| (&a.value / &a.ballot.orig_value).cmp(&(&b.value / &b.ballot.orig_value))); + for (_value, parcel) in &votes.into_iter().group_by(|v| &v.value / &v.ballot.orig_value) { + let mut num_votes = N::new(); + let mut num_ballots = N::new(); + for vote in parcel { + num_votes += &vote.value; + num_ballots += &vote.ballot.orig_value; + } + //state.logger.log_literal(format!("Transferring {:.0} ballot papers, totalling {:.dps$} votes, received at value {:.dps2$}.", num_ballots, num_votes, value, dps=opts.pp_decimals, dps2=max(opts.pp_decimals, 2))); + result += reweight_vote(&num_votes, &num_ballots, surplus, is_weighted, surplus_fraction, surplus_denom, opts.round_tvs, opts.round_votes); + } + + return result; + } + SumSurplusTransfersMode::PerBallot => { + // Sum transfer per each individual ballot + // TODO: This could be moved to distribute_surplus to avoid looping over the votes and calculating transfer values twice + let mut result = N::new(); + for vote in entry.votes.iter() { + result += reweight_vote(&vote.value, &vote.ballot.orig_value, surplus, is_weighted, surplus_fraction, surplus_denom, opts.round_tvs, opts.round_votes); + } + //state.logger.log_literal(format!("Transferring {:.0} ballot papers, totalling {:.dps$} votes.", entry.num_ballots, entry.num_votes, dps=opts.pp_decimals)); + return result; + } + } +} + +/// Distribute the surplus of a given candidate according to the Gregory method, based on [STVOptions::surplus] +fn distribute_surplus(state: &mut CountState, opts: &STVOptions, elected_candidate: &Candidate) +where + for<'r> &'r N: ops::Sub<&'r N, Output=N>, + for<'r> &'r N: ops::Div<&'r N, Output=N>, + for<'r> &'r N: ops::Neg +{ + state.logger.log_literal(format!("Surplus of {} distributed.", elected_candidate.name)); + + let count_card = state.candidates.get(elected_candidate).unwrap(); + let surplus = &count_card.votes - state.quota.as_ref().unwrap(); + + let votes; + match opts.surplus { + SurplusMethod::WIG | SurplusMethod::UIG => { + // Inclusive Gregory + votes = state.candidates.get(elected_candidate).unwrap().parcels.concat(); + } + SurplusMethod::EG => { + // Exclusive Gregory + // Should be safe to unwrap() - or else how did we get a quota! + votes = state.candidates.get_mut(elected_candidate).unwrap().parcels.pop().unwrap(); + } + _ => { panic!("Invalid --surplus for Gregory method"); } + } + + // Count next preferences + let result = super::next_preferences(state, votes); + + state.kind = Some("Surplus of"); + state.title = String::from(&elected_candidate.name); + + // Transfer candidate votes + // TODO: Refactor?? + let is_weighted = match opts.surplus { + SurplusMethod::WIG => { true } + SurplusMethod::UIG | SurplusMethod::EG => { false } + SurplusMethod::Meek => { todo!() } + }; + + let transferable_votes = &result.total_votes - &result.exhausted.num_votes; + let surplus_denom = calculate_surplus_denom(&surplus, &result, &transferable_votes, is_weighted, opts.transferable_only); + let mut surplus_fraction; + match surplus_denom { + Some(ref v) => { + surplus_fraction = Some(surplus.clone() / v); + + // Round down if requested + if let Some(dps) = opts.round_tvs { + surplus_fraction.as_mut().unwrap().floor_mut(dps); + } + + if opts.transferable_only { + state.logger.log_literal(format!("Transferring {:.0} transferable ballots, totalling {:.dps$} transferable votes, with surplus fraction {:.dps2$}.", &result.total_ballots - &result.exhausted.num_ballots, transferable_votes, surplus_fraction.as_ref().unwrap(), dps=opts.pp_decimals, dps2=max(opts.pp_decimals, 2))); + } else { + state.logger.log_literal(format!("Transferring {:.0} ballots, totalling {:.dps$} votes, with surplus fraction {:.dps2$}.", result.total_ballots, result.total_votes, surplus_fraction.as_ref().unwrap(), dps=opts.pp_decimals, dps2=max(opts.pp_decimals, 2))); + } + } + None => { + surplus_fraction = None; + + if opts.transferable_only { + state.logger.log_literal(format!("Transferring {:.0} transferable ballots, totalling {:.dps$} transferable votes, at values received.", &result.total_ballots - &result.exhausted.num_ballots, transferable_votes, dps=opts.pp_decimals)); + } else { + state.logger.log_literal(format!("Transferring {:.0} ballots, totalling {:.dps$} votes, at values received.", result.total_ballots, result.total_votes, dps=opts.pp_decimals)); + } + } + } + + let mut checksum = N::new(); + + for (candidate, entry) in result.candidates.into_iter() { + // Credit transferred votes + let candidate_transfers = sum_surplus_transfers(&entry, &surplus, is_weighted, &surplus_fraction, &surplus_denom, state, opts); + let count_card = state.candidates.get_mut(candidate).unwrap(); + count_card.transfer(&candidate_transfers); + checksum += candidate_transfers; + + let mut parcel = entry.votes as Parcel; + + // Reweight votes + for vote in parcel.iter_mut() { + vote.value = reweight_vote(&vote.value, &vote.ballot.orig_value, &surplus, is_weighted, &surplus_fraction, &surplus_denom, opts.round_tvs, opts.round_weights); + } + + count_card.parcels.push(parcel); + } + + // Credit exhausted votes + let mut exhausted_transfers; + if opts.transferable_only { + if transferable_votes > surplus { + // No ballots exhaust + exhausted_transfers = N::new(); + } else { + exhausted_transfers = &surplus - &transferable_votes; + + if let Some(dps) = opts.round_votes { + exhausted_transfers.floor_mut(dps); + } + } + } else { + exhausted_transfers = sum_surplus_transfers(&result.exhausted, &surplus, is_weighted, &surplus_fraction, &surplus_denom, state, opts); + } + + state.exhausted.transfer(&exhausted_transfers); + checksum += exhausted_transfers; + + // Transfer exhausted votes + let parcel = result.exhausted.votes as Parcel; + state.exhausted.parcels.push(parcel); + + // Finalise candidate votes + let count_card = state.candidates.get_mut(elected_candidate).unwrap(); + count_card.transfers = -&surplus; + count_card.votes.assign(state.quota.as_ref().unwrap()); + checksum -= surplus; + + // Update loss by fraction + state.loss_fraction.transfer(&-checksum); +} diff --git a/src/stv/mod.rs b/src/stv/mod.rs index 4ca30f1..a0ca667 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -17,6 +17,8 @@ #![allow(mutable_borrow_reservation_conflict)] +pub mod gregory; + //#[cfg(target_arch = "wasm32")] pub mod wasm; @@ -32,6 +34,7 @@ use std::cmp::max; use std::collections::HashMap; use std::ops; +/// Options for conducting an STV count pub struct STVOptions { pub round_tvs: Option, pub round_weights: Option, @@ -53,6 +56,7 @@ pub struct STVOptions { } impl STVOptions { + /// Returns a new [STVOptions] based on arguments given as strings pub fn new( round_tvs: Option, round_weights: Option, @@ -134,6 +138,7 @@ impl STVOptions { }; } + /// Converts the [STVOptions] into CLI argument representation pub fn describe(&self) -> String { let mut flags = Vec::new(); let n_str = N::describe_opt(); if !n_str.is_empty() { flags.push(N::describe_opt()) }; @@ -160,6 +165,7 @@ impl STVOptions { } } +/// Enum of options for [STVOptions::sum_surplus_transfers] #[wasm_bindgen] #[derive(Clone, Copy)] #[derive(PartialEq)] @@ -170,6 +176,7 @@ pub enum SumSurplusTransfersMode { } impl SumSurplusTransfersMode { + /// Convert to CLI argument representation fn describe(self) -> String { match self { SumSurplusTransfersMode::SingleStep => "--sum-surplus-transfers single_step", @@ -179,6 +186,7 @@ impl SumSurplusTransfersMode { } } +/// Enum of options for [STVOptions::quota] #[wasm_bindgen] #[derive(Clone, Copy)] #[derive(PartialEq)] @@ -190,6 +198,7 @@ pub enum QuotaType { } impl QuotaType { + /// Convert to CLI argument representation fn describe(self) -> String { match self { QuotaType::Droop => "--quota droop", @@ -200,6 +209,7 @@ impl QuotaType { } } +/// Enum of options for [STVOptions::quota_criterion] #[wasm_bindgen] #[derive(Clone, Copy)] #[derive(PartialEq)] @@ -209,6 +219,7 @@ pub enum QuotaCriterion { } impl QuotaCriterion { + /// Convert to CLI argument representation fn describe(self) -> String { match self { QuotaCriterion::GreaterOrEqual => "--quota-criterion geq", @@ -217,6 +228,7 @@ impl QuotaCriterion { } } +/// Enum of options for [STVOptions::quota_mode] #[wasm_bindgen] #[derive(Clone, Copy)] #[derive(PartialEq)] @@ -226,6 +238,7 @@ pub enum QuotaMode { } impl QuotaMode { + /// Convert to CLI argument representation fn describe(self) -> String { match self { QuotaMode::Static => "--quota-mode static", @@ -234,6 +247,7 @@ impl QuotaMode { } } +/// Enum of options for [STVOptions::surplus] #[wasm_bindgen] #[derive(Clone, Copy)] #[derive(PartialEq)] @@ -245,6 +259,7 @@ pub enum SurplusMethod { } impl SurplusMethod { + /// Convert to CLI argument representation fn describe(self) -> String { match self { SurplusMethod::WIG => "--surplus wig", @@ -255,6 +270,7 @@ impl SurplusMethod { } } +/// Enum of options for [STVOptions::surplus_order] #[wasm_bindgen] #[derive(Clone, Copy)] #[derive(PartialEq)] @@ -264,6 +280,7 @@ pub enum SurplusOrder { } impl SurplusOrder { + /// Convert to CLI argument representation fn describe(self) -> String { match self { SurplusOrder::BySize => "--surplus-order by_size", @@ -272,6 +289,7 @@ impl SurplusOrder { } } +/// Enum of options for [STVOptions::exclusion] #[wasm_bindgen] #[derive(Clone, Copy)] #[derive(PartialEq)] @@ -282,6 +300,7 @@ pub enum ExclusionMethod { } impl ExclusionMethod { + /// Convert to CLI argument representation fn describe(self) -> String { match self { ExclusionMethod::SingleStage => "--exclusion single_stage", @@ -291,6 +310,7 @@ impl ExclusionMethod { } } +/// An error during the STV count #[wasm_bindgen] #[derive(Debug)] pub enum STVError { @@ -298,6 +318,7 @@ pub enum STVError { UnresolvedTie, } +/// Distribute first preferences, and initialise other states such as the random number generator and tie-breaking rules pub fn count_init<'a, N: Number>(mut state: &mut CountState<'a, N>, opts: &'a STVOptions) { // Initialise RNG for t in opts.ties.iter() { @@ -312,6 +333,7 @@ pub fn count_init<'a, N: Number>(mut state: &mut CountState<'a, N>, opts: &'a ST init_tiebreaks(&mut state, opts); } +/// Perform a single stage of the STV count pub fn count_one_stage<'a, N: Number>(mut state: &mut CountState<'a, N>, opts: &STVOptions) -> Result where for<'r> &'r N: ops::Sub<&'r N, Output=N>, @@ -358,6 +380,7 @@ where panic!("Count incomplete but unable to proceed"); } +/// See [next_preferences] struct NextPreferencesResult<'a, N> { candidates: HashMap<&'a Candidate, NextPreferencesEntry<'a, N>>, exhausted: NextPreferencesEntry<'a, N>, @@ -365,6 +388,7 @@ struct NextPreferencesResult<'a, N> { total_votes: N, } +/// See [next_preferences] struct NextPreferencesEntry<'a, N> { //count_card: Option<&'a CountCard<'a, N>>, votes: Vec>, @@ -372,6 +396,7 @@ struct NextPreferencesEntry<'a, N> { num_votes: N, } +/// Count the given votes, grouping according to next available preference fn next_preferences<'a, N: Number>(state: &CountState<'a, N>, votes: Vec>) -> NextPreferencesResult<'a, N> { let mut result = NextPreferencesResult { candidates: HashMap::new(), @@ -426,6 +451,7 @@ fn next_preferences<'a, N: Number>(state: &CountState<'a, N>, votes: Vec(state: &mut CountState) { let votes = state.election.ballots.iter().map(|b| Vote { ballot: b, @@ -453,6 +479,7 @@ fn distribute_first_preferences(state: &mut CountState) { state.logger.log_literal("First preferences distributed.".to_string()); } +/// Calculate the quota, given the total vote, according to [STVOptions::quota] fn total_to_quota(mut total: N, seats: usize, opts: &STVOptions) -> N { match opts.quota { QuotaType::Droop | QuotaType::DroopExact => { @@ -484,6 +511,7 @@ fn total_to_quota(mut total: N, seats: usize, opts: &STVOptions) -> N return total; } +/// Calculate the quota according to [STVOptions::quota] fn calculate_quota(state: &mut CountState, opts: &STVOptions) { // Calculate quota if let None = state.quota { @@ -565,6 +593,7 @@ fn calculate_quota(state: &mut CountState, opts: &STVOptions) { } } +/// Determine if the given candidate meets the quota, according to [STVOptions::quota_criterion] fn meets_quota(quota: &N, count_card: &CountCard, opts: &STVOptions) -> bool { match opts.quota_criterion { QuotaCriterion::GreaterOrEqual => { @@ -576,6 +605,7 @@ fn meets_quota(quota: &N, count_card: &CountCard, opts: &STVOption } } +/// Declare elected all candidates meeting the quota fn elect_meeting_quota(state: &mut CountState, opts: &STVOptions) { let vote_req = state.vote_required_election.as_ref().unwrap(); // Have to do this or else the borrow checker gets confused @@ -613,6 +643,9 @@ fn elect_meeting_quota(state: &mut CountState, opts: &STVOptions) } } +/// Determine whether the transfer of all surpluses can be deferred +/// +/// The value of [STVOptions::defer_surpluses] is not taken into account and must be handled by the caller fn can_defer_surpluses(state: &CountState, opts: &STVOptions, total_surpluses: &N) -> bool where for<'r> &'r N: ops::Sub<&'r N, Output=N> @@ -641,291 +674,24 @@ where return true; } +/// Distribute surpluses according to [STVOptions::surplus] fn distribute_surpluses(state: &mut CountState, opts: &STVOptions) -> Result where for<'r> &'r N: ops::Sub<&'r N, Output=N>, for<'r> &'r N: ops::Div<&'r N, Output=N>, for<'r> &'r N: ops::Neg { - let quota = state.quota.as_ref().unwrap(); - let mut has_surplus: Vec<(&Candidate, &CountCard)> = state.election.candidates.iter() // Present in order in case of tie - .map(|c| (c, state.candidates.get(c).unwrap())) - .filter(|(_, cc)| &cc.votes > quota) - .collect(); - let total_surpluses = has_surplus.iter() - .fold(N::new(), |acc, (_, cc)| acc + &cc.votes - quota); - - if !has_surplus.is_empty() { - // Determine if surplues can be deferred - if opts.defer_surpluses { - if can_defer_surpluses(state, opts, &total_surpluses) { - state.logger.log_literal(format!("Distribution of surpluses totalling {:.dps$} votes will be deferred.", total_surpluses, dps=opts.pp_decimals)); - return Ok(false); - } - } - - match opts.surplus_order { - SurplusOrder::BySize => { - // Compare b with a to sort high-low - has_surplus.sort_by(|a, b| b.1.votes.cmp(&a.1.votes)); - } - SurplusOrder::ByOrder => { - has_surplus.sort_by(|a, b| a.1.order_elected.cmp(&b.1.order_elected)); - } - } - - // Distribute top candidate's surplus - let elected_candidate; - - // Handle ties - if has_surplus.len() > 1 && has_surplus[0].1.votes == has_surplus[1].1.votes { - let max_votes = &has_surplus[0].1.votes; - let has_surplus = has_surplus.into_iter().filter_map(|(c, cc)| if &cc.votes == max_votes { Some(c) } else { None }).collect(); - elected_candidate = choose_highest(state, opts, has_surplus)?; - } else { - elected_candidate = has_surplus[0].0; - } - - distribute_surplus(state, &opts, elected_candidate); - - return Ok(true); - } - return Ok(false); -} - -/// Return the denominator of the transfer value -fn calculate_surplus_denom(surplus: &N, result: &NextPreferencesResult, transferable_votes: &N, weighted: bool, transferable_only: bool) -> Option -where - for<'r> &'r N: ops::Sub<&'r N, Output=N> -{ - if transferable_only { - let total_units = if weighted { &result.total_votes } else { &result.total_ballots }; - let exhausted_units = if weighted { &result.exhausted.num_votes } else { &result.exhausted.num_ballots }; - let transferable_units = total_units - exhausted_units; - - if transferable_votes > surplus { - return Some(transferable_units); - } else { - return None; - } - } else { - if weighted { - return Some(result.total_votes.clone()); - } else { - return Some(result.total_ballots.clone()); - } - } -} - -fn reweight_vote( - num_votes: &N, - num_ballots: &N, - surplus: &N, - weighted: bool, - surplus_fraction: &Option, - surplus_denom: &Option, - round_tvs: Option, - rounding: Option) -> N -{ - let mut result; - - match surplus_denom { - Some(v) => { - if let Some(_) = round_tvs { - // Rounding requested: use the rounded transfer value - if weighted { - result = num_votes.clone() * surplus_fraction.as_ref().unwrap(); - } else { - result = num_ballots.clone() * surplus_fraction.as_ref().unwrap(); - } - } else { - // Avoid unnecessary rounding error by first multiplying by the surplus - if weighted { - result = num_votes.clone() * surplus / v; - } else { - result = num_ballots.clone() * surplus / v; - } - } - } - None => { - result = num_votes.clone(); - } - } - - // Round down if requested - if let Some(dps) = rounding { - result.floor_mut(dps); - } - - return result; -} - -fn sum_surplus_transfers(entry: &NextPreferencesEntry, surplus: &N, is_weighted: bool, surplus_fraction: &Option, surplus_denom: &Option, _state: &mut CountState, opts: &STVOptions) -> N -where - for<'r> &'r N: ops::Div<&'r N, Output=N>, -{ - match opts.sum_surplus_transfers { - SumSurplusTransfersMode::SingleStep => { - // Calculate transfer across all votes - //state.logger.log_literal(format!("Transferring {:.0} ballot papers, totalling {:.dps$} votes.", entry.num_ballots, entry.num_votes, dps=opts.pp_decimals)); - return reweight_vote(&entry.num_votes, &entry.num_ballots, surplus, is_weighted, surplus_fraction, surplus_denom, opts.round_tvs, opts.round_votes); - } - SumSurplusTransfersMode::ByValue => { - // Sum transfers by value - let mut result = N::new(); - - // Sort into parcels by value - let mut votes: Vec<&Vote> = entry.votes.iter().collect(); - votes.sort_unstable_by(|a, b| (&a.value / &a.ballot.orig_value).cmp(&(&b.value / &b.ballot.orig_value))); - for (_value, parcel) in &votes.into_iter().group_by(|v| &v.value / &v.ballot.orig_value) { - let mut num_votes = N::new(); - let mut num_ballots = N::new(); - for vote in parcel { - num_votes += &vote.value; - num_ballots += &vote.ballot.orig_value; - } - //state.logger.log_literal(format!("Transferring {:.0} ballot papers, totalling {:.dps$} votes, received at value {:.dps2$}.", num_ballots, num_votes, value, dps=opts.pp_decimals, dps2=max(opts.pp_decimals, 2))); - result += reweight_vote(&num_votes, &num_ballots, surplus, is_weighted, surplus_fraction, surplus_denom, opts.round_tvs, opts.round_votes); - } - - return result; - } - SumSurplusTransfersMode::PerBallot => { - // Sum transfer per each individual ballot - // TODO: This could be moved to distribute_surplus to avoid looping over the votes and calculating transfer values twice - let mut result = N::new(); - for vote in entry.votes.iter() { - result += reweight_vote(&vote.value, &vote.ballot.orig_value, surplus, is_weighted, surplus_fraction, surplus_denom, opts.round_tvs, opts.round_votes); - } - //state.logger.log_literal(format!("Transferring {:.0} ballot papers, totalling {:.dps$} votes.", entry.num_ballots, entry.num_votes, dps=opts.pp_decimals)); - return result; - } - } -} - -fn distribute_surplus(state: &mut CountState, opts: &STVOptions, elected_candidate: &Candidate) -where - for<'r> &'r N: ops::Sub<&'r N, Output=N>, - for<'r> &'r N: ops::Div<&'r N, Output=N>, - for<'r> &'r N: ops::Neg -{ - state.logger.log_literal(format!("Surplus of {} distributed.", elected_candidate.name)); - - let count_card = state.candidates.get(elected_candidate).unwrap(); - let surplus = &count_card.votes - state.quota.as_ref().unwrap(); - - let votes; match opts.surplus { - SurplusMethod::WIG | SurplusMethod::UIG => { - // Inclusive Gregory - votes = state.candidates.get(elected_candidate).unwrap().parcels.concat(); - } - SurplusMethod::EG => { - // Exclusive Gregory - // Should be safe to unwrap() - or else how did we get a quota! - votes = state.candidates.get_mut(elected_candidate).unwrap().parcels.pop().unwrap(); + SurplusMethod::WIG | SurplusMethod::UIG | SurplusMethod::EG => { + return gregory::distribute_surpluses(state, opts); } SurplusMethod::Meek => { todo!(); } } - - // Count next preferences - let result = next_preferences(state, votes); - - state.kind = Some("Surplus of"); - state.title = String::from(&elected_candidate.name); - - // Transfer candidate votes - // TODO: Refactor?? - let is_weighted = match opts.surplus { - SurplusMethod::WIG => { true } - SurplusMethod::UIG | SurplusMethod::EG => { false } - SurplusMethod::Meek => { todo!() } - }; - - let transferable_votes = &result.total_votes - &result.exhausted.num_votes; - let surplus_denom = calculate_surplus_denom(&surplus, &result, &transferable_votes, is_weighted, opts.transferable_only); - let mut surplus_fraction; - match surplus_denom { - Some(ref v) => { - surplus_fraction = Some(surplus.clone() / v); - - // Round down if requested - if let Some(dps) = opts.round_tvs { - surplus_fraction.as_mut().unwrap().floor_mut(dps); - } - - if opts.transferable_only { - state.logger.log_literal(format!("Transferring {:.0} transferable ballots, totalling {:.dps$} transferable votes, with surplus fraction {:.dps2$}.", &result.total_ballots - &result.exhausted.num_ballots, transferable_votes, surplus_fraction.as_ref().unwrap(), dps=opts.pp_decimals, dps2=max(opts.pp_decimals, 2))); - } else { - state.logger.log_literal(format!("Transferring {:.0} ballots, totalling {:.dps$} votes, with surplus fraction {:.dps2$}.", result.total_ballots, result.total_votes, surplus_fraction.as_ref().unwrap(), dps=opts.pp_decimals, dps2=max(opts.pp_decimals, 2))); - } - } - None => { - surplus_fraction = None; - - if opts.transferable_only { - state.logger.log_literal(format!("Transferring {:.0} transferable ballots, totalling {:.dps$} transferable votes, at values received.", &result.total_ballots - &result.exhausted.num_ballots, transferable_votes, dps=opts.pp_decimals)); - } else { - state.logger.log_literal(format!("Transferring {:.0} ballots, totalling {:.dps$} votes, at values received.", result.total_ballots, result.total_votes, dps=opts.pp_decimals)); - } - } - } - - let mut checksum = N::new(); - - for (candidate, entry) in result.candidates.into_iter() { - // Credit transferred votes - let candidate_transfers = sum_surplus_transfers(&entry, &surplus, is_weighted, &surplus_fraction, &surplus_denom, state, opts); - let count_card = state.candidates.get_mut(candidate).unwrap(); - count_card.transfer(&candidate_transfers); - checksum += candidate_transfers; - - let mut parcel = entry.votes as Parcel; - - // Reweight votes - for vote in parcel.iter_mut() { - vote.value = reweight_vote(&vote.value, &vote.ballot.orig_value, &surplus, is_weighted, &surplus_fraction, &surplus_denom, opts.round_tvs, opts.round_weights); - } - - count_card.parcels.push(parcel); - } - - // Credit exhausted votes - let mut exhausted_transfers; - if opts.transferable_only { - if transferable_votes > surplus { - // No ballots exhaust - exhausted_transfers = N::new(); - } else { - exhausted_transfers = &surplus - &transferable_votes; - - if let Some(dps) = opts.round_votes { - exhausted_transfers.floor_mut(dps); - } - } - } else { - exhausted_transfers = sum_surplus_transfers(&result.exhausted, &surplus, is_weighted, &surplus_fraction, &surplus_denom, state, opts); - } - - state.exhausted.transfer(&exhausted_transfers); - checksum += exhausted_transfers; - - // Transfer exhausted votes - let parcel = result.exhausted.votes as Parcel; - state.exhausted.parcels.push(parcel); - - // Finalise candidate votes - let count_card = state.candidates.get_mut(elected_candidate).unwrap(); - count_card.transfers = -&surplus; - count_card.votes.assign(state.quota.as_ref().unwrap()); - checksum -= surplus; - - // Update loss by fraction - state.loss_fraction.transfer(&-checksum); } +/// Declare all continuing candidates elected, if the number equals the number of remaining vacancies fn bulk_elect(state: &mut CountState, opts: &STVOptions) -> Result { if state.election.candidates.len() - state.num_excluded <= state.election.seats { state.kind = None; @@ -973,6 +739,9 @@ fn bulk_elect(state: &mut CountState, opts: &STVOptions) -> Result return Ok(false); } +/// Determine which continuing candidates could be excluded in a bulk exclusion +/// +/// The value of [STVOptions::bulk_exclude] is not taken into account and must be handled by the caller fn hopefuls_to_bulk_exclude<'a, N: Number>(state: &CountState<'a, N>, _opts: &STVOptions) -> Vec<&'a Candidate> { let mut excluded_candidates = Vec::new(); @@ -1012,6 +781,7 @@ fn hopefuls_to_bulk_exclude<'a, N: Number>(state: &CountState<'a, N>, _opts: &ST return excluded_candidates; } +/// Exclude the lowest-ranked hopeful candidate(s) fn exclude_hopefuls<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> Result where for<'r> &'r N: ops::Div<&'r N, Output=N>, @@ -1058,6 +828,7 @@ where return Ok(true); } +/// Continue the exclusion of a candidate who is being excluded fn continue_exclusion<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> bool where for<'r> &'r N: ops::Div<&'r N, Output=N>, @@ -1093,6 +864,7 @@ where return false; } +/// Perform one stage of a candidate exclusion, according to [STVOptions::exclusion] fn exclude_candidates<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, excluded_candidates: Vec<&'a Candidate>) where for<'r> &'r N: ops::Div<&'r N, Output=N>, @@ -1246,6 +1018,7 @@ where state.loss_fraction.transfer(&-checksum); } +/// Determine if the count is complete because the number of elected candidates equals the number of vacancies fn finished_before_stage(state: &CountState) -> bool { if state.num_elected >= state.election.seats { return true; @@ -1253,6 +1026,9 @@ fn finished_before_stage(state: &CountState) -> bool { return false; } +/// Break a tie between the given candidates according to [STVOptions::ties], selecting the highest candidate +/// +/// The given candidates are assumed to be tied in this round fn choose_highest<'c, N: Number>(state: &mut CountState, opts: &STVOptions, candidates: Vec<&'c Candidate>) -> Result<&'c Candidate, STVError> { for strategy in opts.ties.iter() { match strategy.choose_highest(state, &candidates) { @@ -1271,6 +1047,9 @@ fn choose_highest<'c, N: Number>(state: &mut CountState, opts: &STVOptions, c panic!("Unable to resolve tie"); } +/// Break a tie between the given candidates according to [STVOptions::ties], selecting the lowest candidate +/// +/// The given candidates are assumed to be tied in this round fn choose_lowest<'c, N: Number>(state: &mut CountState, opts: &STVOptions, candidates: Vec<&'c Candidate>) -> Result<&'c Candidate, STVError> { for strategy in opts.ties.iter() { match strategy.choose_lowest(state, &candidates) { @@ -1286,9 +1065,10 @@ fn choose_lowest<'c, N: Number>(state: &mut CountState, opts: &STVOptions, ca } } } - return Err(STVError::UnresolvedTie); + panic!("Unable to resolve tie"); } +/// If required, initialise the state of the forwards or backwards tie-breaking strategies, according to [STVOptions::ties] fn init_tiebreaks(state: &mut CountState, opts: &STVOptions) { if !opts.ties.iter().any(|t| t == &TieStrategy::Forwards) && !opts.ties.iter().any(|t| t == &TieStrategy::Backwards) { return; @@ -1326,6 +1106,7 @@ fn init_tiebreaks(state: &mut CountState, opts: &STVOptions) { } } +/// If required, update the state of the forwards or backwards tie-breaking strategies, according to [STVOptions::ties] fn update_tiebreaks(state: &mut CountState, _opts: &STVOptions) { if let None = state.forwards_tiebreak { if let None = state.backwards_tiebreak { diff --git a/src/stv/wasm.rs b/src/stv/wasm.rs index 57b5801..4ac48da 100644 --- a/src/stv/wasm.rs +++ b/src/stv/wasm.rs @@ -15,6 +15,8 @@ * along with this program. If not, see . */ +#![allow(rustdoc::private_intra_doc_links)] + use crate::election::{CandidateState, CountState, Election}; use crate::numbers::{Fixed, NativeFloat64, Number, Rational}; use crate::stv; @@ -26,6 +28,7 @@ use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; // Init +/// Wrapper for [Fixed::set_dps] #[wasm_bindgen] pub fn fixed_set_dps(dps: usize) { Fixed::set_dps(dps); @@ -37,6 +40,7 @@ macro_rules! impl_type { ($type:ident) => { paste::item! { // Counting + /// Wrapper for [Election::from_blt] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](text: String) -> [] { @@ -47,18 +51,21 @@ macro_rules! impl_type { return [](election); } + /// Wrapper for [Election::normalise_ballots] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](election: &mut []) { election.0.normalise_ballots(); } + /// Wrapper for [stv::count_init] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](state: &mut [], opts: &STVOptions) { stv::count_init(&mut state.0, opts.as_static()); } + /// Wrapper for [stv::count_one_stage] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](state: &mut [], opts: &STVOptions) -> Result { @@ -71,36 +78,42 @@ macro_rules! impl_type { // Reporting + /// Wrapper for [init_results_table] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](election: &[], opts: &STVOptions) -> String { return init_results_table(&election.0, &opts.0); } + /// Wrapper for [describe_count] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](filename: String, election: &[], opts: &STVOptions) -> String { return describe_count(filename, &election.0, &opts.0); } + /// Wrapper for [update_results_table] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](stage_num: usize, state: &[], opts: &STVOptions) -> Array { return update_results_table(stage_num, &state.0, &opts.0); } + /// Wrapper for [update_stage_comments] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](state: &[]) -> String { return update_stage_comments(&state.0); } + /// Wrapper for [finalise_results_table] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](state: &[]) -> Array { return finalise_results_table(&state.0); } + /// Wrapper for [final_result_summary] #[wasm_bindgen] #[allow(non_snake_case)] pub fn [](state: &[]) -> String { @@ -110,6 +123,7 @@ macro_rules! impl_type { // Wrapper structs // Required as we cannot specify &'static in wasm-bindgen: issue #1187 + /// Wrapper for [CountState] #[wasm_bindgen] pub struct [](CountState<'static, $type>); #[wasm_bindgen] @@ -119,6 +133,7 @@ macro_rules! impl_type { } } + /// Wrapper for [Election] #[wasm_bindgen] pub struct [](Election<$type>); #[wasm_bindgen] @@ -140,11 +155,13 @@ impl_type!(Fixed); impl_type!(NativeFloat64); impl_type!(Rational); +/// Wrapper for [stv::STVOptions] #[wasm_bindgen] pub struct STVOptions(stv::STVOptions); #[wasm_bindgen] impl STVOptions { + /// Wrapper for [stv::STVOptions::new] pub fn new( round_tvs: Option, round_weights: Option, @@ -189,6 +206,11 @@ impl STVOptions { } impl STVOptions { + /// Return the underlying [stv::STVOptions] as a `&'static stv::STVOptions` + /// + /// # Safety + /// Assumes that the underlying [stv::STVOptions] is valid for the `'static` lifetime, as it would be if the [stv::STVOptions] were created from Javascript + /// fn as_static(&self) -> &'static stv::STVOptions { unsafe { let ptr = &self.0 as *const stv::STVOptions; @@ -199,18 +221,7 @@ impl STVOptions { // Reporting -fn init_results_table(election: &Election, opts: &stv::STVOptions) -> String { - let mut result = String::from(r#""#); - for candidate in election.candidates.iter() { - result.push_str(&format!(r#"{}"#, candidate.name)); - } - result.push_str(r#"ExhaustedLoss by fractionTotalQuota"#); - if opts.quota_mode == stv::QuotaMode::ERS97 { - result.push_str(r#"Vote required for election"#); - } - return result; -} - +/// Generate the lead-in description of the count in HTML fn describe_count(filename: String, election: &Election, opts: &stv::STVOptions) -> String { let mut result = String::from("

Count computed by OpenTally (revision "); result.push_str(crate::VERSION); @@ -227,6 +238,20 @@ fn describe_count(filename: String, election: &Election, opts: &st return result; } +/// Generate the first column of the HTML results table +fn init_results_table(election: &Election, opts: &stv::STVOptions) -> String { + let mut result = String::from(r#""#); + for candidate in election.candidates.iter() { + result.push_str(&format!(r#"{}"#, candidate.name)); + } + result.push_str(r#"ExhaustedLoss by fractionTotalQuota"#); + if opts.quota_mode == stv::QuotaMode::ERS97 { + result.push_str(r#"Vote required for election"#); + } + return result; +} + +/// Generate subsequent columns of the HTML results table fn update_results_table(stage_num: usize, state: &CountState, opts: &stv::STVOptions) -> Array { let result = Array::new(); result.push(&format!(r#"{}"#, stage_num).into()); @@ -271,10 +296,12 @@ fn update_results_table(stage_num: usize, state: &CountState, opts return result; } +/// Get the comment for the current stage fn update_stage_comments(state: &CountState) -> String { return state.logger.render().join(" "); } +/// Generate the final column of the HTML results table fn finalise_results_table(state: &CountState) -> Array { let result = Array::new(); @@ -301,6 +328,7 @@ fn finalise_results_table(state: &CountState) -> Array { return result; } +/// Generate the final lead-out text summarising the result of the election fn final_result_summary(state: &CountState) -> String { let mut result = String::from("

Count complete. The winning candidates are, in order of election:

    "); @@ -320,6 +348,7 @@ fn final_result_summary(state: &CountState) -> String { return result; } +/// HTML pretty-print the number to the specified decimal places fn pp(n: &N, dps: usize) -> String { if n.is_zero() { return "".to_string(); diff --git a/src/ties.rs b/src/ties.rs index 18ae25f..4d09d83 100644 --- a/src/ties.rs +++ b/src/ties.rs @@ -26,6 +26,7 @@ use wasm_bindgen::prelude::wasm_bindgen; #[allow(unused_imports)] use std::io::{stdin, stdout, Write}; +/// Strategy for breaking ties #[derive(PartialEq)] pub enum TieStrategy { Forwards, @@ -35,6 +36,7 @@ pub enum TieStrategy { } impl TieStrategy { + /// Convert to CLI argument representation pub fn describe(&self) -> String { match self { Self::Forwards => "forwards", @@ -44,6 +46,9 @@ impl TieStrategy { }.to_string() } + /// Break a tie between the given candidates, selecting the highest candidate + /// + /// The given candidates are assumed to be tied in this round pub fn choose_highest<'c, N: Number>(&self, state: &mut CountState, candidates: &Vec<&'c Candidate>) -> Result<&'c Candidate, STVError> { match self { Self::Forwards => { @@ -89,6 +94,9 @@ impl TieStrategy { } } + /// Break a tie between the given candidates, selecting the lowest candidate + /// + /// The given candidates are assumed to be tied in this round pub fn choose_lowest<'c, N: Number>(&self, state: &mut CountState, candidates: &Vec<&'c Candidate>) -> Result<&'c Candidate, STVError> { match self { Self::Forwards => { @@ -127,6 +135,7 @@ impl TieStrategy { } } +/// Prompt the candidate for input, depending on CLI or WebAssembly target #[cfg(not(target_arch = "wasm32"))] fn prompt<'c>(candidates: &Vec<&'c Candidate>) -> Result<&'c Candidate, STVError> { println!("Multiple tied candidates:"); diff --git a/tests/ers97.rs b/tests/ers97.rs index 6b6f97c..9a93396 100644 --- a/tests/ers97.rs +++ b/tests/ers97.rs @@ -94,7 +94,7 @@ fn ers97_rational() { let mut candidate_votes: Vec> = records.iter().skip(2) .map(|r| if r[idx*2 + 1].len() > 0 { - Some(opentally::numbers::From::from(r[idx*2 + 1].parse::().expect("Syntax Error"))) + Some(Rational::from(r[idx*2 + 1].parse::().expect("Syntax Error"))) } else { None }) diff --git a/tests/scotland.rs b/tests/scotland.rs index 3e54b04..17c5b88 100644 --- a/tests/scotland.rs +++ b/tests/scotland.rs @@ -140,5 +140,5 @@ fn get_cand_stage(candidate: &Element, idx: usize) -> &Element { fn parse_str(s: String) -> Fixed { if s == "-" { return Fixed::zero(); } let f: f64 = s.parse().expect("Syntax Error"); - return opentally::numbers::From::from(f); + return Fixed::from(f); }