diff --git a/src/main.rs b/src/main.rs index e1740fd..ffaf5b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,10 +61,22 @@ struct STV { // ----------------------- // -- Rounding settings -- + /// Round transfer values to specified decimal places + #[clap(help_heading=Some("ROUNDING"), long, value_name="dps")] + round_tvs: Option, + + /// Round ballot weights to specified decimal places + #[clap(help_heading=Some("ROUNDING"), long, value_name="dps")] + round_weights: Option, + /// Round votes to specified decimal places #[clap(help_heading=Some("ROUNDING"), long, value_name="dps")] round_votes: Option, + /// Round quota to specified decimal places + #[clap(help_heading=Some("ROUNDING"), long, value_name="dps")] + round_quota: Option, + // ------------------ // -- STV variants -- @@ -126,7 +138,10 @@ where { // Copy applicable options let stv_opts = stv::STVOptions::new( + cmd_opts.round_tvs, + cmd_opts.round_weights, cmd_opts.round_votes, + cmd_opts.round_quota, &cmd_opts.surplus, &cmd_opts.surplus_order, cmd_opts.transferable_only, diff --git a/src/numbers/mod.rs b/src/numbers/mod.rs index 9c5e4b0..ad0066d 100644 --- a/src/numbers/mod.rs +++ b/src/numbers/mod.rs @@ -33,11 +33,19 @@ pub trait Assign { fn assign(&mut self, src: Src); } +pub trait From { + fn from(n: T) -> Self; +} + //pub trait Number: NumRef + NumAssignRef + PartialOrd + Assign + Clone + fmt::Display where for<'a> &'a Self: RefNum<&'a Self> { -pub trait Number: NumRef + NumAssignRef + ops::Neg + Ord + Assign + Clone + fmt::Display where for<'a> Self: Assign<&'a Self> { +pub trait Number: + NumRef + NumAssignRef + ops::Neg + Ord + Assign + From + From + Clone + fmt::Display +where + for<'a> Self: Assign<&'a Self> +{ fn new() -> Self; - fn from(n: usize) -> Self; + fn pow_assign(&mut self, exponent: i32); fn floor_mut(&mut self, dps: usize); fn parse(s: &str) -> Self { diff --git a/src/numbers/native.rs b/src/numbers/native.rs index d83c51e..c136850 100644 --- a/src/numbers/native.rs +++ b/src/numbers/native.rs @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -use super::{Assign, Number}; +use super::{Assign, From, Number}; use num_traits::{Num, One, Zero}; @@ -31,7 +31,9 @@ pub struct NativeFloat64(ImplType); impl Number for NativeFloat64 { fn new() -> Self { Self(0.0) } - fn from(n: usize) -> Self { Self(n as ImplType) } + fn pow_assign(&mut self, exponent: i32) { + self.0 = self.0.powi(exponent); + } fn floor_mut(&mut self, dps: usize) { let factor = 10.0_f64.powi(dps as i32); @@ -58,6 +60,14 @@ impl Assign<&NativeFloat64> for NativeFloat64 { fn assign(&mut self, src: &NativeFloat64) { self.0 = src.0 } } +impl From for NativeFloat64 { + fn from(n: usize) -> Self { Self(n as ImplType) } +} + +impl From for NativeFloat64 { + fn from(n: f64) -> Self { Self(n as ImplType) } +} + impl Clone for NativeFloat64 { fn clone(&self) -> Self { Self(self.0) } } @@ -167,9 +177,7 @@ impl ops::SubAssign for NativeFloat64 { } impl ops::MulAssign for NativeFloat64 { - fn mul_assign(&mut self, _rhs: Self) { - todo!() - } + fn mul_assign(&mut self, rhs: Self) { self.0 *= rhs.0 } } impl ops::DivAssign for NativeFloat64 { diff --git a/src/numbers/rational_num.rs b/src/numbers/rational_num.rs index 13da822..d161368 100644 --- a/src/numbers/rational_num.rs +++ b/src/numbers/rational_num.rs @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -use super::{Assign, Number}; +use super::{Assign, From, Number}; use num_bigint::{BigInt, ParseBigIntError}; use num_rational::BigRational; // TODO: Can we do Ratio and combine with ibig? @@ -30,7 +30,9 @@ pub struct Rational(BigRational); impl Number for Rational { fn new() -> Self { Self(BigRational::zero()) } - fn from(n: usize) -> Self { Self(BigRational::from_integer(BigInt::from(n))) } + fn pow_assign(&mut self, exponent: i32) { + self.0 = self.0.pow(exponent); + } fn floor_mut(&mut self, dps: usize) { if dps == 0 { @@ -65,6 +67,17 @@ impl Assign<&Rational> for Rational { fn assign(&mut self, src: &Rational) { self.0 = src.0.clone() } } +impl From for Rational { + fn from(n: usize) -> Self { Self(BigRational::from_integer(BigInt::from(n))) } +} + +impl From for Rational { + fn from(n: f64) -> Self { + // FIXME: This is very broken! + return Self(BigRational::from_float(n).unwrap() * BigRational::from_integer(BigInt::from(100000)).round() / BigRational::from_integer(BigInt::from(100000))); + } +} + impl Clone for Rational { fn clone(&self) -> Self { Self(self.0.clone()) } } @@ -201,15 +214,11 @@ impl ops::SubAssign for Rational { } impl ops::MulAssign for Rational { - fn mul_assign(&mut self, _rhs: Self) { - todo!() - } + fn mul_assign(&mut self, rhs: Self) { self.0 *= rhs.0 } } impl ops::DivAssign for Rational { - fn div_assign(&mut self, rhs: Self) { - self.0 /= &rhs.0; - } + fn div_assign(&mut self, rhs: Self) { self.0 /= &rhs.0 } } impl ops::RemAssign for Rational { diff --git a/src/numbers/rational_rug.rs b/src/numbers/rational_rug.rs index 8c5fb5f..0411b3b 100644 --- a/src/numbers/rational_rug.rs +++ b/src/numbers/rational_rug.rs @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -use super::{Assign, Number}; +use super::{Assign, From, Number}; use num_traits::{Num, One, Zero}; -use rug::{self, ops::Pow, rational::ParseRationalError}; +use rug::{self, ops::Pow, ops::PowAssign, rational::ParseRationalError}; use rug::Assign as RugAssign; use std::cmp::{Ord, Ordering, PartialEq, PartialOrd}; @@ -30,7 +30,9 @@ pub struct Rational(rug::Rational); impl Number for Rational { fn new() -> Self { Self(rug::Rational::new()) } - fn from(n: usize) -> Self { Self(rug::Rational::from(n)) } + fn pow_assign(&mut self, exponent: i32) { + self.0.pow_assign(exponent); + } fn floor_mut(&mut self, dps: usize) { if dps == 0 { @@ -63,6 +65,17 @@ impl Assign<&Rational> for Rational { fn assign(&mut self, src: &Rational) { self.0.assign(&src.0) } } +impl From for Rational { + fn from(n: usize) -> Self { Self(rug::Rational::from(n)) } +} + +impl From for Rational { + fn from(n: f64) -> Self { + // FIXME: This is very broken! + return Self((rug::Rational::from_f64(n).unwrap() * rug::Rational::from(100000)).round() / rug::Rational::from(100000)); + } +} + impl Clone for Rational { fn clone(&self) -> Self { Self(self.0.clone()) } } @@ -199,15 +212,11 @@ impl ops::SubAssign for Rational { } impl ops::MulAssign for Rational { - fn mul_assign(&mut self, _rhs: Self) { - todo!() - } + fn mul_assign(&mut self, rhs: Self) { self.0 *= rhs.0 } } impl ops::DivAssign for Rational { - fn div_assign(&mut self, rhs: Self) { - self.0 /= &rhs.0; - } + fn div_assign(&mut self, rhs: Self) { self.0 /= rhs.0 } } impl ops::RemAssign for Rational { @@ -225,9 +234,7 @@ impl ops::SubAssign<&Rational> for Rational { } impl ops::MulAssign<&Rational> for Rational { - fn mul_assign(&mut self, _rhs: &Rational) { - todo!() - } + fn mul_assign(&mut self, rhs: &Rational) { self.0 *= &rhs.0 } } impl ops::DivAssign<&Rational> for Rational { diff --git a/src/stv/mod.rs b/src/stv/mod.rs index 2ab4d1f..863ec21 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -30,7 +30,10 @@ use std::ops; #[wasm_bindgen] pub struct STVOptions { + pub round_tvs: Option, + pub round_weights: Option, pub round_votes: Option, + pub round_quota: Option, pub surplus: SurplusMethod, pub surplus_order: SurplusOrder, pub transferable_only: bool, @@ -41,7 +44,10 @@ pub struct STVOptions { #[wasm_bindgen] impl STVOptions { pub fn new( + round_tvs: Option, + round_weights: Option, round_votes: Option, + round_quota: Option, surplus: &str, surplus_order: &str, transferable_only: bool, @@ -49,7 +55,10 @@ impl STVOptions { pp_decimals: usize, ) -> Self { return STVOptions { - round_votes: round_votes, + round_tvs, + round_weights, + round_votes, + round_quota, surplus: match surplus { "wig" => SurplusMethod::WIG, "uig" => SurplusMethod::UIG, @@ -62,14 +71,14 @@ impl STVOptions { "by_order" => SurplusOrder::ByOrder, _ => panic!("Invalid --surplus-order"), }, - transferable_only: transferable_only, + transferable_only, exclusion: match exclusion { "single_stage" => ExclusionMethod::SingleStage, "by_value" => ExclusionMethod::ByValue, "parcels_by_order" => ExclusionMethod::ParcelsByOrder, _ => panic!("Invalid --exclusion"), }, - pp_decimals: pp_decimals, + pp_decimals, }; } } @@ -250,9 +259,16 @@ fn calculate_quota(state: &mut CountState, opts: &STVOptions) { // TODO: Different quotas state.quota /= N::from(state.election.seats + 1); - // TODO: Different rounding rules - state.quota += N::one(); - state.quota.floor_mut(0); + // Increment to next available increment + if let Some(dps) = opts.round_quota { + let mut factor = N::from(10); + factor.pow_assign(dps as i32); + state.quota *= &factor; + state.quota.floor_mut(0); + state.quota += N::one(); + state.quota /= factor; + } + log.push_str(format!("{:.dps$}.", state.quota, dps=opts.pp_decimals).as_str()); state.logger.log_literal(log); @@ -299,7 +315,8 @@ where if has_surplus.len() > 0 { match opts.surplus_order { SurplusOrder::BySize => { - has_surplus.sort_unstable_by(|a, b| a.1.votes.partial_cmp(&b.1.votes).unwrap()); + // Compare b with a to sort high-low + has_surplus.sort_unstable_by(|a, b| b.1.votes.partial_cmp(&a.1.votes).unwrap()); } SurplusOrder::ByOrder => { has_surplus.sort_unstable_by(|a, b| a.1.order_elected.partial_cmp(&b.1.order_elected).unwrap()); @@ -340,19 +357,47 @@ where } } -fn reweight_vote(num_votes: &N, num_ballots: &N, surplus: &N, weighted: bool, transfer_denom: &Option) -> N { +fn reweight_vote( + num_votes: &N, + num_ballots: &N, + surplus: &N, + weighted: bool, + transfer_value: &Option, + transfer_denom: &Option, + round_tvs: Option, + rounding: Option) -> N +{ + let mut result; + match transfer_denom { Some(v) => { - if weighted { - return num_votes.clone() * surplus / v; + if let Some(_) = round_tvs { + // Rounding requested: use the rounded transfer value + if weighted { + result = num_votes.clone() * transfer_value.as_ref().unwrap(); + } else { + result = num_ballots.clone() * transfer_value.as_ref().unwrap(); + } } else { - return num_ballots.clone() * surplus / v; + // Avoid unnecessary rounding error by first multiplying by the surplus + if weighted { + result = num_votes.clone() * surplus / v; + } else { + result = num_ballots.clone() * surplus / v; + } } } None => { - return num_votes.clone(); + result = num_votes.clone(); } } + + // Round down if requested + if let Some(dps) = rounding { + result.floor_mut(dps); + } + + return result; } fn distribute_surplus(state: &mut CountState, opts: &STVOptions, elected_candidate: &Candidate) @@ -395,13 +440,20 @@ where let transferable_votes = &result.total_votes - &result.exhausted.num_votes; let transfer_denom = calculate_transfer_denom(&surplus, &result, &transferable_votes, is_weighted, opts.transferable_only); - let transfer_value; + let mut transfer_value; match transfer_denom { Some(ref v) => { - transfer_value = surplus.clone() / v; - state.logger.log_literal(format!("Surplus of {} distributed at value {:.dps$}.", elected_candidate.name, transfer_value, dps=opts.pp_decimals)); + transfer_value = Some(surplus.clone() / v); + + // Round down if requested + if let Some(dps) = opts.round_tvs { + transfer_value.as_mut().unwrap().floor_mut(dps); + } + + state.logger.log_literal(format!("Surplus of {} distributed at value {:.dps$}.", elected_candidate.name, transfer_value.as_ref().unwrap(), dps=opts.pp_decimals)); } None => { + transfer_value = None; state.logger.log_literal(format!("Surplus of {} distributed at values received.", elected_candidate.name)); } } @@ -413,17 +465,13 @@ where // Reweight votes for vote in parcel.iter_mut() { - vote.value = reweight_vote(&vote.value, &vote.ballot.orig_value, &surplus, is_weighted, &transfer_denom); + vote.value = reweight_vote(&vote.value, &vote.ballot.orig_value, &surplus, is_weighted, &transfer_value, &transfer_denom, opts.round_tvs, opts.round_weights); } let count_card = state.candidates.get_mut(candidate).unwrap(); count_card.parcels.push(parcel); - let mut candidate_transfers = reweight_vote(&entry.num_votes, &entry.num_ballots, &surplus, is_weighted, &transfer_denom); - // Round transfers - if let Some(dps) = opts.round_votes { - candidate_transfers.floor_mut(dps); - } + let candidate_transfers = reweight_vote(&entry.num_votes, &entry.num_ballots, &surplus, is_weighted, &transfer_value, &transfer_denom, opts.round_tvs, opts.round_votes); count_card.transfer(&candidate_transfers); checksum += candidate_transfers; } @@ -441,7 +489,7 @@ where exhausted_transfers = &surplus - &transferable_votes; } } else { - exhausted_transfers = reweight_vote(&result.exhausted.num_votes, &result.exhausted.num_ballots, &surplus, is_weighted, &transfer_denom); + exhausted_transfers = reweight_vote(&result.exhausted.num_votes, &result.exhausted.num_ballots, &surplus, is_weighted, &transfer_value, &transfer_denom, opts.round_tvs, opts.round_votes); } if let Some(dps) = opts.round_votes { diff --git a/tests/aec.rs b/tests/aec.rs index d7a8bc5..ec5792c 100644 --- a/tests/aec.rs +++ b/tests/aec.rs @@ -15,15 +15,17 @@ * along with this program. If not, see . */ -use opentally::election::{CandidateState, CountState, Election}; -use opentally::numbers::{Number, Rational}; +mod utils; + +use opentally::election::Election; +use opentally::numbers::Rational; use opentally::stv; use csv::StringRecord; use flate2::bufread::GzDecoder; use std::fs::File; -use std::io::{self, BufRead}; +use std::io::{self, BufRead}; #[test] fn aec_tas19_rational() { @@ -38,6 +40,8 @@ fn aec_tas19_rational() { // Remove exhausted/LBF rows candidates.truncate(candidates.len() - 2); + // TODO: Validate candidate names + let stages: Vec = records.first().unwrap().iter().skip(1).step_by(2).map(|s| s.parse().unwrap()).collect(); // Decompress BLT @@ -50,11 +54,11 @@ fn aec_tas19_rational() { // Read BLT let election: Election = Election::from_blt(lines.map(|r| r.expect("IO Error").to_string()).into_iter()); - // TODO: Validate candidate names - - // Initialise options let stv_opts = stv::STVOptions { + round_tvs: None, + round_weights: None, round_votes: Some(0), + round_quota: Some(0), surplus: stv::SurplusMethod::UIG, surplus_order: stv::SurplusOrder::ByOrder, transferable_only: false, @@ -62,49 +66,5 @@ fn aec_tas19_rational() { pp_decimals: 2, }; - // Initialise count state - let mut state = CountState::new(&election); - - // Distribute first preferences - stv::count_init(&mut state, &stv_opts); - let mut stage_num = 1; - - for (idx, stage) in stages.into_iter().enumerate() { - while stage_num < stage { - // Step through stages - // Assert count not yet done - assert_eq!(stv::count_one_stage(&mut state, &stv_opts), false); - stage_num += 1; - } - validate_stage(idx, &state, &records); - } -} - -fn validate_stage(idx: usize, state: &CountState, records: &Vec) { - // Validate candidate votes - let mut candidate_votes: Vec = records.iter().skip(2).map(|r| N::parse(&r[idx*2 + 1])).collect(); - // Remove exhausted/LBF rows - candidate_votes.truncate(candidate_votes.len() - 2); - - for (candidate, votes) in state.election.candidates.iter().zip(candidate_votes) { - let count_card = state.candidates.get(candidate).unwrap(); - assert!(count_card.votes == votes); - } - - // Validate candidate states - let mut candidate_states: Vec<&str> = records.iter().skip(2).map(|r| &r[idx*2 + 2]).collect(); - candidate_states.truncate(candidate_states.len() - 2); - - for (candidate, candidate_state) in state.election.candidates.iter().zip(candidate_states) { - let count_card = state.candidates.get(candidate).unwrap(); - if candidate_state == "" { - assert!(count_card.state == CandidateState::HOPEFUL); - } else if candidate_state == "EL" || candidate_state == "PEL" { - assert!(count_card.state == CandidateState::ELECTED); - } else if candidate_state == "EX" || candidate_state == "EXCLUDING" { - assert!(count_card.state == CandidateState::EXCLUDED); - } else { - panic!("Unknown state descriptor {}", candidate_state); - } - } + utils::validate_election(stages, records, election, stv_opts); } diff --git a/tests/data/prsa1.blt b/tests/data/prsa1.blt new file mode 100644 index 0000000..fbc54ae --- /dev/null +++ b/tests/data/prsa1.blt @@ -0,0 +1,75 @@ +7 4 +1 1 3 0 +1 2 4 7 0 +1 2 6 1 7 0 +1 2 6 5 3 0 +1 3 0 +1 4 0 +1 3 0 +1 4 0 +1 4 0 +1 3 0 +1 5 7 0 +1 4 1 0 +1 6 0 +1 4 0 +1 2 3 0 +1 3 0 +1 7 0 +1 4 0 +1 2 4 7 0 +1 2 4 7 0 +1 4 0 +1 2 3 0 +1 4 0 +1 7 0 +1 2 6 0 +1 2 1 0 +1 7 0 +1 2 4 1 3 0 +1 4 0 +1 6 0 +1 6 0 +1 2 4 3 7 0 +1 6 0 +1 2 7 0 +1 2 7 0 +1 2 6 1 7 0 +1 3 0 +1 2 4 7 0 +1 2 6 0 +1 2 4 3 7 0 +1 2 6 0 +1 2 4 7 0 +1 4 0 +1 2 6 7 0 +1 2 6 1 0 +1 2 4 7 0 +1 6 0 +1 2 4 7 0 +1 2 3 0 +1 6 0 +1 2 6 5 3 0 +1 2 4 1 7 0 +1 6 0 +1 6 0 +1 2 1 0 +1 2 4 7 0 +1 2 6 5 1 7 0 +1 2 7 0 +1 2 4 3 7 0 +1 6 0 +1 2 3 0 +1 2 5 7 0 +1 2 4 5 1 3 0 +1 6 0 +1 6 0 +0 +"Evans" +"Grey" +"Thomson" +"Ames" +"Reid" +"Spears" +"White" +"1976 Committee of the Utopia Tennis Club" diff --git a/tests/data/prsa1.csv b/tests/data/prsa1.csv new file mode 100644 index 0000000..1ec24bf --- /dev/null +++ b/tests/data/prsa1.csv @@ -0,0 +1,11 @@ +Stage:,1,,2,,3,,4,,5,,6,,7,,8,,9,,10,,11,,12,,13,,14,,15,,16,,17, +Comment:,First preferences,,Surplus of Grey,,Surplus of Ames,,Surplus of Spears,,Exclusion of Reid,,Exclusion of Reid,,Exclusion of Reid,,Exclusion of Reid,,Exclusion of Evans,,Exclusion of Evans,,Exclusion of Evans,,Exclusion of Evans,,Exclusion of Evans,,Exclusion of Evans,,Exclusion of Thomson,,Exclusion of Thomson,,Exclusion of Thomson, +Evans,1,,2.234,,3.038,,4.823,,4.823,,4.823,,5.225,,5.82,,4.82,EX,3.586,EX,2.782,EX,0.997,EX,0.595,EX,0,EX,0,EX,0,EX,0,EX +Grey,34,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL +Thomson,5,,7.468,,8.674,,8.674,,8.674,,8.674,,8.674,,9.864,,10.864,,10.864,,11.266,,11.266,,11.668,,11.668,,6.668,EX,4.2,EX,2.994,EX +Ames,10,,18.638,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL +Reid,1,,1.617,,2.019,,3.804,,2.804,EX,2.187,EX,1.785,EX,0,EX,0,EX,0,EX,0,EX,0,EX,0,EX,0,EX,0,EX,0,EX,0,EX +Spears,11,,17.17,EL,17.17,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL +White,3,,4.851,,8.067,,8.662,,9.662,,10.279,,10.279,,10.279,,10.279,,10.279,,10.681,,11.871,,11.871,,12.466,,12.466,,12.466,,13.672,EL +Exhausted,0,,0,,0,,0,,0,,0,,0,,0,,0,,1.234,,1.234,,1.829,,1.829,,1.829,,6.829,,9.297,,9.297, +Loss to fraction,0,,0.021,,0.03,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034, diff --git a/tests/data/prsa1.ods b/tests/data/prsa1.ods new file mode 100644 index 0000000..09eb3fd Binary files /dev/null and b/tests/data/prsa1.ods differ diff --git a/tests/prsa.rs b/tests/prsa.rs new file mode 100644 index 0000000..0cc78a2 --- /dev/null +++ b/tests/prsa.rs @@ -0,0 +1,37 @@ +/* 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 . + */ + +mod utils; + +use opentally::numbers::Rational; +use opentally::stv; + +#[test] +fn prsa1_rational() { + let stv_opts = stv::STVOptions { + round_tvs: Some(3), + round_weights: Some(3), + round_votes: Some(3), + round_quota: Some(3), + surplus: stv::SurplusMethod::EG, + surplus_order: stv::SurplusOrder::ByOrder, + transferable_only: true, + exclusion: stv::ExclusionMethod::ParcelsByOrder, + pp_decimals: 2, + }; + utils::read_validate_election::("tests/data/prsa1.csv", "tests/data/prsa1.blt", stv_opts); +} diff --git a/tests/utils/mod.rs b/tests/utils/mod.rs new file mode 100644 index 0000000..bd0325b --- /dev/null +++ b/tests/utils/mod.rs @@ -0,0 +1,110 @@ +/* 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 opentally::election::{CandidateState, CountState, Election}; +use opentally::numbers::{Number, Rational}; +use opentally::stv; + +use csv::StringRecord; + +use std::fs::File; +use std::io::{self, BufRead}; +use std::ops; + +#[allow(dead_code)] // Suppress false positive +pub fn read_validate_election(csv_file: &str, blt_file: &str, stv_opts: stv::STVOptions) { + // Read CSV file + let reader = csv::ReaderBuilder::new() + .has_headers(false) + .from_path(csv_file) + .expect("IO Error"); + let records: Vec = reader.into_records().map(|r| r.expect("Syntax Error")).collect(); + + let mut candidates: Vec<&str> = records.iter().skip(2).map(|r| &r[0]).collect(); + // Remove exhausted/LBF rows + candidates.truncate(candidates.len() - 2); + + // TODO: Validate candidate names + + let stages: Vec = records.first().unwrap().iter().skip(1).step_by(2).map(|s| s.parse().unwrap()).collect(); + + // Decompress BLT + let file = File::open(blt_file).expect("IO Error"); + let file_reader = io::BufReader::new(file); + let lines = file_reader.lines(); + + // Read BLT + let election: Election = Election::from_blt(lines.map(|r| r.expect("IO Error").to_string()).into_iter()); + + validate_election(stages, records, election, stv_opts); +} + +pub fn validate_election(stages: Vec, records: Vec, election: Election, stv_opts: stv::STVOptions) +where + for<'r> &'r N: ops::Sub<&'r N, Output=N>, + for<'r> &'r N: ops::Div<&'r N, Output=N>, + for<'r> &'r N: ops::Neg, +{ + // Initialise count state + let mut state = CountState::new(&election); + + // Distribute first preferences + stv::count_init(&mut state, &stv_opts); + let mut stage_num = 1; + + for (idx, stage) in stages.into_iter().enumerate() { + while stage_num < stage { + // Step through stages + // Assert count not yet done + assert_eq!(stv::count_one_stage(&mut state, &stv_opts), false); + stage_num += 1; + } + validate_stage(idx, &state, &records); + } +} + +fn validate_stage(idx: usize, state: &CountState, records: &Vec) { + println!("Col at idx {}", idx); + + // Validate candidate votes + //let mut candidate_votes: Vec = records.iter().skip(2).map(|r| N::parse(&r[idx*2 + 1])).collect(); + let mut candidate_votes: Vec = records.iter().skip(2).map(|r| N::from(r[idx*2 + 1].parse::().expect("Syntax Error"))).collect(); + // Remove exhausted/LBF rows + candidate_votes.truncate(candidate_votes.len() - 2); + + for (candidate, votes) in state.election.candidates.iter().zip(candidate_votes) { + let count_card = state.candidates.get(candidate).unwrap(); + assert!(count_card.votes == votes, "Failed to validate votes for candidate {}. Expected {:}, got {:}", candidate.name, votes, count_card.votes); + } + + // Validate candidate states + let mut candidate_states: Vec<&str> = records.iter().skip(2).map(|r| &r[idx*2 + 2]).collect(); + candidate_states.truncate(candidate_states.len() - 2); + + for (candidate, candidate_state) in state.election.candidates.iter().zip(candidate_states) { + let count_card = state.candidates.get(candidate).unwrap(); + if candidate_state == "" { + assert!(count_card.state == CandidateState::HOPEFUL); + } else if candidate_state == "EL" || candidate_state == "PEL" { + assert!(count_card.state == CandidateState::ELECTED); + } else if candidate_state == "EX" || candidate_state == "EXCLUDING" { + assert!(count_card.state == CandidateState::EXCLUDED); + } else { + panic!("Unknown state descriptor {}", candidate_state); + } + } +}