393 lines
13 KiB
Rust
393 lines
13 KiB
Rust
/* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
use opentally::constraints::Constraints;
|
|
use opentally::election::{CandidateState, CountState, Election};
|
|
use opentally::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational};
|
|
use opentally::stv::{self, STVOptions};
|
|
use opentally::ties;
|
|
|
|
use clap::{AppSettings, Clap};
|
|
|
|
use std::cmp::max;
|
|
use std::fs::File;
|
|
use std::io::{self, BufRead};
|
|
use std::ops;
|
|
|
|
/// Open-source election vote counting
|
|
#[derive(Clap)]
|
|
#[clap(name="OpenTally", version=opentally::VERSION)]
|
|
struct Opts {
|
|
#[clap(subcommand)]
|
|
command: Command,
|
|
}
|
|
|
|
#[derive(Clap)]
|
|
enum Command {
|
|
STV(STV),
|
|
}
|
|
|
|
/// Count a single transferable vote (STV) election
|
|
#[derive(Clap)]
|
|
#[clap(setting=AppSettings::DeriveDisplayOrder)]
|
|
struct STV {
|
|
// ----------------
|
|
// -- File input --
|
|
|
|
/// Path to the BLT file to be counted
|
|
filename: String,
|
|
|
|
// ----------------------
|
|
// -- Numbers settings --
|
|
|
|
/// Numbers mode
|
|
#[clap(help_heading=Some("NUMBERS"), short, long, possible_values=&["rational", "fixed", "gfixed", "float64"], default_value="rational", value_name="mode")]
|
|
numbers: String,
|
|
|
|
/// Decimal places if --numbers fixed
|
|
#[clap(help_heading=Some("NUMBERS"), long, default_value="5", value_name="dps")]
|
|
decimals: usize,
|
|
|
|
/// Convert ballots with value >1 to multiple ballots of value 1
|
|
#[clap(help_heading=Some("NUMBERS"), long)]
|
|
normalise_ballots: bool,
|
|
|
|
// -----------------------
|
|
// -- Rounding settings --
|
|
|
|
/// Round surplus fractions to specified decimal places
|
|
#[clap(help_heading=Some("ROUNDING"), long, alias="round-tvs", value_name="dps")]
|
|
round_surplus_fractions: Option<usize>,
|
|
|
|
/// Round ballot values to specified decimal places
|
|
#[clap(help_heading=Some("ROUNDING"), long, alias="round-weights", value_name="dps")]
|
|
round_values: Option<usize>,
|
|
|
|
/// Round votes to specified decimal places
|
|
#[clap(help_heading=Some("ROUNDING"), long, value_name="dps")]
|
|
round_votes: Option<usize>,
|
|
|
|
/// Round quota to specified decimal places
|
|
#[clap(help_heading=Some("ROUNDING"), long, value_name="dps")]
|
|
round_quota: Option<usize>,
|
|
|
|
/// (Gregory STV) How to calculate votes to credit to candidates in surplus transfers
|
|
#[clap(help_heading=Some("ROUNDING"), long, possible_values=&["single_step", "by_value", "per_ballot"], default_value="single_step", value_name="mode")]
|
|
sum_surplus_transfers: String,
|
|
|
|
/// (Meek STV) Limit for stopping iteration of surplus distribution
|
|
#[clap(help_heading=Some("ROUNDING"), long, default_value="0.001%", value_name="tolerance")]
|
|
meek_surplus_tolerance: String,
|
|
|
|
// -----------
|
|
// -- Quota --
|
|
|
|
/// Quota type
|
|
#[clap(help_heading=Some("QUOTA"), short, long, possible_values=&["droop", "hare", "droop_exact", "hare_exact"], default_value="droop_exact")]
|
|
quota: String,
|
|
|
|
/// Whether to elect candidates on meeting (geq) or strictly exceeding (gt) the quota
|
|
#[clap(help_heading=Some("QUOTA"), short='c', long, possible_values=&["geq", "gt"], default_value="gt", value_name="criterion")]
|
|
quota_criterion: String,
|
|
|
|
/// Whether to apply a form of progressive quota
|
|
#[clap(help_heading=Some("QUOTA"), long, possible_values=&["static", "ers97", "ers76"], default_value="static", value_name="mode")]
|
|
quota_mode: String,
|
|
|
|
// ------------------
|
|
// -- STV variants --
|
|
|
|
/// Tie-breaking method
|
|
#[clap(help_heading=Some("STV VARIANTS"), short='t', long, possible_values=&["forwards", "backwards", "random", "prompt"], default_value="prompt", value_name="methods")]
|
|
ties: Vec<String>,
|
|
|
|
/// Random seed to use with --ties random
|
|
#[clap(help_heading=Some("STV VARIANTS"), long, value_name="seed")]
|
|
random_seed: Option<String>,
|
|
|
|
/// Method of surplus distributions
|
|
#[clap(help_heading=Some("STV VARIANTS"), short='s', long, possible_values=&["wig", "uig", "eg", "meek", "cincinnati", "hare"], default_value="wig", value_name="method")]
|
|
surplus: String,
|
|
|
|
/// (Gregory STV) Order to distribute surpluses
|
|
#[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["by_size", "by_order"], default_value="by_size", value_name="order")]
|
|
surplus_order: String,
|
|
|
|
/// (Gregory STV) Examine only transferable papers during surplus distributions
|
|
#[clap(help_heading=Some("STV VARIANTS"), long)]
|
|
transferable_only: bool,
|
|
|
|
/// (Gregory STV) Method of exclusions
|
|
#[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["single_stage", "by_value", "by_source", "parcels_by_order", "wright"], default_value="single_stage", value_name="method")]
|
|
exclusion: String,
|
|
|
|
/// (Meek STV) NZ Meek STV behaviour: Iterate keep values one round before candidate exclusion
|
|
#[clap(help_heading=Some("STV VARIANTS"), long)]
|
|
meek_nz_exclusion: bool,
|
|
|
|
/// (Cincinnati/Hare) Method of drawing a sample
|
|
#[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["stratified", "by_order", "nth_ballot"], default_value="stratified", value_name="method")]
|
|
sample: String,
|
|
|
|
/// (Cincinnati/Hare) Sample-based methods: Check for candidate election after each individual ballot paper transfer
|
|
#[clap(help_heading=Some("STV VARIANTS"), long)]
|
|
sample_per_ballot: bool,
|
|
|
|
// -------------------------
|
|
// -- Count optimisations --
|
|
|
|
/// Continue count even if continuing candidates fill all remaining vacancies
|
|
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
|
no_early_bulk_elect: bool,
|
|
|
|
/// Use bulk exclusion
|
|
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
|
bulk_exclude: bool,
|
|
|
|
/// Defer surplus distributions if possible
|
|
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
|
defer_surpluses: bool,
|
|
|
|
/// Elect candidates only when their surpluses are distributed; (Meek STV) Wait for keep values to converge before electing candidates
|
|
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
|
no_immediate_elect: bool,
|
|
|
|
/// On exclusion, exclude any candidate with fewer than this many votes
|
|
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long, default_value="0", value_name="votes")]
|
|
min_threshold: String,
|
|
|
|
// -----------------
|
|
// -- Constraints --
|
|
|
|
/// Path to a CON file specifying constraints
|
|
#[clap(help_heading=Some("CONSTRAINTS"), long)]
|
|
constraints: Option<String>,
|
|
|
|
/// Mode of handling constraints
|
|
#[clap(help_heading=Some("CONSTRAINTS"), long, possible_values=&["guard_doom"], default_value="guard_doom")]
|
|
constraint_mode: String,
|
|
|
|
// ----------------------
|
|
// -- Display settings --
|
|
|
|
/// Hide excluded candidates from results report
|
|
#[clap(help_heading=Some("DISPLAY"), long)]
|
|
hide_excluded: bool,
|
|
|
|
/// Sort candidates by votes in results report
|
|
#[clap(help_heading=Some("DISPLAY"), long)]
|
|
sort_votes: bool,
|
|
|
|
/// Print votes to specified decimal places in results report
|
|
#[clap(help_heading=Some("DISPLAY"), long, default_value="2", value_name="dps")]
|
|
pp_decimals: usize,
|
|
}
|
|
|
|
fn main() {
|
|
match main_() {
|
|
Ok(_) => {}
|
|
Err(code) => {
|
|
std::process::exit(code);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main_() -> Result<(), i32> {
|
|
// Read arguments
|
|
let opts: Opts = Opts::parse();
|
|
let Command::STV(cmd_opts) = opts.command;
|
|
|
|
// Read and count election according to --numbers
|
|
if cmd_opts.numbers == "rational" {
|
|
let mut election = election_from_file(&cmd_opts.filename)?;
|
|
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
|
|
|
// Must specify ::<N> here and in a few other places because ndarray causes E0275 otherwise
|
|
count_election::<Rational>(election, cmd_opts)?;
|
|
} else if cmd_opts.numbers == "float64" {
|
|
let mut election = election_from_file(&cmd_opts.filename)?;
|
|
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
|
count_election::<NativeFloat64>(election, cmd_opts)?;
|
|
} else if cmd_opts.numbers == "fixed" {
|
|
Fixed::set_dps(cmd_opts.decimals);
|
|
|
|
let mut election = election_from_file(&cmd_opts.filename)?;
|
|
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
|
count_election::<Fixed>(election, cmd_opts)?;
|
|
} else if cmd_opts.numbers == "gfixed" {
|
|
GuardedFixed::set_dps(cmd_opts.decimals);
|
|
|
|
let mut election = election_from_file(&cmd_opts.filename)?;
|
|
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
|
count_election::<GuardedFixed>(election, cmd_opts)?;
|
|
}
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
fn election_from_file<N: Number>(path: &str) -> Result<Election<N>, i32> {
|
|
match Election::from_file(path) {
|
|
Ok(e) => return Ok(e),
|
|
Err(err) => {
|
|
println!("Syntax Error: {}", err);
|
|
return Err(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn maybe_load_constraints<N: Number>(election: &mut Election<N>, constraints: &Option<String>) {
|
|
if let Some(c) = constraints {
|
|
let file = File::open(c).expect("IO Error");
|
|
let lines = io::BufReader::new(file).lines();
|
|
election.constraints = Some(Constraints::from_con(lines.map(|r| r.expect("IO Error").to_string()).into_iter()));
|
|
}
|
|
}
|
|
|
|
fn count_election<N: Number>(mut election: Election<N>, cmd_opts: STV) -> Result<(), i32>
|
|
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<Output=N>
|
|
{
|
|
// Copy applicable options
|
|
let opts = STVOptions::new(
|
|
cmd_opts.round_surplus_fractions,
|
|
cmd_opts.round_values,
|
|
cmd_opts.round_votes,
|
|
cmd_opts.round_quota,
|
|
cmd_opts.sum_surplus_transfers.into(),
|
|
cmd_opts.meek_surplus_tolerance.into(),
|
|
cmd_opts.normalise_ballots,
|
|
cmd_opts.quota.into(),
|
|
cmd_opts.quota_criterion.into(),
|
|
cmd_opts.quota_mode.into(),
|
|
ties::from_strs(cmd_opts.ties, cmd_opts.random_seed),
|
|
cmd_opts.surplus.into(),
|
|
cmd_opts.surplus_order.into(),
|
|
cmd_opts.transferable_only,
|
|
cmd_opts.exclusion.into(),
|
|
cmd_opts.meek_nz_exclusion,
|
|
cmd_opts.sample.into(),
|
|
cmd_opts.sample_per_ballot,
|
|
!cmd_opts.no_early_bulk_elect,
|
|
cmd_opts.bulk_exclude,
|
|
cmd_opts.defer_surpluses,
|
|
!cmd_opts.no_immediate_elect,
|
|
cmd_opts.min_threshold,
|
|
cmd_opts.constraints,
|
|
cmd_opts.constraint_mode.into(),
|
|
cmd_opts.hide_excluded,
|
|
cmd_opts.sort_votes,
|
|
cmd_opts.pp_decimals,
|
|
);
|
|
|
|
// Validate options
|
|
match opts.validate() {
|
|
Ok(_) => {}
|
|
Err(err) => {
|
|
println!("Error: {}", err.describe());
|
|
return Err(1);
|
|
}
|
|
}
|
|
|
|
// Describe count
|
|
let total_ballots = election.ballots.iter().fold(N::zero(), |acc, b| { acc + &b.orig_value });
|
|
print!("Count computed by OpenTally (revision {}). Read {:.0} ballots from \"{}\" for election \"{}\". There are {} candidates for {} vacancies. ", opentally::VERSION, total_ballots, cmd_opts.filename, election.name, election.candidates.len(), election.seats);
|
|
let opts_str = opts.describe::<N>();
|
|
if opts_str.len() > 0 {
|
|
println!("Counting using options \"{}\".", opts_str);
|
|
} else {
|
|
println!("Counting using default options.");
|
|
}
|
|
println!();
|
|
|
|
// Normalise ballots if requested
|
|
if cmd_opts.normalise_ballots {
|
|
election.normalise_ballots();
|
|
}
|
|
|
|
// Initialise count state
|
|
let mut state = CountState::new(&election);
|
|
|
|
// Distribute first preferences
|
|
match stv::count_init(&mut state, &opts) {
|
|
Ok(_) => {}
|
|
Err(err) => {
|
|
println!("Error: {}", err.describe());
|
|
return Err(1);
|
|
}
|
|
}
|
|
|
|
let mut stage_num = 1;
|
|
print_stage(stage_num, &state, &opts);
|
|
|
|
loop {
|
|
match stv::count_one_stage(&mut state, &opts) {
|
|
Ok(is_done) => {
|
|
if is_done {
|
|
break;
|
|
}
|
|
}
|
|
Err(err) => {
|
|
println!("Error: {}", err.describe());
|
|
return Err(1);
|
|
}
|
|
}
|
|
|
|
stage_num += 1;
|
|
print_stage(stage_num, &state, &opts);
|
|
}
|
|
|
|
println!("Count complete. The winning candidates are, in order of election:");
|
|
|
|
let mut winners = Vec::new();
|
|
for (candidate, count_card) in state.candidates.iter() {
|
|
if count_card.state == CandidateState::Elected {
|
|
winners.push((candidate, count_card));
|
|
}
|
|
}
|
|
winners.sort_unstable_by(|a, b| a.1.order_elected.cmp(&b.1.order_elected));
|
|
|
|
for (i, (winner, count_card)) in winners.into_iter().enumerate() {
|
|
if let Some(kv) = &count_card.keep_value {
|
|
println!("{}. {} (kv = {:.dps2$})", i + 1, winner.name, kv, dps2=max(opts.pp_decimals, 2));
|
|
} else {
|
|
println!("{}. {}", i + 1, winner.name);
|
|
}
|
|
}
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
fn print_stage<N: Number>(stage_num: usize, state: &CountState<N>, opts: &STVOptions) {
|
|
// Print stage details
|
|
match state.kind {
|
|
None => { println!("{}. {}", stage_num, state.title); }
|
|
Some(kind) => { println!("{}. {} {}", stage_num, kind, state.title); }
|
|
};
|
|
println!("{}", state.logger.render().join(" "));
|
|
|
|
// Print candidates
|
|
print!("{}", state.describe_candidates(opts));
|
|
|
|
// Print summary rows
|
|
print!("{}", state.describe_summary(opts));
|
|
|
|
println!("");
|
|
}
|