Implement dynamic quotas

This commit is contained in:
RunasSudo 2021-08-08 19:34:02 +10:00
parent ee1008b509
commit ae0d1d8411
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
6 changed files with 54 additions and 26 deletions

View File

@ -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: 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 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 ## STV variants

View File

@ -94,6 +94,8 @@
<!--<option value="progressive">Progressive quota</option>--> <!--<option value="progressive">Progressive quota</option>-->
<option value="ers97">Static with ERS97 rules</option> <option value="ers97">Static with ERS97 rules</option>
<option value="ers76">Static with ERS76 rules</option> <option value="ers76">Static with ERS76 rules</option>
<option value="dynamic_by_total">Dynamic by total vote</option>
<option value="dynamic_by_active">Dynamic by active vote</option>
</select> </select>
</label> </label>
</div> </div>

View File

@ -413,7 +413,7 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'meek87') { } else if (document.getElementById('selPreset').value === 'meek87') {
document.getElementById('selQuotaCriterion').value = 'gt'; document.getElementById('selQuotaCriterion').value = 'gt';
document.getElementById('selQuota').value = 'droop_exact'; 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('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false; document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('chkDeferSurpluses').checked = false; document.getElementById('chkDeferSurpluses').checked = false;
@ -438,7 +438,7 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'meek06') { } else if (document.getElementById('selPreset').value === 'meek06') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop'; document.getElementById('selQuota').value = 'droop';
document.getElementById('selQuotaMode').value = 'static'; document.getElementById('selQuotaMode').value = 'dynamic_by_total';
document.getElementById('chkBulkElection').checked = true; document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false; document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('chkDeferSurpluses').checked = true; document.getElementById('chkDeferSurpluses').checked = true;
@ -467,7 +467,7 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'meeknz') { } else if (document.getElementById('selPreset').value === 'meeknz') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop'; document.getElementById('selQuota').value = 'droop';
document.getElementById('selQuotaMode').value = 'static'; document.getElementById('selQuotaMode').value = 'dynamic_by_total';
document.getElementById('chkBulkElection').checked = true; document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false; document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('chkDeferSurpluses').checked = true; document.getElementById('chkDeferSurpluses').checked = true;

View File

@ -105,7 +105,7 @@ struct STV {
quota_criterion: String, quota_criterion: String,
/// Whether to apply a form of progressive quota /// 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, quota_mode: String,
// ------------------ // ------------------

View File

@ -206,6 +206,7 @@ impl STVOptions {
/// Validate the combination of [STVOptions] and panic if invalid /// Validate the combination of [STVOptions] and panic if invalid
pub fn validate(&self) -> Result<(), STVError> { pub fn validate(&self) -> Result<(), STVError> {
if self.surplus == SurplusMethod::Meek { 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.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.exclusion != ExclusionMethod::SingleStage { return Err(STVError::InvalidOptions("--surplus meek requires --exclusion single_stage")); }
} }
@ -336,6 +337,10 @@ pub enum QuotaMode {
ERS97, ERS97,
/// Static quota with ERS76 rules /// Static quota with ERS76 rules
ERS76, ERS76,
/// Dynamic quota by total vote
DynamicByTotal,
/// Dynamic quota by active vote
DynamicByActive,
} }
impl QuotaMode { impl QuotaMode {
@ -345,6 +350,8 @@ impl QuotaMode {
QuotaMode::Static => "--quota-mode static", QuotaMode::Static => "--quota-mode static",
QuotaMode::ERS97 => "--quota-mode ers97", QuotaMode::ERS97 => "--quota-mode ers97",
QuotaMode::ERS76 => "--quota-mode ers76", QuotaMode::ERS76 => "--quota-mode ers76",
QuotaMode::DynamicByTotal => "--quota-mode dynamic_by_total",
QuotaMode::DynamicByActive => "--quota-mode dynamic_by_active",
}.to_string() }.to_string()
} }
} }
@ -355,6 +362,8 @@ impl<S: AsRef<str>> From<S> for QuotaMode {
"static" => QuotaMode::Static, "static" => QuotaMode::Static,
"ers97" => QuotaMode::ERS97, "ers97" => QuotaMode::ERS97,
"ers76" => QuotaMode::ERS76, "ers76" => QuotaMode::ERS76,
"dynamic_by_total" => QuotaMode::DynamicByTotal,
"dynamic_by_active" => QuotaMode::DynamicByActive,
_ => panic!("Invalid --quota-mode"), _ => panic!("Invalid --quota-mode"),
} }
} }
@ -780,16 +789,17 @@ fn total_to_quota<N: Number>(mut total: N, seats: usize, opts: &STVOptions) -> N
fn update_vre<N: Number>(state: &mut CountState<N>, opts: &STVOptions) { fn update_vre<N: Number>(state: &mut CountState<N>, opts: &STVOptions) {
let mut log = String::new(); let mut log = String::new();
// Calculate total active vote // Calculate active vote
let total_active_vote = state.candidates.values().fold(N::zero(), |acc, cc| { let active_vote = state.candidates.values().fold(N::zero(), |acc, cc| {
match cc.state { 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 } } 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 } _ => { 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() { if &vote_req < state.quota.as_ref().unwrap() {
// VRE is less than the quota // VRE is less than the quota
@ -812,8 +822,9 @@ fn update_vre<N: Number>(state: &mut CountState<N>, opts: &STVOptions) {
/// Calculate the quota according to [STVOptions::quota] /// Calculate the quota according to [STVOptions::quota]
fn calculate_quota<N: Number>(state: &mut CountState<N>, opts: &STVOptions) { fn calculate_quota<N: Number>(state: &mut CountState<N>, opts: &STVOptions) {
// Calculate quota if state.quota.is_none() || opts.quota_mode == QuotaMode::DynamicByTotal {
if state.quota.is_none() || opts.surplus == SurplusMethod::Meek { // Calculate quota by total vote
let mut log = String::new(); let mut log = String::new();
// Calculate the total vote // Calculate the total vote
@ -822,6 +833,27 @@ fn calculate_quota<N: Number>(state: &mut CountState<N>, opts: &STVOptions) {
let quota = total_to_quota(total_vote, state.election.seats, opts); 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()); log.push_str(format!("{:.dps$}.", quota, dps=opts.pp_decimals).as_str());
state.quota = Some(quota); state.quota = Some(quota);
state.logger.log_literal(log); state.logger.log_literal(log);
@ -856,13 +888,7 @@ fn calculate_quota<N: Number>(state: &mut CountState<N>, opts: &STVOptions) {
update_vre(state, opts); update_vre(state, opts);
} }
} else { } else {
// No ERS97/ERS76 rules // No ERS97/ERS76 rules, so no use of VRE
if opts.surplus == SurplusMethod::Meek {
// Update quota and so VRE every stage
state.vote_required_election = state.quota.clone();
} else {
// No use of VRE
}
} }
} }
@ -881,13 +907,8 @@ fn cmp_quota_criterion<N: Number>(quota: &N, count_card: &CountCard<N>, opts: &S
/// Determine if the given candidate meets the vote required to be elected, according to [STVOptions::quota_criterion] and [STVOptions::quota_mode] /// Determine if the given candidate meets the vote required to be elected, according to [STVOptions::quota_criterion] and [STVOptions::quota_mode]
fn meets_vre<N: Number>(state: &CountState<N>, count_card: &CountCard<N>, opts: &STVOptions) -> bool { fn meets_vre<N: Number>(state: &CountState<N>, count_card: &CountCard<N>, opts: &STVOptions) -> bool {
if let Some(vre) = &state.vote_required_election { 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 // VRE is set because ERS97/ERS76 rules
return cmp_quota_criterion(vre, count_card, opts); 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;
}
} else { } else {
return cmp_quota_criterion(state.quota.as_ref().unwrap(), count_card, opts); return cmp_quota_criterion(state.quota.as_ref().unwrap(), count_card, opts);
} }

View File

@ -27,6 +27,7 @@ fn meek87_ers97_float64() {
let stv_opts = stv::STVOptionsBuilder::default() let stv_opts = stv::STVOptionsBuilder::default()
.meek_surplus_tolerance("0.001%".to_string()) .meek_surplus_tolerance("0.001%".to_string())
.quota_criterion(stv::QuotaCriterion::GreaterOrEqual) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual)
.quota_mode(stv::QuotaMode::DynamicByTotal)
.surplus(stv::SurplusMethod::Meek) .surplus(stv::SurplusMethod::Meek)
.immediate_elect(false) .immediate_elect(false)
.build().unwrap(); .build().unwrap();
@ -45,6 +46,7 @@ fn meek06_ers97_fixed12() {
.meek_surplus_tolerance("0.0001".to_string()) .meek_surplus_tolerance("0.0001".to_string())
.quota(stv::QuotaType::Droop) .quota(stv::QuotaType::Droop)
.quota_criterion(stv::QuotaCriterion::GreaterOrEqual) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual)
.quota_mode(stv::QuotaMode::DynamicByTotal)
.surplus(stv::SurplusMethod::Meek) .surplus(stv::SurplusMethod::Meek)
.defer_surpluses(true) .defer_surpluses(true)
.build().unwrap(); .build().unwrap();
@ -104,6 +106,7 @@ fn meeknz_ers97_fixed12() {
.meek_surplus_tolerance("0.0001".to_string()) .meek_surplus_tolerance("0.0001".to_string())
.quota(stv::QuotaType::Droop) .quota(stv::QuotaType::Droop)
.quota_criterion(stv::QuotaCriterion::GreaterOrEqual) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual)
.quota_mode(stv::QuotaMode::DynamicByTotal)
.surplus(stv::SurplusMethod::Meek) .surplus(stv::SurplusMethod::Meek)
.meek_nz_exclusion(true) .meek_nz_exclusion(true)
.defer_surpluses(true) .defer_surpluses(true)