Prevent bulk election and bulk exclusion violating constraints

This commit is contained in:
RunasSudo 2021-08-01 23:50:15 +10:00
parent 116ff39fa5
commit ea8c452737
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
9 changed files with 129 additions and 23 deletions

View File

@ -178,8 +178,6 @@ In either case, candidates are declared elected in descending order of votes. Th
Note that the OpenTally rules for early bulk election are aggressive, and many STV rules do not implement all 3 (if any at all). It is not possible at this time to selectively apply only some of the rules. In order to reproduce the result of a count performed by others, where not all rules were implemented, consider disabling early bulk election and comparing the results at the time a bulk election would have been made.
Note also that early bulk election can conflict with constraints. If an election is to be run with constraints, it is recommend that early bulk election be disabled.
### Bulk exclusion (--bulk-exclude)
When bulk exclusion is disabled (default), only one candidate is ever excluded per stage.

View File

@ -161,7 +161,8 @@ pub struct ConstraintMatrixCell {
pub cands: usize,
}
/// Hypercube/tensor of [ConstraintMatrixCell]s representing the conformant combinations of elected candidates
/// N-dimensional cube of [ConstraintMatrixCell]s representing the conformant combinations of elected candidates
#[derive(Clone)]
pub struct ConstraintMatrix(pub Array<ConstraintMatrixCell, IxDyn>);
impl ConstraintMatrix {
@ -491,6 +492,28 @@ fn candidates_in_constraint_cell<'a, N: Number>(election: &'a Election<N>, candi
return result;
}
/// Clone and update the constraints matrix, with the state of the given candidates set to candidate_state
pub fn try_constraints<N: Number>(state: &CountState<N>, candidates: &Vec<&Candidate>, candidate_state: CandidateState) -> Result<(), ConstraintError> {
if state.constraint_matrix.is_none() {
return Ok(());
}
let mut cm = state.constraint_matrix.as_ref().unwrap().clone();
let mut trial_candidates = state.candidates.clone(); // TODO: Can probably be optimised by not cloning CountCard::parcels
for candidate in candidates {
trial_candidates.get_mut(candidate).unwrap().state = candidate_state.clone();
}
// Update cands/elected
cm.update_from_state(&state.election, &trial_candidates);
cm.recount_cands();
// Iterate for stable state
while !cm.step()? {}
return Ok(());
}
/// Update the constraints matrix, and perform the necessary actions given by [STVOptions::constraint_mode]
pub fn update_constraints<N: Number>(state: &mut CountState<N>, opts: &STVOptions) -> bool {
if state.constraint_matrix.is_none() {
@ -503,11 +526,12 @@ pub fn update_constraints<N: Number>(state: &mut CountState<N>, opts: &STVOption
cm.recount_cands();
// Iterate for stable state
//println!("{}", cm);
while !cm.step().expect("No conformant result is possible") {
//println!("{}", cm);
while !cm.step().expect("No conformant result is possible") {}
if state.num_elected == state.election.seats {
// Election is complete, so skip guarding/dooming candidates
return false;
}
//println!("{}", cm);
match opts.constraint_mode {
ConstraintMode::GuardDoom => {

View File

@ -378,6 +378,7 @@ pub struct Ballot<N> {
#[allow(dead_code)]
#[derive(PartialEq)]
#[derive(Clone)]
#[derive(Debug)]
pub enum CandidateState {
/// Hopeful (continuing candidate)
Hopeful,

View File

@ -829,11 +829,16 @@ fn elect_sure_winners<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOp
return Ok(false);
}
let mut hopefuls: Vec<&Candidate> = hopefuls.iter().map(|(c, _)| *c).collect();
let mut leading_hopefuls: Vec<&Candidate> = hopefuls.iter().take(num_vacancies).map(|(c, _)| *c).collect();
// Bulk elect all remaining candidates
match constraints::try_constraints(state, &leading_hopefuls, CandidateState::Elected) {
Ok(_) => {}
Err(_) => { return Ok(false); } // Bulk election conflicts with constraints
}
// Bulk elect all leading candidates
while state.num_elected < state.election.seats {
let max_cands = ties::multiple_max_by(&hopefuls, |c| &state.candidates[c].votes);
let max_cands = ties::multiple_max_by(&leading_hopefuls, |c| &state.candidates[c].votes);
let candidate = if max_cands.len() > 1 {
choose_highest(state, opts, max_cands, "Which candidate to elect?")?
} else {
@ -851,14 +856,11 @@ fn elect_sure_winners<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOp
vec![&candidate.name]
);
if constraints::update_constraints(state, opts) {
// FIXME: Work out interaction between early bulk election and constraints
panic!("Attempted early bulk election resulted in changes to constraint matrix");
} else {
hopefuls.remove(hopefuls.iter().position(|c| *c == candidate).unwrap());
}
leading_hopefuls.remove(leading_hopefuls.iter().position(|c| *c == candidate).unwrap());
}
constraints::update_constraints(state, opts);
return Ok(true);
}
@ -1148,9 +1150,15 @@ fn hopefuls_to_bulk_exclude<'a, N: Number>(state: &CountState<'a, N>, _opts: &ST
continue;
}
for (c, _) in try_exclude.into_iter() {
excluded_candidates.push(**c);
let try_exclude = try_exclude.into_iter().map(|(c, _)| **c).collect();
// Do not exclude if this violates constraints
match constraints::try_constraints(state, &try_exclude, CandidateState::Excluded) {
Ok(_) => {}
Err(_) => { break; } // Bulk exclusion conflicts with constraints
}
excluded_candidates.extend(try_exclude);
break;
}

View File

@ -213,3 +213,58 @@ fn prsa1_constr3_rational() {
assert_eq!(winners[2].0.name, "Thomson");
assert_eq!(winners[3].0.name, "Reid");
}
/// Same election data as ers97_rational, but with a constraint that prevents the bulk exclusion of Glazier and Wright
#[test]
fn ers97_cantbulkexclude_rational() {
// Read CSV file
let reader = csv::ReaderBuilder::new()
.has_headers(false)
.from_path("tests/data/ers97_cantbulkexclude.csv")
.expect("IO Error");
let records: Vec<csv::StringRecord> = reader.into_records().map(|r| r.expect("Syntax Error")).collect();
let mut candidates: Vec<&str> = records.iter().skip(2).map(|r| &r[0]).collect();
// Remove exhausted/LBF rows
candidates.truncate(candidates.len() - 2);
let stages: Vec<usize> = records.first().unwrap().iter().skip(1).step_by(2).map(|s| s.parse().unwrap()).collect();
// Read BLT
let mut election: Election<Rational> = Election::from_file("tests/data/ers97.blt").expect("Syntax Error");
// Read CON
let file = File::open("tests/data/ers97_cantbulkexclude.con").expect("IO Error");
let file_reader = io::BufReader::new(file);
let lines = file_reader.lines();
election.constraints = Some(Constraints::from_con(lines.map(|r| r.expect("IO Error").to_string()).into_iter()));
let stv_opts = stv::STVOptions {
round_tvs: Some(2),
round_weights: Some(2),
round_votes: Some(2),
round_quota: Some(2),
sum_surplus_transfers: stv::SumSurplusTransfersMode::SingleStep,
meek_surplus_tolerance: String::new(),
normalise_ballots: false,
quota: stv::QuotaType::DroopExact,
quota_criterion: stv::QuotaCriterion::GreaterOrEqual,
quota_mode: stv::QuotaMode::ERS97,
ties: vec![],
surplus: stv::SurplusMethod::EG,
surplus_order: stv::SurplusOrder::BySize,
transferable_only: true,
exclusion: stv::ExclusionMethod::ByValue,
meek_nz_exclusion: false,
early_bulk_elect: false,
bulk_exclude: true,
defer_surpluses: true,
meek_immediate_elect: false,
constraints_path: Some("tests/data/ers97_cantbulkexclude".to_string()),
constraint_mode: stv::ConstraintMode::GuardDoom,
hide_excluded: false,
sort_votes: false,
pp_decimals: 2,
};
utils::validate_election::<Rational>(stages, records, election, stv_opts, None, &["nt", "vre"]);
}

View File

@ -0,0 +1,2 @@
"Constraint" "Constrained" 1 99 3 4
"Constraint" "Placeholder" 0 99 1 2 5 6 7 8 9 10 11

View File

@ -0,0 +1,15 @@
Stage:,1,,2,,3,,4,,5,
Comment:,First preferences,,Surplus of Smith,,Exclusion of Monk,,Exclusion of Monk,,Exclusion of Glazier,
Smith,134,EL,107.58,EL,107.58,EL,107.58,EL,,EL
Carpenter,81,H,88.35,H,88.35,H,88.35,H,,H
Wright,27,H,32.25,H,32.25,H,32.25,H,,G
Glazier,24,H,30.51,H,30.51,H,30.51,H,,EX
Duke,105,H,106.68,H,108.68,EL,108.68,EL,,EL
Prince,91,H,91,H,91,H,91,H,,H
Baron,64,H,64,H,64,H,64.21,H,,H
Abbot,59,H,59.84,H,64.84,H,64.84,H,,H
Vicar,55,H,55,H,69,H,69.21,H,,H
Monk,23,H,23.42,H,0.42,EX,0,EX,,EX
Freeman,90,H,93.78,H,95.78,H,95.78,H,,H
Non-transferable,0,,0.59,,0.59,,0.59,,,
Votes required,,,,,,,,,,
1 Stage: 1 2 3 4 5
2 Comment: First preferences Surplus of Smith Exclusion of Monk Exclusion of Monk Exclusion of Glazier
3 Smith 134 EL 107.58 EL 107.58 EL 107.58 EL EL
4 Carpenter 81 H 88.35 H 88.35 H 88.35 H H
5 Wright 27 H 32.25 H 32.25 H 32.25 H G
6 Glazier 24 H 30.51 H 30.51 H 30.51 H EX
7 Duke 105 H 106.68 H 108.68 EL 108.68 EL EL
8 Prince 91 H 91 H 91 H 91 H H
9 Baron 64 H 64 H 64 H 64.21 H H
10 Abbot 59 H 59.84 H 64.84 H 64.84 H H
11 Vicar 55 H 55 H 69 H 69.21 H H
12 Monk 23 H 23.42 H 0.42 EX 0 EX EX
13 Freeman 90 H 93.78 H 95.78 H 95.78 H H
14 Non-transferable 0 0.59 0.59 0.59
15 Votes required

Binary file not shown.

View File

@ -134,13 +134,16 @@ where
for (candidate, candidate_state) in state.election.candidates.iter().zip(candidate_states) {
let count_card = state.candidates.get(candidate).unwrap();
if candidate_state == "" {
}
else if candidate_state == "H" {
assert!(count_card.state == CandidateState::Hopeful, "Unexpected state for \"{}\" at stage {}", candidate.name, stage_num);
} else if candidate_state == "H" {
assert!(count_card.state == CandidateState::Hopeful, "Unexpected state for \"{}\" at stage {}, expected \"Hopeful\", got \"{:?}\"", candidate.name, stage_num, count_card.state);
} else if candidate_state == "G" {
assert!(count_card.state == CandidateState::Guarded, "Unexpected state for \"{}\" at stage {}, expected \"Guarded\", got \"{:?}\"", candidate.name, stage_num, count_card.state);
} else if candidate_state == "EL" || candidate_state == "PEL" {
assert!(count_card.state == CandidateState::Elected, "Unexpected state for \"{}\" at stage {}", candidate.name, stage_num);
assert!(count_card.state == CandidateState::Elected, "Unexpected state for \"{}\" at stage {}, expected \"Elected\", got \"{:?}\"", candidate.name, stage_num, count_card.state);
} else if candidate_state == "D" {
assert!(count_card.state == CandidateState::Doomed, "Unexpected state for \"{}\" at stage {}, expected \"Doomed\", got \"{:?}\"", candidate.name, stage_num, count_card.state);
} else if candidate_state == "EX" || candidate_state == "EXCLUDING" {
assert!(count_card.state == CandidateState::Excluded, "Unexpected state for \"{}\" at stage {}", candidate.name, stage_num);
assert!(count_card.state == CandidateState::Excluded, "Unexpected state for \"{}\" at stage {}, expected \"Excluded\", got \"{:?}\"", candidate.name, stage_num, count_card.state);
} else {
panic!("Unknown state descriptor {}", candidate_state);
}