Implement --round-subtransfers by_parcel for NSW Local Government rules

This commit is contained in:
RunasSudo 2022-03-25 03:04:00 +11:00
parent 26d45cac50
commit df9223ebe6
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
8 changed files with 47 additions and 21 deletions

View File

@ -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 <a href="https://dl.acm.org/doi/10.1145/3014812.3014837">Conway et al.</a>
* [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 <a href="https://github.com/AndrewConway/ConcreteSTV/blob/main/nsw/NSWLocalCouncilLegislation2021Commentary.md">Conway</a>.
* [E5] The legislation is drafted such that a consistent interpretation is impossible – see <a href="https://github.com/AndrewConway/ConcreteSTV/blob/main/nsw/NSWLocalCouncilLegislation2021Commentary.md">Conway</a> 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.

View File

@ -301,6 +301,7 @@
<option value="single_step" selected>Single step</option>
<option value="by_value">By value</option>
<option value="by_value_and_source">By value and source</option>
<option value="by_parcel">By parcel</option>
<option value="per_ballot">Per ballot</option>
</select>
</label>

View File

@ -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';

View File

@ -80,7 +80,7 @@ pub struct SubcmdOptions {
round_quota: Option<usize>,
/// (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

View File

@ -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
);

View File

@ -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<usize>, candidate: &'e Candidate, ballots: &N) {
///
/// order: Pass `None` to force a new column
pub fn add_transfers(&mut self, value_fraction: &N, order: Option<usize>, 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<usize>, ballots: &N) {
///
/// order: Pass `None` to force a new column
pub fn add_exhausted(&mut self, value_fraction: &N, order: Option<usize>, 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<usize>,
/// Number to separate parcels in modes where subtransfers by parcel, etc.
pub order: usize,
/// Cells in this column
pub cells: HashMap<&'e Candidate, TransferTableCell<N>>,

View File

@ -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<S: AsRef<str>> From<S> 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"),
}
}
}

View File

@ -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::<Rational>(), "--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::<Rational>(), "--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::<Rational>("tests/data/City_of_Albury-finalpreferencedatafile.csv", "tests/data/City_of_Albury-finalpreferencedatafile.blt", stv_opts, None, &[]);
}