diff --git a/docs/options.md b/docs/options.md index 5072479..4959403 100644 --- a/docs/options.md +++ b/docs/options.md @@ -167,7 +167,7 @@ When early bulk election is enabled (default), the count terminates as soon as t * 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. -* At the end of any stage, if only 1 vacancy remains and one continuing candidate has more votes than all other continuing candidates (plus votes awaiting transfer), that candidate is immediately declared elected. +* At the end of any stage, if *n* vacancies remain and the *n*-th top continuing candidate has more votes than all lower continuing candidates (plus votes awaiting transfer), the *n* top continuing candidate are immediately declared elected. 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. @@ -175,6 +175,10 @@ When early bulk election is disabled, surpluses continue to be distributed, and 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. +Note that the OpenTally rules for early bulk election are aggressive, and many STV rules do not implement all 3 (if any at all). It is not possible at this time to selectively apply only some of the rules. In order to reproduce the result of a count performed by others, where not all rules were implemented, consider disabling early bulk election and comparing the results at the time a bulk election would have been made. + +Note also that early bulk election can conflict with constraints. If an election is to be run with constraints, it is recommend that early bulk election be disabled. + ### Bulk exclusion (--bulk-exclude) When bulk exclusion is disabled (default), only one candidate is ever excluded per stage. diff --git a/src/election.rs b/src/election.rs index e5a14dc..2521ec4 100644 --- a/src/election.rs +++ b/src/election.rs @@ -165,7 +165,7 @@ pub struct CountState<'a, N: Number> { pub quota: Option, /// Vote required for election /// - /// Only used in ERS97/ERS76, or if early bulk election is enabled and there is 1 vacancy remaining. + /// Only used in ERS97/ERS76. pub vote_required_election: Option, /// Number of candidates who have been declared elected diff --git a/src/stv/meek.rs b/src/stv/meek.rs index ea83dd0..c7fba93 100644 --- a/src/stv/meek.rs +++ b/src/stv/meek.rs @@ -281,7 +281,7 @@ where if opts.meek_immediate_elect { // Try to elect candidates - if super::elect_meeting_quota(state, opts)? { + if super::elect_hopefuls(state, opts)? { candidates_elected = Some(state.logger.entries.pop().unwrap()); break; } diff --git a/src/stv/mod.rs b/src/stv/mod.rs index 66442f1..84b15e7 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -467,7 +467,7 @@ where distribute_first_preferences(state, opts); calculate_quota(state, opts); - elect_meeting_quota(state, opts)?; + elect_hopefuls(state, opts)?; init_tiebreaks(state, opts); return Ok(true); @@ -499,7 +499,7 @@ where // Continue exclusions if continue_exclusion(state, &opts)? { calculate_quota(state, opts); - elect_meeting_quota(state, opts)?; + elect_hopefuls(state, opts)?; update_tiebreaks(state, opts); return Ok(false); } @@ -507,7 +507,7 @@ where // Exclude doomed candidates if exclude_doomed(state, &opts)? { calculate_quota(state, opts); - elect_meeting_quota(state, opts)?; + elect_hopefuls(state, opts)?; update_tiebreaks(state, opts); return Ok(false); } @@ -515,7 +515,7 @@ where // Distribute surpluses if distribute_surpluses(state, &opts)? { calculate_quota(state, opts); - elect_meeting_quota(state, opts)?; + elect_hopefuls(state, opts)?; update_tiebreaks(state, opts); return Ok(false); } @@ -528,7 +528,7 @@ where // Exclude lowest hopeful if exclude_hopefuls(state, &opts)? { calculate_quota(state, opts); - elect_meeting_quota(state, opts)?; + elect_hopefuls(state, opts)?; update_tiebreaks(state, opts); return Ok(false); } @@ -656,8 +656,6 @@ fn total_to_quota(mut total: N, seats: usize, opts: &STVOptions) -> N } /// Update vote required for election according to ERS97 rules -/// -/// This is also used to compute the vote required for election when early bulk election is enabled and 1 vacancy remains. fn update_vre(state: &mut CountState, opts: &STVOptions) { let mut log = String::new(); @@ -670,7 +668,6 @@ fn update_vre(state: &mut CountState, opts: &STVOptions) { }); log.push_str(format!("Total active vote is {:.dps$}, so the vote required for election is ", total_active_vote, dps=opts.pp_decimals).as_str()); - //let vote_req = total_to_quota(total_active_vote, state.election.seats - state.num_elected, opts); let vote_req = total_active_vote / N::from(state.election.seats - state.num_elected + 1); if &vote_req < state.quota.as_ref().unwrap() { @@ -743,13 +740,7 @@ fn calculate_quota(state: &mut CountState, opts: &STVOptions) { // Update quota and so VRE every stage state.vote_required_election = state.quota.clone(); } else { - if opts.early_bulk_elect && state.num_elected + 1 == state.election.seats { - // Early bulk election and one seat remains: VRE is majority of total active vote - // FIXME: This probably conflicts with constraints in some cases - update_vre(state, opts); - } else { - // No use of VRE - } + // No use of VRE } } } @@ -781,15 +772,94 @@ fn meets_vre(state: &CountState, count_card: &CountCard, opts: } } -/// Declare elected all candidates meeting the quota -fn elect_meeting_quota<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> Result { +/// Declare elected the continuing candidates leading for remaining vacancies if they cannot be overtaken +fn elect_sure_winners<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> Result { + let num_vacancies = state.election.seats - state.num_elected; + if num_vacancies == 0 { + return Ok(false); + } + + let mut hopefuls: Vec<(&Candidate, &CountCard)> = state.election.candidates.iter() + .map(|c| (c, &state.candidates[c])) + .filter(|(_, cc)| cc.state == CandidateState::Hopeful || cc.state == CandidateState::Guarded) + .collect(); + + hopefuls.sort_unstable_by(|a, b| b.1.votes.cmp(&a.1.votes)); + + let mut total_trailing = N::new(); + // For leading candidates, count only untransferred surpluses + total_trailing += hopefuls.iter().take(num_vacancies).fold(N::new(), |acc, (_, cc)| { + if &cc.votes > state.quota.as_ref().unwrap() { + acc + &cc.votes - state.quota.as_ref().unwrap() + } else { + acc + } + }); + // For trailing candidates, count all votes + total_trailing += hopefuls.iter().skip(num_vacancies).fold(N::new(), |acc, (_, cc)| acc + &cc.votes); + // Add finally any votes awaiting transfer + total_trailing += state.candidates.values().fold(N::zero(), |acc, cc| { + match cc.state { + CandidateState::Elected => { if &cc.votes > state.quota.as_ref().unwrap() { acc + &cc.votes - state.quota.as_ref().unwrap() } else { acc } } + CandidateState::Hopeful | CandidateState::Guarded | CandidateState::Withdrawn => { acc } + CandidateState::Excluded | CandidateState::Doomed => { acc + &cc.votes } + } + }); + + let last_winner = hopefuls[num_vacancies - 1].1; + if last_winner.votes <= total_trailing { + return Ok(false); + } + + let mut hopefuls: Vec<&Candidate> = hopefuls.iter().map(|(c, _)| *c).collect(); + + // Bulk elect all remaining candidates + while state.num_elected < state.election.seats { + 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( + "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] + ); + + if constraints::update_constraints(state, opts) { + // FIXME: Work out interaction between early bulk election and constraints + panic!("Attempted early bulk election resulted in changes to constraint matrix"); + } else { + hopefuls.remove(hopefuls.iter().position(|c| *c == candidate).unwrap()); + } + } + + return Ok(true); +} + +/// Declare elected all candidates meeting the quota, and (if enabled) any candidates who can be early bulk elected because they have sufficiently many votes +fn elect_hopefuls<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> Result { + // Determine if early bulk election can be effected + if opts.early_bulk_elect { + if elect_sure_winners(state, opts)? { + return Ok(true); + } + } + let mut cands_meeting_quota: Vec<(&Candidate, &CountCard)> = state.election.candidates.iter() // Present in order in case of tie .map(|c| (c, &state.candidates[c])) .filter(|(_, cc)| { (cc.state == CandidateState::Hopeful || cc.state == CandidateState::Guarded) && meets_vre(state, cc, opts) }) .collect(); // Sort by votes - cands_meeting_quota.sort_unstable_by(|a, b| a.1.votes.cmp(&b.1.votes)); + cands_meeting_quota.sort_unstable_by(|a, b| b.1.votes.cmp(&a.1.votes)); let mut cands_meeting_quota: Vec<&Candidate> = cands_meeting_quota.iter().map(|(c, _)| *c).collect(); let elected = !cands_meeting_quota.is_empty(); @@ -829,7 +899,7 @@ fn elect_meeting_quota<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVO .map(|c| (c, &state.candidates[c])) .filter(|(_, cc)| { (cc.state == CandidateState::Hopeful || cc.state == CandidateState::Guarded) && meets_vre(state, cc, opts) }) .collect(); - cmq.sort_unstable_by(|a, b| a.1.votes.cmp(&b.1.votes)); + cmq.sort_unstable_by(|a, b| b.1.votes.cmp(&a.1.votes)); cands_meeting_quota = cmq.iter().map(|(c, _)| *c).collect(); } else { cands_meeting_quota.remove(cands_meeting_quota.iter().position(|c| *c == candidate).unwrap()); diff --git a/src/ties.rs b/src/ties.rs index 3afe172..64c8820 100644 --- a/src/ties.rs +++ b/src/ties.rs @@ -198,6 +198,7 @@ where } /// Prompt the candidate for input, depending on CLI or WebAssembly target +// FIXME: This may have unexpected behaviour if the tie occurs in the middle of a stage #[cfg(not(target_arch = "wasm32"))] fn prompt<'c>(candidates: &Vec<&'c Candidate>) -> Result<&'c Candidate, STVError> { println!("Multiple tied candidates:"); diff --git a/tests/ers97.rs b/tests/ers97.rs index e5c6913..74687b1 100644 --- a/tests/ers97.rs +++ b/tests/ers97.rs @@ -39,7 +39,7 @@ fn ers97_rational() { transferable_only: true, exclusion: stv::ExclusionMethod::ByValue, meek_nz_exclusion: false, - early_bulk_elect: true, + early_bulk_elect: false, bulk_exclude: true, defer_surpluses: true, meek_immediate_elect: false, diff --git a/tests/scotland.rs b/tests/scotland.rs index 4d496c7..6ee5b1d 100644 --- a/tests/scotland.rs +++ b/tests/scotland.rs @@ -46,7 +46,7 @@ fn scotland_linn07_fixed5() { transferable_only: false, exclusion: stv::ExclusionMethod::SingleStage, meek_nz_exclusion: false, - early_bulk_elect: true, + early_bulk_elect: false, bulk_exclude: false, defer_surpluses: false, meek_immediate_elect: false, @@ -77,7 +77,7 @@ fn scotland_linn07_gfixed5() { transferable_only: false, exclusion: stv::ExclusionMethod::SingleStage, meek_nz_exclusion: false, - early_bulk_elect: true, + early_bulk_elect: false, bulk_exclude: false, defer_surpluses: false, meek_immediate_elect: false,