diff --git a/README.md b/README.md index 0c565bd..ebcd5b7 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ OpenTally accepts data in the [BLT file format](https://yingtongli.me/git/OpenTa OpenTally is highly customisable, including options for: * different quotas and quota rules (e.g. exact Droop, Hare) -* calculations using fixed-point arithmetic or exact rational numbers +* calculations using fixed-point arithmetic, guarded fixed-point ([quasi-exact](http://www.votingmatters.org.uk/ISSUE24/I24P2.pdf)) or exact rational numbers * different tie breaking rules (backwards, random, manual) with auditable deterministic random number generation ## Online usage diff --git a/docs/options.md b/docs/options.md index 2ae5d06..3bf048e 100644 --- a/docs/options.md +++ b/docs/options.md @@ -102,6 +102,7 @@ The algorithm used by the random number generator is specified at [rng.md](rng.m This dropdown allows you to select how numbers (vote totals, etc.) are represented internally in memory. The options are: * *Fixed*: Numbers are represented as fixed-precision decimals, up to a certain number of decimal places (default: 5). +* *Fixed (guarded)*: Numbers are represented as fixed-precision decimals with ‘guard digits’ – also known as [‘quasi-exact’ arithmetic](http://www.votingmatters.org.uk/ISSUE24/I24P2.pdf). If *n* decimal places are requested, numbers are represented up to 2*n* decimal places, and two values are considered equal if the absolute difference is less than (10−*n*)/2. * *Rational*: Numbers are represented exactly as fractions, resulting in the elimination of rounding error, but increasing computational complexity when the number of surplus transfers is very large. * *Float (64-bit)*: Numbers are represented as native 64-bit floating-point numbers. This is fast, but not recommended as unexpectedly large rounding errors may be introduced in some circumstances. @@ -113,7 +114,7 @@ This option allows you to specify to how many decimal places votes will be repor In the BLT file format, each set of preferences can have a specified weight – this is typically used to indicate multiple voters who had the same preferences. -When ballots are not normalised (default), a set of preferences with weight *n* > 1 is represented as a single ballot with value *n*. This is known as [tree-packed ballots](http://www.votingmatters.org.uk/ISSUE21/I21P1.pdf). +When ballots are not normalised (default), a set of preferences with weight *n* > 1 is represented as a single ballot with value *n*. This is known as [list-packed ballots](http://www.votingmatters.org.uk/ISSUE21/I21P1.pdf). When ballots are normalised, a set of preferences with weight *n* > 1 is instead converted to *n* ballots each with value 1. This is generally required only when the rules directly deal with individual ballot weights, such as when *Sum surplus transfers* is set to *Per ballot*. diff --git a/html/index.html b/html/index.html index 22eb9af..d014ead 100644 --- a/html/index.html +++ b/html/index.html @@ -149,7 +149,7 @@ diff --git a/html/worker.js b/html/worker.js index 67a7888..10ea1a1 100644 --- a/html/worker.js +++ b/html/worker.js @@ -15,6 +15,9 @@ onmessage = function(evt) { if (evt.data.numbers === 'fixed') { numbers = 'Fixed'; wasm.fixed_set_dps(evt.data.decimals); + } else if (evt.data.numbers === 'gfixed') { + numbers = 'GuardedFixed'; + wasm.gfixed_set_dps(evt.data.decimals); } else if (evt.data.numbers === 'float64') { numbers = 'NativeFloat64'; } else if (evt.data.numbers === 'rational') { diff --git a/src/main.rs b/src/main.rs index bad581f..693b6f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use opentally::stv; use opentally::election::{Candidate, CandidateState, CountCard, CountState, CountStateOrRef, Election, StageResult}; -use opentally::numbers::{Fixed, NativeFloat64, Number, Rational}; +use opentally::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational}; use clap::{AppSettings, Clap}; @@ -52,7 +52,7 @@ struct STV { // -- Numbers settings -- /// Numbers mode - #[clap(help_heading=Some("NUMBERS"), short, long, possible_values=&["rational", "float64", "fixed"], default_value="rational", value_name="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 @@ -175,6 +175,10 @@ fn main() { Fixed::set_dps(cmd_opts.decimals); let election: Election = Election::from_blt(lines.map(|r| r.expect("IO Error").to_string()).into_iter()); count_election(election, cmd_opts); + } else if cmd_opts.numbers == "gfixed" { + GuardedFixed::set_dps(cmd_opts.decimals); + let election: Election = Election::from_blt(lines.map(|r| r.expect("IO Error").to_string()).into_iter()); + count_election(election, cmd_opts); } } diff --git a/src/numbers/gfixed.rs b/src/numbers/gfixed.rs new file mode 100644 index 0000000..5c09a53 --- /dev/null +++ b/src/numbers/gfixed.rs @@ -0,0 +1,358 @@ +/* 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::{Assign, Number}; + +use ibig::{IBig, ops::Abs}; +use num_traits::{Num, One, Zero}; + +use std::cmp::{Ord, Ordering, PartialEq, PartialOrd}; +use std::ops; +use std::fmt; + +static mut DPS: Option = None; +static mut FACTOR: Option = None; +static mut FACTOR_CMP: Option = None; + +#[inline] +pub fn get_dps() -> usize { + unsafe { DPS.unwrap() } +} + +#[inline] +fn get_factor() -> &'static IBig { + unsafe { FACTOR.as_ref().unwrap() } +} + +#[inline] +fn get_factor_cmp() -> &'static IBig { + unsafe { FACTOR_CMP.as_ref().unwrap() } +} + +/// Guarded fixed-point number +#[derive(Clone, Eq)] +pub struct GuardedFixed(IBig); + +impl GuardedFixed { + pub fn set_dps(dps: usize) { + unsafe { + DPS = Some(dps); + FACTOR = Some(IBig::from(10).pow(dps * 2)); + FACTOR_CMP = Some(IBig::from(10).pow(dps) / IBig::from(2)); + } + } +} + +impl Number for GuardedFixed { + fn new() -> Self { Self(IBig::zero()) } + + fn describe() -> String { format!("--numbers gfixed --decimals {}", get_dps()) } + + fn pow_assign(&mut self, exponent: i32) { + self.0 = self.0.pow(exponent as usize) * get_factor() / get_factor().pow(exponent as usize); + } + + fn floor_mut(&mut self, dps: usize) { + // Only do something if truncating + if dps < get_dps() * 2 { + let factor = IBig::from(10).pow(get_dps() * 2 - dps); + self.0 /= &factor; + self.0 *= factor; + } + } + + fn ceil_mut(&mut self, dps: usize) { + // Only do something if truncating + if dps < get_dps() * 2 { + self.0 -= IBig::one(); + let factor = IBig::from(10).pow(get_dps() * 2 - dps); + self.0 /= &factor; + self.0 += IBig::one(); + self.0 *= factor; + } + } +} + +impl Num for GuardedFixed { + type FromStrRadixErr = ibig::error::ParseError; + fn from_str_radix(str: &str, radix: u32) -> Result { + match IBig::from_str_radix(str, radix) { + Ok(value) => Ok(Self(value * get_factor())), + Err(err) => Err(err) + } + } +} + +impl PartialEq for GuardedFixed { + fn eq(&self, other: &GuardedFixed) -> bool { + if &(&self.0 - &other.0).abs() < get_factor_cmp() { + return true; + } else { + return false; + } + } +} + +impl PartialOrd for GuardedFixed { + fn partial_cmp(&self, other: &GuardedFixed) -> Option { + return Some(self.cmp(other)); + } +} + +impl Ord for GuardedFixed { + fn cmp(&self, other: &GuardedFixed) -> Ordering { + if self.eq(other) { + return Ordering::Equal; + } else { + return self.0.cmp(&other.0); + } + } +} + +impl Assign for GuardedFixed { + fn assign(&mut self, src: Self) { self.0 = src.0 } +} + +impl Assign<&Self> for GuardedFixed { + fn assign(&mut self, src: &Self) { self.0 = src.0.clone() } +} + +impl From for GuardedFixed { + fn from(n: usize) -> Self { Self(IBig::from(n) * get_factor()) } +} + +impl From for GuardedFixed { + fn from(n: f64) -> Self { + return Self(IBig::from((n * 10_f64.powi((get_dps() * 2) as i32)).round() as u32)) + } +} + +impl fmt::Display for GuardedFixed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let dps = match f.precision() { + Some(precision) => if precision < get_dps() * 2 { precision } else { get_dps() * 2 }, + None => get_dps(), + }; + + let factor = IBig::from(10).pow(get_dps() * 2 - dps); + let mut result = (&self.0 / factor).abs().to_string(); + + let should_add_minus = (self.0 < IBig::zero()) && result != "0"; + + // Add leading 0s + result = format!("{0:0>1$}", result, dps + 1); + + // Add the decimal point + if dps > 0 { + result.insert(result.len() - dps, '.'); + } + + // Add the sign + if should_add_minus { + result.insert(0, '-'); + } + + return f.write_str(&result); + } +} + +impl One for GuardedFixed { + fn one() -> Self { Self(get_factor().clone()) } +} + +impl Zero for GuardedFixed { + fn zero() -> Self { Self::new() } + fn is_zero(&self) -> bool { self.0.is_zero() } +} + +impl ops::Neg for GuardedFixed { + type Output = Self; + fn neg(self) -> Self::Output { Self(-self.0) } +} + +impl ops::Add for GuardedFixed { + type Output = Self; + fn add(self, _rhs: Self) -> Self::Output { + todo!() + } +} + +impl ops::Sub for GuardedFixed { + type Output = Self; + fn sub(self, _rhs: Self) -> Self::Output { + todo!() + } +} + +impl ops::Mul for GuardedFixed { + type Output = Self; + fn mul(self, _rhs: Self) -> Self::Output { + todo!() + } +} + +impl ops::Div for GuardedFixed { + type Output = Self; + fn div(self, _rhs: Self) -> Self::Output { + todo!() + } +} + +impl ops::Rem for GuardedFixed { + type Output = Self; + fn rem(self, _rhs: Self) -> Self::Output { + todo!() + } +} + +impl ops::Add<&Self> for GuardedFixed { + type Output = Self; + fn add(self, rhs: &Self) -> Self::Output { Self(self.0 + &rhs.0) } +} + +impl ops::Sub<&Self> for GuardedFixed { + type Output = Self; + fn sub(self, rhs: &Self) -> Self::Output { Self(self.0 - &rhs.0) } +} + +impl ops::Mul<&Self> for GuardedFixed { + type Output = Self; + fn mul(self, rhs: &Self) -> Self::Output { Self(self.0 * &rhs.0 / get_factor()) } +} + +impl ops::Div<&Self> for GuardedFixed { + type Output = Self; + fn div(self, rhs: &Self) -> Self::Output { Self(self.0 * get_factor() / &rhs.0) } +} + +impl ops::Rem<&Self> for GuardedFixed { + type Output = Self; + fn rem(self, _rhs: &Self) -> Self::Output { + todo!() + } +} + +impl ops::AddAssign for GuardedFixed { + fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; } +} + +impl ops::SubAssign for GuardedFixed { + fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; } +} + +impl ops::MulAssign for GuardedFixed { + fn mul_assign(&mut self, rhs: Self) { + self.0 *= rhs.0; + self.0 /= get_factor(); + } +} + +impl ops::DivAssign for GuardedFixed { + fn div_assign(&mut self, rhs: Self) { + self.0 *= get_factor(); + self.0 /= rhs.0; + } +} + +impl ops::RemAssign for GuardedFixed { + fn rem_assign(&mut self, _rhs: Self) { + todo!() + } +} + +impl ops::AddAssign<&Self> for GuardedFixed { + fn add_assign(&mut self, rhs: &Self) { self.0 += &rhs.0; } +} + +impl ops::SubAssign<&Self> for GuardedFixed { + fn sub_assign(&mut self, rhs: &Self) { self.0 -= &rhs.0; } +} + +impl ops::MulAssign<&Self> for GuardedFixed { + fn mul_assign(&mut self, rhs: &Self) { + self.0 *= &rhs.0; + self.0 /= get_factor(); + } +} + +impl ops::DivAssign<&Self> for GuardedFixed { + fn div_assign(&mut self, _rhs: &Self) { + todo!() + } +} + +impl ops::RemAssign<&Self> for GuardedFixed { + fn rem_assign(&mut self, _rhs: &Self) { + todo!() + } +} + +impl ops::Neg for &GuardedFixed { + type Output = GuardedFixed; + fn neg(self) -> Self::Output { GuardedFixed(-&self.0) } +} + +impl ops::Add for &GuardedFixed { + type Output = GuardedFixed; + fn add(self, rhs: Self) -> Self::Output { GuardedFixed(&self.0 + &rhs.0) } +} + +impl ops::Sub for &GuardedFixed { + type Output = GuardedFixed; + fn sub(self, rhs: Self) -> Self::Output { GuardedFixed(&self.0 - &rhs.0) } +} + +impl ops::Mul for &GuardedFixed { + type Output = GuardedFixed; + fn mul(self, _rhs: Self) -> Self::Output { + todo!() + } +} + +impl ops::Div for &GuardedFixed { + type Output = GuardedFixed; + fn div(self, rhs: Self) -> Self::Output { GuardedFixed(&self.0 * get_factor() / &rhs.0) } +} + +impl ops::Rem for &GuardedFixed { + type Output = GuardedFixed; + fn rem(self, _rhs: Self) -> Self::Output { + todo!() + } +} + +/* +impl ops::Add<&&Rational> for &Rational { + +} + +impl ops::Sub<&&Rational> for &Rational { + +} + +impl ops::Mul<&&Rational> for &Rational { + +} + +impl ops::Div<&&Rational> for &Rational { + +} + +impl ops::Rem<&&Rational> for &Rational { + +} +*/ diff --git a/src/numbers/mod.rs b/src/numbers/mod.rs index e3893a5..c1e115f 100644 --- a/src/numbers/mod.rs +++ b/src/numbers/mod.rs @@ -17,6 +17,8 @@ /// Fixed-point arithmetic (using `ibig`) mod fixed; +/// Guarded fixed-point arithmetic (using `ibig`) +mod gfixed; /// Native 64-bit floating point arithmetic mod native; @@ -73,6 +75,7 @@ where } pub use self::fixed::Fixed; +pub use self::gfixed::GuardedFixed; pub use self::native::NativeFloat64; #[cfg(not(target_arch = "wasm32"))] diff --git a/src/stv/wasm.rs b/src/stv/wasm.rs index 4ac48da..8639ec1 100644 --- a/src/stv/wasm.rs +++ b/src/stv/wasm.rs @@ -18,7 +18,7 @@ #![allow(rustdoc::private_intra_doc_links)] use crate::election::{CandidateState, CountState, Election}; -use crate::numbers::{Fixed, NativeFloat64, Number, Rational}; +use crate::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational}; use crate::stv; extern crate console_error_panic_hook; @@ -34,6 +34,12 @@ pub fn fixed_set_dps(dps: usize) { Fixed::set_dps(dps); } +/// Wrapper for [GuardedFixed::set_dps] +#[wasm_bindgen] +pub fn gfixed_set_dps(dps: usize) { + GuardedFixed::set_dps(dps); +} + // Helper macros for making functions macro_rules! impl_type { @@ -152,6 +158,7 @@ macro_rules! impl_type { } impl_type!(Fixed); +impl_type!(GuardedFixed); impl_type!(NativeFloat64); impl_type!(Rational); diff --git a/tests/scotland.rs b/tests/scotland.rs index 17c5b88..9ef05d1 100644 --- a/tests/scotland.rs +++ b/tests/scotland.rs @@ -18,7 +18,7 @@ // https://web.archive.org/web/20121004213938/http://www.glasgow.gov.uk/en/YourCouncil/Elections_Voting/Election_Results/ElectionScotland2007/LGWardResults.htm?ward=1&wardname=1%20-%20Linn use opentally::election::{CandidateState, CountState, Election}; -use opentally::numbers::Fixed; +use opentally::numbers::{Fixed, GuardedFixed, Number}; use opentally::stv; use num_traits::Zero; @@ -31,8 +31,8 @@ use std::fs::File; fn scotland_linn07_fixed5() { let stv_opts = stv::STVOptions { round_tvs: Some(5), - round_weights: None, - round_votes: None, + round_weights: Some(5), + round_votes: Some(5), round_quota: Some(0), sum_surplus_transfers: stv::SumSurplusTransfersMode::PerBallot, normalise_ballots: true, @@ -49,7 +49,35 @@ fn scotland_linn07_fixed5() { pp_decimals: 5, }; Fixed::set_dps(5); - + scotland_linn07::(stv_opts); +} + +#[test] +fn scotland_linn07_gfixed5() { + let stv_opts = stv::STVOptions { + round_tvs: Some(5), + round_weights: Some(5), + round_votes: Some(5), + round_quota: Some(0), + sum_surplus_transfers: stv::SumSurplusTransfersMode::PerBallot, + normalise_ballots: true, + quota: stv::QuotaType::Droop, + quota_criterion: stv::QuotaCriterion::GreaterOrEqual, + quota_mode: stv::QuotaMode::Static, + ties: vec![], + surplus: stv::SurplusMethod::WIG, + surplus_order: stv::SurplusOrder::BySize, + transferable_only: false, + exclusion: stv::ExclusionMethod::SingleStage, + bulk_exclude: false, + defer_surpluses: false, + pp_decimals: 5, + }; + GuardedFixed::set_dps(5); + scotland_linn07::(stv_opts); +} + +fn scotland_linn07(stv_opts: stv::STVOptions) { // Read XML file let file = File::open("tests/data/linn07.xml").expect("IO Error"); let root = Element::parse(file).expect("Parse Error");