diff --git a/docs/options.md b/docs/options.md index f32b63f..7ae227f 100644 --- a/docs/options.md +++ b/docs/options.md @@ -4,10 +4,10 @@ The preset dropdown allows you to choose from a hardcoded list of preloaded STV counting rules. These are: -* *Recommended WIGM*: A recommended set of simple STV rules designed for computer counting, using the weighted inclusive Gregory method and rational arithmetic. +* *OpenTally WIGM*: A recommended set of simple STV rules designed for computer counting, using the weighted inclusive Gregory method and rational arithmetic. * *Scottish STV*: Rules from the [*Scottish Local Government Elections Order 2011*](https://www.legislation.gov.uk/ssi/2011/399/schedule/1/made), using the weighted inclusive Gregory method. Validated against the [2007 Scottish local government election result for Linn ward](https://web.archive.org/web/20121004213938/http://www.glasgow.gov.uk/en/YourCouncil/Elections_Voting/Election_Results/ElectionScotland2007/LGWardResults.htm?ward=1&wardname=1%20-%20Linn). * [*Meek STV*](http://www.dia.govt.nz/diawebsite.NSF/Files/meekm/%24file/meekm.pdf): Advanced STV rules designed for computer counting, recognised by the Proportional Representation Society of Australia (Victoria–Tasmania) as the superior STV system. - * *Meek STV (1987)* operates according to the original [Hill–Wichmann–Woodall specification](https://www.dia.govt.nz/diawebsite.NSF/Files/meekm/%24file/meekm.pdf) of Meek STV, with the modifications, relevant only in exceptional cases, that (a) fixed-point arithmetic with 5 decimal places is used, and (b) candidates are elected on strictly exceeding the quota. Validated against the Hill–Wichmann–Woodall implementation for the [ERS97 model election](https://www.electoral-reform.org.uk/latest-news-and-research/publications/how-to-conduct-an-election-by-the-single-transferable-vote-3rd-edition/#sub-section-24). + * *Meek STV (1987)* operates according to the original Hill–Wichmann–Woodall [‘Algorithm 123’](https://www.dia.govt.nz/diawebsite.NSF/Files/meekm/%24file/meekm.pdf) specification of Meek STV, with the modifications, relevant only in exceptional cases, that (a) fixed-point arithmetic with 5 decimal places is used, and (b) candidates are elected on strictly exceeding the quota. Validated against the Hill–Wichmann–Woodall implementation for ballot papers [derived from the ERS97 model election](https://yingtongli.me/blog/2021/01/04/ers97.html). * *Meek STV (2006)* operates according to [Hill's 2006 revisions](http://www.votingmatters.org.uk/ISSUE22/I22P2.pdf). This is the algorithm referred to in OpenSTV/OpaVote as ‘Meek STV’, and forms the basis of New Zealand's Meek STV rules. Validated against OpenSTV 1.7 for the ERS97 model election. * *Meek STV (New Zealand)* operates according to Schedule 1A of the [*Local Electoral Regulations 2001*](https://www.legislation.govt.nz/regulation/public/2001/0145/latest/DLM57125.html). Validated against OpenSTV 1.7, and Hill's nzmeek version 6.7.7, for the ERS97 model election. * *Australian Senate STV*: Rules from the [*Commonwealth Electoral Act 1918*](https://www.legislation.gov.au/Details/C2020C00400/Html/Text#_Toc59107700), using the unweighted inclusive Gregory method. Validated against the [2019 Australian Senate election result for Tasmania](https://results.aec.gov.au/24310/Website/SenateDownloadsMenu-24310-Csv.htm). @@ -150,9 +150,14 @@ When ballots are normalised, a set of preferences with weight *n* > 1 is instead ### Early bulk election (--no-early-bulk-elect) -When early bulk election is enabled (default), all remaining candidates are declared elected in a single stage as soon as the number of not-excluded candidates exactly equals the number of vacancies to fill. Further surplus distributions are not performed, and outstanding exclusions, if any, are not completed. This is typical of most STV rules. +When early bulk election is enabled (default), the count terminates as soon as the set of winning candidates is known. Specifically: -When early bulk election is disabled, surpluses continue to be distributed, and outstanding exclusions continue to be completed, even once the number of not-excluded candidates exactly equals the number of vacancies to fill. Bulk election is performed only once there are no more surpluses to distribute, and no exclusions to complete. +* At the beginning of each stage, if the number of not-excluded candidates exactly equals the number of vacancies to fill, all remaining candidates are declared elected in a single stage. This is typical of most STV rules. +* If a proposed exclusion would cause the number of not-excluded candidates to exactly equal the number of vacancies, all remaining candidates are declared elected without transfers arising from the proposed exclusion being performed. + +If an early bulk election is performed, further surplus distributions are not performed, and outstanding exclusions, if any, are not completed, even if they could change the order of election. + +When early bulk election is disabled, surpluses continue to be distributed, and outstanding exclusions continue to be completed, even once the number of not-excluded candidates exactly equals the number of vacancies to fill. Bulk election is performed only as a final measure once there are no more surpluses to distribute, and no exclusions to complete. In either case, candidates are declared elected in descending order of votes. This ensures that only one candidate is ever elected at a time and the order of election is well-defined, which is required e.g. for some affirmative action rules. diff --git a/src/stv/mod.rs b/src/stv/mod.rs index 94c4f9f..4b62d80 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -497,7 +497,7 @@ where } // Continue exclusions - if continue_exclusion(state, &opts) { + if continue_exclusion(state, &opts)? { calculate_quota(state, opts); elect_meeting_quota(state, opts)?; update_tiebreaks(state, opts); @@ -860,8 +860,23 @@ where } } -/// Declare all continuing candidates elected, if the number equals the number of remaining vacancies -fn bulk_elect(state: &mut CountState, opts: &STVOptions) -> Result { +/// Determine if, with the proposed exclusion of num_to_exclude candidates (if any), a bulk election can be made +fn can_bulk_elect(state: &CountState, num_to_exclude: usize) -> bool { + let num_hopefuls = state.election.candidates.iter() + .filter(|c| { + let cc = &state.candidates[c]; + return cc.state == CandidateState::Hopeful || cc.state == CandidateState::Guarded; + }) + .count(); + + if state.num_elected + num_hopefuls - num_to_exclude <= state.election.seats { + return true; + } + return false; +} + +/// Declare all continuing candidates to be elected +fn do_bulk_elect(state: &mut CountState, opts: &STVOptions, template1: &'static str, template2: &'static str) -> Result { let mut hopefuls: Vec<&Candidate> = state.election.candidates.iter() .filter(|c| { let cc = &state.candidates[c]; @@ -869,44 +884,49 @@ fn bulk_elect(state: &mut CountState, opts: &STVOptions) -> Result }) .collect(); - if state.num_elected + hopefuls.len() <= state.election.seats { + // Bulk elect all remaining candidates + while !hopefuls.is_empty() { + let max_cands = ties::multiple_max_by(&hopefuls, |c| &state.candidates[c].votes); + let candidate = if max_cands.len() > 1 { + choose_highest(state, opts, max_cands)? + } else { + max_cands[0] + }; + + let count_card = state.candidates.get_mut(candidate).unwrap(); + count_card.state = CandidateState::Elected; + state.num_elected += 1; + count_card.order_elected = state.num_elected as isize; + + state.logger.log_smart( + template1, + template2, + vec![&candidate.name] + ); + + if constraints::update_constraints(state, opts) { + // Recheck as some candidates may have been doomed + hopefuls = state.election.candidates.iter() + .filter(|c| { + let cc = &state.candidates[c]; + return cc.state == CandidateState::Hopeful || cc.state == CandidateState::Guarded; + }) + .collect(); + } else { + hopefuls.remove(hopefuls.iter().position(|c| *c == candidate).unwrap()); + } + } + + return Ok(true); +} + +/// Declare all continuing candidates elected, if the number equals the number of remaining vacancies +fn bulk_elect(state: &mut CountState, opts: &STVOptions) -> Result { + if can_bulk_elect(state, 0) { state.kind = None; state.title = "Bulk election".to_string(); - // Bulk elect all remaining candidates - while !hopefuls.is_empty() { - let max_cands = ties::multiple_max_by(&hopefuls, |c| &state.candidates[c].votes); - let candidate = if max_cands.len() > 1 { - choose_highest(state, opts, max_cands)? - } else { - max_cands[0] - }; - - let count_card = state.candidates.get_mut(candidate).unwrap(); - count_card.state = CandidateState::Elected; - state.num_elected += 1; - count_card.order_elected = state.num_elected as isize; - - state.logger.log_smart( - "{} is elected to fill the remaining vacancy.", - "{} are elected to fill the remaining vacancies.", - vec![&candidate.name] - ); - - if constraints::update_constraints(state, opts) { - // Recheck as some candidates may have been doomed - hopefuls = state.election.candidates.iter() - .filter(|c| { - let cc = &state.candidates[c]; - return cc.state == CandidateState::Hopeful || cc.state == CandidateState::Guarded; - }) - .collect(); - } else { - hopefuls.remove(hopefuls.iter().position(|c| *c == candidate).unwrap()); - } - } - - return Ok(true); + return do_bulk_elect(state, opts, "{} is elected to fill the remaining vacancy.", "{} are elected to fill the remaining vacancies."); } return Ok(false); } @@ -944,8 +964,23 @@ where names ); - exclude_candidates(state, opts, excluded_candidates); - return Ok(true); + if opts.early_bulk_elect { + // Determine if the proposed exclusion would enable a bulk election + if can_bulk_elect(state, excluded_candidates.len()) { + // Exclude candidates without further transfers + let order_excluded = state.num_excluded + 1; + for candidate in excluded_candidates { + let count_card = state.candidates.get_mut(candidate).unwrap(); + count_card.state = CandidateState::Excluded; + state.num_excluded += 1; + count_card.order_elected = -(order_excluded as isize); + } + + return do_bulk_elect(state, opts, "As a result of the proposed exclusion, {} is elected to fill the remaining vacancy.", "As a result of the proposed exclusion, {} are elected to fill the remaining vacancies."); + } + } + + return exclude_candidates(state, opts, excluded_candidates); } return Ok(false); @@ -1030,13 +1065,27 @@ where names ); - exclude_candidates(state, opts, excluded_candidates); + if opts.early_bulk_elect { + // Determine if the proposed exclusion would enable a bulk election + if can_bulk_elect(state, excluded_candidates.len()) { + // Exclude candidates without further transfers + let order_excluded = state.num_excluded + 1; + for candidate in excluded_candidates { + let count_card = state.candidates.get_mut(candidate).unwrap(); + count_card.state = CandidateState::Excluded; + state.num_excluded += 1; + count_card.order_elected = -(order_excluded as isize); + } + + return do_bulk_elect(state, opts, "As a result of the proposed exclusion, {} is elected to fill the remaining vacancy.", "As a result of the proposed exclusion, {} are elected to fill the remaining vacancies."); + } + } - return Ok(true); + return exclude_candidates(state, opts, excluded_candidates); } /// Continue the exclusion of a candidate who is being excluded -fn continue_exclusion<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> bool +fn continue_exclusion<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> Result where for<'r> &'r N: ops::Sub<&'r N, Output=N>, for<'r> &'r N: ops::Mul<&'r N, Output=N>, @@ -1065,15 +1114,14 @@ where names ); - exclude_candidates(state, opts, excluded_candidates); - return true; + return exclude_candidates(state, opts, excluded_candidates); } - return false; + return Ok(false); } /// 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>) +fn exclude_candidates<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, excluded_candidates: Vec<&'a Candidate>) -> Result where for<'r> &'r N: ops::Sub<&'r N, Output=N>, for<'r> &'r N: ops::Mul<&'r N, Output=N>, @@ -1098,6 +1146,8 @@ where gregory::wright_exclude_candidates(state, opts, excluded_candidates); } } + + return Ok(true); } /// Determine if the count is complete because the number of elected candidates equals the number of vacancies diff --git a/tests/csm.rs b/tests/csm.rs index cbe0143..8ef3033 100644 --- a/tests/csm.rs +++ b/tests/csm.rs @@ -39,7 +39,7 @@ fn csm15_float64() { transferable_only: false, exclusion: stv::ExclusionMethod::Wright, meek_nz_exclusion: false, - early_bulk_elect: true, + early_bulk_elect: false, // Required for validation bulk_exclude: true, defer_surpluses: false, meek_immediate_elect: false, diff --git a/tests/prsa.rs b/tests/prsa.rs index e9a861b..6b6c3ec 100644 --- a/tests/prsa.rs +++ b/tests/prsa.rs @@ -39,7 +39,7 @@ fn prsa1_rational() { transferable_only: true, exclusion: stv::ExclusionMethod::ParcelsByOrder, meek_nz_exclusion: false, - early_bulk_elect: false, + early_bulk_elect: false, // Required for validation bulk_exclude: false, defer_surpluses: false, meek_immediate_elect: false,