/* OpenTally: Open-source election vote counting * Copyright © 2021–2022 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::STVError; use crate::numbers::Number; use crate::ties::TieStrategy; use derive_builder::Builder; use derive_more::Constructor; use itertools::Itertools; #[allow(unused_imports)] use wasm_bindgen::prelude::wasm_bindgen; /// 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 round votes in transfer table #[builder(default="RoundSubtransfersMode::SingleStep")] pub round_subtransfers: RoundSubtransfersMode, /// (Meek STV) Limit for stopping iteration of surplus distribution #[builder(default=r#"String::from("0.001%")"#)] pub meek_surplus_tolerance: String, /// 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="vec![TieStrategy::Prompt]")] 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) If --transferable-only, calculate value of transferable papers by subtracting value of non-transferable papers #[builder(default="false")] pub subtract_nontransferable: 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, /// (Hare) Method of drawing a sample #[builder(default="SampleMethod::StratifyLR")] pub sample: SampleMethod, /// (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, /// (CLI) Hide excluded candidates from results report #[builder(default="false")] pub hide_excluded: bool, /// (CLI) Sort candidates by votes in results report #[builder(default="false")] pub sort_votes: bool, /// (CLI) Show details of transfers to candidates during surplus distributions/candidate exclusions #[builder(default="false")] pub transfers_detail: 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::IHare && 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.round_subtransfers != RoundSubtransfersMode::SingleStep { flags.push(self.round_subtransfers.describe()); } if self.surplus == SurplusMethod::Meek && self.meek_surplus_tolerance != "0.001%" { flags.push(format!("--meek-surplus-tolerance {}", self.meek_surplus_tolerance)); } if self.quota != QuotaType::Droop { flags.push(self.quota.describe()); } if self.quota_criterion != QuotaCriterion::Greater { flags.push(self.quota_criterion.describe()); } if 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 { if self.surplus_order != SurplusOrder::BySize { flags.push(self.surplus_order.describe()); } if self.transferable_only { flags.push("--transferable-only".to_string()); } if self.subtract_nontransferable { flags.push("--subtract-nontransferable".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::IHare || self.surplus == SurplusMethod::Hare) && self.sample != SampleMethod::StratifyLR { flags.push(self.sample.describe()); } if (self.surplus == SurplusMethod::IHare || 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("--hide-excluded".to_string()); } if self.sort_votes { flags.push("--sort-votes".to_string()); } if self.transfers_detail { flags.push("--transfers-detail".to_string()); } if self.pp_decimals != 2 { flags.push(format!("--pp-decimals {}", self.pp_decimals)); } return flags.join(" "); } /// Validate the combination of [STVOptions] and error if invalid pub fn validate(&self) -> Result<(), STVError> { if self.surplus == SurplusMethod::Meek { if self.quota_mode == QuotaMode::ERS97 { // Invalid because keep values cannot be calculated for a candidate elected with less than a surplus return Err(STVError::InvalidOptions("--surplus meek is incompatible with --quota-mode ers97")); } if self.quota_mode == QuotaMode::ERS76 { // Invalid because keep values cannot be calculated for a candidate elected with less than a surplus return Err(STVError::InvalidOptions("--surplus meek is incompatible with --quota-mode ers76")); } if self.quota_mode == QuotaMode::DynamicByActive { // Invalid because all votes are "active" in Meek STV return Err(STVError::InvalidOptions("--surplus meek is incompatible with --quota-mode dynamic_by_active")); } if self.transferable_only { // Invalid because this would imply a different keep value applies to nontransferable ballots (?) // TODO: NYI? return Err(STVError::InvalidOptions("--surplus meek is incompatible with --transferable-only")); } if self.exclusion != ExclusionMethod::SingleStage { // Invalid because Meek STV is independent of order of exclusion, so segmented exclusion has no impact return Err(STVError::InvalidOptions("--surplus meek requires --exclusion single_stage")); } if self.constraints_path.is_some() && self.constraint_mode == ConstraintMode::RepeatCount { // TODO: NYI? return Err(STVError::InvalidOptions("--constraint-mode repeat_count requires a Gregory method for --surplus")); } } if self.surplus == SurplusMethod::IHare || self.surplus == SurplusMethod::Hare { if self.round_quota != Some(0) { // Invalid because votes are counted only in whole numbers return Err(STVError::InvalidOptions("--surplus ihare and --surplus hare require --round-quota 0")); } if self.sample == SampleMethod::StratifyLR && self.sample_per_ballot { // Invalid because a stratification cannot be made until all relevant ballots are transferred return Err(STVError::InvalidOptions("--sample stratify is incompatible with --sample-per-ballot")); } if self.sample_per_ballot && !self.immediate_elect { // Invalid because otherwise --sample-per-ballot would be ineffectual return Err(STVError::InvalidOptions("--sample-per-ballot is incompatible with --no-immediate-elect")); } if self.constraints_path.is_some() && self.constraint_mode == ConstraintMode::RepeatCount { // TODO: NYI? return Err(STVError::InvalidOptions("--constraint-mode repeat_count requires a Gregory method for --surplus")); } } if self.subtract_nontransferable { if self.surplus != SurplusMethod::WIG { // Invalid because other methods do not distinguish between ballots of different value during surplus transfer return Err(STVError::InvalidOptions("--subtract-nontransferable requires --surplus wig")); } if !self.transferable_only { // Invalid because nontransferables are only subtracted with --transferable-only return Err(STVError::InvalidOptions("--subtract-nontransferable requires --transferable-only")); } } if !self.immediate_elect && self.surplus_order != SurplusOrder::BySize { // Invalid because there is no other metric to determine which surplus to distribute return Err(STVError::InvalidOptions("--no-immediate-elect requires --surplus-order by_size")); } if self.min_threshold != "0" && self.defer_surpluses { // TODO: NYI return Err(STVError::InvalidOptions("--min-threshold is incompatible with --defer-surpluses (not yet implemented)")); } if self.round_subtransfers == RoundSubtransfersMode::ByValueAndSource && self.bulk_exclude { // TODO: NYI return Err(STVError::InvalidOptions("--round-subtransfers by_value_and_source is incompatible with --bulk-exclude (not yet implemented)")); } return Ok(()); } } /// Enum of options for [STVOptions::round_subtransfers] #[cfg_attr(feature = "wasm", wasm_bindgen)] #[derive(Clone, Copy)] #[derive(PartialEq)] pub enum RoundSubtransfersMode { /// Do not round subtransfers (only round final number of votes credited) SingleStep, /// Round in subtransfers according to the value when received ByValue, /// Round in subtransfers according to the candidate from who each vote was received, and the value when received ByValueAndSource, /// Round in subtransfers according to parcel ByParcel, /// Sum and round transfers individually for each ballot paper PerBallot, } impl RoundSubtransfersMode { /// Convert to CLI argument representation fn describe(self) -> String { match self { RoundSubtransfersMode::SingleStep => "--round-subtransfers single_step", RoundSubtransfersMode::ByValue => "--round-subtransfers by_value", RoundSubtransfersMode::ByValueAndSource => "--round-subtransfers by_value_and_source", RoundSubtransfersMode::ByParcel => "--round-subtransfers by_parcel", RoundSubtransfersMode::PerBallot => "--round-subtransfers per_ballot", }.to_string() } } impl> From for RoundSubtransfersMode { fn from(s: S) -> Self { match s.as_ref() { "single_step" => RoundSubtransfersMode::SingleStep, "by_value" => RoundSubtransfersMode::ByValue, "by_value_and_source" => RoundSubtransfersMode::ByValueAndSource, "by_parcel" => RoundSubtransfersMode::ByParcel, "per_ballot" => RoundSubtransfersMode::PerBallot, _ => panic!("Invalid --round-subtransfers"), } } } /// Enum of options for [STVOptions::quota] #[cfg_attr(feature = "wasm", 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] #[cfg_attr(feature = "wasm", 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] #[cfg_attr(feature = "wasm", 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] #[cfg_attr(feature = "wasm", 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, /// Inclusive Hare method (random subset) IHare, /// (Exclusive) Hare method (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::IHare => "--surplus ihare", SurplusMethod::Hare => "--surplus hare", }.to_string() } /// Returns `true` if this is a weighted method pub fn is_weighted(&self) -> bool { return match self { SurplusMethod::WIG => { true } SurplusMethod::UIG | SurplusMethod::EG => { false } _ => unreachable!() }; } } 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, "ihare" | "ih" | "cincinnati" => SurplusMethod::IHare, // Inclusive Hare method used to be erroneously referred to as "Cincinnati" method - accept for backwards compatibility "hare" | "eh" => SurplusMethod::Hare, _ => panic!("Invalid --surplus"), } } } /// Enum of options for [STVOptions::surplus_order] #[cfg_attr(feature = "wasm", 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] #[cfg_attr(feature = "wasm", 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, /// Reset count and re-iterate from count of first preferences ResetAndReiterate, } 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::ResetAndReiterate => "--exclusion reset_and_reiterate", }.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, "reset_and_reiterate" => ExclusionMethod::ResetAndReiterate, "wright" => ExclusionMethod::ResetAndReiterate, _ => panic!("Invalid --exclusion"), } } } /// Enum of options for [STVOptions::sample] #[cfg_attr(feature = "wasm", 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; round fractions according to largest remainders StratifyLR, // Stratify the ballots into parcels according to next available preference and transfer the last ballots from each parcel; disregard fractions //StratifyFloor, /// Transfer the last ballots ByOrder, /// Transfer every n-th ballot, Cincinnati style Cincinnati, } impl SampleMethod { /// Convert to CLI argument representation fn describe(self) -> String { match self { SampleMethod::StratifyLR => "--sample stratify", //SampleMethod::StratifyFloor => "--sample stratify_floor", SampleMethod::ByOrder => "--sample by_order", SampleMethod::Cincinnati => "--sample cincinnati", }.to_string() } } impl> From for SampleMethod { fn from(s: S) -> Self { match s.as_ref() { "stratify" | "stratify_lr" => SampleMethod::StratifyLR, //"stratify_floor" => SampleMethod::StratifyFloor, "by_order" => SampleMethod::ByOrder, "cincinnati" | "nth_ballot" => SampleMethod::Cincinnati, _ => 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, /// If constraints violated, exclude/reintroduce candidates as required and redistribute ballot papers RepeatCount, } impl ConstraintMode { /// Convert to CLI argument representation fn describe(self) -> String { match self { ConstraintMode::GuardDoom => "--constraint-mode guard_doom", ConstraintMode::RepeatCount => "--constraint-mode repeat_count", }.to_string() } } impl> From for ConstraintMode { fn from(s: S) -> Self { match s.as_ref() { "guard_doom" => ConstraintMode::GuardDoom, "repeat_count" => ConstraintMode::RepeatCount, _ => panic!("Invalid --constraint-mode"), } } }