/* 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 .
*/
#![allow(mutable_borrow_reservation_conflict)]
/// Gregory methods of surplus distributions
pub mod gregory;
/// Meek method of surplus distributions, etc.
pub mod meek;
/// Random subset methods of surplus distributions
pub mod subset;
/// WebAssembly wrappers
//#[cfg(target_arch = "wasm32")]
pub mod wasm;
use crate::constraints;
use crate::numbers::Number;
use crate::election::{Candidate, CandidateState, CountCard, CountState, Vote};
use crate::sharandom::SHARandom;
use crate::ties::{self, TieStrategy};
use itertools::Itertools;
use wasm_bindgen::prelude::wasm_bindgen;
use std::collections::HashMap;
use std::fmt;
use std::ops;
/// Options for conducting an STV count
pub struct STVOptions {
/// Round surplus fractions to specified decimal places
pub round_surplus_fractions: Option,
/// Round ballot values to specified decimal places
pub round_values: Option,
/// Round votes to specified decimal places
pub round_votes: Option,
/// Round quota to specified decimal places
pub round_quota: Option,
/// How to calculate votes to credit to candidates in surplus transfers
pub sum_surplus_transfers: SumSurplusTransfersMode,
/// (Meek STV) Limit for stopping iteration of surplus distribution
pub meek_surplus_tolerance: String,
/// Convert ballots with value >1 to multiple ballots of value 1 (used only for [STVOptions::describe])
pub normalise_ballots: bool,
/// Quota type
pub quota: QuotaType,
/// Whether to elect candidates on meeting (geq) or strictly exceeding (gt) the quota
pub quota_criterion: QuotaCriterion,
/// Whether to apply a form of progressive quota
pub quota_mode: QuotaMode,
/// Tie-breaking method
pub ties: Vec,
/// Method of surplus distributions
pub surplus: SurplusMethod,
/// Order to distribute surpluses
pub surplus_order: SurplusOrder,
/// Examine only transferable papers during surplus distributions
pub transferable_only: bool,
/// Method of exclusions
pub exclusion: ExclusionMethod,
/// (Meek STV) NZ Meek STV behaviour: Iterate keep values one round before candidate exclusion
pub meek_nz_exclusion: bool,
/// Bulk elect as soon as continuing candidates fill all remaining vacancies
pub early_bulk_elect: bool,
/// Use bulk exclusion
pub bulk_exclude: bool,
/// Defer surplus distributions if possible
pub defer_surpluses: bool,
/// (Meek STV) Immediately elect candidates even if keep values have not converged
pub meek_immediate_elect: bool,
/// On exclusion, exclude any candidate with this many votes or fewer
pub min_threshold: String,
/// Path to constraints file (used only for [STVOptions::describe])
pub constraints_path: Option,
/// Mode of handling constraints
pub constraint_mode: ConstraintMode,
/// Hide excluded candidates from results report
pub hide_excluded: bool,
/// Sort candidates by votes in results report
pub sort_votes: bool,
/// Print votes to specified decimal places in results report
pub pp_decimals: usize,
}
impl STVOptions {
/// Returns a new [STVOptions] based on arguments given as strings
pub fn new(
round_surplus_fractions: Option,
round_values: Option,
round_votes: Option,
round_quota: Option,
sum_surplus_transfers: &str,
meek_surplus_tolerance: &str,
normalise_ballots: bool,
quota: &str,
quota_criterion: &str,
quota_mode: &str,
ties: &Vec,
random_seed: &Option,
surplus: &str,
surplus_order: &str,
transferable_only: bool,
exclusion: &str,
meek_nz_exclusion: bool,
early_bulk_elect: bool,
bulk_exclude: bool,
defer_surpluses: bool,
meek_immediate_elect: bool,
min_threshold: String,
constraints_path: Option<&str>,
constraint_mode: &str,
hide_excluded: bool,
sort_votes: bool,
pp_decimals: usize,
) -> Self {
return STVOptions {
round_surplus_fractions,
round_values,
round_votes,
round_quota,
sum_surplus_transfers: match sum_surplus_transfers {
"single_step" => SumSurplusTransfersMode::SingleStep,
"by_value" => SumSurplusTransfersMode::ByValue,
"per_ballot" => SumSurplusTransfersMode::PerBallot,
_ => panic!("Invalid --sum-transfers"),
},
meek_surplus_tolerance: meek_surplus_tolerance.to_string(),
normalise_ballots,
quota: match quota {
"droop" => QuotaType::Droop,
"hare" => QuotaType::Hare,
"droop_exact" => QuotaType::DroopExact,
"hare_exact" => QuotaType::HareExact,
_ => panic!("Invalid --quota"),
},
quota_criterion: match quota_criterion {
"geq" => QuotaCriterion::GreaterOrEqual,
"gt" => QuotaCriterion::Greater,
_ => panic!("Invalid --quota-criterion"),
},
quota_mode: match quota_mode {
"static" => QuotaMode::Static,
"ers97" => QuotaMode::ERS97,
"ers76" => QuotaMode::ERS76,
_ => panic!("Invalid --quota-mode"),
},
ties: ties.into_iter().map(|t| match t.as_str() {
"forwards" => TieStrategy::Forwards,
"backwards" => TieStrategy::Backwards,
"random" => TieStrategy::Random(random_seed.as_ref().expect("Must provide a --random-seed if using --ties random").clone()),
"prompt" => TieStrategy::Prompt,
_ => panic!("Invalid --ties"),
}).collect(),
surplus: match surplus {
"wig" => SurplusMethod::WIG,
"uig" => SurplusMethod::UIG,
"eg" => SurplusMethod::EG,
"meek" => SurplusMethod::Meek,
"cincinnati" => SurplusMethod::Cincinnati,
"hare" => SurplusMethod::Hare,
_ => panic!("Invalid --surplus"),
},
surplus_order: match surplus_order {
"by_size" => SurplusOrder::BySize,
"by_order" => SurplusOrder::ByOrder,
_ => panic!("Invalid --surplus-order"),
},
transferable_only,
exclusion: match exclusion {
"single_stage" => ExclusionMethod::SingleStage,
"by_value" => ExclusionMethod::ByValue,
"by_source" => ExclusionMethod::BySource,
"parcels_by_order" => ExclusionMethod::ParcelsByOrder,
"wright" => ExclusionMethod::Wright,
_ => panic!("Invalid --exclusion"),
},
meek_nz_exclusion,
early_bulk_elect,
bulk_exclude,
defer_surpluses,
meek_immediate_elect,
min_threshold,
constraints_path: match constraints_path {
Some(p) => Some(p.to_string()),
None => None,
},
constraint_mode: match constraint_mode {
"guard_doom" => ConstraintMode::GuardDoom,
"rollback" => ConstraintMode::Rollback,
_ => panic!("Invalid --constraint-mode"),
},
hide_excluded,
sort_votes,
pp_decimals,
};
}
/// 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()) };
if self.surplus != SurplusMethod::Cincinnati && self.surplus != SurplusMethod::Hare {
if let Some(dps) = self.round_surplus_fractions { flags.push(format!("--round-tvs {}", dps)); }
if let Some(dps) = self.round_values { flags.push(format!("--round-weights {}", dps)); }
if let Some(dps) = self.round_votes { flags.push(format!("--round-votes {}", dps)); }
}
if let Some(dps) = self.round_quota { flags.push(format!("--round-quota {}", dps)); }
if self.surplus != SurplusMethod::Meek && self.sum_surplus_transfers != SumSurplusTransfersMode::SingleStep { flags.push(self.sum_surplus_transfers.describe()); }
if self.surplus == SurplusMethod::Meek && self.meek_surplus_tolerance != "0.001%" { flags.push(format!("--meek-surplus-tolerance {}", self.meek_surplus_tolerance)); }
if self.normalise_ballots { flags.push("--normalise-ballots".to_string()); }
if self.quota != QuotaType::DroopExact { flags.push(self.quota.describe()); }
if self.quota_criterion != QuotaCriterion::Greater { flags.push(self.quota_criterion.describe()); }
if self.surplus != SurplusMethod::Meek && self.quota_mode != QuotaMode::Static { flags.push(self.quota_mode.describe()); }
let ties_str = self.ties.iter().map(|t| t.describe()).join(" ");
if ties_str != "prompt" { flags.push(format!("--ties {}", ties_str)); }
for t in self.ties.iter() { if let TieStrategy::Random(seed) = t { flags.push(format!("--random-seed {}", seed)); } }
if self.surplus != SurplusMethod::WIG { flags.push(self.surplus.describe()); }
if self.surplus != SurplusMethod::Meek && self.surplus_order != SurplusOrder::BySize { flags.push(self.surplus_order.describe()); }
if self.surplus != SurplusMethod::Meek && self.transferable_only { flags.push("--transferable-only".to_string()); }
if self.surplus != SurplusMethod::Meek && self.exclusion != ExclusionMethod::SingleStage { flags.push(self.exclusion.describe()); }
if self.surplus == SurplusMethod::Meek && self.meek_nz_exclusion { flags.push("--meek-nz-exclusion".to_string()); }
if !self.early_bulk_elect { flags.push("--no-early-bulk-elect".to_string()); }
if self.bulk_exclude { flags.push("--bulk-exclude".to_string()); }
if self.defer_surpluses { flags.push("--defer-surpluses".to_string()); }
if self.surplus == SurplusMethod::Meek && self.meek_immediate_elect { flags.push("--meek-immediate-elect".to_string()); }
if self.min_threshold != "1" { flags.push(format!("--min-threshold {}", self.min_threshold)); }
if let Some(path) = &self.constraints_path {
flags.push(format!("--constraints {}", path));
if self.constraint_mode != ConstraintMode::GuardDoom { flags.push(self.constraint_mode.describe()); }
}
if self.hide_excluded { flags.push(format!("--hide-excluded")); }
if self.sort_votes { flags.push(format!("--sort-votes")); }
if self.pp_decimals != 2 { flags.push(format!("--pp-decimals {}", self.pp_decimals)); }
return flags.join(" ");
}
/// Validate the combination of [STVOptions] and panic if invalid
pub fn validate(&self) -> Result<(), STVError> {
if self.surplus == SurplusMethod::Meek {
if self.transferable_only { return Err(STVError::InvalidOptions("--surplus meek is incompatible with --transferable-only")); }
if self.exclusion != ExclusionMethod::SingleStage { return Err(STVError::InvalidOptions("--surplus meek requires --exclusion single_stage")); }
}
if self.surplus == SurplusMethod::Cincinnati || self.surplus == SurplusMethod::Hare {
if self.exclusion != ExclusionMethod::SingleStage { return Err(STVError::InvalidOptions("--surplus cincinnati and --surplus hare require --exclusion single_stage")); }
if self.quota_criterion != QuotaCriterion::GreaterOrEqual { return Err(STVError::InvalidOptions("--surplus cincinnati and --surplus hare require --quota-criterion geq")); }
if !self.normalise_ballots { return Err(STVError::InvalidOptions("--surplus cincinnati and --surplus hare require --normalise-ballots")); }
}
if self.min_threshold != "0" && self.defer_surpluses { return Err(STVError::InvalidOptions("--min-threshold is incompatible with --defer-surpluses")); } // TODO: Permit this
return Ok(());
}
}
/// Enum of options for [STVOptions::sum_surplus_transfers]
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
pub enum SumSurplusTransfersMode {
/// Sum and round all surplus transfers for a candidate in a single step
SingleStep,
/// Sum and round a candidate's surplus transfers separately for ballot papers received at each particular value
ByValue,
/// Sum and round a candidate's surplus transfers individually for each ballot paper
PerBallot,
}
impl SumSurplusTransfersMode {
/// Convert to CLI argument representation
fn describe(self) -> String {
match self {
SumSurplusTransfersMode::SingleStep => "--sum-surplus-transfers single_step",
SumSurplusTransfersMode::ByValue => "--sum-surplus-transfers by_value",
SumSurplusTransfersMode::PerBallot => "--sum-surplus-transfers per_ballot",
}.to_string()
}
}
/// Enum of options for [STVOptions::quota]
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
pub enum QuotaType {
/// Droop quota
Droop,
/// Hare quota
Hare,
/// Exact Droop quota (Newland–Britton/Hagenbach-Bischoff quota)
DroopExact,
/// Exact Hare quota
HareExact,
}
impl QuotaType {
/// Convert to CLI argument representation
fn describe(self) -> String {
match self {
QuotaType::Droop => "--quota droop",
QuotaType::Hare => "--quota hare",
QuotaType::DroopExact => "--quota droop_exact",
QuotaType::HareExact => "--quota hare_exact",
}.to_string()
}
}
/// Enum of options for [STVOptions::quota_criterion]
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
pub enum QuotaCriterion {
/// Elect candidates on equalling or exceeding the quota
GreaterOrEqual,
/// Elect candidates on strictly exceeding the quota
Greater,
}
impl QuotaCriterion {
/// Convert to CLI argument representation
fn describe(self) -> String {
match self {
QuotaCriterion::GreaterOrEqual => "--quota-criterion geq",
QuotaCriterion::Greater => "--quota-criterion gt",
}.to_string()
}
}
/// Enum of options for [STVOptions::quota_mode]
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
pub enum QuotaMode {
/// Static quota
Static,
/// Static quota with ERS97 rules
ERS97,
/// Static quota with ERS76 rules
ERS76,
}
impl QuotaMode {
/// Convert to CLI argument representation
fn describe(self) -> String {
match self {
QuotaMode::Static => "--quota-mode static",
QuotaMode::ERS97 => "--quota-mode ers97",
QuotaMode::ERS76 => "--quota-mode ers76",
}.to_string()
}
}
/// Enum of options for [STVOptions::surplus]
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
pub enum SurplusMethod {
/// Weighted inclusive Gregory method
WIG,
/// Unweighted inclusive Gregory method
UIG,
/// Exclusive Gregory method (last bundle)
EG,
/// Meek method
Meek,
/// Cincinnati method (inclusive random subset)
Cincinnati,
/// Hare method (exclusive random subset)
Hare,
}
impl SurplusMethod {
/// Convert to CLI argument representation
fn describe(self) -> String {
match self {
SurplusMethod::WIG => "--surplus wig",
SurplusMethod::UIG => "--surplus uig",
SurplusMethod::EG => "--surplus eg",
SurplusMethod::Meek => "--surplus meek",
SurplusMethod::Cincinnati => "--surplus cincinnati",
SurplusMethod::Hare => "--surplus hare",
}.to_string()
}
}
/// Enum of options for [STVOptions::surplus_order]
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
pub enum SurplusOrder {
/// Transfer the largest surplus first, even if it arose at a later stage of the count
BySize,
/// Transfer the surplus of the candidate elected first, even if it is smaller than another
ByOrder,
}
impl SurplusOrder {
/// Convert to CLI argument representation
fn describe(self) -> String {
match self {
SurplusOrder::BySize => "--surplus-order by_size",
SurplusOrder::ByOrder => "--surplus-order by_order",
}.to_string()
}
}
/// Enum of options for [STVOptions::exclusion]
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
pub enum ExclusionMethod {
/// Transfer all ballot papers of an excluded candidate in one stage
SingleStage,
/// Transfer the ballot papers of an excluded candidate in descending order of accumulated transfer value
ByValue,
/// Transfer the ballot papers of an excluded candidate according to the candidate who transferred the papers to the excluded candidate, in the order the transferring candidates were elected or excluded
BySource,
/// Transfer the ballot papers of an excluded candidate parcel by parcel in the order received
ParcelsByOrder,
/// Wright method (re-iterate)
Wright,
}
impl ExclusionMethod {
/// Convert to CLI argument representation
fn describe(self) -> String {
match self {
ExclusionMethod::SingleStage => "--exclusion single_stage",
ExclusionMethod::ByValue => "--exclusion by_value",
ExclusionMethod::BySource => "--exclusion by_source",
ExclusionMethod::ParcelsByOrder => "--exclusion parcels_by_order",
ExclusionMethod::Wright => "--exclusion wright",
}.to_string()
}
}
/// Enum of options for [STVOptions::constraint_mode]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
pub enum ConstraintMode {
/// Guard or doom candidates as soon as required to secure a conformant result
GuardDoom,
/// TODO: NYI
Rollback,
}
impl ConstraintMode {
/// Convert to CLI argument representation
fn describe(self) -> String {
match self {
ConstraintMode::GuardDoom => "--constraint-mode guard_doom",
ConstraintMode::Rollback => "--constraint-mode rollback",
}.to_string()
}
}
/// An error during the STV count
#[derive(Debug)]
pub enum STVError {
/// Options for the count are invalid
InvalidOptions(&'static str),
/// Tie could not be resolved
UnresolvedTie,
}
impl STVError {
/// Describe the error
pub fn describe(&self) -> &'static str {
match self {
STVError::InvalidOptions(s) => s,
STVError::UnresolvedTie => "Unable to resolve tie",
}
}
}
impl fmt::Display for STVError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.describe())?;
return Ok(());
}
}
/// Distribute first preferences, and initialise other states such as the random number generator and tie-breaking rules
pub fn count_init<'a, N: Number>(state: &mut CountState<'a, N>, opts: &'a STVOptions) -> Result
where
for<'r> &'r N: ops::Sub<&'r N, Output=N>,
for<'r> &'r N: ops::Mul<&'r N, Output=N>,
{
// Initialise RNG
for t in opts.ties.iter() {
if let TieStrategy::Random(seed) = t {
state.random = Some(SHARandom::new(seed));
}
}
constraints::update_constraints(state, opts);
distribute_first_preferences(state, opts);
calculate_quota(state, opts);
elect_hopefuls(state, opts)?;
init_tiebreaks(state, opts);
return Ok(true);
}
/// Perform a single stage of the STV count
pub fn count_one_stage<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> Result
where
for<'r> &'r N: ops::Sub<&'r N, Output=N>,
for<'r> &'r N: ops::Mul<&'r N, Output=N>,
for<'r> &'r N: ops::Div<&'r N, Output=N>,
for<'r> &'r N: ops::Neg