Implement --sum-surplus-transfers

This commit is contained in:
RunasSudo 2021-06-11 21:22:28 +10:00
parent 9d4cac2e89
commit 96a3eaec84
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
10 changed files with 176 additions and 40 deletions

16
Cargo.lock generated
View File

@ -151,6 +151,12 @@ dependencies = [
"syn",
]
[[package]]
name = "either"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
[[package]]
name = "flate2"
version = "1.0.20"
@ -233,6 +239,15 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "itertools"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69ddb889f9d0d08a67338271fa9b62996bc788c7796a5c18cf057420aaed5eaf"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "0.4.7"
@ -338,6 +353,7 @@ dependencies = [
"flate2",
"git-version",
"ibig",
"itertools",
"js-sys",
"num-bigint",
"num-rational",

View File

@ -11,6 +11,7 @@ crate-type = ["lib", "cdylib"]
derive_more = "0.99.14"
git-version = "0.3.4"
ibig = "0.3.2"
itertools = "0.10.1"
num-traits = "0.2"
wasm-bindgen = "0.2.74"

View File

@ -35,7 +35,8 @@
<label>
Preset:
<select id="selPreset" onchange="changePreset()">
<option value="scottish" selected>Scottish STV</option>
<option value="wigm" selected>Recommended WIGM</option>
<option value="scottish">Scottish STV</option>
<option value="senate">Australian Senate STV</option>
<!--<option value="meek">Meek STV</option>
<option value="wright">Wright STV</option>-->
@ -58,14 +59,14 @@
<label>
Quota:
<select id="selQuotaCriterion">
<option value="geq" selected>&gt;=</option>
<option value="gt">&gt;</option>
<option value="geq">&geq;</option>
<option value="gt" selected>&gt;</option>
</select>
</label>
<label>
<select id="selQuota">
<option value="droop" selected>Droop</option>
<option value="droop_exact">Droop (exact)</option>
<option value="droop">Droop</option>
<option value="droop_exact" selected>Droop (exact)</option>
<option value="hare">Hare</option>
<option value="hare_exact">Hare (exact)</option>
</select>
@ -146,8 +147,8 @@
<label>
Numbers:
<select id="selNumbers">
<option value="rational">Rational</option>
<option value="fixed" selected>Fixed</option>
<option value="rational" selected>Rational</option>
<option value="fixed">Fixed</option>
<!--<option value="gfixed">Fixed (guarded)</option>-->
<option value="float64">Float (64-bit)</option>
</select>
@ -184,7 +185,7 @@
</div>
<div class="col-6">
<label>
<input type="checkbox" id="chkRoundQuota" checked>
<input type="checkbox" id="chkRoundQuota">
Quota:
</label>
<label>
@ -205,7 +206,7 @@
<div class="col-6">
<label>
<input type="checkbox" id="chkRoundTVs">
Transfer values:
Surplus fractions:
</label>
<label>
<input type="number" id="txtRoundTVs" value="0" min="0" style="width: 3em;">
@ -222,6 +223,14 @@
d.p.
</label>
</div>
<label class="col-12">
Sum surplus transfers:
<select id="selSumTransfers">
<option value="single_step" selected>Single step</option>
<option value="by_value">By value</option>
<option value="per_ballot">Per ballot</option>
</select>
</label>
</div>
</div>

View File

@ -95,6 +95,7 @@ async function clickCount() {
document.getElementById('chkRoundWeights').checked ? parseInt(document.getElementById('txtRoundWeights').value) : null,
document.getElementById('chkRoundVotes').checked ? parseInt(document.getElementById('txtRoundVotes').value) : null,
document.getElementById('chkRoundQuota').checked ? parseInt(document.getElementById('txtRoundQuota').value) : null,
document.getElementById('selSumTransfers').value,
document.getElementById('selQuota').value,
document.getElementById('selQuotaCriterion').value,
document.getElementById('selQuotaMode').value,
@ -291,7 +292,26 @@ async function printResult() {
// Presets
function changePreset() {
if (document.getElementById('selPreset').value === 'scottish') {
if (document.getElementById('selPreset').value === 'wigm') {
document.getElementById('selQuotaCriterion').value = 'gt';
document.getElementById('selQuota').value = 'droop_exact';
document.getElementById('selQuotaMode').value = 'static';
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('chkDeferSurpluses').checked = false;
document.getElementById('selNumbers').value = 'rational';
document.getElementById('txtPPDP').value = '2';
document.getElementById('chkRoundQuota').checked = false;
document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundTVs').checked = false;
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSumTransfers').value = 'single_step';
document.getElementById('selSurplus').value = 'by_size';
document.getElementById('selTransfers').value = 'wig';
document.getElementById('selPapers').value = 'both';
document.getElementById('selExclusion').value = 'single_stage';
//document.getElementById('selTies').value = 'backwards_random';
} else if (document.getElementById('selPreset').value === 'scottish') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop';
document.getElementById('selQuotaMode').value = 'static';
@ -300,12 +320,14 @@ function changePreset() {
document.getElementById('chkDeferSurpluses').checked = false;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
document.getElementById('txtPPDP').value = '2';
document.getElementById('txtPPDP').value = '5';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '0';
document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundTVs').checked = false;
document.getElementById('chkRoundTVs').checked = true;
document.getElementById('txtRoundTVs').value = '5';
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSumTransfers').value = 'per_ballot';
document.getElementById('selSurplus').value = 'by_size';
document.getElementById('selTransfers').value = 'wig';
document.getElementById('selPapers').value = 'both';
@ -327,6 +349,7 @@ function changePreset() {
document.getElementById('txtRoundVotes').value = '0';
document.getElementById('chkRoundTVs').checked = false;
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSumTransfers').value = 'single_step';
document.getElementById('selSurplus').value = 'by_order';
document.getElementById('selTransfers').value = 'uig';
document.getElementById('selPapers').value = 'both';
@ -350,6 +373,7 @@ function changePreset() {
document.getElementById('txtRoundTVs').value = '3';
document.getElementById('chkRoundWeights').checked = true;
document.getElementById('txtRoundWeights').value = '3';
document.getElementById('selSumTransfers').value = 'single_step';
document.getElementById('selSurplus').value = 'by_order';
document.getElementById('selTransfers').value = 'eg';
document.getElementById('selPapers').value = 'transferable';
@ -373,6 +397,7 @@ function changePreset() {
document.getElementById('txtRoundTVs').value = '2';
document.getElementById('chkRoundWeights').checked = true;
document.getElementById('txtRoundWeights').value = '2';
document.getElementById('selSumTransfers').value = 'single_step';
document.getElementById('selSurplus').value = 'by_size';
document.getElementById('selTransfers').value = 'eg';
document.getElementById('selPapers').value = 'transferable';

View File

@ -78,6 +78,9 @@ struct STV {
#[clap(help_heading=Some("ROUNDING"), long, value_name="dps")]
round_quota: Option<usize>,
#[clap(help_heading=Some("ROUNDING"), long, possible_values=&["single_step", "by_value", "per_ballot"], default_value="single_step", value_name="mode")]
sum_surplus_transfers: String,
// -----------
// -- Quota --
@ -173,6 +176,7 @@ where
cmd_opts.round_weights,
cmd_opts.round_votes,
cmd_opts.round_quota,
&cmd_opts.sum_surplus_transfers,
&cmd_opts.quota,
&cmd_opts.quota_criterion,
&cmd_opts.quota_mode,

View File

@ -23,6 +23,7 @@ pub mod wasm;
use crate::numbers::Number;
use crate::election::{Candidate, CandidateState, CountCard, CountState, Parcel, Vote};
use itertools::Itertools;
use wasm_bindgen::prelude::wasm_bindgen;
use std::cmp::max;
@ -35,6 +36,7 @@ pub struct STVOptions {
pub round_weights: Option<usize>,
pub round_votes: Option<usize>,
pub round_quota: Option<usize>,
pub sum_surplus_transfers: SumSurplusTransfersMode,
pub quota: QuotaType,
pub quota_criterion: QuotaCriterion,
pub quota_mode: QuotaMode,
@ -54,6 +56,7 @@ impl STVOptions {
round_weights: Option<usize>,
round_votes: Option<usize>,
round_quota: Option<usize>,
sum_transfers: &str,
quota: &str,
quota_criterion: &str,
quota_mode: &str,
@ -70,6 +73,12 @@ impl STVOptions {
round_weights,
round_votes,
round_quota,
sum_surplus_transfers: match sum_transfers {
"single_step" => SumSurplusTransfersMode::SingleStep,
"by_value" => SumSurplusTransfersMode::ByValue,
"per_ballot" => SumSurplusTransfersMode::PerBallot,
_ => panic!("Invalid --sum-transfers"),
},
quota: match quota {
"droop" => QuotaType::Droop,
"hare" => QuotaType::Hare,
@ -134,6 +143,15 @@ impl STVOptions {
}
}
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
pub enum SumSurplusTransfersMode {
SingleStep,
ByValue,
PerBallot,
}
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
@ -581,6 +599,7 @@ where
fn distribute_surpluses<N: Number>(state: &mut CountState<N>, opts: &STVOptions) -> bool
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>
{
let quota = state.quota.as_ref().unwrap();
@ -620,7 +639,7 @@ where
}
/// Return the denominator of the transfer value
fn calculate_transfer_denom<N: Number>(surplus: &N, result: &NextPreferencesResult<N>, transferable_votes: &N, weighted: bool, transferable_only: bool) -> Option<N>
fn calculate_surplus_denom<N: Number>(surplus: &N, result: &NextPreferencesResult<N>, transferable_votes: &N, weighted: bool, transferable_only: bool) -> Option<N>
where
for<'r> &'r N: ops::Sub<&'r N, Output=N>
{
@ -648,21 +667,21 @@ fn reweight_vote<N: Number>(
num_ballots: &N,
surplus: &N,
weighted: bool,
transfer_value: &Option<N>,
transfer_denom: &Option<N>,
surplus_fraction: &Option<N>,
surplus_denom: &Option<N>,
round_tvs: Option<usize>,
rounding: Option<usize>) -> N
{
let mut result;
match transfer_denom {
match surplus_denom {
Some(v) => {
if let Some(_) = round_tvs {
// Rounding requested: use the rounded transfer value
if weighted {
result = num_votes.clone() * transfer_value.as_ref().unwrap();
result = num_votes.clone() * surplus_fraction.as_ref().unwrap();
} else {
result = num_ballots.clone() * transfer_value.as_ref().unwrap();
result = num_ballots.clone() * surplus_fraction.as_ref().unwrap();
}
} else {
// Avoid unnecessary rounding error by first multiplying by the surplus
@ -686,11 +705,57 @@ fn reweight_vote<N: Number>(
return result;
}
fn sum_surplus_transfers<N: Number>(entry: &NextPreferencesEntry<N>, surplus: &N, is_weighted: bool, surplus_fraction: &Option<N>, surplus_denom: &Option<N>, _state: &mut CountState<N>, opts: &STVOptions) -> N
where
for<'r> &'r N: ops::Div<&'r N, Output=N>,
{
match opts.sum_surplus_transfers {
SumSurplusTransfersMode::SingleStep => {
// Calculate transfer across all votes
//state.logger.log_literal(format!("Transferring {:.0} ballot papers, totalling {:.dps$} votes.", entry.num_ballots, entry.num_votes, dps=opts.pp_decimals));
return reweight_vote(&entry.num_votes, &entry.num_ballots, surplus, is_weighted, surplus_fraction, surplus_denom, opts.round_tvs, opts.round_votes);
}
SumSurplusTransfersMode::ByValue => {
// Sum transfers by value
let mut result = N::new();
// Sort into parcels by value
let mut votes: Vec<&Vote<N>> = entry.votes.iter().collect();
votes.sort_unstable_by(|a, b| (&a.value / &a.ballot.orig_value).cmp(&(&b.value / &b.ballot.orig_value)));
for (_value, parcel) in &votes.into_iter().group_by(|v| &v.value / &v.ballot.orig_value) {
let mut num_votes = N::new();
let mut num_ballots = N::new();
for vote in parcel {
num_votes += &vote.value;
num_ballots += &vote.ballot.orig_value;
}
//state.logger.log_literal(format!("Transferring {:.0} ballot papers, totalling {:.dps$} votes, received at value {:.dps2$}.", num_ballots, num_votes, value, dps=opts.pp_decimals, dps2=max(opts.pp_decimals, 2)));
result += reweight_vote(&num_votes, &num_ballots, surplus, is_weighted, surplus_fraction, surplus_denom, opts.round_tvs, opts.round_votes);
}
return result;
}
SumSurplusTransfersMode::PerBallot => {
// Sum transfer per each individual ballot
// TODO: This could be moved to distribute_surplus to avoid looping over the votes and calculating transfer values twice
let mut result = N::new();
for vote in entry.votes.iter() {
result += reweight_vote(&vote.value, &vote.ballot.orig_value, surplus, is_weighted, surplus_fraction, surplus_denom, opts.round_tvs, opts.round_votes);
}
//state.logger.log_literal(format!("Transferring {:.0} ballot papers, totalling {:.dps$} votes.", entry.num_ballots, entry.num_votes, dps=opts.pp_decimals));
return result;
}
}
}
fn distribute_surplus<N: Number>(state: &mut CountState<N>, opts: &STVOptions, elected_candidate: &Candidate)
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>
{
state.logger.log_literal(format!("Surplus of {} distributed.", elected_candidate.name));
let count_card = state.candidates.get(elected_candidate).unwrap();
let surplus = &count_card.votes - state.quota.as_ref().unwrap();
@ -725,47 +790,54 @@ where
};
let transferable_votes = &result.total_votes - &result.exhausted.num_votes;
let transfer_denom = calculate_transfer_denom(&surplus, &result, &transferable_votes, is_weighted, opts.transferable_only);
let mut transfer_value;
match transfer_denom {
let surplus_denom = calculate_surplus_denom(&surplus, &result, &transferable_votes, is_weighted, opts.transferable_only);
let mut surplus_fraction;
match surplus_denom {
Some(ref v) => {
transfer_value = Some(surplus.clone() / v);
surplus_fraction = Some(surplus.clone() / v);
// Round down if requested
if let Some(dps) = opts.round_tvs {
transfer_value.as_mut().unwrap().floor_mut(dps);
surplus_fraction.as_mut().unwrap().floor_mut(dps);
}
state.logger.log_literal(format!("Surplus of {} distributed at value {:.dps2$}.", elected_candidate.name, transfer_value.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2)));
if opts.transferable_only {
state.logger.log_literal(format!("Transferring {:.0} transferable ballots, totalling {:.dps$} transferable votes, with surplus fraction {:.dps2$}.", &result.total_ballots - &result.exhausted.num_ballots, transferable_votes, surplus_fraction.as_ref().unwrap(), dps=opts.pp_decimals, dps2=max(opts.pp_decimals, 2)));
} else {
state.logger.log_literal(format!("Transferring {:.0} ballots, totalling {:.dps$} votes, with surplus fraction {:.dps2$}.", result.total_ballots, result.total_votes, surplus_fraction.as_ref().unwrap(), dps=opts.pp_decimals, dps2=max(opts.pp_decimals, 2)));
}
}
None => {
transfer_value = None;
state.logger.log_literal(format!("Surplus of {} distributed at values received.", elected_candidate.name));
surplus_fraction = None;
if opts.transferable_only {
state.logger.log_literal(format!("Transferring {:.0} transferable ballots, totalling {:.dps$} transferable votes, at values received.", &result.total_ballots - &result.exhausted.num_ballots, transferable_votes, dps=opts.pp_decimals));
} else {
state.logger.log_literal(format!("Transferring {:.0} ballots, totalling {:.dps$} votes, at values received.", result.total_ballots, result.total_votes, dps=opts.pp_decimals));
}
}
}
let mut checksum = N::new();
for (candidate, entry) in result.candidates.into_iter() {
// Credit transferred votes
let candidate_transfers = sum_surplus_transfers(&entry, &surplus, is_weighted, &surplus_fraction, &surplus_denom, state, opts);
let count_card = state.candidates.get_mut(candidate).unwrap();
count_card.transfer(&candidate_transfers);
checksum += candidate_transfers;
let mut parcel = entry.votes as Parcel<N>;
// Reweight votes
for vote in parcel.iter_mut() {
vote.value = reweight_vote(&vote.value, &vote.ballot.orig_value, &surplus, is_weighted, &transfer_value, &transfer_denom, opts.round_tvs, opts.round_weights);
vote.value = reweight_vote(&vote.value, &vote.ballot.orig_value, &surplus, is_weighted, &surplus_fraction, &surplus_denom, opts.round_tvs, opts.round_weights);
}
let count_card = state.candidates.get_mut(candidate).unwrap();
count_card.parcels.push(parcel);
let candidate_transfers = reweight_vote(&entry.num_votes, &entry.num_ballots, &surplus, is_weighted, &transfer_value, &transfer_denom, opts.round_tvs, opts.round_votes);
count_card.transfer(&candidate_transfers);
checksum += candidate_transfers;
}
// Transfer exhausted votes
let parcel = result.exhausted.votes as Parcel<N>;
state.exhausted.parcels.push(parcel);
// Credit exhausted votes
let mut exhausted_transfers;
if opts.transferable_only {
if transferable_votes > surplus {
@ -773,17 +845,22 @@ where
exhausted_transfers = N::new();
} else {
exhausted_transfers = &surplus - &transferable_votes;
if let Some(dps) = opts.round_votes {
exhausted_transfers.floor_mut(dps);
}
}
} else {
exhausted_transfers = reweight_vote(&result.exhausted.num_votes, &result.exhausted.num_ballots, &surplus, is_weighted, &transfer_value, &transfer_denom, opts.round_tvs, opts.round_votes);
exhausted_transfers = sum_surplus_transfers(&result.exhausted, &surplus, is_weighted, &surplus_fraction, &surplus_denom, state, opts);
}
if let Some(dps) = opts.round_votes {
exhausted_transfers.floor_mut(dps);
}
state.exhausted.transfer(&exhausted_transfers);
checksum += exhausted_transfers;
// Transfer exhausted votes
let parcel = result.exhausted.votes as Parcel<N>;
state.exhausted.parcels.push(parcel);
// Finalise candidate votes
let count_card = state.candidates.get_mut(elected_candidate).unwrap();
count_card.transfers = -&surplus;

View File

@ -59,6 +59,7 @@ fn aec_tas19_rational() {
round_weights: None,
round_votes: Some(0),
round_quota: Some(0),
sum_surplus_transfers: stv::SumSurplusTransfersMode::SingleStep,
quota: stv::QuotaType::Droop,
quota_criterion: stv::QuotaCriterion::GreaterOrEqual,
quota_mode: stv::QuotaMode::Static,

View File

@ -33,6 +33,7 @@ fn ers97_rational() {
round_weights: Some(2),
round_votes: Some(2),
round_quota: Some(2),
sum_surplus_transfers: stv::SumSurplusTransfersMode::SingleStep,
quota: stv::QuotaType::DroopExact,
quota_criterion: stv::QuotaCriterion::GreaterOrEqual,
quota_mode: stv::QuotaMode::ERS97,

View File

@ -27,6 +27,7 @@ fn prsa1_rational() {
round_weights: Some(3),
round_votes: Some(3),
round_quota: Some(3),
sum_surplus_transfers: stv::SumSurplusTransfersMode::SingleStep,
quota: stv::QuotaType::Droop,
quota_criterion: stv::QuotaCriterion::GreaterOrEqual,
quota_mode: stv::QuotaMode::Static,

View File

@ -34,6 +34,7 @@ fn scotland_linn07_fixed5() {
round_weights: None,
round_votes: None,
round_quota: Some(0),
sum_surplus_transfers: stv::SumSurplusTransfersMode::PerBallot,
quota: stv::QuotaType::Droop,
quota_criterion: stv::QuotaCriterion::GreaterOrEqual,
quota_mode: stv::QuotaMode::Static,