2021-08-03 18:38:45 +10:00
|
|
|
/* OpenTally: Open-source election vote counting
|
|
|
|
* Copyright © 2021 Lee Yingtong Li (RunasSudo)
|
|
|
|
*
|
|
|
|
* This program is free software: you can redistribute it and/or modify
|
|
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
|
|
* (at your option) any later version.
|
|
|
|
*
|
|
|
|
* This program is distributed in the hope that it will be useful,
|
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
* GNU Affero General Public License for more details.
|
|
|
|
*
|
|
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
*/
|
|
|
|
|
|
|
|
use crate::constraints;
|
2021-09-06 02:43:33 +10:00
|
|
|
use crate::election::{Candidate, CandidateState, CountState, Parcel, StageKind, Vote};
|
2021-08-03 18:38:45 +10:00
|
|
|
use crate::numbers::Number;
|
2021-08-05 18:41:39 +10:00
|
|
|
use crate::stv::{STVOptions, SampleMethod, SurplusMethod};
|
2021-08-03 18:38:45 +10:00
|
|
|
|
2021-08-05 18:41:39 +10:00
|
|
|
use std::cmp::max;
|
2021-08-03 18:38:45 +10:00
|
|
|
use std::collections::HashMap;
|
|
|
|
use std::ops;
|
|
|
|
|
2021-08-05 18:41:39 +10:00
|
|
|
use super::{NextPreferencesResult, STVError};
|
|
|
|
|
|
|
|
/// Return the denominator of the surplus fraction
|
|
|
|
///
|
2021-08-05 20:18:10 +10:00
|
|
|
/// Returns `None` if transferable ballots <= surplus (i.e. all transferable ballots are transferred at full value).
|
2021-08-05 18:41:39 +10:00
|
|
|
fn calculate_surplus_denom<N: Number>(surplus: &N, result: &NextPreferencesResult<N>, transferable_ballots: &N, transferable_only: bool) -> Option<N>
|
|
|
|
where
|
|
|
|
for<'r> &'r N: ops::Sub<&'r N, Output=N>
|
|
|
|
{
|
|
|
|
if transferable_only {
|
|
|
|
if transferable_ballots > surplus {
|
|
|
|
return Some(transferable_ballots.clone());
|
|
|
|
} else {
|
|
|
|
return None;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
return Some(result.total_ballots.clone());
|
|
|
|
}
|
|
|
|
}
|
2021-08-03 18:38:45 +10:00
|
|
|
|
|
|
|
/// Distribute the surplus of a given candidate according to the random subset method, based on [STVOptions::surplus]
|
2021-09-06 02:43:33 +10:00
|
|
|
pub fn distribute_surplus<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, elected_candidate: &'a Candidate) -> Result<(), STVError>
|
2021-08-03 18:38:45 +10:00
|
|
|
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<Output=N>
|
|
|
|
{
|
2021-09-06 02:43:33 +10:00
|
|
|
state.title = StageKind::SurplusOf(&elected_candidate);
|
2021-08-03 18:38:45 +10:00
|
|
|
state.logger.log_literal(format!("Surplus of {} distributed.", elected_candidate.name));
|
|
|
|
|
|
|
|
let count_card = state.candidates.get_mut(elected_candidate).unwrap();
|
|
|
|
let surplus = &count_card.votes - state.quota.as_ref().unwrap();
|
|
|
|
|
2021-08-05 18:41:39 +10:00
|
|
|
let mut votes;
|
2021-08-03 18:38:45 +10:00
|
|
|
match opts.surplus {
|
|
|
|
SurplusMethod::Cincinnati => {
|
|
|
|
// Inclusive
|
|
|
|
votes = count_card.concat_parcels();
|
|
|
|
}
|
|
|
|
SurplusMethod::Hare => {
|
|
|
|
// Exclusive
|
|
|
|
// Should be safe to unwrap() - or else how did we get a quota!
|
|
|
|
votes = count_card.parcels.pop().unwrap().votes;
|
|
|
|
}
|
|
|
|
_ => unreachable!()
|
|
|
|
}
|
|
|
|
|
2021-08-05 18:41:39 +10:00
|
|
|
match opts.sample {
|
|
|
|
SampleMethod::Stratified => {
|
|
|
|
// Stratified by next available preference
|
|
|
|
// FIXME: This is untested
|
|
|
|
|
|
|
|
let result = super::next_preferences(state, votes);
|
|
|
|
|
|
|
|
let transferable_ballots = &result.total_ballots - &result.exhausted.num_ballots;
|
|
|
|
let surplus_denom = calculate_surplus_denom(&surplus, &result, &transferable_ballots, opts.transferable_only);
|
|
|
|
let mut surplus_fraction;
|
|
|
|
match surplus_denom {
|
|
|
|
Some(v) => {
|
|
|
|
surplus_fraction = Some(surplus.clone() / v);
|
|
|
|
|
|
|
|
// Round down if requested
|
|
|
|
if let Some(dps) = opts.round_surplus_fractions {
|
|
|
|
surplus_fraction.as_mut().unwrap().floor_mut(dps);
|
|
|
|
}
|
|
|
|
|
|
|
|
if opts.transferable_only {
|
|
|
|
if &result.total_ballots - &result.exhausted.num_ballots == N::one() {
|
|
|
|
state.logger.log_literal(format!("Examining 1 transferable ballot, with surplus fraction {:.dps2$}.", surplus_fraction.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2)));
|
|
|
|
} else {
|
|
|
|
state.logger.log_literal(format!("Examining {:.0} transferable ballots, with surplus fraction {:.dps2$}.", &result.total_ballots - &result.exhausted.num_ballots, surplus_fraction.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2)));
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if result.total_ballots == N::one() {
|
|
|
|
state.logger.log_literal(format!("Examining 1 ballot, with surplus fraction {:.dps2$}.", surplus_fraction.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2)));
|
|
|
|
} else {
|
|
|
|
state.logger.log_literal(format!("Examining {:.0} ballots, with surplus fraction {:.dps2$}.", result.total_ballots, surplus_fraction.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2)));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
None => {
|
|
|
|
surplus_fraction = None;
|
|
|
|
|
|
|
|
// This can only happen if --transferable-only
|
|
|
|
if result.total_ballots == N::one() {
|
|
|
|
state.logger.log_literal("Transferring 1 ballot at full value.".to_string());
|
|
|
|
} else {
|
|
|
|
state.logger.log_literal(format!("Transferring {:.0} ballots at full value.", result.total_ballots));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let mut checksum = N::new();
|
|
|
|
|
|
|
|
for (candidate, entry) in result.candidates.into_iter() {
|
|
|
|
// Credit transferred votes
|
|
|
|
let mut candidate_transfers;
|
|
|
|
match surplus_fraction {
|
|
|
|
Some(ref f) => {
|
|
|
|
candidate_transfers = entry.num_ballots * f;
|
|
|
|
candidate_transfers.floor_mut(0);
|
|
|
|
}
|
|
|
|
None => {
|
|
|
|
// All ballots transferred
|
|
|
|
candidate_transfers = entry.num_ballots.clone();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
let candidate_transfers_usize: usize = format!("{:.0}", candidate_transfers).parse().expect("Transfers overflow usize");
|
|
|
|
|
|
|
|
let count_card = state.candidates.get_mut(candidate).unwrap();
|
|
|
|
count_card.transfer(&candidate_transfers);
|
|
|
|
checksum += candidate_transfers;
|
|
|
|
|
|
|
|
let parcel = Parcel {
|
|
|
|
votes: entry.votes.into_iter().rev().take(candidate_transfers_usize).rev().collect(),
|
2021-08-16 00:46:05 +10:00
|
|
|
value_fraction: N::one(),
|
2021-08-05 18:41:39 +10:00
|
|
|
source_order: state.num_elected + state.num_excluded,
|
|
|
|
};
|
|
|
|
|
|
|
|
count_card.parcels.push(parcel);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Credit exhausted votes
|
|
|
|
let mut exhausted_transfers;
|
|
|
|
if opts.transferable_only {
|
|
|
|
if transferable_ballots > surplus {
|
|
|
|
// No ballots exhaust
|
|
|
|
exhausted_transfers = N::new();
|
|
|
|
} else {
|
|
|
|
exhausted_transfers = &surplus - &transferable_ballots;
|
|
|
|
|
|
|
|
if let Some(dps) = opts.round_votes {
|
|
|
|
exhausted_transfers.floor_mut(dps);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
exhausted_transfers = result.exhausted.num_ballots * surplus_fraction.as_ref().unwrap();
|
|
|
|
exhausted_transfers.floor_mut(0);
|
|
|
|
}
|
|
|
|
let exhausted_transfers_usize: usize = format!("{:.0}", exhausted_transfers).parse().expect("Transfers overflow usize");
|
|
|
|
|
|
|
|
state.exhausted.transfer(&exhausted_transfers);
|
|
|
|
checksum += exhausted_transfers;
|
|
|
|
|
|
|
|
// Transfer exhausted votes
|
|
|
|
let parcel = Parcel {
|
|
|
|
votes: result.exhausted.votes.into_iter().rev().take(exhausted_transfers_usize).rev().collect(),
|
2021-08-16 00:46:05 +10:00
|
|
|
value_fraction: N::one(),
|
2021-08-05 18:41:39 +10:00
|
|
|
source_order: state.num_elected + state.num_excluded,
|
|
|
|
};
|
|
|
|
state.exhausted.parcels.push(parcel);
|
|
|
|
|
|
|
|
// Finalise candidate votes
|
|
|
|
let count_card = state.candidates.get_mut(elected_candidate).unwrap();
|
|
|
|
count_card.transfers = -&surplus;
|
|
|
|
count_card.votes.assign(state.quota.as_ref().unwrap());
|
|
|
|
checksum -= surplus;
|
|
|
|
|
|
|
|
// Update loss by fraction
|
|
|
|
state.loss_fraction.transfer(&-checksum);
|
|
|
|
}
|
|
|
|
SampleMethod::ByOrder => {
|
|
|
|
// Ballots by order
|
|
|
|
// FIXME: This is untested
|
|
|
|
|
|
|
|
state.logger.log_literal(format!("Examining {:.0} ballots.", votes.len())); // votes.len() is total ballots as --normalise-ballots is required
|
|
|
|
|
|
|
|
// Transfer candidate votes
|
|
|
|
while &state.candidates[elected_candidate].votes > state.quota.as_ref().unwrap() {
|
|
|
|
match votes.pop() {
|
|
|
|
Some(vote) => {
|
|
|
|
// Transfer to next preference
|
2021-08-05 20:18:10 +10:00
|
|
|
transfer_ballot(state, opts, elected_candidate, vote, opts.transferable_only)?;
|
|
|
|
if state.num_elected == state.election.seats {
|
|
|
|
return Ok(());
|
|
|
|
}
|
2021-08-05 18:41:39 +10:00
|
|
|
}
|
|
|
|
None => {
|
|
|
|
// We have run out of ballot papers
|
|
|
|
// Remaining ballot papers exhaust
|
|
|
|
|
|
|
|
let surplus = &state.candidates[elected_candidate].votes - state.quota.as_ref().unwrap();
|
|
|
|
state.exhausted.transfer(&surplus);
|
|
|
|
state.candidates.get_mut(elected_candidate).unwrap().transfer(&-surplus);
|
2021-08-09 00:17:14 +10:00
|
|
|
break;
|
2021-08-05 18:41:39 +10:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
SampleMethod::NthBallot => {
|
|
|
|
// Every nth-ballot (Cincinnati-style)
|
|
|
|
|
|
|
|
// Calculate skip value
|
|
|
|
let total_ballots = votes.len();
|
|
|
|
let mut skip_fraction = N::from(total_ballots) / &surplus;
|
|
|
|
skip_fraction.round_mut(0);
|
|
|
|
|
|
|
|
state.logger.log_literal(format!("Examining {:.0} ballots, with skip value {:.0}.", total_ballots, skip_fraction));
|
|
|
|
|
|
|
|
// Number the votes
|
|
|
|
let mut numbered_votes: HashMap<usize, Vote<N>> = HashMap::new();
|
|
|
|
for (i, vote) in votes.into_iter().enumerate() {
|
|
|
|
numbered_votes.insert(i, vote);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Transfer candidate votes
|
|
|
|
let skip_value: usize = format!("{:.0}", skip_fraction).parse().expect("Skip value overflows usize");
|
|
|
|
let mut iteration = 0;
|
|
|
|
let mut index = skip_value - 1; // Subtract 1 as votes are 0-indexed
|
|
|
|
|
|
|
|
while &state.candidates[elected_candidate].votes > state.quota.as_ref().unwrap() {
|
|
|
|
// Transfer one vote to next available preference
|
2021-08-09 00:17:14 +10:00
|
|
|
match numbered_votes.remove(&index) {
|
|
|
|
Some(vote) => {
|
|
|
|
transfer_ballot(state, opts, elected_candidate, vote, opts.transferable_only)?;
|
|
|
|
if state.num_elected == state.election.seats {
|
|
|
|
return Ok(());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
None => {
|
2021-08-05 18:41:39 +10:00
|
|
|
// We have run out of ballot papers
|
|
|
|
// Remaining ballot papers exhaust
|
|
|
|
let surplus = &state.candidates[elected_candidate].votes - state.quota.as_ref().unwrap();
|
|
|
|
state.exhausted.transfer(&surplus);
|
|
|
|
state.candidates.get_mut(elected_candidate).unwrap().transfer(&-surplus);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
2021-08-09 00:17:14 +10:00
|
|
|
|
|
|
|
index += skip_value;
|
|
|
|
if index >= total_ballots {
|
|
|
|
iteration += 1;
|
|
|
|
index = iteration + skip_value - 1;
|
|
|
|
}
|
2021-08-05 18:41:39 +10:00
|
|
|
}
|
|
|
|
}
|
2021-08-03 18:38:45 +10:00
|
|
|
}
|
|
|
|
|
2021-08-09 00:17:14 +10:00
|
|
|
let count_card = state.candidates.get_mut(elected_candidate).unwrap();
|
|
|
|
count_card.finalised = true; // Mark surpluses as done
|
|
|
|
|
2021-08-05 18:41:39 +10:00
|
|
|
return Ok(());
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Transfer the given ballot paper to its next available preference, and check for candidates meeting the quota if --sample-per-ballot
|
|
|
|
///
|
2021-08-05 20:18:10 +10:00
|
|
|
/// If `ignore_nontransferable`, does nothing if --transferable-only and the ballot is nontransferable.
|
|
|
|
fn transfer_ballot<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, source_candidate: &Candidate, mut vote: Vote<'a, N>, ignore_nontransferable: bool) -> Result<(), STVError> {
|
2021-08-05 18:41:39 +10:00
|
|
|
// Get next preference
|
|
|
|
let mut next_candidate = None;
|
2021-09-03 23:53:15 +10:00
|
|
|
|
|
|
|
loop {
|
|
|
|
match vote.next_preference() {
|
|
|
|
Some(preference) => {
|
|
|
|
let candidate = &state.election.candidates[preference];
|
|
|
|
let count_card = &state.candidates[candidate];
|
|
|
|
|
|
|
|
if let CandidateState::Hopeful | CandidateState::Guarded = count_card.state {
|
|
|
|
next_candidate = Some(candidate);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
None => { break; }
|
2021-08-05 18:41:39 +10:00
|
|
|
}
|
|
|
|
}
|
2021-08-03 18:38:45 +10:00
|
|
|
|
2021-08-05 18:41:39 +10:00
|
|
|
// Have to structure like this to satisfy Rust's borrow checker
|
|
|
|
if let Some(candidate) = next_candidate {
|
|
|
|
// Available preference
|
2021-08-16 00:46:05 +10:00
|
|
|
state.candidates.get_mut(source_candidate).unwrap().transfer(&-vote.ballot.orig_value.clone());
|
2021-08-03 18:38:45 +10:00
|
|
|
|
2021-08-05 18:41:39 +10:00
|
|
|
let count_card = state.candidates.get_mut(candidate).unwrap();
|
2021-08-16 00:46:05 +10:00
|
|
|
count_card.transfer(&vote.ballot.orig_value);
|
2021-08-05 18:41:39 +10:00
|
|
|
|
|
|
|
match count_card.parcels.last_mut() {
|
|
|
|
Some(parcel) => {
|
|
|
|
if parcel.source_order == state.num_elected + state.num_excluded {
|
|
|
|
parcel.votes.push(vote);
|
|
|
|
} else {
|
|
|
|
let parcel = Parcel {
|
|
|
|
votes: vec![vote],
|
2021-08-16 00:46:05 +10:00
|
|
|
value_fraction: N::one(),
|
2021-08-05 18:41:39 +10:00
|
|
|
source_order: state.num_elected + state.num_excluded,
|
|
|
|
};
|
|
|
|
count_card.parcels.push(parcel);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
None => {
|
|
|
|
let parcel = Parcel {
|
|
|
|
votes: vec![vote],
|
2021-08-16 00:46:05 +10:00
|
|
|
value_fraction: N::one(),
|
2021-08-05 18:41:39 +10:00
|
|
|
source_order: state.num_elected + state.num_excluded,
|
|
|
|
};
|
|
|
|
count_card.parcels.push(parcel);
|
2021-08-03 18:38:45 +10:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-08-05 18:41:39 +10:00
|
|
|
if opts.sample_per_ballot {
|
2021-08-07 18:51:48 +10:00
|
|
|
super::elect_hopefuls(state, opts, true)?;
|
2021-08-05 18:41:39 +10:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// Exhausted
|
2021-08-05 20:18:10 +10:00
|
|
|
if opts.transferable_only && ignore_nontransferable {
|
2021-08-05 18:41:39 +10:00
|
|
|
// Another ballot paper required
|
|
|
|
} else {
|
2021-08-16 00:46:05 +10:00
|
|
|
state.candidates.get_mut(source_candidate).unwrap().transfer(&-vote.ballot.orig_value.clone());
|
|
|
|
state.exhausted.transfer(&vote.ballot.orig_value);
|
2021-08-03 18:38:45 +10:00
|
|
|
|
2021-08-05 18:41:39 +10:00
|
|
|
match state.exhausted.parcels.last_mut() {
|
2021-08-03 18:38:45 +10:00
|
|
|
Some(parcel) => {
|
|
|
|
if parcel.source_order == state.num_elected + state.num_excluded {
|
|
|
|
parcel.votes.push(vote);
|
|
|
|
} else {
|
|
|
|
let parcel = Parcel {
|
|
|
|
votes: vec![vote],
|
2021-08-16 00:46:05 +10:00
|
|
|
value_fraction: N::one(),
|
2021-08-03 18:38:45 +10:00
|
|
|
source_order: state.num_elected + state.num_excluded,
|
|
|
|
};
|
2021-08-05 18:41:39 +10:00
|
|
|
state.exhausted.parcels.push(parcel);
|
2021-08-03 18:38:45 +10:00
|
|
|
}
|
|
|
|
}
|
|
|
|
None => {
|
|
|
|
let parcel = Parcel {
|
|
|
|
votes: vec![vote],
|
2021-08-16 00:46:05 +10:00
|
|
|
value_fraction: N::one(),
|
2021-08-03 18:38:45 +10:00
|
|
|
source_order: state.num_elected + state.num_excluded,
|
|
|
|
};
|
2021-08-05 18:41:39 +10:00
|
|
|
state.exhausted.parcels.push(parcel);
|
2021-08-03 18:38:45 +10:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return Ok(());
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Perform one stage of a candidate exclusion according to the random subset method
|
|
|
|
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::Div<&'r N, Output=N>,
|
|
|
|
{
|
|
|
|
// Used to give bulk excluded candidate the same order_elected
|
|
|
|
let order_excluded = state.num_excluded + 1;
|
|
|
|
|
|
|
|
for excluded_candidate in excluded_candidates.iter() {
|
|
|
|
let count_card = state.candidates.get_mut(excluded_candidate).unwrap();
|
|
|
|
|
|
|
|
// Rust borrow checker is unhappy if we try to put this in exclude_hopefuls ??!
|
|
|
|
if count_card.state != CandidateState::Excluded {
|
|
|
|
count_card.state = CandidateState::Excluded;
|
|
|
|
state.num_excluded += 1;
|
|
|
|
count_card.order_elected = -(order_excluded as isize);
|
|
|
|
|
|
|
|
constraints::update_constraints(state, opts);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-08-05 20:18:10 +10:00
|
|
|
// Count votes
|
|
|
|
let mut total_ballots: usize = 0;
|
2021-08-03 18:38:45 +10:00
|
|
|
for excluded_candidate in excluded_candidates.iter() {
|
|
|
|
let count_card = state.candidates.get_mut(excluded_candidate).unwrap();
|
2021-08-05 20:18:10 +10:00
|
|
|
total_ballots = count_card.parcels.iter()
|
|
|
|
.fold(total_ballots, |acc, p| acc + p.votes.len());
|
2021-08-03 18:38:45 +10:00
|
|
|
}
|
|
|
|
|
2021-08-05 20:18:10 +10:00
|
|
|
if total_ballots == 1 {
|
|
|
|
state.logger.log_literal("Transferring 1 ballot.".to_string());
|
|
|
|
} else {
|
|
|
|
state.logger.log_literal(format!("Transferring {:.0} ballots.", total_ballots));
|
|
|
|
}
|
2021-08-03 18:38:45 +10:00
|
|
|
|
2021-08-05 20:18:10 +10:00
|
|
|
// Transfer votes
|
|
|
|
for excluded_candidate in excluded_candidates.iter() {
|
|
|
|
let count_card = state.candidates.get_mut(excluded_candidate).unwrap();
|
|
|
|
let votes = count_card.concat_parcels();
|
2021-08-03 18:38:45 +10:00
|
|
|
|
2021-08-05 20:18:10 +10:00
|
|
|
for vote in votes {
|
|
|
|
transfer_ballot(state, opts, excluded_candidate, vote, false)?;
|
|
|
|
if state.num_elected == state.election.seats {
|
|
|
|
return Ok(());
|
2021-08-03 18:38:45 +10:00
|
|
|
}
|
|
|
|
}
|
2021-08-08 19:11:15 +10:00
|
|
|
|
|
|
|
let count_card = state.candidates.get_mut(excluded_candidate).unwrap();
|
|
|
|
count_card.finalised = true;
|
2021-08-03 18:38:45 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
return Ok(());
|
|
|
|
}
|