diff --git a/docs/options.md b/docs/options.md index 8a6398f..e0211c0 100644 --- a/docs/options.md +++ b/docs/options.md @@ -32,7 +32,7 @@ Exceptions: * [E2] When breaking ties backwards, OpenTally selects the candidate who had more/fewer votes at the last stage when *any* tied candidate had more/fewer votes, rather than the method described in the legislation (when each all had unequal votes). The OpenTally developers regard the method described in the legislation as a defect. For an independent discussion, see Conway et al. * [E3] A tie between 2 candidates for the final vacancy will be broken backwards then at random, rather than the method described in the legislation. * [E4] Bulk exclusion is not performed, as the prescribed rules are more conservative than OpenTally's. See also the section on *Bulk exclusion* for further discussion. -* [E5] The legislation specifies that, when rounding subtransfers, surpluses are to be transferred ‘by value’ while exclusions are to be transferred ‘by value and source’. One imagines this differing treatment was a drafting oversight, and the New South Wales Electoral Commission has instead applied the ‘by value and source’ method for both in practice, which OpenTally follows. See also Conway. +* [E5] The legislation is drafted such that a consistent interpretation is impossible – see Conway for a discussion. In practice, the New South Wales Electoral Commission has applied the ‘by parcel’ method of rounding subtransfers, which OpenTally follows. * [E6] The ‘mathematically eliminated by the sum of all ranked-choice votes comparison’ is not implemented. * [E7] The ‘quarter of a quota’ provision is disregarded when determining whether to defer surplus distributions. * [E8] No distinction is made between stages and substages (during exclusion). This affects only the numbering of stages and not the result. @@ -281,6 +281,7 @@ When *Surplus method* is set to a Gregory method, this option allows you to spec * *Single step* (default): The total value of all votes expressing a next available preference for that candidate is multiplied by the surplus fraction. The product (rounded if requested) is credited to that candidate. * *By value*: The votes expressing a next available preference for that candidate are further divided according to value. For each group of votes at a particular value, the total value of all such votes is multiplied by the surplus fraction. The product (rounded if requested) is credited to that candidate. * *By value and source*: The votes are further divided according to value, and according to who they were received from by the elected/excluded candidate. Then as per *By value*. +* *By parcel*: For each parcel of votes, the total value of the votes in the parcel expressing a next available preference for that candidate is multiplied by the surplus fraction. The product (rounded if requested) is credited to that candidate. * *Per ballot*: For each individual vote expressing a next available preference for that candidate, the value of the vote is multiplied by the surplus fraction. The product (rounded if requested) is credited to that candidate. This option affects the result only as far as rounding (due to use of fixed-precision/floating-point arithmetic, or an explicit rounding option) is concerned. diff --git a/html/index.html b/html/index.html index 149a195..f9d5670 100644 --- a/html/index.html +++ b/html/index.html @@ -301,6 +301,7 @@ + diff --git a/html/presets.js b/html/presets.js index a8416ad..fdb311c 100644 --- a/html/presets.js +++ b/html/presets.js @@ -236,7 +236,7 @@ function changePreset() { document.getElementById('txtRoundVotes').value = '0'; document.getElementById('chkRoundSFs').checked = false; document.getElementById('chkRoundValues').checked = false; - document.getElementById('selSumTransfers').value = 'by_value_and_source'; + document.getElementById('selSumTransfers').value = 'by_parcel'; document.getElementById('selSurplus').value = 'by_order'; document.getElementById('selMethod').value = 'wig'; document.getElementById('selPapers').value = 'subtract_nontransferable'; diff --git a/src/cli/stv.rs b/src/cli/stv.rs index 5f7c5b1..6a763b7 100644 --- a/src/cli/stv.rs +++ b/src/cli/stv.rs @@ -80,7 +80,7 @@ pub struct SubcmdOptions { round_quota: Option, /// (Gregory STV) How to round subtransfers during surpluses/exclusions - #[clap(help_heading=Some("ROUNDING"), long, possible_values=&["single_step", "by_value", "by_value_and_source", "per_ballot"], default_value="single_step", value_name="mode")] + #[clap(help_heading=Some("ROUNDING"), long, possible_values=&["single_step", "by_value", "by_value_and_source", "by_parcel", "per_ballot"], default_value="single_step", value_name="mode")] round_subtransfers: String, /// (Meek STV) Limit for stopping iteration of surplus distribution diff --git a/src/stv/gregory/mod.rs b/src/stv/gregory/mod.rs index 22a5ad1..2e88ee7 100644 --- a/src/stv/gregory/mod.rs +++ b/src/stv/gregory/mod.rs @@ -326,7 +326,11 @@ where // Record transfers transfer_table.add_transfers( &value_fraction, - if opts.round_subtransfers == RoundSubtransfersMode::ByValueAndSource { Some(source_order) } else { None }, + match opts.round_subtransfers { + RoundSubtransfersMode::ByValueAndSource => Some(source_order), + RoundSubtransfersMode::ByParcel => None, // Force new column per parcel + _ => Some(0) + }, candidate, &entry.num_ballots ); @@ -363,7 +367,11 @@ where // Record exhausted votes transfer_table.add_exhausted( &value_fraction, - if opts.round_subtransfers == RoundSubtransfersMode::ByValueAndSource { Some(source_order) } else { None }, + match opts.round_subtransfers { + RoundSubtransfersMode::ByValueAndSource => Some(source_order), + RoundSubtransfersMode::ByParcel => None, // Force new column per parcel + _ => Some(0) + }, &result.exhausted.num_ballots ); @@ -603,7 +611,11 @@ where // Record transfers transfer_table.add_transfers( &parcel.value_fraction, - if opts.round_subtransfers == RoundSubtransfersMode::ByValueAndSource { Some(src_parcel.source_order) } else { None }, + match opts.round_subtransfers { + RoundSubtransfersMode::ByValueAndSource => Some(src_parcel.source_order), + RoundSubtransfersMode::ByParcel => None, // Force new column per parcel + _ => Some(0) + }, candidate, &entry.num_ballots ); @@ -622,7 +634,11 @@ where // Record transfers transfer_table.add_exhausted( &parcel.value_fraction, - if opts.round_subtransfers == RoundSubtransfersMode::ByValueAndSource { Some(src_parcel.source_order) } else { None }, + match opts.round_subtransfers { + RoundSubtransfersMode::ByValueAndSource => Some(src_parcel.source_order), + RoundSubtransfersMode::ByParcel => None, // Force new column per parcel + _ => Some(0) + }, &result.exhausted.num_ballots ); diff --git a/src/stv/gregory/transfers.rs b/src/stv/gregory/transfers.rs index 01917d0..f1e27c1 100644 --- a/src/stv/gregory/transfers.rs +++ b/src/stv/gregory/transfers.rs @@ -56,7 +56,7 @@ impl<'e, N: Number> TransferTable<'e, N> { columns: Vec::new(), total: TransferTableColumn { value_fraction: N::new(), - source_order: None, + order: 0, cells: HashMap::new(), exhausted: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, total: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, @@ -75,7 +75,7 @@ impl<'e, N: Number> TransferTable<'e, N> { columns: Vec::new(), total: TransferTableColumn { value_fraction: N::new(), - source_order: None, + order: 0, cells: HashMap::new(), exhausted: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, total: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, @@ -88,9 +88,11 @@ impl<'e, N: Number> TransferTable<'e, N> { } /// Record the specified transfer - pub fn add_transfers(&mut self, value_fraction: &N, source_order: Option, candidate: &'e Candidate, ballots: &N) { + /// + /// order: Pass `None` to force a new column + pub fn add_transfers(&mut self, value_fraction: &N, order: Option, candidate: &'e Candidate, ballots: &N) { for col in self.columns.iter_mut() { - if &col.value_fraction == value_fraction && col.source_order == source_order { + if &col.value_fraction == value_fraction && order.map(|o| col.order == o).unwrap_or(false) { col.add_transfers(candidate, ballots); return; } @@ -98,7 +100,7 @@ impl<'e, N: Number> TransferTable<'e, N> { let mut col = TransferTableColumn { value_fraction: value_fraction.clone(), - source_order: source_order, + order: order.unwrap_or(0), cells: HashMap::new(), exhausted: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, total: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, @@ -108,9 +110,11 @@ impl<'e, N: Number> TransferTable<'e, N> { } /// Record the specified exhaustion - pub fn add_exhausted(&mut self, value_fraction: &N, source_order: Option, ballots: &N) { + /// + /// order: Pass `None` to force a new column + pub fn add_exhausted(&mut self, value_fraction: &N, order: Option, ballots: &N) { for col in self.columns.iter_mut() { - if &col.value_fraction == value_fraction && col.source_order == source_order { + if &col.value_fraction == value_fraction && order.map(|o| col.order == o).unwrap_or(false) { col.exhausted.ballots += ballots; return; } @@ -118,7 +122,7 @@ impl<'e, N: Number> TransferTable<'e, N> { let col = TransferTableColumn { value_fraction: value_fraction.clone(), - source_order: source_order, + order: order.unwrap_or(0), cells: HashMap::new(), exhausted: TransferTableCell { ballots: ballots.clone(), votes_in: N::new(), votes_out: N::new() }, total: TransferTableCell { ballots: N::new(), votes_in: N::new(), votes_out: N::new() }, @@ -203,7 +207,7 @@ impl<'e, N: Number> TransferTable<'e, N> { self.total.exhausted.votes_out.floor_mut(dps); } } - RoundSubtransfersMode::ByValue | RoundSubtransfersMode::ByValueAndSource => { + RoundSubtransfersMode::ByValue | RoundSubtransfersMode::ByValueAndSource | RoundSubtransfersMode::ByParcel => { // Calculate votes_out for each column for column in self.columns.iter_mut() { // Calculate votes_out per candidate in the column @@ -565,8 +569,8 @@ pub struct TransferTableColumn<'e, N: Number> { /// Value fraction of ballots counted in this column pub value_fraction: N, - /// Number to separate parcels in modes where subtransfers by parcel, or `None` if unused - pub source_order: Option, + /// Number to separate parcels in modes where subtransfers by parcel, etc. + pub order: usize, /// Cells in this column pub cells: HashMap<&'e Candidate, TransferTableCell>, diff --git a/src/stv/mod.rs b/src/stv/mod.rs index 31f65e4..13d4701 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -247,6 +247,8 @@ pub enum RoundSubtransfersMode { ByValue, /// Round in subtransfers according to the candidate from who each vote was received, and the value when received ByValueAndSource, + /// Round in subtransfers according to parcel + ByParcel, /// Sum and round transfers individually for each ballot paper PerBallot, } @@ -258,6 +260,7 @@ impl RoundSubtransfersMode { RoundSubtransfersMode::SingleStep => "--round-subtransfers single_step", RoundSubtransfersMode::ByValue => "--round-subtransfers by_value", RoundSubtransfersMode::ByValueAndSource => "--round-subtransfers by_value_and_source", + RoundSubtransfersMode::ByParcel => "--round-subtransfers by_parcel", RoundSubtransfersMode::PerBallot => "--round-subtransfers per_ballot", }.to_string() } @@ -269,8 +272,9 @@ impl> From for RoundSubtransfersMode { "single_step" => RoundSubtransfersMode::SingleStep, "by_value" => RoundSubtransfersMode::ByValue, "by_value_and_source" => RoundSubtransfersMode::ByValueAndSource, + "by_parcel" => RoundSubtransfersMode::ByParcel, "per_ballot" => RoundSubtransfersMode::PerBallot, - _ => panic!("Invalid --sum-transfers"), + _ => panic!("Invalid --round-subtransfers"), } } } diff --git a/tests/tests_impl/nswlg.rs b/tests/tests_impl/nswlg.rs index 8d8abed..a3bd0a5 100644 --- a/tests/tests_impl/nswlg.rs +++ b/tests/tests_impl/nswlg.rs @@ -28,7 +28,7 @@ fn nswlg_albury21_rational() { let stv_opts = stv::STVOptionsBuilder::default() .round_votes(Some(0)) .round_quota(Some(0)) - .round_subtransfers(stv::RoundSubtransfersMode::ByValueAndSource) + .round_subtransfers(stv::RoundSubtransfersMode::ByParcel) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual) .ties(vec![TieStrategy::Backwards, TieStrategy::Random(String::from("20220322"))]) .surplus_order(stv::SurplusOrder::ByOrder) @@ -36,7 +36,7 @@ fn nswlg_albury21_rational() { .subtract_nontransferable(true) .build().unwrap(); - assert_eq!(stv_opts.describe::(), "--round-votes 0 --round-quota 0 --round-subtransfers by_value_and_source --quota-criterion geq --ties backwards random --random-seed 20220322 --surplus-order by_order --transferable-only --subtract-nontransferable"); + assert_eq!(stv_opts.describe::(), "--round-votes 0 --round-quota 0 --round-subtransfers by_parcel --quota-criterion geq --ties backwards random --random-seed 20220322 --surplus-order by_order --transferable-only --subtract-nontransferable"); utils::read_validate_election::("tests/data/City_of_Albury-finalpreferencedatafile.csv", "tests/data/City_of_Albury-finalpreferencedatafile.blt", stv_opts, None, &[]); }