diff --git a/docs/options.md b/docs/options.md index e0211c0..d32881d 100644 --- a/docs/options.md +++ b/docs/options.md @@ -167,10 +167,33 @@ The algorithm used by the random number generator is specified at [rng.md](https This file selector allows you to load a [CON file](https://yingtongli.me/git/OpenTally/about/docs/con-fmt.md) specifying constraints on the election. For example, if a certain minimum or maximum number of candidates can be elected from a particular category. -OpenTally applies constraints using the Grey–Fitzgerald method. Whenever a candidate is declared elected or excluded, any candidate who must be elected to secure a conformant result is deemed *guarded*, and any candidate who must not be elected to secure a conformant result is deemed *doomed*. Any candidate who is doomed is excluded at the next opportunity. Any candidate who is guarded is prevented from being excluded. +### Constraint method (--constraint-method) + +This dropdown allows you to select how constraints are applied. The options are: + +*Guard/doom* (default): + +When this option is selected, OpenTally applies constraints using the Grey–Fitzgerald method. Whenever a candidate is declared elected or excluded, any candidate who must be elected to secure a conformant result is deemed *guarded*, and any candidate who must not be elected to secure a conformant result is deemed *doomed*. Any candidate who is doomed is excluded at the next opportunity. Any candidate who is guarded is prevented from being excluded. Multiple constraints are supported using the method described by Hill ([*Voting Matters* 1998;(9):2–4](http://www.votingmatters.org.uk/ISSUE9/P1.HTM)) and Otten ([*Voting Matters* 2001;(13):4–7](http://www.votingmatters.org.uk/ISSUE13/P3.HTM)). +*Repeat count*: + +When this option is selected, only constraints specifying a maximum number of candidates to be elected from a particular group are supported. Other constraint groups will be **silently ignored**. Note that each candidate must still be assigned to exactly one group within each constraint. + +The count proceeds as normal, until the point that a candidate would be elected who would violate the constraint. At this point, that candidate and all other candidates from the constrained group are excluded, and all previously excluded candidates from the non-constrained group are reintroduced. + +All ballot papers are removed from the count, and redistributed among the candidates in the following order: + +* Any undistributed surpluses, each surplus comprising one stage +* Any exhausted ballot papers, in one or more stages (according to *Exclusion method*) +* The ballot papers of each continuing candidate from the non-constrained group, in one or more stages (according to *Exclusion method*), candidate-by-candidate in random order or an order specified by the user (according to *Ties*, with options other than *Random* and *Prompt* ignored) +* The ballot papers of each continuing candidate from the constrained group, in like manner + +Once all ballot papers have been so redistributed, the count resumes as usual. + +This method is specified, for example, in Schedule 1.1 of the [Monash Student Association *Election Regulations* (2021)](https://msa.monash.edu/app/uploads/2021/07/MSA-Election-Regulations-2021.pdf). + ## Report options ### Report style diff --git a/html/index.html b/html/index.html index f9d5670..d26c216 100644 --- a/html/index.html +++ b/html/index.html @@ -183,7 +183,16 @@ Constraints:
- + +
Report options: diff --git a/html/index.js b/html/index.js index 2c3c7ec..cd8a296 100644 --- a/html/index.js +++ b/html/index.js @@ -165,7 +165,7 @@ async function clickCount() { document.getElementById('chkImmediateElect').checked, document.getElementById('txtMinThreshold').value, conPath, - "guard_doom", + document.getElementById('selConstraintMethod').value, parseInt(document.getElementById('txtPPDP').value), ]; diff --git a/html/worker.js b/html/worker.js index fe4daeb..103c3a5 100644 --- a/html/worker.js +++ b/html/worker.js @@ -1,3 +1,20 @@ +/* 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 . + */ + importScripts('opentally.js?v=GITVERSION'); var wasm = wasm_bindgen; @@ -53,7 +70,7 @@ onmessage = function(evt) { // Init constraints if applicable if (evt.data.conData) { - wasm['election_load_constraints_' + numbers](election, evt.data.conData); + wasm['election_load_constraints_' + numbers](election, evt.data.conData, opts); } // Describe count diff --git a/src/cli/stv.rs b/src/cli/stv.rs index 6a763b7..fef4708 100644 --- a/src/cli/stv.rs +++ b/src/cli/stv.rs @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -use crate::constraints::Constraints; +use crate::constraints::{self, Constraints}; use crate::election::{CandidateState, CountState, Election, StageKind}; use crate::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational}; use crate::parser::{bin, blt}; @@ -176,7 +176,7 @@ pub struct SubcmdOptions { constraints: Option, /// Mode of handling constraints - #[clap(help_heading=Some("CONSTRAINTS"), long, possible_values=&["guard_doom"], default_value="guard_doom")] + #[clap(help_heading=Some("CONSTRAINTS"), long, possible_values=&["guard_doom", "repeat_count"], default_value="guard_doom")] constraint_mode: String, // --------------------- @@ -207,25 +207,25 @@ pub fn main(cmd_opts: SubcmdOptions) -> Result<(), i32> { // Read and count election according to --numbers if cmd_opts.numbers == "rational" { let mut election = election_from_file(&cmd_opts.filename, cmd_opts.bin)?; - maybe_load_constraints(&mut election, &cmd_opts.constraints)?; + maybe_load_constraints(&mut election, &cmd_opts.constraints, &cmd_opts.constraint_mode)?; // Must specify :: here and in a few other places because ndarray causes E0275 otherwise count_election::(election, cmd_opts)?; } else if cmd_opts.numbers == "float64" { let mut election = election_from_file(&cmd_opts.filename, cmd_opts.bin)?; - maybe_load_constraints(&mut election, &cmd_opts.constraints)?; + maybe_load_constraints(&mut election, &cmd_opts.constraints, &cmd_opts.constraint_mode)?; count_election::(election, cmd_opts)?; } else if cmd_opts.numbers == "fixed" { Fixed::set_dps(cmd_opts.decimals); let mut election = election_from_file(&cmd_opts.filename, cmd_opts.bin)?; - maybe_load_constraints(&mut election, &cmd_opts.constraints)?; + maybe_load_constraints(&mut election, &cmd_opts.constraints, &cmd_opts.constraint_mode)?; count_election::(election, cmd_opts)?; } else if cmd_opts.numbers == "gfixed" { GuardedFixed::set_dps(cmd_opts.decimals); let mut election = election_from_file(&cmd_opts.filename, cmd_opts.bin)?; - maybe_load_constraints(&mut election, &cmd_opts.constraints)?; + maybe_load_constraints(&mut election, &cmd_opts.constraints, &cmd_opts.constraint_mode)?; count_election::(election, cmd_opts)?; } @@ -248,7 +248,7 @@ fn election_from_file(path: &str, bin: bool) -> Result, i } } -fn maybe_load_constraints(election: &mut Election, constraints: &Option) -> Result<(), i32> { +fn maybe_load_constraints(election: &mut Election, constraints: &Option, constraint_mode: &str) -> Result<(), i32> { if let Some(c) = constraints { let file = File::open(c).expect("IO Error"); let lines = io::BufReader::new(file).lines(); @@ -265,10 +265,14 @@ fn maybe_load_constraints(election: &mut Election, constraints: &O } // Validate constraints - if let Err(err) = election.constraints.as_ref().unwrap().validate_constraints(election.candidates.len()) { + if let Err(err) = election.constraints.as_ref().unwrap().validate_constraints(election.candidates.len(), constraint_mode.into()) { println!("Constraint Validation Error: {}", err); return Err(1); } + + if constraint_mode == "repeat_count" { + constraints::init_repeat_count(election); + } } Ok(()) @@ -345,7 +349,7 @@ where { // 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. ", crate::VERSION, total_ballots, filename, election.name, election.candidates.len(), election.seats); + print!("Count computed by OpenTally (revision {}). Read {:.0} ballots from \"{}\" for election \"{}\". There are {} candidates for {} vacancies. ", crate::VERSION, total_ballots, filename, election.name, election.candidates.iter().filter(|c| !c.is_dummy).count(), election.seats); let opts_str = opts.describe::(); if !opts_str.is_empty() { println!("Counting using options \"{}\".", opts_str); @@ -538,6 +542,11 @@ where StageKind::ExclusionOf(candidates) => { stage_results[2].push(format!(r#""{}""#, candidates.iter().map(|c| &c.name).sorted().join("+"))); } + StageKind::Rollback => todo!(), + StageKind::RollbackExhausted => todo!(), + StageKind::BallotsOf(candidate) => { + stage_results[2].push(format!(r#""{}""#, candidate.name)); + } StageKind::SurplusesDistributed => todo!(), StageKind::BulkElection => { //let mut elected_candidates = Vec::new(); diff --git a/src/constraints.rs b/src/constraints.rs index 41dacba..ef87eaf 100644 --- a/src/constraints.rs +++ b/src/constraints.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * 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 @@ -15,9 +15,10 @@ * along with this program. If not, see . */ -use crate::election::{Candidate, CandidateState, CountCard, CountState, Election}; +use crate::election::{Candidate, CandidateState, CountCard, CountState, Election, StageKind, RollbackState}; use crate::numbers::Number; -use crate::stv::{ConstraintMode, STVOptions}; +use crate::stv::{self, gregory, sample, ConstraintMode, STVError, STVOptions, SurplusMethod, SurplusOrder}; +use crate::ties::{self, TieStrategy}; use itertools::Itertools; use ndarray::{Array, Dimension, IxDyn}; @@ -89,8 +90,8 @@ impl Constraints { return Ok(constraints); } - /// Validate that each candidate is specified exactly once in each constraint - pub fn validate_constraints(&self, num_candidates: usize) -> Result<(), ValidationError> { + /// Validate that each candidate is specified exactly once in each constraint, and (if applicable) limitations of the constraint mode are applied + pub fn validate_constraints(&self, num_candidates: usize, constraint_mode: ConstraintMode) -> Result<(), ValidationError> { for constraint in &self.0 { let mut remaining_candidates: Vec = (0..num_candidates).collect(); @@ -105,6 +106,19 @@ impl Constraints { } } } + + if constraint_mode == ConstraintMode::RepeatCount { + // Each group must be either a maximum constraint, or the remaining group + if group.min == 0 { + // Maximum constraint: OK + } else if group.max >= group.candidates.len() { + // Remaining group: OK + } else { + return Err(ValidationError::InvalidTwoStage(constraint.name.clone(), group.name.clone())); + } + + // FIXME: Is other validation required? + } } if !remaining_candidates.is_empty() { @@ -114,6 +128,24 @@ impl Constraints { Ok(()) } + + /// Check if any elected candidates exceed constrained maximums + pub fn exceeds_maximum<'a, N: Number>(&self, election: &Election, candidates: HashMap<&'a Candidate, CountCard<'a, N>>) -> Option<(&Constraint, &ConstrainedGroup)> { + for constraint in &self.0 { + for group in &constraint.groups { + let mut num_elected = 0; + for candidate in &group.candidates { + if candidates[&election.candidates[*candidate]].state == CandidateState::Elected { + num_elected += 1; + } + } + if num_elected > group.max { + return Some((&constraint, &group)); + } + } + } + return None; + } } /// Error parsing constraints @@ -154,6 +186,8 @@ pub enum ValidationError { DuplicateCandidate(usize, String), /// Unassigned candidate in a constraint UnassignedCandidate(usize, String), + /// Constraint is incompatible with ConstraintMode::TwoStage + InvalidTwoStage(String, String), } impl fmt::Display for ValidationError { @@ -165,6 +199,9 @@ impl fmt::Display for ValidationError { ValidationError::UnassignedCandidate(candidate, constraint_name) => { f.write_fmt(format_args!(r#"Unassigned candidate {} in constraint "{}""#, candidate + 1, constraint_name)) } + ValidationError::InvalidTwoStage(constraint_name, group_name) => { + f.write_fmt(format_args!(r#"Constraint "{}" group "{}" is incompatible with --constraint-mode repeat_count"#, constraint_name, group_name)) + } } } } @@ -187,7 +224,7 @@ fn duplicate_candidate() { let input = r#""Constraint 1" "Group 1" 0 3 1 2 3 4 "Constraint 1" "Group 2" 0 3 4 5 6"#; let constraints = Constraints::from_con(input.lines()).unwrap(); - constraints.validate_constraints(6).unwrap_err(); + constraints.validate_constraints(6, ConstraintMode::GuardDoom).unwrap_err(); } #[test] @@ -195,7 +232,7 @@ fn unassigned_candidate() { let input = r#""Constraint 1" "Group 1" 0 3 1 2 3 "Constraint 1" "Group 2" 0 3 4 5 6"#; let constraints = Constraints::from_con(input.lines()).unwrap(); - constraints.validate_constraints(7).unwrap_err(); + constraints.validate_constraints(7, ConstraintMode::GuardDoom).unwrap_err(); } /// Read an optionally quoted string, returning the string without quotes @@ -262,6 +299,10 @@ pub enum ConstraintError { NoConformantResult, } +// ---------------------- +// GUARD/DOOM CONSTRAINTS +// ---------------------- + /// Cell in a [ConstraintMatrix] #[derive(Clone)] pub struct ConstraintMatrixCell { @@ -332,6 +373,10 @@ impl ConstraintMatrix { } for (i, candidate) in election.candidates.iter().enumerate() { + if candidate.is_dummy { + continue; + } + let idx: Vec = constraints.0.iter().map(|c| { for (j, group) in c.groups.iter().enumerate() { if group.candidates.contains(&i) { @@ -607,8 +652,8 @@ fn candidates_in_constraint_cell<'a, N: Number>(election: &'a Election, candi return result; } -/// Clone and update the constraints matrix, with the state of the given candidates set to candidate_state -pub fn try_constraints(state: &CountState, candidates: &[&Candidate], candidate_state: CandidateState) -> Result<(), ConstraintError> { +/// Clone and update the constraints matrix, with the state of the given candidates set to candidate_state – check if a conformant result is possible +pub fn test_constraints_any_time(state: &CountState, candidates: &[&Candidate], candidate_state: CandidateState) -> Result<(), ConstraintError> { if state.constraint_matrix.is_none() { return Ok(()); } @@ -697,10 +742,312 @@ pub fn update_constraints(state: &mut CountState, opts: &STVOption return guarded_or_doomed; } - _ => { todo!() } + ConstraintMode::RepeatCount => { return false; } // No action needed here: elect_hopefuls checks test_constraints_immediate + } +} + +// ---------------------------------- +// FOR --constraint-mode repeat_count +// ---------------------------------- + +/// Check constraints, with the state of the given candidates set to candidate_state – check if this immediately violates constraints +pub fn test_constraints_immediate<'a, N: Number>(state: &CountState<'a, N>, candidates: &[&Candidate], candidate_state: CandidateState) -> Result<(), (&'a Constraint, &'a ConstrainedGroup)> { + if state.election.constraints.is_none() { + return Ok(()); } - //return false; + let mut trial_candidates = state.candidates.clone(); // TODO: Can probably be optimised by not cloning CountCard::parcels + for candidate in candidates { + trial_candidates.get_mut(candidate).unwrap().state = candidate_state; + } + + if let Some((a, b)) = state.election.constraints.as_ref().unwrap().exceeds_maximum(state.election, trial_candidates) { + return Err((a, b)); + } + + return Ok(()); +} + +/// Initialise the [Election] as required for --constraint-mode repeat_count +pub fn init_repeat_count(election: &mut Election) { + // Add dummy candidates + let mut new_candidates = Vec::new(); + for candidate in &election.candidates { + let mut new_candidate = candidate.clone(); + new_candidate.is_dummy = true; + new_candidates.push(new_candidate); + } + election.candidates.append(&mut new_candidates); +} + +/// Initialise the rollback for [ConstraintMode::TwoStage] +pub fn init_repeat_count_rollback<'a, N: Number>(state: &mut CountState<'a, N>, constraint: &'a Constraint, group: &'a ConstrainedGroup) { + let mut rollback_candidates = HashMap::new(); + let rollback_exhausted = state.exhausted.clone(); + + // Copy ballot papers to rollback state + for (candidate, count_card) in state.candidates.iter_mut() { + rollback_candidates.insert(*candidate, count_card.clone()); + } + + state.rollback_state = RollbackState::NeedsRollback { candidates: Some(rollback_candidates), exhausted: Some(rollback_exhausted), constraint, group }; +} + +/// Process one stage of rollback for [ConstraintMode::TwoStage] +pub fn rollback_one_stage(state: &mut CountState, opts: &STVOptions) -> Result +where + for<'r> &'r N: ops::Add<&'r N, Output=N>, + 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 +{ + if let RollbackState::NeedsRollback { candidates, exhausted, constraint, group } = &mut state.rollback_state { + let mut candidates = candidates.take().unwrap(); + + // Exclude candidates who cannot be elected due to constraint violations + let order_excluded = state.num_excluded + 1; + let mut excluded_candidates = Vec::new(); + for candidate_idx in &group.candidates { + let count_card = state.candidates.get_mut(&state.election.candidates[*candidate_idx]).unwrap(); + if count_card.state == CandidateState::Hopeful { + count_card.state = CandidateState::Excluded; + count_card.finalised = true; + state.num_excluded += 1; + count_card.order_elected = -(order_excluded as isize); + excluded_candidates.push(state.election.candidates[*candidate_idx].name.as_str()); + } + } + + // Prepare dummy candidates, etc. + for candidate in &state.election.candidates { + if candidate.is_dummy { + continue; + } + + // Move ballot papers to dummy candidate + let dummy_candidate = state.election.candidates.iter().find(|c| c.name == candidate.name && c.is_dummy).unwrap(); + let dummy_count_card = state.candidates.get_mut(dummy_candidate).unwrap(); + dummy_count_card.parcels.append(&mut candidates.get_mut(candidate).unwrap().parcels); + dummy_count_card.votes = candidates[candidate].votes.clone(); + + // Reset count + let count_card = state.candidates.get_mut(candidate).unwrap(); + count_card.parcels.clear(); + count_card.votes = N::new(); + count_card.transfers = N::new(); + + if candidates[candidate].state == CandidateState::Elected { + if &candidates[candidate].votes > state.quota.as_ref().unwrap() { + count_card.votes = state.quota.as_ref().unwrap().clone(); + } else { + count_card.votes = candidates[candidate].votes.clone(); + } + } + } + + state.title = StageKind::Rollback; + state.logger.log_smart( + "Rolled back to apply constraints. {} is excluded.", + "Rolled back to apply constraints. {} are excluded.", + excluded_candidates.into_iter().sorted().collect() + ); + + state.rollback_state = RollbackState::RollingBack { candidates: Some(candidates), exhausted: exhausted.take(), candidate_distributing: None, constraint: Some(constraint), group: Some(group) }; + return Ok(true); + } + + if let RollbackState::RollingBack { candidates, exhausted, candidate_distributing, constraint, group } = &mut state.rollback_state { + let candidates = candidates.take().unwrap(); + let mut exhausted = exhausted.take().unwrap(); + let mut candidate_distributing = candidate_distributing.take(); + let constraint = constraint.take().unwrap(); + let group = group.take().unwrap(); + + // -------------------- + // Distribute surpluses + + let has_surplus: Vec<&Candidate> = state.election.candidates.iter() // Present in order in case of tie + .filter(|c| { + let cc = &candidates[c]; + if !c.is_dummy && cc.state == CandidateState::Elected && !cc.finalised { + let dummy_candidate = state.election.candidates.iter().find(|x| x.name == c.name && x.is_dummy).unwrap(); + !state.candidates[dummy_candidate].finalised + } else { + false + } + }) + .collect(); + + if !has_surplus.is_empty() { + // Distribute top candidate's surplus + let max_cands = match opts.surplus_order { + SurplusOrder::BySize => { + ties::multiple_max_by(&has_surplus, |c| &candidates[c].votes) + } + SurplusOrder::ByOrder => { + ties::multiple_min_by(&has_surplus, |c| candidates[c].order_elected) + } + }; + let elected_candidate = if max_cands.len() > 1 { + stv::choose_highest(state, opts, &max_cands, "Which candidate's surplus to distribute?")? + } else { + max_cands[0] + }; + + let dummy_candidate = state.election.candidates.iter().find(|c| c.name == elected_candidate.name && c.is_dummy).unwrap(); + + match opts.surplus { + SurplusMethod::WIG | SurplusMethod::UIG | SurplusMethod::EG => { gregory::distribute_surplus(state, opts, dummy_candidate); } + SurplusMethod::IHare | SurplusMethod::Hare => { sample::distribute_surplus(state, opts, dummy_candidate)?; } + _ => unreachable!() + } + + state.rollback_state = RollbackState::RollingBack { candidates: Some(candidates), exhausted: Some(exhausted), candidate_distributing, constraint: Some(constraint), group: Some(group) }; + return Ok(true); + } + + // ---------------------------------- + // Distribute exhausted ballot papers + // FIXME: Untested! + + if exhausted.parcels.iter().any(|p| !p.votes.is_empty()) { + // Use arbitrary dummy candidate + let dummy_candidate = state.election.candidates.iter().find(|c| c.is_dummy).unwrap(); + + let dummy_count_card = state.candidates.get_mut(dummy_candidate).unwrap(); + dummy_count_card.parcels.append(&mut exhausted.parcels); + + state.title = StageKind::RollbackExhausted; + state.logger.log_literal(String::from("Distributing exhausted ballots.")); + stv::exclude_candidates(state, opts, vec![dummy_candidate])?; + + let dummy_count_card = state.candidates.get_mut(dummy_candidate).unwrap(); + exhausted.parcels.append(&mut dummy_count_card.parcels); + + state.rollback_state = RollbackState::RollingBack { candidates: Some(candidates), exhausted: Some(exhausted), candidate_distributing: None, constraint: Some(constraint), group: Some(group) }; + return Ok(true); + } + + // ------------------------------------------------ + // Distribute ballot papers of electable candidates + + let electable_candidates_old: Vec<&Candidate> = state.election.candidates.iter() // Present in order in case of multiple + .filter(|c| { + let cc = &candidates[c]; + let cand_idx = state.election.candidates.iter().position(|x| x == *c).unwrap(); + if !c.is_dummy && !group.candidates.contains(&cand_idx) && !cc.finalised { + let dummy_candidate = state.election.candidates.iter().find(|x| x.name == c.name && x.is_dummy).unwrap(); + !state.candidates[dummy_candidate].finalised + } else { + false + } + }) + .collect(); + + if !electable_candidates_old.is_empty() { + if candidate_distributing.is_none() || !electable_candidates_old.contains(candidate_distributing.as_ref().unwrap()) { + if electable_candidates_old.len() > 1 { + // Determine or prompt for which candidate to distribute + for strategy in opts.ties.iter() { + match strategy { + TieStrategy::Random(_) | TieStrategy::Prompt => { + candidate_distributing = Some(strategy.choose_lowest(state, opts, &electable_candidates_old, "Which candidate's ballots to distribute?").unwrap()); + break; + } + TieStrategy::Forwards | TieStrategy::Backwards => {} + } + } + } else { + candidate_distributing = Some(electable_candidates_old[0]); + } + } + + let candidate_distributing = candidate_distributing.unwrap(); + let dummy_candidate = state.election.candidates.iter().find(|c| c.name == candidate_distributing.name && c.is_dummy).unwrap(); + + state.title = StageKind::BallotsOf(candidate_distributing); + state.logger.log_smart( + "Distributing ballot papers of {}.", + "Distributing ballot papers of {}.", + vec![candidate_distributing.name.as_str()] + ); + stv::exclude_candidates(state, opts, vec![dummy_candidate])?; + + state.rollback_state = RollbackState::RollingBack { candidates: Some(candidates), exhausted: Some(exhausted), candidate_distributing: Some(candidate_distributing), constraint: Some(constraint), group: Some(group) }; + return Ok(true); + } + + // -------------------------------------------------- + // Distribute ballot papers of unelectable candidates + + let unelectable_candidates_old: Vec<&Candidate> = state.election.candidates.iter() // Present in order in case of multiple + .filter(|c| { + let cc = &candidates[c]; + let cand_idx = state.election.candidates.iter().position(|x| x == *c).unwrap(); + if !c.is_dummy && group.candidates.contains(&cand_idx) && !cc.finalised { + let dummy_candidate = state.election.candidates.iter().find(|x| x.name == c.name && x.is_dummy).unwrap(); + !state.candidates[dummy_candidate].finalised + } else { + false + } + }) + .collect(); + + if !unelectable_candidates_old.is_empty() { + if candidate_distributing.is_none() || !unelectable_candidates_old.contains(candidate_distributing.as_ref().unwrap()) { + if unelectable_candidates_old.len() > 1 { + // Determine or prompt for which candidate to distribute + for strategy in opts.ties.iter() { + match strategy { + TieStrategy::Random(_) | TieStrategy::Prompt => { + candidate_distributing = Some(strategy.choose_lowest(state, opts, &unelectable_candidates_old, "Which candidate's ballots to distribute?").unwrap()); + break; + } + TieStrategy::Forwards | TieStrategy::Backwards => {} + } + } + } else { + candidate_distributing = Some(unelectable_candidates_old[0]); + } + } + + let candidate_distributing = candidate_distributing.unwrap(); + let dummy_candidate = state.election.candidates.iter().find(|c| c.name == candidate_distributing.name && c.is_dummy).unwrap(); + + state.title = StageKind::BallotsOf(candidate_distributing); + state.logger.log_smart( + "Distributing ballot papers of {}.", + "Distributing ballot papers of {}.", + vec![candidate_distributing.name.as_str()] + ); + stv::exclude_candidates(state, opts, vec![dummy_candidate])?; + + state.rollback_state = RollbackState::RollingBack { candidates: Some(candidates), exhausted: Some(exhausted), candidate_distributing: Some(candidate_distributing), constraint: Some(constraint), group: Some(group) }; + return Ok(true); + } + + // --------------------------- + // Rollback complete: finalise + + // Delete dummy candidates + for (candidate, count_card) in state.candidates.iter_mut() { + if candidate.is_dummy { + count_card.state = CandidateState::Withdrawn; + count_card.parcels.clear(); + count_card.votes = N::new(); + count_card.transfers = N::new(); + } + } + + state.logger.log_literal(String::from("Rollback complete.")); + state.rollback_state = RollbackState::Normal; + state.num_excluded = state.candidates.values().filter(|cc| cc.state == CandidateState::Excluded).count(); + + return Ok(false); + } + + unreachable!(); } #[cfg(test)] diff --git a/src/election.rs b/src/election.rs index 1260414..877a3bd 100644 --- a/src/election.rs +++ b/src/election.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * 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 @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -use crate::constraints::{Constraints, ConstraintMatrix}; +use crate::constraints::{Constraint, Constraints, ConstrainedGroup, ConstraintMatrix}; use crate::logger::Logger; use crate::numbers::Number; use crate::sharandom::SHARandom; @@ -36,6 +36,7 @@ use std::fmt; /// An election to be counted #[cfg_attr(not(target_arch = "wasm32"), derive(Archive, Deserialize, Serialize))] +#[derive(Clone)] pub struct Election { /// Name of the election pub name: String, @@ -92,14 +93,17 @@ impl Election { } /// A candidate in an [Election] -#[derive(Eq, Hash, PartialEq)] +#[derive(Clone, Eq, Hash, PartialEq)] #[cfg_attr(not(target_arch = "wasm32"), derive(Archive, Deserialize, Serialize))] pub struct Candidate { /// Name of the candidate pub name: String, + /// If this candidate is a dummy candidate (e.g. for --constraint-mode repeat_count) + pub is_dummy: bool, } /// The current state of counting an [Election] +#[derive(Clone)] pub struct CountState<'a, N: Number> { /// Pointer to the [Election] being counted pub election: &'a Election, @@ -135,6 +139,8 @@ pub struct CountState<'a, N: Number> { /// [ConstraintMatrix] for constrained elections pub constraint_matrix: Option, + /// [RollbackState] when using [ConstraintMode::Rollback] + pub rollback_state: RollbackState<'a, N>, /// Transfer table for this surplus/exclusion pub transfer_table: Option>, @@ -162,6 +168,7 @@ impl<'a, N: Number> CountState<'a, N> { num_elected: 0, num_excluded: 0, constraint_matrix: None, + rollback_state: RollbackState::Normal, transfer_table: None, title: StageKind::FirstPreferences, logger: Logger { entries: Vec::new() }, @@ -169,7 +176,11 @@ impl<'a, N: Number> CountState<'a, N> { // Init candidate count cards for candidate in election.candidates.iter() { - state.candidates.insert(candidate, CountCard::new()); + let mut count_card = CountCard::new(); + if candidate.is_dummy { + count_card.state = CandidateState::Withdrawn; + } + state.candidates.insert(candidate, count_card); } // Set withdrawn candidates state @@ -241,6 +252,10 @@ impl<'a, N: Number> CountState<'a, N> { let mut result = String::new(); for (candidate, count_card) in candidates { + if candidate.is_dummy { + continue; + } + match count_card.state { CandidateState::Hopeful => { result.push_str(&format!("- {}: {:.dps$} ({:.dps$})\n", candidate.name, count_card.votes, count_card.transfers, dps=opts.pp_decimals)); @@ -281,7 +296,7 @@ impl<'a, N: Number> CountState<'a, N> { result.push_str(&format!("Exhausted: {:.dps$} ({:.dps$})\n", self.exhausted.votes, self.exhausted.transfers, dps=opts.pp_decimals)); result.push_str(&format!("Loss by fraction: {:.dps$} ({:.dps$})\n", self.loss_fraction.votes, self.loss_fraction.transfers, dps=opts.pp_decimals)); - let mut total_vote = self.candidates.values().fold(N::zero(), |acc, cc| { acc + &cc.votes }); + let mut total_vote = self.candidates.iter().filter_map(|(c, cc)| if c.is_dummy { None } else { Some(cc) }).fold(N::zero(), |acc, cc| { acc + &cc.votes }); total_vote += &self.exhausted.votes; total_vote += &self.loss_fraction.votes; result.push_str(&format!("Total votes: {:.dps$}\n", total_vote, dps=opts.pp_decimals)); @@ -306,6 +321,12 @@ pub enum StageKind<'a> { SurplusOf(&'a Candidate), /// Exclusion of ... ExclusionOf(Vec<&'a Candidate>), + /// Rolled back (--constraint-mode repeat_count) + Rollback, + /// Exhausted ballots (--constraint-mode repeat_count) + RollbackExhausted, + /// Ballots of ... (--constraint-mode repeat_count) + BallotsOf(&'a Candidate), /// Surpluses distributed (Meek) SurplusesDistributed, /// Bulk election @@ -319,6 +340,9 @@ impl<'a> StageKind<'a> { StageKind::FirstPreferences => "", StageKind::SurplusOf(_) => "Surplus of", StageKind::ExclusionOf(_) => "Exclusion of", + StageKind::Rollback => "", + StageKind::RollbackExhausted => "", + StageKind::BallotsOf(_) => "Ballots of", StageKind::SurplusesDistributed => "", StageKind::BulkElection => "", }; @@ -337,6 +361,15 @@ impl<'a> fmt::Display for StageKind<'a> { StageKind::ExclusionOf(candidates) => { return f.write_fmt(format_args!("{} {}", self.kind_as_string(), candidates.iter().map(|c| &c.name).sorted().join(", "))); } + StageKind::Rollback => { + return f.write_str("Constraints applied"); + } + StageKind::RollbackExhausted => { + return f.write_str("Exhausted ballots"); + } + StageKind::BallotsOf(candidate) => { + return f.write_fmt(format_args!("{} {}", self.kind_as_string(), candidate.name)); + } StageKind::SurplusesDistributed => { return f.write_str("Surpluses distributed"); } @@ -465,6 +498,7 @@ impl<'a, N> Vote<'a, N> { } /// A record of a voter's preferences +#[derive(Clone)] #[cfg_attr(not(target_arch = "wasm32"), derive(Archive, Deserialize, Serialize))] pub struct Ballot { /// Original value/weight of the ballot @@ -530,3 +564,15 @@ pub enum CandidateState { /// Declared excluded Excluded, } + +/// If --constraint-mode repeat_count and redistribution is required, tracks the ballot papers being redistributed +#[allow(missing_docs)] +#[derive(Clone)] +pub enum RollbackState<'a, N> { + /// Not rolling back + Normal, + /// Start rolling back next stage + NeedsRollback { candidates: Option>>, exhausted: Option>, constraint: &'a Constraint, group: &'a ConstrainedGroup }, + /// Rolling back + RollingBack { candidates: Option>>, exhausted: Option>, candidate_distributing: Option<&'a Candidate>, constraint: Option<&'a Constraint>, group: Option<&'a ConstrainedGroup> }, +} diff --git a/src/logger.rs b/src/logger.rs index 07a19cb..f624e55 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * 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 @@ -16,6 +16,7 @@ */ /// Smart logger used in election counts +#[derive(Clone)] pub struct Logger<'a> { /// [Vec] of log entries for the current stage pub entries: Vec>, @@ -71,6 +72,7 @@ impl<'a> Logger<'a> { } /// Represents either a literal or smart log entry +#[derive(Clone)] pub enum LogEntry<'a> { /// Smart log entry - see [SmartLogEntry] Smart(SmartLogEntry<'a>), @@ -79,6 +81,7 @@ pub enum LogEntry<'a> { } /// Smart log entry +#[derive(Clone)] pub struct SmartLogEntry<'a> { template1: &'a str, template2: &'a str, diff --git a/src/numbers/rational_rug.rs b/src/numbers/rational_rug.rs index 92ba6cd..3690835 100644 --- a/src/numbers/rational_rug.rs +++ b/src/numbers/rational_rug.rs @@ -233,12 +233,21 @@ impl ops::Mul for Rational { impl ops::Div for Rational { type Output = Self; - fn div(self, rhs: Self) -> Self::Output { Self(self.0 / rhs.0) } + fn div(self, rhs: Self) -> Self::Output { + if rhs.0.cmp0() == Ordering::Equal { + panic!("Divide by zero"); + } + return Self(self.0 / rhs.0); + } } impl ops::Rem for Rational { type Output = Self; fn rem(self, rhs: Self) -> Self::Output { + if rhs.0.cmp0() == Ordering::Equal { + panic!("Divide by zero"); + } + // TODO: Is there a cleaner way of implementing this? let mut quotient = self.0 / &rhs.0; quotient.rem_trunc_mut(); @@ -276,12 +285,20 @@ impl ops::Mul<&Self> for Rational { impl ops::Div<&Self> for Rational { type Output = Self; - fn div(self, rhs: &Self) -> Self::Output { Self(self.0 / &rhs.0) } + fn div(self, rhs: &Self) -> Self::Output { + if rhs.0.cmp0() == Ordering::Equal { + panic!("Divide by zero"); + } + return Self(self.0 / &rhs.0); + } } impl ops::Rem<&Self> for Rational { type Output = Self; fn rem(self, rhs: &Self) -> Self::Output { + if rhs.0.cmp0() == Ordering::Equal { + panic!("Divide by zero"); + } let mut quotient = self.0 / &rhs.0; quotient.rem_trunc_mut(); quotient *= &rhs.0; @@ -314,11 +331,19 @@ impl ops::MulAssign for Rational { } impl ops::DivAssign for Rational { - fn div_assign(&mut self, rhs: Self) { self.0 /= rhs.0; } + fn div_assign(&mut self, rhs: Self) { + if rhs.0.cmp0() == Ordering::Equal { + panic!("Divide by zero"); + } + self.0 /= rhs.0; + } } impl ops::RemAssign for Rational { fn rem_assign(&mut self, rhs: Self) { + if rhs.0.cmp0() == Ordering::Equal { + panic!("Divide by zero"); + } self.0 /= &rhs.0; self.0.rem_trunc_mut(); self.0 *= rhs.0; @@ -350,11 +375,19 @@ impl ops::MulAssign<&Self> for Rational { } impl ops::DivAssign<&Self> for Rational { - fn div_assign(&mut self, rhs: &Self) { self.0 /= &rhs.0; } + fn div_assign(&mut self, rhs: &Self) { + if rhs.0.cmp0() == Ordering::Equal { + panic!("Divide by zero"); + } + self.0 /= &rhs.0; + } } impl ops::RemAssign<&Self> for Rational { fn rem_assign(&mut self, rhs: &Self) { + if rhs.0.cmp0() == Ordering::Equal { + panic!("Divide by zero"); + } self.0 /= &rhs.0; self.0.rem_trunc_mut(); self.0 *= &rhs.0; @@ -395,12 +428,20 @@ impl ops::Mul for &Rational { impl ops::Div for &Rational { type Output = Rational; - fn div(self, rhs: Self) -> Self::Output { Rational(rug::Rational::from(&self.0 / &rhs.0)) } + fn div(self, rhs: Self) -> Self::Output { + if rhs.0.cmp0() == Ordering::Equal { + panic!("Divide by zero"); + } + return Rational(rug::Rational::from(&self.0 / &rhs.0)); + } } impl ops::Rem for &Rational { type Output = Rational; fn rem(self, rhs: Self) -> Self::Output { + if rhs.0.cmp0() == Ordering::Equal { + panic!("Divide by zero"); + } let mut quotient = rug::Rational::from(&self.0 / &rhs.0); quotient.rem_trunc_mut(); quotient *= &rhs.0; diff --git a/src/parser/blt.rs b/src/parser/blt.rs index ef74245..85a1cf0 100644 --- a/src/parser/blt.rs +++ b/src/parser/blt.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * 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 @@ -178,7 +178,8 @@ impl> BLTParser { for _ in 0..self.num_candidates { let name = self.string()?; self.election.candidates.push(Candidate { - name + name, + is_dummy: false, }); } let name = self.string()?; diff --git a/src/parser/csp.rs b/src/parser/csp.rs index 3d6136b..257a6c9 100644 --- a/src/parser/csp.rs +++ b/src/parser/csp.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * 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 @@ -44,6 +44,7 @@ pub fn parse_reader(reader: R, require_1: bool, require_sequ col_map.insert(i, candidates.len()); candidates.push(Candidate { name: cand_name.to_string(), + is_dummy: false, }); } diff --git a/src/sharandom.rs b/src/sharandom.rs index 48dffe0..3ba018e 100644 --- a/src/sharandom.rs +++ b/src/sharandom.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * 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 @@ -19,6 +19,7 @@ use ibig::UBig; use sha2::{Digest, Sha256}; /// Deterministic random number generator using SHA256 +#[derive(Clone)] pub struct SHARandom<'r> { seed: &'r str, counter: usize, diff --git a/src/stv/gregory/mod.rs b/src/stv/gregory/mod.rs index 2e88ee7..a764d74 100644 --- a/src/stv/gregory/mod.rs +++ b/src/stv/gregory/mod.rs @@ -201,7 +201,7 @@ where } /// Distribute the surplus of a given candidate according to the Gregory method, based on [STVOptions::surplus] -fn distribute_surplus<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, elected_candidate: &'a Candidate) +pub fn distribute_surplus<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, elected_candidate: &'a Candidate) where for<'r> &'r N: ops::Add<&'r N, Output=N>, for<'r> &'r N: ops::Sub<&'r N, Output=N>, diff --git a/src/stv/gregory/transfers.rs b/src/stv/gregory/transfers.rs index f1e27c1..ce30032 100644 --- a/src/stv/gregory/transfers.rs +++ b/src/stv/gregory/transfers.rs @@ -28,6 +28,7 @@ use std::cmp::max; use std::collections::HashMap; /// Table describing vote transfers during a surplus distribution or exclusion +#[derive(Clone)] pub struct TransferTable<'e, N: Number> { /// Continuing candidates pub hopefuls: Vec<&'e Candidate>, @@ -565,6 +566,7 @@ fn multiply_surpfrac(mut number: N, surpfrac_numer: &Option, surpf } /// Column in a [TransferTable] +#[derive(Clone)] pub struct TransferTableColumn<'e, N: Number> { /// Value fraction of ballots counted in this column pub value_fraction: N, @@ -599,6 +601,7 @@ impl<'e, N: Number> TransferTableColumn<'e, N> { } /// Cell in a [TransferTable], representing transfers to one candidate at a particular value +#[derive(Clone)] pub struct TransferTableCell { /// Ballots expressing a next preference for the continuing candidate pub ballots: N, diff --git a/src/stv/meek.rs b/src/stv/meek.rs index b21a62a..5a8e21f 100644 --- a/src/stv/meek.rs +++ b/src/stv/meek.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * 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 @@ -35,6 +35,7 @@ struct BallotInTree<'b, N: Number> { } /// Tree-packed ballot representation +#[derive(Clone)] pub struct BallotTree<'t, N: Number> { num_ballots: N, ballots: Vec>, diff --git a/src/stv/mod.rs b/src/stv/mod.rs index 13d4701..3058734 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -29,9 +29,8 @@ pub mod sample; pub mod wasm; use crate::constraints; -use crate::election::Election; use crate::numbers::Number; -use crate::election::{Candidate, CandidateState, CountCard, CountState, StageKind, Vote}; +use crate::election::{Candidate, CandidateState, CountCard, CountState, Election, RollbackState, StageKind, Vote}; use crate::sharandom::SHARandom; use crate::ties::{self, TieStrategy}; @@ -221,6 +220,7 @@ impl STVOptions { if self.quota_mode != QuotaMode::DynamicByTotal { return Err(STVError::InvalidOptions("--surplus meek requires --quota-mode dynamic_by_total")); } if self.transferable_only { return Err(STVError::InvalidOptions("--surplus meek is incompatible with --transferable-only")); } if self.exclusion != ExclusionMethod::SingleStage { return Err(STVError::InvalidOptions("--surplus meek requires --exclusion single_stage")); } + if self.constraints_path.is_some() && self.constraint_mode == ConstraintMode::RepeatCount { return Err(STVError::InvalidOptions("--constraint-mode repeat_count requires a Gregory method for --surplus")); } // TODO: NYI? } if self.surplus == SurplusMethod::IHare || self.surplus == SurplusMethod::Hare { if self.round_quota != Some(0) { return Err(STVError::InvalidOptions("--surplus ihare and --surplus hare require --round-quota 0")); } @@ -228,6 +228,7 @@ impl STVOptions { if self.sample == SampleMethod::StratifyLR && self.sample_per_ballot { return Err(STVError::InvalidOptions("--sample stratify is incompatible with --sample-per-ballot")); } //if self.sample == SampleMethod::StratifyFloor && self.sample_per_ballot { return Err(STVError::InvalidOptions("--sample stratify_floor is incompatible with --sample-per-ballot")); } if self.sample_per_ballot && !self.immediate_elect { return Err(STVError::InvalidOptions("--sample-per-ballot is incompatible with --no-immediate-elect")); } + if self.constraints_path.is_some() && self.constraint_mode == ConstraintMode::RepeatCount { return Err(STVError::InvalidOptions("--constraint-mode repeat_count requires a Gregory method for --surplus")); } // TODO: NYI? } if self.subtract_nontransferable && !self.transferable_only { return Err(STVError::InvalidOptions("--subtract-nontransferable requires --transferable-only")) } if self.min_threshold != "0" && self.defer_surpluses { return Err(STVError::InvalidOptions("--min-threshold is incompatible with --defer-surpluses (not yet implemented)")); } // TODO: NYI @@ -567,8 +568,8 @@ impl> From for SampleMethod { pub enum ConstraintMode { /// Guard or doom candidates as soon as required to secure a conformant result GuardDoom, - /// TODO: NYI - Rollback, + /// If constraints violated, exclude/reintroduce candidates as required and redistribute ballot papers + RepeatCount, } impl ConstraintMode { @@ -576,7 +577,7 @@ impl ConstraintMode { fn describe(self) -> String { match self { ConstraintMode::GuardDoom => "--constraint-mode guard_doom", - ConstraintMode::Rollback => "--constraint-mode rollback", + ConstraintMode::RepeatCount => "--constraint-mode repeat_count", }.to_string() } } @@ -585,7 +586,7 @@ impl> From for ConstraintMode { fn from(s: S) -> Self { match s.as_ref() { "guard_doom" => ConstraintMode::GuardDoom, - "rollback" => ConstraintMode::Rollback, + "repeat_count" => ConstraintMode::RepeatCount, _ => panic!("Invalid --constraint-mode"), } } @@ -674,6 +675,13 @@ where return Ok(true); } + if let RollbackState::Normal = state.rollback_state { + } else if constraints::rollback_one_stage(state, opts)? { + elect_hopefuls(state, opts, true)?; + update_tiebreaks(state, opts); + return Ok(false); + } + // Attempt early bulk election if opts.early_bulk_elect { if bulk_elect(state, opts)? { @@ -727,20 +735,25 @@ where } /// See [next_preferences] -struct NextPreferencesResult<'a, N> { - candidates: HashMap<&'a Candidate, NextPreferencesEntry<'a, N>>, - exhausted: NextPreferencesEntry<'a, N>, - total_ballots: N, +pub struct NextPreferencesResult<'a, N> { + /// [NextPreferencesEntry] for each [Candidate] + pub candidates: HashMap<&'a Candidate, NextPreferencesEntry<'a, N>>, + /// [NextPreferencesEntry] for exhausted ballots + pub exhausted: NextPreferencesEntry<'a, N>, + /// Total weight of ballots examined + pub total_ballots: N, } /// See [next_preferences] -struct NextPreferencesEntry<'a, N> { - votes: Vec>, - num_ballots: N, +pub struct NextPreferencesEntry<'a, N> { + /// Votes recording a next preference for the candidate + pub votes: Vec>, + /// Weight of such ballots + pub num_ballots: N, } /// Count the given votes, grouping according to next available preference -fn next_preferences<'a, N: Number>(state: &CountState<'a, N>, votes: Vec>) -> NextPreferencesResult<'a, N> { +pub fn next_preferences<'a, N: Number>(state: &CountState<'a, N>, votes: Vec>) -> NextPreferencesResult<'a, N> { let mut result = NextPreferencesResult { candidates: HashMap::new(), exhausted: NextPreferencesEntry { @@ -1015,6 +1028,12 @@ fn meets_vre(state: &CountState, count_card: &CountCard, opts: /// /// Returns `true` if any candidates were elected. fn elect_sure_winners<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> Result { + // Do not interrupt rolling back!! + if let RollbackState::Normal = state.rollback_state { + } else { + return Ok(false); + } + if state.num_elected >= state.election.seats { return Ok(false); } @@ -1057,7 +1076,7 @@ fn elect_sure_winners<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOp let mut leading_hopefuls: Vec<&Candidate> = hopefuls.iter().take(num_vacancies).map(|(c, _)| *c).collect(); - match constraints::try_constraints(state, &leading_hopefuls, CandidateState::Elected) { + match constraints::test_constraints_any_time(state, &leading_hopefuls, CandidateState::Elected) { Ok(_) => {} Err(_) => { return Ok(false); } // Bulk election conflicts with constraints } @@ -1087,7 +1106,7 @@ fn elect_sure_winners<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOp state.logger.log_smart( "As they cannot now be overtaken, {} is elected to fill the remaining vacancy.", "As they cannot now be overtaken, {} are elected to fill the remaining vacancies.", - vec![&candidate.name] + vec![candidate.name.as_str()] // rust-analyzer doesn't understand &String -> &str ); leading_hopefuls.remove(leading_hopefuls.iter().position(|c| *c == candidate).unwrap()); @@ -1127,6 +1146,22 @@ fn elect_hopefuls<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOption max_cands[0] }; + if opts.constraint_mode == ConstraintMode::RepeatCount && state.election.constraints.is_some() { + if let Err((constraint, group)) = constraints::test_constraints_immediate(state, &[candidate], CandidateState::Elected) { + // This election would violate a constraint, so stop here + state.logger.log_smart( + "The election of {} now would violate constraints.", + "The election of {} now would violate constraints.", + vec![candidate.name.as_str()] + ); + + // Trigger rollback + constraints::init_repeat_count_rollback(state, constraint, group); + + return Ok(elected); + } + } + let count_card = state.candidates.get_mut(candidate).unwrap(); count_card.state = CandidateState::Elected; state.num_elected += 1; @@ -1139,7 +1174,7 @@ fn elect_hopefuls<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOption state.logger.log_smart( "{} meets the quota and is elected.", "{} meet the quota and are elected.", - vec![&candidate.name] + vec![candidate.name.as_str()] ); } else { // Elected with vote required @@ -1147,7 +1182,7 @@ fn elect_hopefuls<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOption state.logger.log_smart( "{} meets the vote required and is elected.", "{} meet the vote required and are elected.", - vec![&candidate.name] + vec![candidate.name.as_str()] ); } @@ -1291,7 +1326,7 @@ fn do_bulk_elect(state: &mut CountState, opts: &STVOptions, templa state.logger.log_smart( template1, template2, - vec![&candidate.name] + vec![candidate.name.as_str()] ); if constraints::update_constraints(state, opts) { @@ -1396,7 +1431,7 @@ fn hopefuls_below_threshold<'a, N: Number>(state: &CountState<'a, N>, opts: &STV .collect(); // Do not exclude if this violates constraints - match constraints::try_constraints(state, &excluded_candidates, CandidateState::Excluded) { + match constraints::test_constraints_any_time(state, &excluded_candidates, CandidateState::Excluded) { Ok(_) => { return excluded_candidates; } Err(_) => { return Vec::new(); } // Bulk exclusion conflicts with constraints } @@ -1438,7 +1473,7 @@ fn hopefuls_to_bulk_exclude<'a, N: Number>(state: &CountState<'a, N>, _opts: &ST let try_exclude: Vec<&Candidate> = try_exclude.iter().map(|(c, _)| **c).collect(); // Do not exclude if this violates constraints - match constraints::try_constraints(state, &try_exclude, CandidateState::Excluded) { + match constraints::test_constraints_any_time(state, &try_exclude, CandidateState::Excluded) { Ok(_) => {} Err(_) => { break; } // Bulk exclusion conflicts with constraints } @@ -1556,7 +1591,7 @@ where } /// Perform one stage of a candidate exclusion, according to [STVOptions::exclusion] -fn exclude_candidates<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, excluded_candidates: Vec<&'a Candidate>) -> Result<(), STVError> +pub fn exclude_candidates<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, excluded_candidates: Vec<&'a Candidate>) -> Result<(), STVError> where for<'r> &'r N: ops::Sub<&'r N, Output=N>, for<'r> &'r N: ops::Mul<&'r N, Output=N>, diff --git a/src/stv/wasm.rs b/src/stv/wasm.rs index d470389..4488e4c 100644 --- a/src/stv/wasm.rs +++ b/src/stv/wasm.rs @@ -18,7 +18,7 @@ #![allow(rustdoc::private_intra_doc_links)] #![allow(unused_unsafe)] // Confuses cargo check -use crate::constraints::Constraints; +use crate::constraints::{self, Constraints}; use crate::election::{CandidateState, CountState, Election, StageKind}; //use crate::numbers::{DynNum, Fixed, GuardedFixed, NativeFloat64, Number, NumKind, Rational}; use crate::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational}; @@ -91,16 +91,21 @@ macro_rules! impl_type { /// Call [Constraints::from_con] and set [Election::constraints] #[cfg_attr(feature = "wasm", wasm_bindgen)] #[allow(non_snake_case)] - pub fn [](election: &mut [], text: String) { + pub fn [](election: &mut [], text: String, opts: &STVOptions) { election.0.constraints = match Constraints::from_con(text.lines()) { Ok(c) => Some(c), Err(err) => wasm_error!("Constraint Syntax Error", err), }; // Validate constraints - if let Err(err) = election.0.constraints.as_ref().unwrap().validate_constraints(election.0.candidates.len()) { + if let Err(err) = election.0.constraints.as_ref().unwrap().validate_constraints(election.0.candidates.len(), opts.0.constraint_mode) { wasm_error!("Constraint Validation Error", err); } + + // Add dummy candidates if required + if opts.0.constraint_mode == stv::ConstraintMode::RepeatCount { + constraints::init_repeat_count(&mut election.0); + } } /// Wrapper for [stv::preprocess_election] @@ -327,7 +332,7 @@ pub fn describe_count(filename: String, election: &Election, opts: let mut result = String::from("

Count computed by OpenTally (revision "); result.push_str(crate::VERSION); let total_ballots = election.ballots.iter().fold(N::zero(), |acc, b| { acc + &b.orig_value }); - result.push_str(&format!(r#"). Read {:.0} ballots from ‘{}’ for election ‘{}’. There are {} candidates for {} vacancies. "#, total_ballots, filename, election.name, election.candidates.len(), election.seats)); + result.push_str(&format!(r#"). Read {:.0} ballots from ‘{}’ for election ‘{}’. There are {} candidates for {} vacancies. "#, total_ballots, filename, election.name, election.candidates.iter().filter(|c| !c.is_dummy).count(), election.seats)); let opts_str = opts.describe::(); if !opts_str.is_empty() { @@ -348,6 +353,10 @@ pub fn init_results_table(election: &Election, opts: &stv::STVOpti } for candidate in election.candidates.iter() { + if candidate.is_dummy { + continue; + } + if report_style == "votes_transposed" { result.push_str(&format!(r#"{}"#, candidate.name)); } else { @@ -381,7 +390,7 @@ pub fn update_results_table(stage_num: usize, state: &CountState, // Insert borders to left of new exclusions in Wright STV let classes_o; // Outer version let classes_i; // Inner version - if opts.exclusion == stv::ExclusionMethod::Wright && matches!(state.title, StageKind::ExclusionOf(_)) { + if (opts.exclusion == stv::ExclusionMethod::Wright && matches!(state.title, StageKind::ExclusionOf(_))) || matches!(state.title, StageKind::Rollback) { classes_o = r#" class="blw""#; classes_i = r#"blw "#; } else { @@ -395,6 +404,10 @@ pub fn update_results_table(stage_num: usize, state: &CountState, hide_xfers_trsp = true; } else if opts.exclusion == stv::ExclusionMethod::Wright && matches!(state.title, StageKind::ExclusionOf(_)) { hide_xfers_trsp = true; + } else if let StageKind::Rollback = state.title { + hide_xfers_trsp = true; + } else if let StageKind::BulkElection = state.title { + hide_xfers_trsp = true; } else { hide_xfers_trsp = false; } @@ -403,7 +416,7 @@ pub fn update_results_table(stage_num: usize, state: &CountState, let kind_str = state.title.kind_as_string(); let title_str; match &state.title { - StageKind::FirstPreferences | StageKind::SurplusesDistributed | StageKind::BulkElection => { + StageKind::FirstPreferences | StageKind::Rollback | StageKind::RollbackExhausted | StageKind::SurplusesDistributed | StageKind::BulkElection => { title_str = format!("{}", state.title); } StageKind::SurplusOf(candidate) => { @@ -417,6 +430,9 @@ pub fn update_results_table(stage_num: usize, state: &CountState, title_str = candidates.iter().map(|c| &c.name).join(",
"); } } + StageKind::BallotsOf(candidate) => { + title_str = candidate.name.clone(); + } }; match report_style { @@ -447,6 +463,10 @@ pub fn update_results_table(stage_num: usize, state: &CountState, } for candidate in state.election.candidates.iter() { + if candidate.is_dummy { + continue; + } + let count_card = &state.candidates[candidate]; // TODO: REFACTOR THIS!! @@ -585,7 +605,7 @@ pub fn update_results_table(stage_num: usize, state: &CountState, } // Calculate total votes - let mut total_vote = state.candidates.values().fold(N::zero(), |acc, cc| { acc + &cc.votes }); + let mut total_vote = state.candidates.iter().filter_map(|(c, cc)| if c.is_dummy { None } else { Some(cc) }).fold(N::zero(), |acc, cc| { acc + &cc.votes }); total_vote += &state.exhausted.votes; total_vote += &state.loss_fraction.votes; @@ -667,6 +687,10 @@ pub fn finalise_results_table(state: &CountState, report_style: &s // Candidate states for candidate in state.election.candidates.iter() { + if candidate.is_dummy { + continue; + } + let count_card = &state.candidates[candidate]; if count_card.state == stv::CandidateState::Elected { result.push(&format!(r#"ELECTED {}"#, rowspan, count_card.order_elected).into()); diff --git a/src/ties.rs b/src/ties.rs index 701b70c..15951b0 100644 --- a/src/ties.rs +++ b/src/ties.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * 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 @@ -220,7 +220,7 @@ where /// Prompt the candidate for input, depending on CLI or WebAssembly target #[cfg(not(target_arch = "wasm32"))] -fn prompt<'c, N: Number>(state: &CountState, opts: &STVOptions, candidates: &[&'c Candidate], prompt_text: &str) -> Result<&'c Candidate, STVError> { +pub fn prompt<'c, N: Number>(state: &CountState, opts: &STVOptions, candidates: &[&'c Candidate], prompt_text: &str) -> Result<&'c Candidate, STVError> { // Show intrastage progress if required if !state.logger.entries.is_empty() { // Print stage details @@ -269,8 +269,9 @@ extern "C" { fn get_user_input(s: &str) -> Option; } +/// Prompt the candidate for input, depending on CLI or WebAssembly target #[cfg(target_arch = "wasm32")] -fn prompt<'c, N: Number>(state: &CountState, opts: &STVOptions, candidates: &[&'c Candidate], prompt_text: &str) -> Result<&'c Candidate, STVError> { +pub fn prompt<'c, N: Number>(state: &CountState, opts: &STVOptions, candidates: &[&'c Candidate], prompt_text: &str) -> Result<&'c Candidate, STVError> { let mut message = String::new(); // Show intrastage progress if required