diff --git a/docs/options.md b/docs/options.md index d3ec4d9..9cb83ee 100644 --- a/docs/options.md +++ b/docs/options.md @@ -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. diff --git a/src/constraints.rs b/src/constraints.rs index f691b04..c06e252 100644 --- a/src/constraints.rs +++ b/src/constraints.rs @@ -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); impl ConstraintMatrix { @@ -491,6 +492,28 @@ fn candidates_in_constraint_cell<'a, N: Number>(election: &'a Election, candi return result; } +/// Clone and update the constraints matrix, with the state of the given candidates set to candidate_state +pub fn try_constraints(state: &CountState, 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(state: &mut CountState, opts: &STVOptions) -> bool { if state.constraint_matrix.is_none() { @@ -503,11 +526,12 @@ pub fn update_constraints(state: &mut CountState, 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 => { diff --git a/src/election.rs b/src/election.rs index 4f0795d..1d384cf 100644 --- a/src/election.rs +++ b/src/election.rs @@ -378,6 +378,7 @@ pub struct Ballot { #[allow(dead_code)] #[derive(PartialEq)] #[derive(Clone)] +#[derive(Debug)] pub enum CandidateState { /// Hopeful (continuing candidate) Hopeful, diff --git a/src/stv/mod.rs b/src/stv/mod.rs index d070d1a..056fe46 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -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; } diff --git a/tests/constraints.rs b/tests/constraints.rs index bdb3f38..4acf010 100644 --- a/tests/constraints.rs +++ b/tests/constraints.rs @@ -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 = 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 = records.first().unwrap().iter().skip(1).step_by(2).map(|s| s.parse().unwrap()).collect(); + + // Read BLT + let mut election: Election = 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::(stages, records, election, stv_opts, None, &["nt", "vre"]); +} diff --git a/tests/data/ers97_cantbulkexclude.con b/tests/data/ers97_cantbulkexclude.con new file mode 100644 index 0000000..706a019 --- /dev/null +++ b/tests/data/ers97_cantbulkexclude.con @@ -0,0 +1,2 @@ +"Constraint" "Constrained" 1 99 3 4 +"Constraint" "Placeholder" 0 99 1 2 5 6 7 8 9 10 11 diff --git a/tests/data/ers97_cantbulkexclude.csv b/tests/data/ers97_cantbulkexclude.csv new file mode 100644 index 0000000..49b236c --- /dev/null +++ b/tests/data/ers97_cantbulkexclude.csv @@ -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,,,,,,,,,, diff --git a/tests/data/ers97_cantbulkexclude.ods b/tests/data/ers97_cantbulkexclude.ods new file mode 100644 index 0000000..55aa6a2 Binary files /dev/null and b/tests/data/ers97_cantbulkexclude.ods differ diff --git a/tests/utils/mod.rs b/tests/utils/mod.rs index d4bd874..464c997 100644 --- a/tests/utils/mod.rs +++ b/tests/utils/mod.rs @@ -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); }