diff --git a/Cargo.lock b/Cargo.lock index a8e25d2..cb324ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43bb833f0bf979d8475d38fbf09ed3b8a55e1885fe93ad3f93239fc6a4f17b98" dependencies = [ - "getrandom", + "getrandom 0.2.3", "once_cell", "version_check", ] @@ -28,6 +28,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "arrayref" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" + [[package]] name = "arrayvec" version = "0.5.2" @@ -48,6 +54,17 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.0.1" @@ -60,12 +77,29 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "822d7d63e0c0260a050f6b1f0d316f5c79b9eab830aca526ed904e1011bd64ca" +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + [[package]] name = "bitflags" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +[[package]] +name = "blake2b_simd" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -174,6 +208,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27d614f23f34f7b5165a77dc1591f497e2518f9cec4b4f4b92bfc4dc6cf7a190" +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "convert_case" version = "0.4.0" @@ -198,6 +238,16 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db" +dependencies = [ + "cfg-if 1.0.0", + "lazy_static", +] + [[package]] name = "csv" version = "1.1.6" @@ -314,6 +364,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "dirs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "doc-comment" version = "0.3.3" @@ -326,6 +387,12 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "flate2" version = "1.0.20" @@ -363,6 +430,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.3" @@ -371,7 +449,7 @@ checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" dependencies = [ "cfg-if 1.0.0", "libc", - "wasi", + "wasi 0.10.2+wasi-snapshot-preview1", ] [[package]] @@ -430,6 +508,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "ibig" version = "0.3.2" @@ -632,6 +719,7 @@ dependencies = [ "num-traits", "paste", "predicates", + "prettytable-rs", "rkyv", "rug", "sha2", @@ -690,6 +778,20 @@ dependencies = [ "treeline", ] +[[package]] +name = "prettytable-rs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd04b170004fa2daccf418a7f8253aaf033c27760b5f225889024cf66d7ac2e" +dependencies = [ + "atty", + "csv", + "encode_unicode", + "lazy_static", + "term", + "unicode-width", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -779,6 +881,23 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + +[[package]] +name = "redox_users" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" +dependencies = [ + "getrandom 0.1.16", + "redox_syscall", + "rust-argon2", +] + [[package]] name = "regex" version = "1.5.4" @@ -850,6 +969,18 @@ dependencies = [ "libc", ] +[[package]] +name = "rust-argon2" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" +dependencies = [ + "base64", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + [[package]] name = "rustc_version" version = "0.3.3" @@ -931,6 +1062,17 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "term" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd106a334b7657c10b7c540a0106114feadeb4dc314513e97df481d5d966f42" +dependencies = [ + "byteorder", + "dirs", + "winapi", +] + [[package]] name = "textwrap" version = "0.13.4" @@ -961,6 +1103,12 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + [[package]] name = "unicode-xid" version = "0.2.2" @@ -997,6 +1145,12 @@ dependencies = [ "libc", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.10.2+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index da36119..c22cbea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ paste = "1.0.5" assert_cmd = "1.0.5" csv = "1.1.6" flate2 = "1.0" +prettytable-rs = "0.8.0" rkyv = "0.7.15" utf8-chars = "1.0.2" xmltree = "0.10.3" diff --git a/src/cli/stv.rs b/src/cli/stv.rs index 3bf98dd..fd2255b 100644 --- a/src/cli/stv.rs +++ b/src/cli/stv.rs @@ -391,13 +391,20 @@ fn print_stage(stage_num: u32, state: &CountState, opts: &STVOptio println!("{}. {}", stage_num, state.title); println!("{}", state.logger.render().join(" ")); + if opts.transfers_detail { + if let Some(tt) = &state.transfer_table { + println!(); + println!("{}", tt.render_text(state, opts)); + } + } + // Print candidates print!("{}", state.describe_candidates(opts)); // Print summary rows print!("{}", state.describe_summary(opts)); - println!(""); + println!(); } // ---------------------------------- diff --git a/src/election.rs b/src/election.rs index fb17228..ad2327a 100644 --- a/src/election.rs +++ b/src/election.rs @@ -20,8 +20,8 @@ use crate::logger::Logger; use crate::numbers::Number; use crate::sharandom::SHARandom; use crate::stv::{self, STVOptions}; +use crate::stv::gregory::TransferTable; use crate::stv::meek::BallotTree; -use crate::stv::transfers::TransferTable; use itertools::Itertools; diff --git a/src/stv/gregory.rs b/src/stv/gregory/mod.rs similarity index 97% rename from src/stv/gregory.rs rename to src/stv/gregory/mod.rs index ade495f..47ffa35 100644 --- a/src/stv/gregory.rs +++ b/src/stv/gregory/mod.rs @@ -15,13 +15,15 @@ * along with this program. If not, see . */ +mod transfers; +pub use transfers::{TransferTable, TransferTableCell, TransferTableColumn}; + use super::{ExclusionMethod, STVError, STVOptions, SurplusMethod, SurplusOrder}; use super::sample; use crate::constraints; use crate::election::{Candidate, CandidateState, CountState, Parcel, StageKind, Vote}; use crate::numbers::Number; -use crate::stv::transfers::TransferTable; use crate::ties; use std::cmp::max; @@ -299,7 +301,7 @@ where // Reweight and transfer parcels - let mut transfer_table = TransferTable::new(); + let mut transfer_table = TransferTable::new_surplus(surplus.clone(), surplus_fraction.clone(), surplus_numer.clone(), surplus_denom.clone()); for (value_fraction, result) in parcels_next_prefs { for (candidate, entry) in result.candidates.into_iter() { @@ -350,7 +352,9 @@ where let mut checksum = N::new(); // Credit transferred votes - checksum += transfer_table.apply_to(state, opts, Some(&surplus), &surplus_numer, &surplus_denom); + transfer_table.calculate(opts); + checksum += transfer_table.apply_to(state, opts); + state.transfer_table = Some(transfer_table); // Finalise candidate votes let count_card = state.candidates.get_mut(elected_candidate).unwrap(); @@ -549,7 +553,7 @@ where let value = match parcels.first() { Some(p) => Some(p.value_fraction.clone()), _ => None }; - let mut transfer_table = TransferTable::new(); + let mut transfer_table = TransferTable::new_exclusion(); for parcel in parcels { // Count next preferences @@ -603,7 +607,9 @@ where } // Credit transferred votes - checksum += transfer_table.apply_to(state, opts, None, &None, &None); + transfer_table.calculate(opts); + checksum += transfer_table.apply_to(state, opts); + state.transfer_table = Some(transfer_table); if !votes_remain { // Finalise candidate votes diff --git a/src/stv/gregory/transfers.rs b/src/stv/gregory/transfers.rs new file mode 100644 index 0000000..ed2240e --- /dev/null +++ b/src/stv/gregory/transfers.rs @@ -0,0 +1,499 @@ +/* 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 prettytable::{Cell, Row, Table}; + +use crate::election::{Candidate, CountState}; +use crate::numbers::Number; +use crate::stv::{STVOptions, SumSurplusTransfersMode}; + +use std::collections::HashMap; + +/// Table describing vote transfers during a surplus distribution or exclusion +pub struct TransferTable<'e, N: Number> { + /// Columns in the table + pub columns: Vec>, + + /// Total column + pub total: TransferTableColumn<'e, N>, + + /// Size of surplus, or `None` if an exclusion + pub surplus: Option, + /// Surplus fraction, or `None` if votes not reweighted/an exclusion (for display/optimisation only) + pub surpfrac: Option, + /// Numerator of surplus fraction, or `None` if votes not reweighted/an exclusion + pub surpfrac_numer: Option, + /// Denominator of surplus fraction, or `None` + pub surpfrac_denom: Option, +} + +impl<'e, N: Number> TransferTable<'e, N> { + /// Return a new [TransferTable] for an exclusion + pub fn new_exclusion() -> Self { + TransferTable { + columns: Vec::new(), + total: TransferTableColumn { + value_fraction: N::new(), + cells: HashMap::new(), + exhausted: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, + total: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, + }, + surplus: None, + surpfrac: None, + surpfrac_numer: None, + surpfrac_denom: None, + } + } + + /// Return a new [TransferTable] for a surplus distribution + pub fn new_surplus(surplus: N, surpfrac: Option, surpfrac_numer: Option, surpfrac_denom: Option) -> Self { + TransferTable { + columns: Vec::new(), + total: TransferTableColumn { + value_fraction: N::new(), + cells: HashMap::new(), + exhausted: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, + total: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, + }, + surplus: Some(surplus), + surpfrac, + surpfrac_numer, + surpfrac_denom, + } + } + + /// Record the specified transfer + pub fn add_transfers(&mut self, value_fraction: &N, candidate: &'e Candidate, ballots: &N) { + for col in self.columns.iter_mut() { + if &col.value_fraction == value_fraction { + col.add_transfers(candidate, ballots); + return; + } + } + + let mut col = TransferTableColumn { + value_fraction: value_fraction.clone(), + cells: HashMap::new(), + exhausted: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, + total: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, + }; + col.add_transfers(candidate, ballots); + self.columns.push(col); + } + + /// Record the specified exhaustion + pub fn add_exhausted(&mut self, value_fraction: &N, ballots: &N) { + for col in self.columns.iter_mut() { + if &col.value_fraction == value_fraction { + col.exhausted.ballots += ballots; + return; + } + } + + let col = TransferTableColumn { + value_fraction: value_fraction.clone(), + cells: HashMap::new(), + exhausted: TransferTableCell { ballots: ballots.clone(), votes_in: N::new(), votes_out: N::new() }, + total: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, + }; + self.columns.push(col); + } + + /// Calculate the votes to be transferred according to this table + pub fn calculate(&mut self, opts: &STVOptions) { + // Use weighted rules if exclusion or WIGM + let is_weighted = self.surplus.is_none() || opts.surplus.is_weighted(); + + // Iterate through columns + for column in self.columns.iter_mut() { + let mut new_value_fraction = N::new(); + if self.surplus.is_some() && opts.sum_surplus_transfers == SumSurplusTransfersMode::PerBallot { + if is_weighted { + new_value_fraction = column.value_fraction.clone(); + // If surplus, multiply by surplus fraction + if let Some(n) = &self.surpfrac_numer { + new_value_fraction *= n; + } + if let Some(n) = &self.surpfrac_denom { + new_value_fraction /= n; + } + // Round if required + if let Some(dps) = opts.round_values { + new_value_fraction.floor_mut(dps); + } + } else { + if let Some(n) = &self.surpfrac_numer { + new_value_fraction = n.clone(); + } else { + // Transferred at original value + new_value_fraction = column.value_fraction.clone(); + } + if let Some(n) = &self.surpfrac_denom { + new_value_fraction /= n; + } + // Round if required + if let Some(dps) = opts.round_values { + new_value_fraction.floor_mut(dps); + } + } + } + + // Candidate votes + for (candidate, cell) in column.cells.iter_mut() { + column.total.ballots += &cell.ballots; + self.total.add_transfers(*candidate, &cell.ballots); + self.total.total.ballots += &cell.ballots; + + let votes_in = cell.ballots.clone() * &column.value_fraction; + cell.votes_in += &votes_in; + column.total.votes_in += &votes_in; + self.total.cells.get_mut(*candidate).unwrap().votes_in += &votes_in; + self.total.total.votes_in += votes_in; + + if self.surplus.is_some() && opts.sum_surplus_transfers == SumSurplusTransfersMode::PerBallot { + let votes_out = cell.ballots.clone() * &new_value_fraction; + cell.votes_out += &votes_out; + column.total.votes_out += &votes_out; + self.total.cells.get_mut(*candidate).unwrap().votes_out += &votes_out; + self.total.total.votes_out += votes_out; + } + } + + // Exhausted votes + column.total.ballots += &column.exhausted.ballots; + self.total.exhausted.ballots += &column.exhausted.ballots; + self.total.total.ballots += &column.exhausted.ballots; + + let votes_in = column.exhausted.ballots.clone() * &column.value_fraction; + column.exhausted.votes_in += &votes_in; + column.total.votes_in += &votes_in; + self.total.exhausted.votes_in += &votes_in; + self.total.total.votes_in += votes_in; + + if self.surplus.is_some() && opts.sum_surplus_transfers == SumSurplusTransfersMode::PerBallot { + if !opts.transferable_only { + let votes_out = column.exhausted.ballots.clone() * &new_value_fraction; + column.exhausted.votes_out += &votes_out; + column.total.votes_out += &votes_out; + self.total.exhausted.votes_out += &votes_out; + self.total.total.votes_out += votes_out; + } + } + } + + // Need to calculate votes_out? + if self.surplus.is_none() || opts.sum_surplus_transfers == SumSurplusTransfersMode::ByValue { + for (_candidate, cell) in self.total.cells.iter_mut() { + let mut votes_out; + if is_weighted { + votes_out = cell.votes_in.clone(); + } else { + votes_out = cell.ballots.clone(); + } + + // If surplus, multiply by surplus fraction + if let Some(n) = &self.surpfrac_numer { + votes_out *= n; + } + if let Some(n) = &self.surpfrac_denom { + votes_out /= n; + } + + cell.votes_out = votes_out; // Rounded later + } + + if self.surplus.is_none() || !opts.transferable_only { + let mut votes_out; + if is_weighted { + votes_out = self.total.exhausted.votes_in.clone(); + } else { + votes_out = self.total.exhausted.ballots.clone(); + } + + // If surplus, multiply by surplus fraction + if let Some(n) = &self.surpfrac_numer { + votes_out *= n; + } + if let Some(n) = &self.surpfrac_denom { + votes_out /= n; + } + + self.total.exhausted.votes_out = votes_out; // Rounded later + } + } + + // Round if required + if let Some(dps) = opts.round_votes { + for (_candidate, cell) in self.total.cells.iter_mut() { + cell.votes_out.floor_mut(dps); + } + + self.total.exhausted.votes_out.floor_mut(dps); + } + } + + /// Apply the transfers described in the table to the count sheet + /// + /// Credit continuing candidates and exhausted pile with the appropriate number of ballot papers and votes. + pub fn apply_to(&self, state: &mut CountState, opts: &STVOptions) -> N { + let mut checksum = N::new(); + + // Credit transferred votes + for (candidate, count_card) in state.candidates.iter_mut() { + if let Some(cell) = self.total.cells.get(candidate) { + count_card.transfer(&cell.votes_out); + count_card.ballot_transfers += &cell.ballots; + checksum += &cell.votes_out; + } + } + + // Credit exhausted votes + // If exclusion or not --transferable-only + if self.surplus.is_none() || !opts.transferable_only { + // Standard rules + state.exhausted.transfer(&self.total.exhausted.votes_out); + state.exhausted.ballot_transfers += &self.total.exhausted.ballots; + checksum += &self.total.exhausted.votes_out; + } else { + // Credit only nontransferable difference + if self.surpfrac_numer.is_none() { + // TODO: Is there a purer way of calculating this? + let difference = self.surplus.as_ref().unwrap().clone() - &checksum; + state.exhausted.transfer(&difference); + checksum += difference; + + for column in self.columns.iter() { + state.exhausted.ballot_transfers += &column.exhausted.ballots; + } + } else { + // No ballots exhaust + } + } + + return checksum; + } + + /// Render table as plain text + pub fn render_text(&self, state: &CountState, opts: &STVOptions) -> String { + let mut table = Table::new(); + table.set_format(*prettytable::format::consts::FORMAT_NO_LINESEP_WITH_TITLE); + + let show_transfers_per_ballot = !self.surpfrac.is_none() && opts.sum_surplus_transfers == SumSurplusTransfersMode::PerBallot; + + let num_cols; + if show_transfers_per_ballot { + num_cols = state.candidates.len() * 3 + 4; + } else { + if self.surpfrac.is_none() { + num_cols = state.candidates.len() * 2 + 3; + } else { + num_cols = state.candidates.len() * 2 + 4; + } + } + + // ---------- + // Header row + + let mut row = Vec::with_capacity(num_cols); + row.push(Cell::new("Preference")); + for column in self.columns.iter() { + row.push(Cell::new(&format!("Ballots @ {:.dps$}", column.value_fraction, dps=opts.pp_decimals)).style_spec("cH2")); + + if show_transfers_per_ballot { + row.push(Cell::new(&format!("× {:.dps$}", self.surpfrac.as_ref().unwrap(), dps=opts.pp_decimals)).style_spec("r")); + } + } + row.push(Cell::new("Total").style_spec("cH2")); + if self.surpfrac.is_some() { + row.push(Cell::new(&format!("× {:.dps$}", self.surpfrac.as_ref().unwrap(), dps=opts.pp_decimals)).style_spec("r")); + } + table.set_titles(Row::new(row)); + + // -------------- + // Candidate rows + + for candidate in state.election.candidates.iter() { + let mut row = Vec::with_capacity(num_cols); + row.push(Cell::new(&candidate.name)); + for column in self.columns.iter() { + if let Some(cell) = column.cells.get(candidate) { + row.push(Cell::new(&format!("{:.0}", cell.ballots)).style_spec("r")); + row.push(Cell::new(&format!("{:.dps$}", cell.votes_in, dps=opts.pp_decimals)).style_spec("r")); + if show_transfers_per_ballot { + row.push(Cell::new(&format!("{:.dps$}", cell.votes_out, dps=opts.pp_decimals)).style_spec("r")); + } + } else { + row.push(Cell::new("")); + row.push(Cell::new("")); + if show_transfers_per_ballot { + row.push(Cell::new("")); + } + } + } + + // Totals + if let Some(cell) = self.total.cells.get(candidate) { + row.push(Cell::new(&format!("{:.0}", cell.ballots)).style_spec("r")); + row.push(Cell::new(&format!("{:.dps$}", cell.votes_in, dps=opts.pp_decimals)).style_spec("r")); + if self.surpfrac.is_some() { + row.push(Cell::new(&format!("{:.dps$}", cell.votes_out, dps=opts.pp_decimals)).style_spec("r")); + } + } else { + row.push(Cell::new("")); + row.push(Cell::new("")); + if self.surpfrac.is_some() { + row.push(Cell::new("")); + } + } + + table.add_row(Row::new(row)); + } + + // ------------- + // Exhausted row + + let mut row = Vec::with_capacity(num_cols); + row.push(Cell::new("Exhausted")); + for column in self.columns.iter() { + if !column.exhausted.ballots.is_zero() { + row.push(Cell::new(&format!("{:.0}", column.exhausted.ballots)).style_spec("r")); + row.push(Cell::new(&format!("{:.dps$}", column.exhausted.votes_in, dps=opts.pp_decimals)).style_spec("r")); + if show_transfers_per_ballot { + if column.exhausted.votes_out.is_zero() { + row.push(Cell::new("-").style_spec("c")); + } else { + row.push(Cell::new(&format!("{:.dps$}", column.exhausted.votes_out, dps=opts.pp_decimals)).style_spec("r")); + } + } + } else { + row.push(Cell::new("")); + row.push(Cell::new("")); + if show_transfers_per_ballot { + row.push(Cell::new("")); + } + } + } + + // Totals + if !self.total.exhausted.ballots.is_zero() { + row.push(Cell::new(&format!("{:.0}", self.total.exhausted.ballots)).style_spec("r")); + row.push(Cell::new(&format!("{:.dps$}", self.total.exhausted.votes_in, dps=opts.pp_decimals)).style_spec("r")); + if self.surpfrac.is_some() { + if self.total.exhausted.votes_out.is_zero() { + row.push(Cell::new("-").style_spec("c")); + } else { + row.push(Cell::new(&format!("{:.dps$}", self.total.exhausted.votes_out, dps=opts.pp_decimals)).style_spec("r")); + } + } + } else { + row.push(Cell::new("")); + row.push(Cell::new("")); + if self.surpfrac.is_some() { + row.push(Cell::new("")); + } + } + + table.add_row(Row::new(row)); + + // ---------- + // Totals row + + let mut row = Vec::with_capacity(num_cols); + row.push(Cell::new("Total")); + + for column in self.columns.iter() { + row.push(Cell::new(&format!("{:.0}", column.total.ballots)).style_spec("r")); + row.push(Cell::new(&format!("{:.dps$}", column.total.votes_in, dps=opts.pp_decimals)).style_spec("r")); + if show_transfers_per_ballot { + row.push(Cell::new(&format!("{:.dps$}", column.total.votes_out, dps=opts.pp_decimals)).style_spec("r")); + } + } + + // Grand total cell + + let mut gt_ballots = N::new(); + let mut gt_votes_in = N::new(); + let mut gt_votes_out = N::new(); + + for candidate in state.election.candidates.iter() { + if let Some(cell) = self.total.cells.get(candidate) { + gt_ballots += &cell.ballots; + gt_votes_in += &cell.votes_in; + gt_votes_out += &cell.votes_out; + } + } + gt_ballots += &self.total.exhausted.ballots; + gt_votes_in += &self.total.exhausted.votes_in; + gt_votes_out += &self.total.exhausted.votes_out; + + row.push(Cell::new(&format!("{:.0}", gt_ballots)).style_spec("r")); + row.push(Cell::new(&format!("{:.dps$}", gt_votes_in, dps=opts.pp_decimals)).style_spec("r")); + if self.surpfrac.is_some() { + row.push(Cell::new(&format!("{:.dps$}", gt_votes_out, dps=opts.pp_decimals)).style_spec("r")); + } + + table.add_row(Row::new(row)); + + return table.to_string(); + } + + /// Render table as HTML + pub fn render_html(&self) -> String { + todo!(); + } +} + +/// Column in a [TransferTable] +pub struct TransferTableColumn<'e, N: Number> { + /// Value fraction of ballots counted in this column + pub value_fraction: N, + + /// Cells in this column + pub cells: HashMap<&'e Candidate, TransferTableCell>, + + /// Exhausted cell + pub exhausted: TransferTableCell, + + /// Totals cell + pub total: TransferTableCell, +} + +impl<'e, N: Number> TransferTableColumn<'e, N> { + /// Record the specified transfer + pub fn add_transfers(&mut self, candidate: &'e Candidate, ballots: &N) { + if let Some(cell) = self.cells.get_mut(candidate) { + cell.ballots += ballots; + } else { + let cell = TransferTableCell { + ballots: ballots.clone(), + votes_in: N::new(), + votes_out: N::new(), + }; + self.cells.insert(candidate, cell); + } + } +} + +/// Cell in a [TransferTable], representing transfers to one candidate at a particular value +pub struct TransferTableCell { + /// Ballots expressing a next preference for the continuing candidate + pub ballots: N, + /// Value of votes when received by the transferring candidate + pub votes_in: N, + /// Votes transferred to the continuing candidate + pub votes_out: N, +} diff --git a/src/stv/mod.rs b/src/stv/mod.rs index bd075a2..38e4908 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -23,8 +23,6 @@ pub mod gregory; pub mod meek; /// Random sample methods of surplus distributions pub mod sample; -/// Transfer tables -pub mod transfers; /// WebAssembly wrappers //#[cfg(target_arch = "wasm32")] @@ -149,15 +147,15 @@ pub struct STVOptions { #[builder(default="ConstraintMode::GuardDoom")] pub constraint_mode: ConstraintMode, - /// Hide excluded candidates from results report + /// (CLI) Hide excluded candidates from results report #[builder(default="false")] pub hide_excluded: bool, - /// Sort candidates by votes in results report + /// (CLI) Sort candidates by votes in results report #[builder(default="false")] pub sort_votes: bool, - /// Show details of transfers to candidates during surplus distributions/candidate exclusions + /// (CLI) Show details of transfers to candidates during surplus distributions/candidate exclusions #[builder(default="false")] pub transfers_detail: bool, @@ -206,6 +204,7 @@ impl STVOptions { } if self.hide_excluded { flags.push(format!("--hide-excluded")); } if self.sort_votes { flags.push(format!("--sort-votes")); } + if self.transfers_detail { flags.push(format!("--transfers-detail")); } if self.pp_decimals != 2 { flags.push(format!("--pp-decimals {}", self.pp_decimals)); } return flags.join(" "); } @@ -641,6 +640,7 @@ where for<'r> &'r N: ops::Div<&'r N, Output=N>, for<'r> &'r N: ops::Neg, { + state.transfer_table = None; state.logger.entries.clear(); state.step_all(); diff --git a/src/stv/transfers.rs b/src/stv/transfers.rs deleted file mode 100644 index cfe8ba5..0000000 --- a/src/stv/transfers.rs +++ /dev/null @@ -1,250 +0,0 @@ -/* 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::{STVOptions, SumSurplusTransfersMode}; - -use crate::election::{Candidate, CountState}; -use crate::numbers::Number; - -use std::collections::HashMap; - -/// Table describing vote transfers during a surplus distribution or exclusion -pub struct TransferTable<'e, N: Number> { - /// Columns in the table - pub columns: Vec>, -} - -impl<'e, N: Number> TransferTable<'e, N> { - /// Return a new [TransferTable] - pub fn new() -> Self { - TransferTable { - columns: Vec::new(), - } - } - - /// Record the specified transfer - pub fn add_transfers(&mut self, value_fraction: &N, candidate: &'e Candidate, ballots: &N) { - for col in self.columns.iter_mut() { - if &col.value_fraction == value_fraction { - col.add_transfers(candidate, ballots); - return; - } - } - - let mut col = TransferTableColumn { - value_fraction: value_fraction.clone(), - cells: HashMap::new(), - exhausted: TransferTableCell { ballots: N::new() }, - }; - col.add_transfers(candidate, ballots); - self.columns.push(col); - } - - /// Record the specified exhaustion - pub fn add_exhausted(&mut self, value_fraction: &N, ballots: &N) { - for col in self.columns.iter_mut() { - if &col.value_fraction == value_fraction { - col.exhausted.ballots += ballots; - return; - } - } - - let col = TransferTableColumn { - value_fraction: value_fraction.clone(), - cells: HashMap::new(), - exhausted: TransferTableCell { ballots: ballots.clone() }, - }; - self.columns.push(col); - } - - /// Apply the transfers described in the table to the count sheet - /// - /// Credit continuing candidates and exhausted pile with the appropriate number of ballot papers and votes. - pub fn apply_to(&self, state: &mut CountState, opts: &STVOptions, surplus: Option<&N>, surplus_numer: &Option, surplus_denom: &Option) -> N { - // Use weighted rules if exclusion or WIGM - let is_weighted = surplus.is_none() || opts.surplus.is_weighted(); - - let mut checksum = N::new(); - - // Credit transferred votes - for (candidate, count_card) in state.candidates.iter_mut() { - let mut votes_transferred = N::new(); - let mut ballots_transferred = N::new(); - - // If exclusion, or surplus at present value, or SumSurplusTransfersMode::ByValue - if surplus_numer.is_none() || opts.sum_surplus_transfers == SumSurplusTransfersMode::ByValue { - // Calculate transfer across all votes in this parcel - for column in self.columns.iter() { - if let Some(cell) = column.cells.get(*candidate) { - if is_weighted { - votes_transferred += cell.ballots.clone() * &column.value_fraction; - } - ballots_transferred += &cell.ballots; - } - } - - if !is_weighted { - votes_transferred = ballots_transferred.clone(); - } - - // If surplus, multiply by surplus fraction - if let Some(n) = &surplus_numer { - votes_transferred *= n; - } - if let Some(n) = &surplus_denom { - votes_transferred /= n; - } - } else if opts.sum_surplus_transfers == SumSurplusTransfersMode::PerBallot { - // Sum transfer per each individual ballot - for column in self.columns.iter() { - if let Some(cell) = column.cells.get(*candidate) { - ballots_transferred += &cell.ballots; - - let mut new_value_fraction; - if is_weighted { - new_value_fraction = column.value_fraction.clone(); - // If surplus, multiply by surplus fraction - if let Some(n) = &surplus_numer { - new_value_fraction *= n; - } - if let Some(n) = &surplus_denom { - new_value_fraction /= n; - } - // Round if required - if let Some(dps) = opts.round_values { - new_value_fraction.floor_mut(dps); - } - } else { - if let Some(n) = &surplus_numer { - new_value_fraction = n.clone(); - } else { - // Transferred at original value - new_value_fraction = column.value_fraction.clone(); - } - if let Some(n) = &surplus_denom { - new_value_fraction /= n; - } - // Round if required - if let Some(dps) = opts.round_values { - new_value_fraction.floor_mut(dps); - } - } - - votes_transferred += cell.ballots.clone() * new_value_fraction; - } - } - } else { - unreachable!(); - } - - // Round if required - if let Some(dps) = opts.round_votes { - votes_transferred.floor_mut(dps); - } - - count_card.transfer(&votes_transferred); - count_card.ballot_transfers += ballots_transferred; - - checksum += votes_transferred; - } - - // Credit exhausted votes - // If exclusion or not --transferable-only - if surplus.is_none() || !opts.transferable_only { - // Standard rules - let mut votes_transferred = N::new(); - let mut ballots_transferred = N::new(); - - for column in self.columns.iter() { - if is_weighted { - votes_transferred += column.exhausted.ballots.clone() * &column.value_fraction; - } - ballots_transferred += &column.exhausted.ballots; - } - - if !is_weighted { - votes_transferred = ballots_transferred.clone(); - } - - // If surplus, multiply by surplus fraction - if let Some(n) = &surplus_numer { - votes_transferred *= n; - } - if let Some(n) = &surplus_denom { - votes_transferred /= n; - } - - // Round if required - if let Some(dps) = opts.round_votes { - votes_transferred.floor_mut(dps); - } - - state.exhausted.transfer(&votes_transferred); - state.exhausted.ballot_transfers += ballots_transferred; - - checksum += votes_transferred; - } else { - // Credit only nontransferable difference - if surplus_numer.is_none() { - // TODO: Is there a purer way of calculating this? - let difference = surplus.unwrap().clone() - &checksum; - state.exhausted.transfer(&difference); - checksum += difference; - - for column in self.columns.iter() { - state.exhausted.ballot_transfers += &column.exhausted.ballots; - } - } else { - // No ballots exhaust - } - } - - return checksum; - } -} - -/// Column in a [TransferTable] -pub struct TransferTableColumn<'e, N: Number> { - /// Value fraction of ballots counted in this column - pub value_fraction: N, - - /// Cells in this column - pub cells: HashMap<&'e Candidate, TransferTableCell>, - - /// Exhausted cell - pub exhausted: TransferTableCell, -} - -impl<'e, N: Number> TransferTableColumn<'e, N> { - /// Record the specified transfer - pub fn add_transfers(&mut self, candidate: &'e Candidate, ballots: &N) { - if let Some(cell) = self.cells.get_mut(candidate) { - cell.ballots += ballots; - } else { - let cell = TransferTableCell { - ballots: ballots.clone(), - }; - self.cells.insert(candidate, cell); - } - } -} - -/// Cell in a [TransferTable], representing transfers to one candidate at a particular value -pub struct TransferTableCell { - /// Ballots transferred to this candidate - pub ballots: N, -} diff --git a/src/stv/wasm.rs b/src/stv/wasm.rs index 36095f2..8d182b2 100644 --- a/src/stv/wasm.rs +++ b/src/stv/wasm.rs @@ -240,7 +240,6 @@ impl STVOptions { min_threshold: String, constraints_path: Option, constraint_mode: &str, - transfers_detail: bool, pp_decimals: usize, ) -> Self { Self(stv::STVOptions::new( @@ -271,7 +270,7 @@ impl STVOptions { constraint_mode.into(), false, false, - transfers_detail, + false, pp_decimals, )) }