Implement --quota-mode ers97

This commit is contained in:
RunasSudo 2021-06-07 20:52:18 +10:00
parent 0fbe2d562e
commit d50af1161e
No known key found for this signature in database
GPG Key ID: 7234E476BF21C61A
13 changed files with 203 additions and 60 deletions

View File

@ -40,7 +40,7 @@
<!--<option value="meek">Meek STV</option>
<option value="wright">Wright STV</option>-->
<option value="prsa77">PRSA 1977</option>
<!--<option value="ers97">ERS97</option>-->
<option value="ers97">ERS97</option>
</select>
</label>
<button id="btnAdvancedOptions" onclick="clickAdvancedOptions()">Show advanced options</button>
@ -70,13 +70,13 @@
<option value="hare_exact">Hare (exact)</option>
</select>
</label>
<!--<label>
<label>
<select id="selQuotaMode">
<option value="static" selected>Static quota</option>
<option value="progressive">Progressive quota</option>
<!--<option value="progressive">Progressive quota</option>-->
<option value="ers97">Static with ERS97 rules</option>
</select>
</label>-->
</label>
</div>
<div>
<label>

View File

@ -97,6 +97,7 @@ async function clickCount() {
document.getElementById('chkRoundQuota').checked ? parseInt(document.getElementById('txtRoundQuota').value) : null,
document.getElementById('selQuota').value,
document.getElementById('selQuotaCriterion').value,
document.getElementById('selQuotaMode').value,
document.getElementById('selTransfers').value,
document.getElementById('selSurplus').value,
document.getElementById('selPapers').value == 'transferable',
@ -291,7 +292,7 @@ function changePreset() {
if (document.getElementById('selPreset').value === 'scottish') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop';
//document.getElementById('selQuotaMode').value = 'static';
document.getElementById('selQuotaMode').value = 'static';
//document.getElementById('chkBulkElection').checked = true;
//document.getElementById('chkBulkExclusion').checked = false;
//document.getElementById('chkDeferSurpluses').checked = false;
@ -311,7 +312,7 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'senate') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop';
//document.getElementById('selQuotaMode').value = 'static';
document.getElementById('selQuotaMode').value = 'static';
//document.getElementById('chkBulkElection').checked = true;
//document.getElementById('chkBulkExclusion').checked = true;
//document.getElementById('chkDeferSurpluses').checked = false;
@ -332,7 +333,7 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'prsa77') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop';
//document.getElementById('selQuotaMode').value = 'static';
document.getElementById('selQuotaMode').value = 'static';
//document.getElementById('chkBulkElection').checked = true;
//document.getElementById('chkBulkExclusion').checked = false;
//document.getElementById('chkDeferSurpluses').checked = true;
@ -352,5 +353,28 @@ function changePreset() {
document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'parcels_by_order';
//document.getElementById('selTies').value = 'backwards_random';
} else if (document.getElementById('selPreset').value === 'ers97') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop_exact';
document.getElementById('selQuotaMode').value = 'ers97';
//document.getElementById('chkBulkElection').checked = true;
//document.getElementById('chkBulkExclusion').checked = true;
//document.getElementById('chkDeferSurpluses').checked = true;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
document.getElementById('txtPPDP').value = '2';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '2';
document.getElementById('chkRoundVotes').checked = true;
document.getElementById('txtRoundVotes').value = '2';
document.getElementById('chkRoundTVs').checked = true;
document.getElementById('txtRoundTVs').value = '2';
document.getElementById('chkRoundWeights').checked = true;
document.getElementById('txtRoundWeights').value = '2';
document.getElementById('selSurplus').value = 'by_size';
document.getElementById('selTransfers').value = 'eg';
document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'by_value';
//document.getElementById('selTies').value = 'forwards_random';
}
}

View File

@ -25,15 +25,15 @@ onmessage = function(evt) {
// Init election
let election = wasm['election_from_blt_' + numbers](evt.data.electionData);
// Init results table
postMessage({'type': 'initResultsTable', 'content': wasm['init_results_table_' + numbers](election)});
// Init STV options
let opts = wasm.STVOptions.new.apply(null, evt.data.optsStr);
// Describe count
postMessage({'type': 'describeCount', 'content': wasm['describe_count_' + numbers](evt.data.filePath, election, opts)});
// Init results table
postMessage({'type': 'initResultsTable', 'content': wasm['init_results_table_' + numbers](election, opts)});
// Step election
let state = wasm['CountState' + numbers].new(election);
wasm['count_init_' + numbers](state, opts);

View File

@ -102,7 +102,8 @@ pub struct CountState<'a, N> {
pub exhausted: CountCard<'a, N>,
pub loss_fraction: CountCard<'a, N>,
pub quota: N,
pub quota: Option<N>,
pub vote_required_election: Option<N>,
pub num_elected: usize,
pub num_excluded: usize,
@ -119,7 +120,8 @@ impl<'a, N: Number> CountState<'a, N> {
candidates: HashMap::new(),
exhausted: CountCard::new(),
loss_fraction: CountCard::new(),
quota: N::new(),
quota: None,
vote_required_election: None,
num_elected: 0,
num_excluded: 0,
kind: None,

View File

@ -89,6 +89,10 @@ struct STV {
#[clap(help_heading=Some("QUOTA"), short='c', long, possible_values=&["geq", "gt"], default_value="gt", value_name="criterion")]
quota_criterion: String,
// Whether to apply a form of progressive quota
#[clap(help_heading=Some("QUOTA"), long, possible_values=&["static", "ers97"], default_value="static", value_name="mode")]
quota_mode: String,
// ------------------
// -- STV variants --
@ -160,6 +164,7 @@ where
cmd_opts.round_quota,
&cmd_opts.quota,
&cmd_opts.quota_criterion,
&cmd_opts.quota_mode,
&cmd_opts.surplus,
&cmd_opts.surplus_order,
cmd_opts.transferable_only,
@ -249,7 +254,10 @@ fn print_stage<N: Number>(stage_num: usize, result: &StageResult<N>, cmd_opts: &
total_vote += &state.loss_fraction.votes;
println!("Total votes: {:.dps$}", total_vote, dps=cmd_opts.pp_decimals);
println!("Quota: {:.dps$}", state.quota, dps=cmd_opts.pp_decimals);
println!("Quota: {:.dps$}", state.quota.as_ref().unwrap(), dps=cmd_opts.pp_decimals);
if cmd_opts.quota_mode == "ers97" {
println!("Vote required for election: {:.dps$}", state.vote_required_election.as_ref().unwrap(), dps=cmd_opts.pp_decimals);
}
println!("");
}

View File

@ -192,9 +192,7 @@ impl ops::Add<&Self> for Fixed {
impl ops::Sub<&Self> for Fixed {
type Output = Self;
fn sub(self, _rhs: &Self) -> Self::Output {
todo!()
}
fn sub(self, rhs: &Self) -> Self::Output { Self(self.0 - &rhs.0) }
}
impl ops::Mul<&Self> for Fixed {

View File

@ -136,9 +136,7 @@ impl ops::Add<&NativeFloat64> for NativeFloat64 {
impl ops::Sub<&NativeFloat64> for NativeFloat64 {
type Output = NativeFloat64;
fn sub(self, _rhs: &NativeFloat64) -> Self::Output {
todo!()
}
fn sub(self, rhs: &NativeFloat64) -> Self::Output { Self(self.0 - &rhs.0) }
}
impl ops::Mul<&NativeFloat64> for NativeFloat64 {

View File

@ -183,9 +183,7 @@ impl ops::Add<&Rational> for Rational {
impl ops::Sub<&Rational> for Rational {
type Output = Rational;
fn sub(self, _rhs: &Rational) -> Self::Output {
todo!()
}
fn sub(self, rhs: &Rational) -> Self::Output { Self(self.0 - &rhs.0) }
}
impl ops::Mul<&Rational> for Rational {

View File

@ -181,9 +181,7 @@ impl ops::Add<&Self> for Rational {
impl ops::Sub<&Self> for Rational {
type Output = Self;
fn sub(self, _rhs: &Self) -> Self::Output {
todo!()
}
fn sub(self, rhs: &Self) -> Self::Output { Self(self.0 - &rhs.0) }
}
impl ops::Mul<&Self> for Rational {

View File

@ -37,6 +37,7 @@ pub struct STVOptions {
pub round_quota: Option<usize>,
pub quota: QuotaType,
pub quota_criterion: QuotaCriterion,
pub quota_mode: QuotaMode,
pub surplus: SurplusMethod,
pub surplus_order: SurplusOrder,
pub transferable_only: bool,
@ -53,6 +54,7 @@ impl STVOptions {
round_quota: Option<usize>,
quota: &str,
quota_criterion: &str,
quota_mode: &str,
surplus: &str,
surplus_order: &str,
transferable_only: bool,
@ -76,6 +78,11 @@ impl STVOptions {
"gt" => QuotaCriterion::Greater,
_ => panic!("Invalid --quota-criterion"),
},
quota_mode: match quota_mode {
"static" => QuotaMode::Static,
"ers97" => QuotaMode::ERS97,
_ => panic!("Invalid --quota-mode"),
},
surplus: match surplus {
"wig" => SurplusMethod::WIG,
"uig" => SurplusMethod::UIG,
@ -111,6 +118,7 @@ impl STVOptions {
if let Some(dps) = self.round_quota { flags.push(format!("--round-quota {}", dps)); }
if self.quota != QuotaType::Droop { flags.push(self.quota.describe()); }
if self.quota_criterion != QuotaCriterion::GreaterOrEqual { flags.push(self.quota_criterion.describe()); }
if self.quota_mode != QuotaMode::Static { flags.push(self.quota_mode.describe()); }
if self.surplus != SurplusMethod::WIG { flags.push(self.surplus.describe()); }
if self.surplus_order != SurplusOrder::BySize { flags.push(self.surplus_order.describe()); }
if self.transferable_only { flags.push("--transferable-only".to_string()); }
@ -158,6 +166,23 @@ impl QuotaCriterion {
}
}
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
pub enum QuotaMode {
Static,
ERS97,
}
impl QuotaMode {
fn describe(self) -> String {
match self {
QuotaMode::Static => "--quota-mode static",
QuotaMode::ERS97 => "--quota-mode ers97",
}.to_string()
}
}
#[wasm_bindgen]
#[derive(Clone, Copy)]
#[derive(PartialEq)]
@ -210,7 +235,7 @@ impl ExclusionMethod {
match self {
ExclusionMethod::SingleStage => "--exclusion single_stage",
ExclusionMethod::ByValue => "--exclusion by_value",
ExclusionMethod::ParcelsByOrder => "--exclusion parcels_by_value",
ExclusionMethod::ParcelsByOrder => "--exclusion parcels_by_order",
}.to_string()
}
}
@ -237,24 +262,26 @@ where
// Continue exclusions
if continue_exclusion(&mut state, &opts) {
calculate_quota(&mut state, opts);
elect_meeting_quota(&mut state, opts);
return false;
}
// Distribute surpluses
if distribute_surpluses(&mut state, &opts) {
calculate_quota(&mut state, opts);
elect_meeting_quota(&mut state, opts);
return false;
}
// Attempt bulk election
if bulk_elect(&mut state) {
elect_meeting_quota(&mut state, opts);
return false;
}
// Exclude lowest hopeful
if exclude_hopefuls(&mut state, &opts) {
calculate_quota(&mut state, opts);
elect_meeting_quota(&mut state, opts);
return false;
}
@ -357,19 +384,13 @@ fn distribute_first_preferences<N: Number>(state: &mut CountState<N>) {
state.logger.log_literal("First preferences distributed.".to_string());
}
fn calculate_quota<N: Number>(state: &mut CountState<N>, opts: &STVOptions) {
let mut log = String::new();
// Calculate the total vote
state.quota = state.candidates.values().fold(N::zero(), |acc, cc| { acc + &cc.votes });
log.push_str(format!("{:.dps$} usable votes, so the quota is ", state.quota, dps=opts.pp_decimals).as_str());
fn total_to_quota<N: Number>(mut total: N, seats: usize, opts: &STVOptions) -> N {
match opts.quota {
QuotaType::Droop | QuotaType::DroopExact => {
state.quota /= N::from(state.election.seats + 1);
total /= N::from(seats + 1);
}
QuotaType::Hare | QuotaType::HareExact => {
state.quota /= N::from(state.election.seats);
total /= N::from(seats);
}
}
@ -379,27 +400,102 @@ fn calculate_quota<N: Number>(state: &mut CountState<N>, opts: &STVOptions) {
// Increment to next available increment
let mut factor = N::from(10);
factor.pow_assign(dps as i32);
state.quota *= &factor;
state.quota.floor_mut(0);
state.quota += N::one();
state.quota /= factor;
total *= &factor;
total.floor_mut(0);
total += N::one();
total /= factor;
}
QuotaType::DroopExact | QuotaType::HareExact => {
// Round up to next available increment if necessary
let mut factor = N::from(10);
factor.pow_assign(dps as i32);
state.quota *= &factor;
state.quota.ceil_mut(0);
state.quota /= factor;
total.ceil_mut(dps);
}
}
}
log.push_str(format!("{:.dps$}.", state.quota, dps=opts.pp_decimals).as_str());
return total;
}
fn calculate_quota<N: Number>(state: &mut CountState<N>, opts: &STVOptions) {
// Calculate quota
if let None = state.quota {
let mut log = String::new();
// Calculate the total vote
let total_vote = state.candidates.values().fold(N::zero(), |acc, cc| { acc + &cc.votes });
log.push_str(format!("{:.dps$} usable votes, so the quota is ", total_vote, dps=opts.pp_decimals).as_str());
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);
}
if let QuotaMode::ERS97 = opts.quota_mode {
// ERS97 rules
// -------------------------
// Reduce quota if allowable
if state.num_elected == 0 {
let mut log = String::new();
// Calculate the total vote
let total_vote = state.candidates.values().fold(N::zero(), |acc, cc| { acc + &cc.votes });
log.push_str(format!("{:.dps$} usable votes, so the quota is reduced to ", total_vote, dps=opts.pp_decimals).as_str());
let quota = total_to_quota(total_vote, state.election.seats, opts);
if &quota < state.quota.as_ref().unwrap() {
log.push_str(format!("{:.dps$}.", quota, dps=opts.pp_decimals).as_str());
state.quota = Some(quota);
state.logger.log_literal(log);
}
}
// ------------------------------------
// Calculate vote required for election
if state.num_elected < state.election.seats {
let mut log = String::new();
// Calculate total active vote
let total_active_vote = state.candidates.values().fold(N::zero(), |acc, cc| {
match cc.state {
CandidateState::ELECTED => { acc + &cc.votes - state.quota.as_ref().unwrap() }
_ => { 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());
let vote_req = total_to_quota(total_active_vote, state.election.seats - state.num_elected, opts);
if &vote_req < state.quota.as_ref().unwrap() {
// VRE is less than the quota
if let Some(v) = &state.vote_required_election {
if &vote_req != v {
log.push_str(format!("{:.dps$}.", vote_req, dps=opts.pp_decimals).as_str());
state.vote_required_election = Some(vote_req);
state.logger.log_literal(log);
}
} else {
log.push_str(format!("{:.dps$}.", vote_req, dps=opts.pp_decimals).as_str());
state.vote_required_election = Some(vote_req);
state.logger.log_literal(log);
}
} else {
// VRE is not less than the quota, so use the quota
state.vote_required_election = state.quota.clone();
}
}
} else {
// No ERS97 rules
if let None = state.vote_required_election {
state.vote_required_election = state.quota.clone();
}
}
}
fn meets_quota<N: Number>(quota: &N, count_card: &CountCard<N>, opts: &STVOptions) -> bool {
match opts.quota_criterion {
QuotaCriterion::GreaterOrEqual => {
@ -412,17 +508,19 @@ fn meets_quota<N: Number>(quota: &N, count_card: &CountCard<N>, opts: &STVOption
}
fn elect_meeting_quota<N: Number>(state: &mut CountState<N>, opts: &STVOptions) {
let quota = &state.quota; // Have to do this or else the borrow checker gets confused
let mut cands_meeting_quota: Vec<(&&Candidate, &mut CountCard<N>)> = state.candidates.iter_mut()
.filter(|(_, cc)| cc.state == CandidateState::HOPEFUL && meets_quota(quota, cc, opts))
let vote_req = state.vote_required_election.as_ref().unwrap(); // Have to do this or else the borrow checker gets confused
let mut cands_meeting_quota: Vec<&Candidate> = state.election.candidates.iter()
.filter(|c| { let cc = state.candidates.get(c).unwrap(); cc.state == CandidateState::HOPEFUL && meets_quota(vote_req, cc, opts) })
.collect();
if cands_meeting_quota.len() > 0 {
// Sort by votes
cands_meeting_quota.sort_unstable_by(|a, b| a.1.votes.partial_cmp(&b.1.votes).unwrap());
cands_meeting_quota.sort_unstable_by(|a, b| state.candidates.get(a).unwrap().votes.partial_cmp(&state.candidates.get(b).unwrap().votes).unwrap());
// Declare elected in descending order of votes
for (candidate, count_card) in cands_meeting_quota.into_iter().rev() {
for candidate in cands_meeting_quota.into_iter().rev() {
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;
@ -431,6 +529,16 @@ fn elect_meeting_quota<N: Number>(state: &mut CountState<N>, opts: &STVOptions)
"{} meet the quota and are elected.",
vec![&candidate.name]
);
if opts.quota_mode == QuotaMode::ERS97 {
// Vote required for election may have changed
calculate_quota(state, opts);
}
}
if opts.quota_mode == QuotaMode::ERS97 {
// Repeat in case vote required for election has changed
elect_meeting_quota(state, opts);
}
}
}
@ -440,8 +548,9 @@ where
for<'r> &'r N: ops::Sub<&'r N, Output=N>,
for<'r> &'r N: ops::Neg<Output=N>
{
let quota = state.quota.as_ref().unwrap();
let mut has_surplus: Vec<(&&Candidate, &CountCard<N>)> = state.candidates.iter()
.filter(|(_, cc)| cc.votes > state.quota)
.filter(|(_, cc)| &cc.votes > quota)
.collect();
if has_surplus.len() > 0 {
@ -538,7 +647,7 @@ where
for<'r> &'r N: ops::Neg<Output=N>
{
let count_card = state.candidates.get(elected_candidate).unwrap();
let surplus = &count_card.votes - &state.quota;
let surplus = &count_card.votes - state.quota.as_ref().unwrap();
let votes;
match opts.surplus {
@ -633,7 +742,7 @@ where
// Finalise candidate votes
let count_card = state.candidates.get_mut(elected_candidate).unwrap();
count_card.transfers = -&surplus;
count_card.votes.assign(&state.quota);
count_card.votes.assign(state.quota.as_ref().unwrap());
checksum -= surplus;
// Update loss by fraction

View File

@ -63,8 +63,8 @@ macro_rules! impl_type {
#[wasm_bindgen]
#[allow(non_snake_case)]
pub fn [<init_results_table_$type>](election: &[<Election$type>]) -> String {
return init_results_table(&election.0);
pub fn [<init_results_table_$type>](election: &[<Election$type>], opts: &stv::STVOptions) -> String {
return init_results_table(&election.0, opts);
}
#[wasm_bindgen]
@ -132,12 +132,15 @@ impl_type!(Rational);
// Reporting
fn init_results_table<N: Number>(election: &Election<N>) -> String {
fn init_results_table<N: Number>(election: &Election<N>, opts: &stv::STVOptions) -> String {
let mut result = String::from(r#"<tr class="stage-no"><td rowspan="3"></td></tr><tr class="stage-kind"></tr><tr class="stage-comment"></tr>"#);
for candidate in election.candidates.iter() {
result.push_str(&format!(r#"<tr class="candidate transfers"><td rowspan="2">{}</td></tr><tr class="candidate votes"></tr>"#, candidate.name));
}
result.push_str(r#"<tr class="info transfers"><td rowspan="2">Exhausted</td></tr><tr class="info votes"></tr><tr class="info transfers"><td rowspan="2">Loss by fraction</td></tr><tr class="info votes"></tr><tr class="info transfers"><td>Total</td></tr><tr class="info transfers"><td>Quota</td></tr>"#);
if opts.quota_mode == stv::QuotaMode::ERS97 {
result.push_str(r#"<tr class="info transfers"><td>Vote required for election</td></tr>"#);
}
return result;
}
@ -145,7 +148,7 @@ fn describe_count<N: Number>(filename: String, election: &Election<N>, opts: &st
let mut result = String::from("<p>Count computed by OpenTally (revision ");
result.push_str(crate::VERSION);
let total_ballots = election.ballots.iter().fold(N::zero(), |acc, b| { acc + &b.orig_value });
result.push_str(&format!(r#"). Read {:.dps$} ballots from &lsquo;{}&rsquo; for election &lsquo;{}&rsquo;. There are {} candidates for {} vacancies. Counting using options <span style="font-family: monospace;">{}</span>.</p>"#, total_ballots, filename, election.name, election.candidates.len(), election.seats, opts.describe::<N>(), dps=opts.pp_decimals));
result.push_str(&format!(r#"). Read {:.0} ballots from &lsquo;{}&rsquo; for election &lsquo;{}&rsquo;. There are {} candidates for {} vacancies. Counting using options <span style="font-family: monospace;">{}</span>.</p>"#, total_ballots, filename, election.name, election.candidates.len(), election.seats, opts.describe::<N>()));
return result;
}
@ -182,7 +185,10 @@ fn update_results_table<N: Number>(stage_num: usize, state: &CountState<N>, opts
total_vote += &state.loss_fraction.votes;
result.push(&format!(r#"<td class="count">{}</td>"#, pp(&total_vote, opts.pp_decimals)).into());
result.push(&format!(r#"<td class="count">{}</td>"#, pp(&state.quota, opts.pp_decimals)).into());
result.push(&format!(r#"<td class="count">{}</td>"#, pp(state.quota.as_ref().unwrap(), opts.pp_decimals)).into());
if opts.quota_mode == stv::QuotaMode::ERS97 {
result.push(&format!(r#"<td class="count">{}</td>"#, pp(state.vote_required_election.as_ref().unwrap(), opts.pp_decimals)).into());
}
return result;
}

View File

@ -61,6 +61,7 @@ fn aec_tas19_rational() {
round_quota: Some(0),
quota: stv::QuotaType::Droop,
quota_criterion: stv::QuotaCriterion::GreaterOrEqual,
quota_mode: stv::QuotaMode::Static,
surplus: stv::SurplusMethod::UIG,
surplus_order: stv::SurplusOrder::ByOrder,
transferable_only: false,

View File

@ -29,6 +29,7 @@ fn prsa1_rational() {
round_quota: Some(3),
quota: stv::QuotaType::Droop,
quota_criterion: stv::QuotaCriterion::GreaterOrEqual,
quota_mode: stv::QuotaMode::Static,
surplus: stv::SurplusMethod::EG,
surplus_order: stv::SurplusOrder::ByOrder,
transferable_only: true,