Prevent bulk election and bulk exclusion violating constraints
This commit is contained in:
parent
116ff39fa5
commit
ea8c452737
@ -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.
|
||||
|
@ -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 => {
|
||||
|
@ -378,6 +378,7 @@ pub struct Ballot<N> {
|
||||
#[allow(dead_code)]
|
||||
#[derive(PartialEq)]
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug)]
|
||||
pub enum CandidateState {
|
||||
/// Hopeful (continuing candidate)
|
||||
Hopeful,
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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"]);
|
||||
}
|
||||
|
2
tests/data/ers97_cantbulkexclude.con
Normal file
2
tests/data/ers97_cantbulkexclude.con
Normal file
@ -0,0 +1,2 @@
|
||||
"Constraint" "Constrained" 1 99 3 4
|
||||
"Constraint" "Placeholder" 0 99 1 2 5 6 7 8 9 10 11
|
15
tests/data/ers97_cantbulkexclude.csv
Normal file
15
tests/data/ers97_cantbulkexclude.csv
Normal 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,,,,,,,,,,
|
|
BIN
tests/data/ers97_cantbulkexclude.ods
Normal file
BIN
tests/data/ers97_cantbulkexclude.ods
Normal file
Binary file not shown.
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user