diff --git a/docs/options.md b/docs/options.md index b06def5..7cffdc1 100644 --- a/docs/options.md +++ b/docs/options.md @@ -95,8 +95,6 @@ Random sample methods are also supported, but also not recommended: The use of a random sample method requires *Normalise ballots* to be enabled, and will usually be used with a *Quota criterion* set to *>=*. -In both random sample methods, the subset is selected using the deterministic method used in [Cambridge, Massachusetts](https://web.archive.org/web/20081118104049/http://www.fairvote.org/media/1993countmanual.pdf) (derived from Article IX of the former 1938 Cincinnati *Code of Ordinances*). This depends on the order of ballot papers in the BLT file, and is independent of the *Random seed* option. - ### Papers to examine in surplus transfer (--t ransferable-only) * *Include non-transferable papers* (default): When this option is selected, all ballot papers of the transferring candidate are examined. Non-transferable papers are always exhausted at the relevant surplus fractions. @@ -119,6 +117,16 @@ When *Surplus method* is set to *Meek method*, this option controls how candidat * When NZ-style exclusion is disabled (default), the excluded candidate's keep value is immediately reduced to 0. This is the method specified in the 1987 and 2006 Meek rules. * When NZ-style exclusion is enabled, all elected candidates' keep values are first updated by one further iteration; only then is the excluded candidate's keep value reduced to 0. This is the method specified in the New Zealand *Local Electoral Regulations 2001*. +### (Sample) Sample method (--sample) + +When *Surplus method* is set to a random sample method, this option controls which subset of ballot papers is selected for transfer during surplus distributions: + +* *Stratified (then by order)*: The candidate's ballot papers are first stratified into subparcels according to next available preference. From each subparcel, the subset transferred comprises the ballot papers most recently received by the candidate. +* *By order*: The subset transferred comprises the ballot papers most recently received by the candidate. +* *Every n-th ballot*: The subset is selected using the deterministic method used in [Cambridge, Massachusetts](https://web.archive.org/web/20081118104049/http://www.fairvote.org/media/1993countmanual.pdf) (derived from Article IX of the former 1938 Cincinnati *Code of Ordinances*). + +In any case, the subset selected depends on the order of ballot papers in the BLT file, and is independent of the *Random seed* option. + ### (Sample) Transfer ballot-by-ballot (--sample-per-ballot) When *Surplus method* is set to a random sample method, this option controls when candidates are declared elected: diff --git a/html/index.html b/html/index.html index 6ecbd52..f6ca130 100644 --- a/html/index.html +++ b/html/index.html @@ -141,9 +141,17 @@
+
diff --git a/html/index.js b/html/index.js index dffecd5..b26e62c 100644 --- a/html/index.js +++ b/html/index.js @@ -149,6 +149,7 @@ async function clickCount() { document.getElementById('selPapers').value == 'transferable', document.getElementById('selExclusion').value, document.getElementById('chkMeekNZExclusion').checked, + document.getElementById('selSample').value, document.getElementById('chkSamplePerBallot').checked, document.getElementById('chkBulkElection').checked, document.getElementById('chkBulkExclusion').checked, @@ -566,6 +567,7 @@ function changePreset() { document.getElementById('chkBulkElection').checked = true; document.getElementById('chkBulkExclusion').checked = false; document.getElementById('chkDeferSurpluses').checked = false; + document.getElementById('selSample').value = 'nth_ballot'; document.getElementById('chkSamplePerBallot').checked = true; document.getElementById('txtMinThreshold').value = '49'; document.getElementById('selNumbers').value = 'rational'; diff --git a/src/main.rs b/src/main.rs index 6eff379..8b0a385 100644 --- a/src/main.rs +++ b/src/main.rs @@ -139,6 +139,10 @@ struct STV { #[clap(help_heading=Some("STV VARIANTS"), long)] meek_nz_exclusion: bool, + /// (Cincinnati/Hare) Method of drawing a sample + #[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["stratified", "by_order", "nth_ballot"], default_value="stratified", value_name="method")] + sample: String, + /// (Cincinnati/Hare) Sample-based methods: Check for candidate election after each individual ballot paper transfer #[clap(help_heading=Some("STV VARIANTS"), long)] sample_per_ballot: bool, @@ -278,6 +282,7 @@ where cmd_opts.transferable_only, cmd_opts.exclusion.into(), cmd_opts.meek_nz_exclusion, + cmd_opts.sample.into(), cmd_opts.sample_per_ballot, !cmd_opts.no_early_bulk_elect, cmd_opts.bulk_exclude, diff --git a/src/stv/gregory.rs b/src/stv/gregory.rs index c3956dd..d67f7db 100644 --- a/src/stv/gregory.rs +++ b/src/stv/gregory.rs @@ -115,7 +115,9 @@ where return Ok(false); } -/// Return the denominator of the transfer value +/// Return the denominator of the surplus fraction +/// +/// Returns `None` if the value of transferable votes <= surplus (i.e. all transferable votes are transferred at values received) fn calculate_surplus_denom(surplus: &N, result: &NextPreferencesResult, transferable_votes: &N, weighted: bool, transferable_only: bool) -> Option where for<'r> &'r N: ops::Sub<&'r N, Output=N> @@ -295,10 +297,11 @@ where None => { 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)); + // This can only happen if --transferable-only + if &result.total_ballots - &result.exhausted.num_ballots == N::one() { + state.logger.log_literal(format!("Transferring 1 transferable ballot, totalling {:.dps$} transferable votes, at values received.", 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)); + 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)); } } } diff --git a/src/stv/mod.rs b/src/stv/mod.rs index 61bbff4..8193f21 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -21,7 +21,7 @@ pub mod gregory; /// Meek method of surplus distributions, etc. pub mod meek; -/// Random subset methods of surplus distributions +/// Random sample methods of surplus distributions pub mod sample; /// WebAssembly wrappers @@ -110,6 +110,10 @@ pub struct STVOptions { #[builder(default="false")] pub meek_nz_exclusion: bool, + /// (Cincinnati/Hare) Method of drawing a sample + #[builder(default="SampleMethod::Stratified")] + pub sample: SampleMethod, + /// (Cincinnati/Hare) Sample-based methods: Check for candidate election after each individual ballot paper transfer #[builder(default="false")] pub sample_per_ballot: bool, @@ -182,6 +186,7 @@ impl STVOptions { if self.exclusion != ExclusionMethod::SingleStage { flags.push(self.exclusion.describe()); } } if self.surplus == SurplusMethod::Meek && self.meek_nz_exclusion { flags.push("--meek-nz-exclusion".to_string()); } + if (self.surplus == SurplusMethod::Cincinnati || self.surplus == SurplusMethod::Hare) && self.sample != SampleMethod::Stratified { flags.push(self.sample.describe()); } if (self.surplus == SurplusMethod::Cincinnati || self.surplus == SurplusMethod::Hare) && self.sample_per_ballot { flags.push("--sample-per-ballot".to_string()); } if !self.early_bulk_elect { flags.push("--no-early-bulk-elect".to_string()); } if self.bulk_exclude { flags.push("--bulk-exclude".to_string()); } @@ -207,6 +212,7 @@ impl STVOptions { if self.surplus == SurplusMethod::Cincinnati || self.surplus == SurplusMethod::Hare { if self.round_quota != Some(0) { return Err(STVError::InvalidOptions("--surplus cincinnati and --surplus hare require --round-quota 0")); } if !self.normalise_ballots { return Err(STVError::InvalidOptions("--surplus cincinnati and --surplus hare require --normalise-ballots")); } + if self.sample == SampleMethod::Stratified && self.sample_per_ballot { return Err(STVError::InvalidOptions("--sample stratified is incompatible with --sample-per-ballot")); } } if self.min_threshold != "0" && self.defer_surpluses { return Err(STVError::InvalidOptions("--min-threshold is incompatible with --defer-surpluses")); } // TODO: Permit this return Ok(()); @@ -474,6 +480,41 @@ impl> From for ExclusionMethod { } } +/// Enum of options for [STVOptions::sample] +#[wasm_bindgen] +#[derive(Clone, Copy)] +#[derive(PartialEq)] +pub enum SampleMethod { + /// Stratify the ballots into parcels according to next available preference and transfer the last ballots from each parcel + Stratified, + /// Transfer the last ballots + ByOrder, + /// Transfer every n-th ballot, Cincinnati style + NthBallot, +} + +impl SampleMethod { + /// Convert to CLI argument representation + fn describe(self) -> String { + match self { + SampleMethod::Stratified => "--sample stratified", + SampleMethod::ByOrder => "--sample by_order", + SampleMethod::NthBallot => "--sample nth_ballot", + }.to_string() + } +} + +impl> From for SampleMethod { + fn from(s: S) -> Self { + match s.as_ref() { + "stratified" => SampleMethod::Stratified, + "by_order" => SampleMethod::ByOrder, + "nth_ballot" => SampleMethod::NthBallot, + _ => panic!("Invalid --sample-method"), + } + } +} + /// Enum of options for [STVOptions::constraint_mode] #[derive(Clone, Copy)] #[derive(PartialEq)] @@ -1257,9 +1298,13 @@ where { let mut excluded_candidates: Vec<&Candidate> = Vec::new(); - // Exclude candidates below min threshold if state.num_excluded == 0 { - excluded_candidates = hopefuls_below_threshold(state, opts); + if opts.bulk_exclude && opts.min_threshold == "0" { + // Proceed directly to bulk exclusion, as candidates with 0 votes will necessarily be included + } else { + // Exclude candidates below min threshold + excluded_candidates = hopefuls_below_threshold(state, opts); + } } // Attempt a bulk exclusion diff --git a/src/stv/sample.rs b/src/stv/sample.rs index aa39107..430ddb6 100644 --- a/src/stv/sample.rs +++ b/src/stv/sample.rs @@ -18,12 +18,31 @@ use crate::constraints; use crate::election::{Candidate, CandidateState, CountState, Parcel, Vote}; use crate::numbers::Number; -use crate::stv::{STVOptions, SurplusMethod}; +use crate::stv::{STVOptions, SampleMethod, SurplusMethod}; +use std::cmp::max; use std::collections::HashMap; use std::ops; -use super::STVError; +use super::{NextPreferencesResult, STVError}; + +/// Return the denominator of the surplus fraction +/// +/// Returns `None` if transferable ballots <= surplus (i.e. all transferable ballots are transferred at full value) +fn calculate_surplus_denom(surplus: &N, result: &NextPreferencesResult, transferable_ballots: &N, transferable_only: bool) -> Option +where + for<'r> &'r N: ops::Sub<&'r N, Output=N> +{ + if transferable_only { + if transferable_ballots > surplus { + return Some(transferable_ballots.clone()); + } else { + return None; + } + } else { + return Some(result.total_ballots.clone()); + } +} /// Distribute the surplus of a given candidate according to the random subset method, based on [STVOptions::surplus] pub fn distribute_surplus(state: &mut CountState, opts: &STVOptions, elected_candidate: &Candidate) -> Result<(), STVError> @@ -39,7 +58,7 @@ where let count_card = state.candidates.get_mut(elected_candidate).unwrap(); let surplus = &count_card.votes - state.quota.as_ref().unwrap(); - let votes; + let mut votes; match opts.surplus { SurplusMethod::Cincinnati => { // Inclusive @@ -53,49 +72,250 @@ where _ => unreachable!() } - // Calculate skip value - let total_ballots = votes.len(); - let mut skip_fraction = N::from(total_ballots) / &surplus; - skip_fraction.round_mut(0); - - state.logger.log_literal(format!("Examining {:.0} ballots, with skip value {:.0}.", total_ballots, skip_fraction)); - - // Number the votes - let mut numbered_votes: HashMap> = HashMap::new(); - for (i, vote) in votes.into_iter().enumerate() { - numbered_votes.insert(i, vote); + match opts.sample { + SampleMethod::Stratified => { + // Stratified by next available preference + // FIXME: This is untested + + let result = super::next_preferences(state, votes); + + let transferable_ballots = &result.total_ballots - &result.exhausted.num_ballots; + let surplus_denom = calculate_surplus_denom(&surplus, &result, &transferable_ballots, opts.transferable_only); + let mut surplus_fraction; + match surplus_denom { + Some(v) => { + surplus_fraction = Some(surplus.clone() / v); + + // Round down if requested + if let Some(dps) = opts.round_surplus_fractions { + surplus_fraction.as_mut().unwrap().floor_mut(dps); + } + + if opts.transferable_only { + if &result.total_ballots - &result.exhausted.num_ballots == N::one() { + state.logger.log_literal(format!("Examining 1 transferable ballot, with surplus fraction {:.dps2$}.", surplus_fraction.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2))); + } else { + state.logger.log_literal(format!("Examining {:.0} transferable ballots, with surplus fraction {:.dps2$}.", &result.total_ballots - &result.exhausted.num_ballots, surplus_fraction.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2))); + } + } else { + if result.total_ballots == N::one() { + state.logger.log_literal(format!("Examining 1 ballot, with surplus fraction {:.dps2$}.", surplus_fraction.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2))); + } else { + state.logger.log_literal(format!("Examining {:.0} ballots, with surplus fraction {:.dps2$}.", result.total_ballots, surplus_fraction.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2))); + } + } + } + None => { + surplus_fraction = None; + + // This can only happen if --transferable-only + if result.total_ballots == N::one() { + state.logger.log_literal("Transferring 1 ballot at full value.".to_string()); + } else { + state.logger.log_literal(format!("Transferring {:.0} ballots at full value.", result.total_ballots)); + } + } + } + + let mut checksum = N::new(); + + for (candidate, entry) in result.candidates.into_iter() { + // Credit transferred votes + let mut candidate_transfers; + match surplus_fraction { + Some(ref f) => { + candidate_transfers = entry.num_ballots * f; + candidate_transfers.floor_mut(0); + } + None => { + // All ballots transferred + candidate_transfers = entry.num_ballots.clone(); + } + } + let candidate_transfers_usize: usize = format!("{:.0}", candidate_transfers).parse().expect("Transfers overflow usize"); + + let count_card = state.candidates.get_mut(candidate).unwrap(); + count_card.transfer(&candidate_transfers); + checksum += candidate_transfers; + + let parcel = Parcel { + votes: entry.votes.into_iter().rev().take(candidate_transfers_usize).rev().collect(), + source_order: state.num_elected + state.num_excluded, + }; + + count_card.parcels.push(parcel); + } + + // Credit exhausted votes + let mut exhausted_transfers; + if opts.transferable_only { + if transferable_ballots > surplus { + // No ballots exhaust + exhausted_transfers = N::new(); + } else { + exhausted_transfers = &surplus - &transferable_ballots; + + if let Some(dps) = opts.round_votes { + exhausted_transfers.floor_mut(dps); + } + } + } else { + exhausted_transfers = result.exhausted.num_ballots * surplus_fraction.as_ref().unwrap(); + exhausted_transfers.floor_mut(0); + } + let exhausted_transfers_usize: usize = format!("{:.0}", exhausted_transfers).parse().expect("Transfers overflow usize"); + + state.exhausted.transfer(&exhausted_transfers); + checksum += exhausted_transfers; + + // Transfer exhausted votes + let parcel = Parcel { + votes: result.exhausted.votes.into_iter().rev().take(exhausted_transfers_usize).rev().collect(), + source_order: state.num_elected + state.num_excluded, + }; + state.exhausted.parcels.push(parcel); + + // Finalise candidate votes + let count_card = state.candidates.get_mut(elected_candidate).unwrap(); + count_card.transfers = -&surplus; + count_card.votes.assign(state.quota.as_ref().unwrap()); + checksum -= surplus; + + count_card.parcels.clear(); // Mark surpluses as done + + // Update loss by fraction + state.loss_fraction.transfer(&-checksum); + } + SampleMethod::ByOrder => { + // Ballots by order + // FIXME: This is untested + + state.logger.log_literal(format!("Examining {:.0} ballots.", votes.len())); // votes.len() is total ballots as --normalise-ballots is required + + // Transfer candidate votes + while &state.candidates[elected_candidate].votes > state.quota.as_ref().unwrap() { + match votes.pop() { + Some(vote) => { + // Transfer to next preference + transfer_ballot(state, opts, elected_candidate, vote)?; + } + None => { + // We have run out of ballot papers + // Remaining ballot papers exhaust + + let surplus = &state.candidates[elected_candidate].votes - state.quota.as_ref().unwrap(); + state.exhausted.transfer(&surplus); + state.candidates.get_mut(elected_candidate).unwrap().transfer(&-surplus); + } + } + } + } + SampleMethod::NthBallot => { + // Every nth-ballot (Cincinnati-style) + + // Calculate skip value + let total_ballots = votes.len(); + let mut skip_fraction = N::from(total_ballots) / &surplus; + skip_fraction.round_mut(0); + + state.logger.log_literal(format!("Examining {:.0} ballots, with skip value {:.0}.", total_ballots, skip_fraction)); + + // Number the votes + let mut numbered_votes: HashMap> = HashMap::new(); + for (i, vote) in votes.into_iter().enumerate() { + numbered_votes.insert(i, vote); + } + + // Transfer candidate votes + let skip_value: usize = format!("{:.0}", skip_fraction).parse().expect("Skip value overflows usize"); + let mut iteration = 0; + let mut index = skip_value - 1; // Subtract 1 as votes are 0-indexed + + while &state.candidates[elected_candidate].votes > state.quota.as_ref().unwrap() { + // Transfer one vote to next available preference + let vote = numbered_votes.remove(&index).unwrap(); + transfer_ballot(state, opts, elected_candidate, vote)?; + + index += skip_value; + if index >= total_ballots { + iteration += 1; + index = iteration + skip_value - 1; + + if iteration >= skip_value { + // We have run out of ballot papers + // Remaining ballot papers exhaust + + let surplus = &state.candidates[elected_candidate].votes - state.quota.as_ref().unwrap(); + state.exhausted.transfer(&surplus); + state.candidates.get_mut(elected_candidate).unwrap().transfer(&-surplus); + break; + } + } + } + } } - // Transfer candidate votes - let skip_value: usize = format!("{:.0}", skip_fraction).parse().expect("Skip value overflows usize"); - let mut iteration = 0; - let mut index = skip_value - 1; // Subtract 1 as votes are 0-indexed - - while &state.candidates[elected_candidate].votes > state.quota.as_ref().unwrap() { - let mut vote = numbered_votes.remove(&index).unwrap(); + return Ok(()); +} + +/// Transfer the given ballot paper to its next available preference, and check for candidates meeting the quota if --sample-per-ballot +/// +/// Does nothing if --transferable-only and the ballot is nontransferable. +fn transfer_ballot<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, elected_candidate: &Candidate, mut vote: Vote<'a, N>) -> Result<(), STVError> { + // Get next preference + let mut next_candidate = None; + for (i, preference) in vote.ballot.preferences.iter().enumerate().skip(vote.up_to_pref) { + let candidate = &state.election.candidates[*preference]; + let count_card = &state.candidates[candidate]; - // Transfer to next preference - let mut next_candidate = None; - for (i, preference) in vote.ballot.preferences.iter().enumerate().skip(vote.up_to_pref) { - let candidate = &state.election.candidates[*preference]; - let count_card = &state.candidates[candidate]; - - if let CandidateState::Hopeful | CandidateState::Guarded = count_card.state { - next_candidate = Some(candidate); - vote.up_to_pref = i + 1; - break; + if let CandidateState::Hopeful | CandidateState::Guarded = count_card.state { + next_candidate = Some(candidate); + vote.up_to_pref = i + 1; + break; + } + } + + // Have to structure like this to satisfy Rust's borrow checker + if let Some(candidate) = next_candidate { + // Available preference + state.candidates.get_mut(elected_candidate).unwrap().transfer(&-vote.value.clone()); + + let count_card = state.candidates.get_mut(candidate).unwrap(); + count_card.transfer(&vote.value); + + match count_card.parcels.last_mut() { + Some(parcel) => { + if parcel.source_order == state.num_elected + state.num_excluded { + parcel.votes.push(vote); + } else { + let parcel = Parcel { + votes: vec![vote], + source_order: state.num_elected + state.num_excluded, + }; + count_card.parcels.push(parcel); + } + } + None => { + let parcel = Parcel { + votes: vec![vote], + source_order: state.num_elected + state.num_excluded, + }; + count_card.parcels.push(parcel); } } - // Have to structure like this to satisfy Rust's borrow checker - if let Some(candidate) = next_candidate { - // Available preference + if opts.sample_per_ballot { + super::elect_hopefuls(state, opts)?; + } + } else { + // Exhausted + if opts.transferable_only { + // Another ballot paper required + } else { state.candidates.get_mut(elected_candidate).unwrap().transfer(&-vote.value.clone()); + state.exhausted.transfer(&vote.value); - let count_card = state.candidates.get_mut(candidate).unwrap(); - count_card.transfer(&vote.value); - - match count_card.parcels.last_mut() { + match state.exhausted.parcels.last_mut() { Some(parcel) => { if parcel.source_order == state.num_elected + state.num_excluded { parcel.votes.push(vote); @@ -104,7 +324,7 @@ where votes: vec![vote], source_order: state.num_elected + state.num_excluded, }; - count_card.parcels.push(parcel); + state.exhausted.parcels.push(parcel); } } None => { @@ -112,58 +332,9 @@ where votes: vec![vote], source_order: state.num_elected + state.num_excluded, }; - count_card.parcels.push(parcel); + state.exhausted.parcels.push(parcel); } } - - if opts.sample_per_ballot { - super::elect_hopefuls(state, opts)?; - } - } else { - // Exhausted - if opts.transferable_only { - // Another ballot paper required - } else { - state.candidates.get_mut(elected_candidate).unwrap().transfer(&-vote.value.clone()); - state.exhausted.transfer(&vote.value); - - match state.exhausted.parcels.last_mut() { - Some(parcel) => { - if parcel.source_order == state.num_elected + state.num_excluded { - parcel.votes.push(vote); - } else { - let parcel = Parcel { - votes: vec![vote], - source_order: state.num_elected + state.num_excluded, - }; - state.exhausted.parcels.push(parcel); - } - } - None => { - let parcel = Parcel { - votes: vec![vote], - source_order: state.num_elected + state.num_excluded, - }; - state.exhausted.parcels.push(parcel); - } - } - } - } - - index += skip_value; - if index >= total_ballots { - iteration += 1; - index = iteration + skip_value - 1; - - if iteration >= skip_value { - // We have run out of ballot papers - // Remaining ballot papers exhaust - - let surplus = &state.candidates[elected_candidate].votes - state.quota.as_ref().unwrap(); - state.exhausted.transfer(&surplus); - state.candidates.get_mut(elected_candidate).unwrap().transfer(&-surplus); - break; - } } } diff --git a/src/stv/wasm.rs b/src/stv/wasm.rs index 16fe446..6a6b1fe 100644 --- a/src/stv/wasm.rs +++ b/src/stv/wasm.rs @@ -229,6 +229,7 @@ impl STVOptions { transferable_only: bool, exclusion: &str, meek_nz_exclusion: bool, + sample: &str, sample_per_ballot: bool, early_bulk_elect: bool, bulk_exclude: bool, @@ -256,6 +257,7 @@ impl STVOptions { transferable_only, exclusion.into(), meek_nz_exclusion, + sample.into(), sample_per_ballot, early_bulk_elect, bulk_exclude, diff --git a/tests/cambridge.rs b/tests/cambridge.rs index 5702437..3cbc606 100644 --- a/tests/cambridge.rs +++ b/tests/cambridge.rs @@ -29,6 +29,7 @@ fn cambridge_cc03_rational() { .quota_criterion(stv::QuotaCriterion::GreaterOrEqual) .surplus(stv::SurplusMethod::Cincinnati) .transferable_only(true) + .sample(stv::SampleMethod::NthBallot) .sample_per_ballot(true) .early_bulk_elect(false) .min_threshold("49".to_string())