From ae0d1d841127510ddb9ea2a43c8d1f84b6fd6b66 Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Sun, 8 Aug 2021 19:34:02 +1000 Subject: [PATCH] Implement dynamic quotas --- docs/options.md | 6 +++-- html/index.html | 2 ++ html/index.js | 6 ++--- src/main.rs | 2 +- src/stv/mod.rs | 61 +++++++++++++++++++++++++++++++++---------------- tests/meek.rs | 3 +++ 6 files changed, 54 insertions(+), 26 deletions(-) diff --git a/docs/options.md b/docs/options.md index bf5a0d0..dd09da8 100644 --- a/docs/options.md +++ b/docs/options.md @@ -63,9 +63,11 @@ Note that the combination ‘*>= Droop (exact)*’ (with *Round quota to [n] d.p This option allows you to specify whether the votes required for election can change during the count. The options are: * *Static quota*: The quota is calculated once after all first-preference votes are allocated, and remains constant throughout the count. -* *Static with ERS97 rules*: The quota is static, but candidates may be elected if their vote exceeds (or equals, according to the *Quota criterion*) the total active vote, divided by (*S* + 1) (or *S*, according to the *Quota* option). +* *Static with ERS97 rules*: The quota is static, but candidates may be elected if their vote exceeds (or equals, according to the *Quota criterion*) the active vote, divided by (*S* + 1). +* *Dynamic by total vote*: The quota is recalculated at the end of each stage, according to the *Quota* option. +* *Dynamic by active vote*: The quota is recalculated at the end of each stage, according to the *Quota* option, but where *V* is the active vote and *S* is the number of remaining vacancies. -When *Surplus method* is set to *Meek method*, this setting is ignored, and the progressively reducing quota of the Meek method is instead applied. +When a dynamic quota is used, then unless *Surplus method* is set to *Meek*, the quota that applies to an elected candidate is the quota at the start of the stage when the candidate's surplus is distributed. Further distributions are not performed later, even if the quota is later reduced. ## STV variants diff --git a/html/index.html b/html/index.html index 5827e52..1745a5a 100644 --- a/html/index.html +++ b/html/index.html @@ -94,6 +94,8 @@ + + diff --git a/html/index.js b/html/index.js index 602006e..9a5c79e 100644 --- a/html/index.js +++ b/html/index.js @@ -413,7 +413,7 @@ function changePreset() { } else if (document.getElementById('selPreset').value === 'meek87') { document.getElementById('selQuotaCriterion').value = 'gt'; document.getElementById('selQuota').value = 'droop_exact'; - document.getElementById('selQuotaMode').value = 'static'; + document.getElementById('selQuotaMode').value = 'dynamic_by_total'; document.getElementById('chkBulkElection').checked = true; document.getElementById('chkBulkExclusion').checked = false; document.getElementById('chkDeferSurpluses').checked = false; @@ -438,7 +438,7 @@ function changePreset() { } else if (document.getElementById('selPreset').value === 'meek06') { document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuota').value = 'droop'; - document.getElementById('selQuotaMode').value = 'static'; + document.getElementById('selQuotaMode').value = 'dynamic_by_total'; document.getElementById('chkBulkElection').checked = true; document.getElementById('chkBulkExclusion').checked = false; document.getElementById('chkDeferSurpluses').checked = true; @@ -467,7 +467,7 @@ function changePreset() { } else if (document.getElementById('selPreset').value === 'meeknz') { document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuota').value = 'droop'; - document.getElementById('selQuotaMode').value = 'static'; + document.getElementById('selQuotaMode').value = 'dynamic_by_total'; document.getElementById('chkBulkElection').checked = true; document.getElementById('chkBulkExclusion').checked = false; document.getElementById('chkDeferSurpluses').checked = true; diff --git a/src/main.rs b/src/main.rs index 82b3ea5..bd71546 100644 --- a/src/main.rs +++ b/src/main.rs @@ -105,7 +105,7 @@ struct STV { quota_criterion: String, /// Whether to apply a form of progressive quota - #[clap(help_heading=Some("QUOTA"), long, possible_values=&["static", "ers97", "ers76"], default_value="static", value_name="mode")] + #[clap(help_heading=Some("QUOTA"), long, possible_values=&["static", "ers97", "ers76", "dynamic_by_total", "dynamic_by_active"], default_value="static", value_name="mode")] quota_mode: String, // ------------------ diff --git a/src/stv/mod.rs b/src/stv/mod.rs index b19fbbd..3252e8d 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -206,6 +206,7 @@ impl STVOptions { /// Validate the combination of [STVOptions] and panic if invalid pub fn validate(&self) -> Result<(), STVError> { if self.surplus == SurplusMethod::Meek { + 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")); } } @@ -336,6 +337,10 @@ pub enum QuotaMode { ERS97, /// Static quota with ERS76 rules ERS76, + /// Dynamic quota by total vote + DynamicByTotal, + /// Dynamic quota by active vote + DynamicByActive, } impl QuotaMode { @@ -345,6 +350,8 @@ impl QuotaMode { QuotaMode::Static => "--quota-mode static", QuotaMode::ERS97 => "--quota-mode ers97", QuotaMode::ERS76 => "--quota-mode ers76", + QuotaMode::DynamicByTotal => "--quota-mode dynamic_by_total", + QuotaMode::DynamicByActive => "--quota-mode dynamic_by_active", }.to_string() } } @@ -355,6 +362,8 @@ impl> From for QuotaMode { "static" => QuotaMode::Static, "ers97" => QuotaMode::ERS97, "ers76" => QuotaMode::ERS76, + "dynamic_by_total" => QuotaMode::DynamicByTotal, + "dynamic_by_active" => QuotaMode::DynamicByActive, _ => panic!("Invalid --quota-mode"), } } @@ -780,16 +789,17 @@ fn total_to_quota(mut total: N, seats: usize, opts: &STVOptions) -> N fn update_vre(state: &mut CountState, opts: &STVOptions) { let mut log = String::new(); - // Calculate total active vote - let total_active_vote = state.candidates.values().fold(N::zero(), |acc, cc| { + // Calculate active vote + let active_vote = state.candidates.values().fold(N::zero(), |acc, cc| { match cc.state { CandidateState::Elected => { if !cc.finalised && &cc.votes > state.quota.as_ref().unwrap() { acc + &cc.votes - state.quota.as_ref().unwrap() } else { acc } } _ => { acc + &cc.votes } } }); - 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()); + log.push_str(format!("Active vote is {:.dps$}, so the vote required for election is ", active_vote, dps=opts.pp_decimals).as_str()); - let vote_req = total_active_vote / N::from(state.election.seats - state.num_elected + 1); + // TODO: Calculate according to --quota ? + let vote_req = active_vote / N::from(state.election.seats - state.num_elected + 1); if &vote_req < state.quota.as_ref().unwrap() { // VRE is less than the quota @@ -812,8 +822,9 @@ fn update_vre(state: &mut CountState, opts: &STVOptions) { /// Calculate the quota according to [STVOptions::quota] fn calculate_quota(state: &mut CountState, opts: &STVOptions) { - // Calculate quota - if state.quota.is_none() || opts.surplus == SurplusMethod::Meek { + if state.quota.is_none() || opts.quota_mode == QuotaMode::DynamicByTotal { + // Calculate quota by total vote + let mut log = String::new(); // Calculate the total vote @@ -822,6 +833,27 @@ fn calculate_quota(state: &mut CountState, opts: &STVOptions) { let quota = total_to_quota(total_vote, state.election.seats, opts); + log.push_str(format!("{:.dps$}.", quota, dps=opts.pp_decimals).as_str()); + state.quota = Some(quota); + state.logger.log_literal(log); + + } else if opts.quota_mode == QuotaMode::DynamicByActive { + // Calculate quota by active vote + + let mut log = String::new(); + + // Calculate the active vote + let active_vote = state.candidates.values().fold(N::zero(), |acc, cc| { + match cc.state { + CandidateState::Elected => { if !cc.finalised && &cc.votes > state.quota.as_ref().unwrap() { acc + &cc.votes - state.quota.as_ref().unwrap() } else { acc } } + _ => { acc + &cc.votes } + } + }); + log.push_str(format!("Active vote is {:.dps$}, so the quota is is ", active_vote, dps=opts.pp_decimals).as_str()); + + // TODO: Calculate according to --quota ? + let quota = active_vote / N::from(state.election.seats - state.num_elected + 1); + log.push_str(format!("{:.dps$}.", quota, dps=opts.pp_decimals).as_str()); state.quota = Some(quota); state.logger.log_literal(log); @@ -856,13 +888,7 @@ fn calculate_quota(state: &mut CountState, opts: &STVOptions) { update_vre(state, opts); } } else { - // No ERS97/ERS76 rules - if opts.surplus == SurplusMethod::Meek { - // Update quota and so VRE every stage - state.vote_required_election = state.quota.clone(); - } else { - // No use of VRE - } + // No ERS97/ERS76 rules, so no use of VRE } } @@ -881,13 +907,8 @@ fn cmp_quota_criterion(quota: &N, count_card: &CountCard, opts: &S /// Determine if the given candidate meets the vote required to be elected, according to [STVOptions::quota_criterion] and [STVOptions::quota_mode] fn meets_vre(state: &CountState, count_card: &CountCard, opts: &STVOptions) -> bool { if let Some(vre) = &state.vote_required_election { - if opts.quota_mode == QuotaMode::ERS97 || opts.quota_mode == QuotaMode::ERS76 { - // VRE is set because ERS97/ERS76 rules - return cmp_quota_criterion(vre, count_card, opts); - } else { - // VRE is set because early bulk election is enabled and 1 vacancy remains - return count_card.votes > *vre; - } + // VRE is set because ERS97/ERS76 rules + return cmp_quota_criterion(vre, count_card, opts); } else { return cmp_quota_criterion(state.quota.as_ref().unwrap(), count_card, opts); } diff --git a/tests/meek.rs b/tests/meek.rs index d8e3bed..07e717a 100644 --- a/tests/meek.rs +++ b/tests/meek.rs @@ -27,6 +27,7 @@ fn meek87_ers97_float64() { let stv_opts = stv::STVOptionsBuilder::default() .meek_surplus_tolerance("0.001%".to_string()) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual) + .quota_mode(stv::QuotaMode::DynamicByTotal) .surplus(stv::SurplusMethod::Meek) .immediate_elect(false) .build().unwrap(); @@ -45,6 +46,7 @@ fn meek06_ers97_fixed12() { .meek_surplus_tolerance("0.0001".to_string()) .quota(stv::QuotaType::Droop) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual) + .quota_mode(stv::QuotaMode::DynamicByTotal) .surplus(stv::SurplusMethod::Meek) .defer_surpluses(true) .build().unwrap(); @@ -104,6 +106,7 @@ fn meeknz_ers97_fixed12() { .meek_surplus_tolerance("0.0001".to_string()) .quota(stv::QuotaType::Droop) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual) + .quota_mode(stv::QuotaMode::DynamicByTotal) .surplus(stv::SurplusMethod::Meek) .meek_nz_exclusion(true) .defer_surpluses(true)