/* 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 sample methods of surplus distributions
pub mod sample;
/// 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 derive_builder::Builder;
use derive_more::Constructor;
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
#[derive(Builder, Constructor)]
pub struct STVOptions {
/// Round surplus fractions to specified decimal places
#[builder(default="None")]
pub round_surplus_fractions: Option,
/// Round ballot values to specified decimal places
#[builder(default="None")]
pub round_values: Option,
/// Round votes to specified decimal places
#[builder(default="None")]
pub round_votes: Option,
/// Round quota to specified decimal places
#[builder(default="None")]
pub round_quota: Option,
/// How to calculate votes to credit to candidates in surplus transfers
#[builder(default="SumSurplusTransfersMode::SingleStep")]
pub sum_surplus_transfers: SumSurplusTransfersMode,
/// (Meek STV) Limit for stopping iteration of surplus distribution
#[builder(default)]
pub meek_surplus_tolerance: String,
/// Convert ballots with value >1 to multiple ballots of value 1 (used only for [STVOptions::describe])
#[builder(default="false")]
pub normalise_ballots: bool,
/// Quota type
#[builder(default="QuotaType::Droop")]
pub quota: QuotaType,
/// Whether to elect candidates on meeting (geq) or strictly exceeding (gt) the quota
#[builder(default="QuotaCriterion::Greater")]
pub quota_criterion: QuotaCriterion,
/// Whether to apply a form of progressive quota
#[builder(default="QuotaMode::Static")]
pub quota_mode: QuotaMode,
/// Tie-breaking method
#[builder(default)]
pub ties: Vec,
/// Method of surplus distributions
#[builder(default="SurplusMethod::WIG")]
pub surplus: SurplusMethod,
/// (Gregory STV) Order to distribute surpluses
#[builder(default="SurplusOrder::BySize")]
pub surplus_order: SurplusOrder,
/// (Gregory STV) Examine only transferable papers during surplus distributions
#[builder(default="false")]
pub transferable_only: bool,
/// (Gregory STV) Method of exclusions
#[builder(default="ExclusionMethod::SingleStage")]
pub exclusion: ExclusionMethod,
/// (Meek STV) NZ Meek STV behaviour: Iterate keep values one round before candidate exclusion
#[builder(default="false")]
pub meek_nz_exclusion: bool,
/// (Cincinnati/Hare) Method of drawing a sample
#[builder(default="SampleMethod::Stratified")]
pub sample: SampleMethod,
/// (Cincinnati/Hare) Sample-based methods: Check for candidate election after each individual ballot paper transfer
#[builder(default="false")]
pub sample_per_ballot: bool,
/// Bulk elect as soon as continuing candidates fill all remaining vacancies
#[builder(default="true")]
pub early_bulk_elect: bool,
/// Use bulk exclusion
#[builder(default="false")]
pub bulk_exclude: bool,
/// Defer surplus distributions if possible
#[builder(default="false")]
pub defer_surpluses: bool,
/// Elect candidates on meeting the quota, rather than on surpluses being distributed; (Meek STV) Immediately elect candidates even if keep values have not converged
#[builder(default="true")]
pub immediate_elect: bool,
/// On exclusion, exclude any candidate with this many votes or fewer
#[builder(default="\"0\".to_string()")]
pub min_threshold: String,
/// Path to constraints file (used only for [STVOptions::describe])
#[builder(default="None")]
pub constraints_path: Option,
/// Mode of handling constraints
#[builder(default="ConstraintMode::GuardDoom")]
pub constraint_mode: ConstraintMode,
/// Hide excluded candidates from results report
#[builder(default="false")]
pub hide_excluded: bool,
/// Sort candidates by votes in results report
#[builder(default="false")]
pub sort_votes: bool,
/// Print votes to specified decimal places in results report
#[builder(default="2")]
pub pp_decimals: usize,
}
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()) };
if self.surplus != SurplusMethod::Cincinnati && self.surplus != SurplusMethod::Hare {
if let Some(dps) = self.round_surplus_fractions { flags.push(format!("--round-surplus-fractions {}", dps)); }
if let Some(dps) = self.round_values { flags.push(format!("--round-values {}", 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::Droop { 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::WIG || self.surplus == SurplusMethod::UIG || self.surplus == SurplusMethod::EG {
if self.surplus_order != SurplusOrder::BySize { flags.push(self.surplus_order.describe()); }
if self.transferable_only { flags.push("--transferable-only".to_string()); }
if 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.surplus == SurplusMethod::Cincinnati || self.surplus == SurplusMethod::Hare) && self.sample != SampleMethod::Stratified { flags.push(self.sample.describe()); }
if (self.surplus == SurplusMethod::Cincinnati || self.surplus == SurplusMethod::Hare) && self.sample_per_ballot { flags.push("--sample-per-ballot".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.immediate_elect { flags.push("--no-immediate-elect".to_string()); }
if self.min_threshold != "0" { 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.quota_mode != QuotaMode::DynamicByTotal { return Err(STVError::InvalidOptions("--surplus meek requires --quota-mode dynamic_by_total")); }
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.round_quota != Some(0) { return Err(STVError::InvalidOptions("--surplus cincinnati and --surplus hare require --round-quota 0")); }
if !self.normalise_ballots { return Err(STVError::InvalidOptions("--surplus cincinnati and --surplus hare require --normalise-ballots")); }
if self.sample == SampleMethod::Stratified && self.sample_per_ballot { return Err(STVError::InvalidOptions("--sample stratified is incompatible with --sample-per-ballot")); }
if self.sample_per_ballot && !self.immediate_elect { return Err(STVError::InvalidOptions("--sample-per-ballot is incompatible with --no-immediate-elect")); }
}
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()
}
}
impl> From for SumSurplusTransfersMode {
fn from(s: S) -> Self {
match s.as_ref() {
"single_step" => SumSurplusTransfersMode::SingleStep,
"by_value" => SumSurplusTransfersMode::ByValue,
"per_ballot" => SumSurplusTransfersMode::PerBallot,
_ => panic!("Invalid --sum-transfers"),
}
}
}
/// 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()
}
}
impl> From for QuotaType {
fn from(s: S) -> Self {
match s.as_ref() {
"droop" => QuotaType::Droop,
"hare" => QuotaType::Hare,
"droop_exact" => QuotaType::DroopExact,
"hare_exact" => QuotaType::HareExact,
_ => panic!("Invalid --quota"),
}
}
}
/// 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()
}
}
impl> From for QuotaCriterion {
fn from(s: S) -> Self {
match s.as_ref() {
"geq" => QuotaCriterion::GreaterOrEqual,
"gt" => QuotaCriterion::Greater,
_ => panic!("Invalid --quota-criterion"),
}
}
}
/// 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,
/// Dynamic quota by total vote
DynamicByTotal,
/// Dynamic quota by active vote
DynamicByActive,
}
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",
QuotaMode::DynamicByTotal => "--quota-mode dynamic_by_total",
QuotaMode::DynamicByActive => "--quota-mode dynamic_by_active",
}.to_string()
}
}
impl> From for QuotaMode {
fn from(s: S) -> Self {
match s.as_ref() {
"static" => QuotaMode::Static,
"ers97" => QuotaMode::ERS97,
"ers76" => QuotaMode::ERS76,
"dynamic_by_total" => QuotaMode::DynamicByTotal,
"dynamic_by_active" => QuotaMode::DynamicByActive,
_ => panic!("Invalid --quota-mode"),
}
}
}
/// 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()
}
}
impl> From for SurplusMethod {
fn from(s: S) -> Self {
match s.as_ref() {
"wig" => SurplusMethod::WIG,
"uig" => SurplusMethod::UIG,
"eg" => SurplusMethod::EG,
"meek" => SurplusMethod::Meek,
"cincinnati" => SurplusMethod::Cincinnati,
"hare" => SurplusMethod::Hare,
_ => panic!("Invalid --surplus"),
}
}
}
/// 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()
}
}
impl> From for SurplusOrder {
fn from(s: S) -> Self {
match s.as_ref() {
"by_size" => SurplusOrder::BySize,
"by_order" => SurplusOrder::ByOrder,
_ => panic!("Invalid --surplus-order"),
}
}
}
/// 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()
}
}
impl> From for ExclusionMethod {
fn from(s: S) -> Self {
match s.as_ref() {
"single_stage" => ExclusionMethod::SingleStage,
"by_value" => ExclusionMethod::ByValue,
"by_source" => ExclusionMethod::BySource,
"parcels_by_order" => ExclusionMethod::ParcelsByOrder,
"wright" => ExclusionMethod::Wright,
_ => panic!("Invalid --exclusion"),
}
}
}
/// Enum of options for [STVOptions::sample]
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
pub enum SampleMethod {
/// Stratify the ballots into parcels according to next available preference and transfer the last ballots from each parcel
Stratified,
/// Transfer the last ballots
ByOrder,
/// Transfer every n-th ballot, Cincinnati style
NthBallot,
}
impl SampleMethod {
/// Convert to CLI argument representation
fn describe(self) -> String {
match self {
SampleMethod::Stratified => "--sample stratified",
SampleMethod::ByOrder => "--sample by_order",
SampleMethod::NthBallot => "--sample nth_ballot",
}.to_string()
}
}
impl> From for SampleMethod {
fn from(s: S) -> Self {
match s.as_ref() {
"stratified" => SampleMethod::Stratified,
"by_order" => SampleMethod::ByOrder,
"nth_ballot" => SampleMethod::NthBallot,
_ => panic!("Invalid --sample-method"),
}
}
}
/// 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()
}
}
impl> From for ConstraintMode {
fn from(s: S) -> Self {
match s.as_ref() {
"guard_doom" => ConstraintMode::GuardDoom,
"rollback" => ConstraintMode::Rollback,
_ => panic!("Invalid --constraint-mode"),
}
}
}
/// 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<(), STVError>
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, true)?;
init_tiebreaks(state, opts);
return Ok(());
}
/// Perform a single stage of the STV count
///
/// Returns `true` if the count is complete, otherwise `false`.
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