Initial implementation of --constraint-mode repeat_count

This commit is contained in:
RunasSudo 2022-04-16 02:27:59 +10:00
parent df9223ebe6
commit 03af86733e
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
18 changed files with 635 additions and 73 deletions

View File

@ -167,10 +167,33 @@ The algorithm used by the random number generator is specified at [rng.md](https
This file selector allows you to load a [CON file](https://yingtongli.me/git/OpenTally/about/docs/con-fmt.md) specifying constraints on the election. For example, if a certain minimum or maximum number of candidates can be elected from a particular category.
OpenTally applies constraints using the GreyFitzgerald method. Whenever a candidate is declared elected or excluded, any candidate who must be elected to secure a conformant result is deemed *guarded*, and any candidate who must not be elected to secure a conformant result is deemed *doomed*. Any candidate who is doomed is excluded at the next opportunity. Any candidate who is guarded is prevented from being excluded.
### Constraint method (--constraint-method)
This dropdown allows you to select how constraints are applied. The options are:
*Guard/doom* (default):
When this option is selected, OpenTally applies constraints using the GreyFitzgerald method. Whenever a candidate is declared elected or excluded, any candidate who must be elected to secure a conformant result is deemed *guarded*, and any candidate who must not be elected to secure a conformant result is deemed *doomed*. Any candidate who is doomed is excluded at the next opportunity. Any candidate who is guarded is prevented from being excluded.
Multiple constraints are supported using the method described by Hill ([*Voting Matters* 1998;(9):24](http://www.votingmatters.org.uk/ISSUE9/P1.HTM)) and Otten ([*Voting Matters* 2001;(13):47](http://www.votingmatters.org.uk/ISSUE13/P3.HTM)).
*Repeat count*:
When this option is selected, only constraints specifying a maximum number of candidates to be elected from a particular group are supported. Other constraint groups will be **silently ignored**. Note that each candidate must still be assigned to exactly one group within each constraint.
The count proceeds as normal, until the point that a candidate would be elected who would violate the constraint. At this point, that candidate and all other candidates from the constrained group are excluded, and all previously excluded candidates from the non-constrained group are reintroduced.
All ballot papers are removed from the count, and redistributed among the candidates in the following order:
* Any undistributed surpluses, each surplus comprising one stage
* Any exhausted ballot papers, in one or more stages (according to *Exclusion method*)
* The ballot papers of each continuing candidate from the non-constrained group, in one or more stages (according to *Exclusion method*), candidate-by-candidate in random order or an order specified by the user (according to *Ties*, with options other than *Random* and *Prompt* ignored)
* The ballot papers of each continuing candidate from the constrained group, in like manner
Once all ballot papers have been so redistributed, the count resumes as usual.
This method is specified, for example, in Schedule 1.1 of the [Monash Student Association *Election Regulations* (2021)](https://msa.monash.edu/app/uploads/2021/07/MSA-Election-Regulations-2021.pdf).
## Report options
### Report style

View File

@ -183,7 +183,16 @@
Constraints:
</div>
<div>
<input type="file" id="conFile">
<label>
<input type="file" id="conFile">
</label>
<label>
Method:
<select id="selConstraintMethod">
<option value="guard_doom" selected>Guard/doom</option>
<option value="repeat_count">Repeat count</option>
</select>
</label>
</div>
<div class="subheading">
Report options:

View File

@ -165,7 +165,7 @@ async function clickCount() {
document.getElementById('chkImmediateElect').checked,
document.getElementById('txtMinThreshold').value,
conPath,
"guard_doom",
document.getElementById('selConstraintMethod').value,
parseInt(document.getElementById('txtPPDP').value),
];

View File

@ -1,3 +1,20 @@
/* OpenTally: Open-source election vote counting
* Copyright © 20212022 Lee Yingtong Li (RunasSudo)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
importScripts('opentally.js?v=GITVERSION');
var wasm = wasm_bindgen;
@ -53,7 +70,7 @@ onmessage = function(evt) {
// Init constraints if applicable
if (evt.data.conData) {
wasm['election_load_constraints_' + numbers](election, evt.data.conData);
wasm['election_load_constraints_' + numbers](election, evt.data.conData, opts);
}
// Describe count

View File

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::constraints::Constraints;
use crate::constraints::{self, Constraints};
use crate::election::{CandidateState, CountState, Election, StageKind};
use crate::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational};
use crate::parser::{bin, blt};
@ -176,7 +176,7 @@ pub struct SubcmdOptions {
constraints: Option<String>,
/// Mode of handling constraints
#[clap(help_heading=Some("CONSTRAINTS"), long, possible_values=&["guard_doom"], default_value="guard_doom")]
#[clap(help_heading=Some("CONSTRAINTS"), long, possible_values=&["guard_doom", "repeat_count"], default_value="guard_doom")]
constraint_mode: String,
// ---------------------
@ -207,25 +207,25 @@ pub fn main(cmd_opts: SubcmdOptions) -> Result<(), i32> {
// Read and count election according to --numbers
if cmd_opts.numbers == "rational" {
let mut election = election_from_file(&cmd_opts.filename, cmd_opts.bin)?;
maybe_load_constraints(&mut election, &cmd_opts.constraints)?;
maybe_load_constraints(&mut election, &cmd_opts.constraints, &cmd_opts.constraint_mode)?;
// Must specify ::<N> here and in a few other places because ndarray causes E0275 otherwise
count_election::<Rational>(election, cmd_opts)?;
} else if cmd_opts.numbers == "float64" {
let mut election = election_from_file(&cmd_opts.filename, cmd_opts.bin)?;
maybe_load_constraints(&mut election, &cmd_opts.constraints)?;
maybe_load_constraints(&mut election, &cmd_opts.constraints, &cmd_opts.constraint_mode)?;
count_election::<NativeFloat64>(election, cmd_opts)?;
} else if cmd_opts.numbers == "fixed" {
Fixed::set_dps(cmd_opts.decimals);
let mut election = election_from_file(&cmd_opts.filename, cmd_opts.bin)?;
maybe_load_constraints(&mut election, &cmd_opts.constraints)?;
maybe_load_constraints(&mut election, &cmd_opts.constraints, &cmd_opts.constraint_mode)?;
count_election::<Fixed>(election, cmd_opts)?;
} else if cmd_opts.numbers == "gfixed" {
GuardedFixed::set_dps(cmd_opts.decimals);
let mut election = election_from_file(&cmd_opts.filename, cmd_opts.bin)?;
maybe_load_constraints(&mut election, &cmd_opts.constraints)?;
maybe_load_constraints(&mut election, &cmd_opts.constraints, &cmd_opts.constraint_mode)?;
count_election::<GuardedFixed>(election, cmd_opts)?;
}
@ -248,7 +248,7 @@ fn election_from_file<N: Number>(path: &str, bin: bool) -> Result<Election<N>, i
}
}
fn maybe_load_constraints<N: Number>(election: &mut Election<N>, constraints: &Option<String>) -> Result<(), i32> {
fn maybe_load_constraints<N: Number>(election: &mut Election<N>, constraints: &Option<String>, constraint_mode: &str) -> Result<(), i32> {
if let Some(c) = constraints {
let file = File::open(c).expect("IO Error");
let lines = io::BufReader::new(file).lines();
@ -265,10 +265,14 @@ fn maybe_load_constraints<N: Number>(election: &mut Election<N>, constraints: &O
}
// Validate constraints
if let Err(err) = election.constraints.as_ref().unwrap().validate_constraints(election.candidates.len()) {
if let Err(err) = election.constraints.as_ref().unwrap().validate_constraints(election.candidates.len(), constraint_mode.into()) {
println!("Constraint Validation Error: {}", err);
return Err(1);
}
if constraint_mode == "repeat_count" {
constraints::init_repeat_count(election);
}
}
Ok(())
@ -345,7 +349,7 @@ where
{
// Describe count
let total_ballots = election.ballots.iter().fold(N::zero(), |acc, b| { acc + &b.orig_value });
print!("Count computed by OpenTally (revision {}). Read {:.0} ballots from \"{}\" for election \"{}\". There are {} candidates for {} vacancies. ", crate::VERSION, total_ballots, filename, election.name, election.candidates.len(), election.seats);
print!("Count computed by OpenTally (revision {}). Read {:.0} ballots from \"{}\" for election \"{}\". There are {} candidates for {} vacancies. ", crate::VERSION, total_ballots, filename, election.name, election.candidates.iter().filter(|c| !c.is_dummy).count(), election.seats);
let opts_str = opts.describe::<N>();
if !opts_str.is_empty() {
println!("Counting using options \"{}\".", opts_str);
@ -538,6 +542,11 @@ where
StageKind::ExclusionOf(candidates) => {
stage_results[2].push(format!(r#""{}""#, candidates.iter().map(|c| &c.name).sorted().join("+")));
}
StageKind::Rollback => todo!(),
StageKind::RollbackExhausted => todo!(),
StageKind::BallotsOf(candidate) => {
stage_results[2].push(format!(r#""{}""#, candidate.name));
}
StageKind::SurplusesDistributed => todo!(),
StageKind::BulkElection => {
//let mut elected_candidates = Vec::new();

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo)
* Copyright © 20212022 Lee Yingtong Li (RunasSudo)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -15,9 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::election::{Candidate, CandidateState, CountCard, CountState, Election};
use crate::election::{Candidate, CandidateState, CountCard, CountState, Election, StageKind, RollbackState};
use crate::numbers::Number;
use crate::stv::{ConstraintMode, STVOptions};
use crate::stv::{self, gregory, sample, ConstraintMode, STVError, STVOptions, SurplusMethod, SurplusOrder};
use crate::ties::{self, TieStrategy};
use itertools::Itertools;
use ndarray::{Array, Dimension, IxDyn};
@ -89,8 +90,8 @@ impl Constraints {
return Ok(constraints);
}
/// Validate that each candidate is specified exactly once in each constraint
pub fn validate_constraints(&self, num_candidates: usize) -> Result<(), ValidationError> {
/// Validate that each candidate is specified exactly once in each constraint, and (if applicable) limitations of the constraint mode are applied
pub fn validate_constraints(&self, num_candidates: usize, constraint_mode: ConstraintMode) -> Result<(), ValidationError> {
for constraint in &self.0 {
let mut remaining_candidates: Vec<usize> = (0..num_candidates).collect();
@ -105,6 +106,19 @@ impl Constraints {
}
}
}
if constraint_mode == ConstraintMode::RepeatCount {
// Each group must be either a maximum constraint, or the remaining group
if group.min == 0 {
// Maximum constraint: OK
} else if group.max >= group.candidates.len() {
// Remaining group: OK
} else {
return Err(ValidationError::InvalidTwoStage(constraint.name.clone(), group.name.clone()));
}
// FIXME: Is other validation required?
}
}
if !remaining_candidates.is_empty() {
@ -114,6 +128,24 @@ impl Constraints {
Ok(())
}
/// Check if any elected candidates exceed constrained maximums
pub fn exceeds_maximum<'a, N: Number>(&self, election: &Election<N>, candidates: HashMap<&'a Candidate, CountCard<'a, N>>) -> Option<(&Constraint, &ConstrainedGroup)> {
for constraint in &self.0 {
for group in &constraint.groups {
let mut num_elected = 0;
for candidate in &group.candidates {
if candidates[&election.candidates[*candidate]].state == CandidateState::Elected {
num_elected += 1;
}
}
if num_elected > group.max {
return Some((&constraint, &group));
}
}
}
return None;
}
}
/// Error parsing constraints
@ -154,6 +186,8 @@ pub enum ValidationError {
DuplicateCandidate(usize, String),
/// Unassigned candidate in a constraint
UnassignedCandidate(usize, String),
/// Constraint is incompatible with ConstraintMode::TwoStage
InvalidTwoStage(String, String),
}
impl fmt::Display for ValidationError {
@ -165,6 +199,9 @@ impl fmt::Display for ValidationError {
ValidationError::UnassignedCandidate(candidate, constraint_name) => {
f.write_fmt(format_args!(r#"Unassigned candidate {} in constraint "{}""#, candidate + 1, constraint_name))
}
ValidationError::InvalidTwoStage(constraint_name, group_name) => {
f.write_fmt(format_args!(r#"Constraint "{}" group "{}" is incompatible with --constraint-mode repeat_count"#, constraint_name, group_name))
}
}
}
}
@ -187,7 +224,7 @@ fn duplicate_candidate() {
let input = r#""Constraint 1" "Group 1" 0 3 1 2 3 4
"Constraint 1" "Group 2" 0 3 4 5 6"#;
let constraints = Constraints::from_con(input.lines()).unwrap();
constraints.validate_constraints(6).unwrap_err();
constraints.validate_constraints(6, ConstraintMode::GuardDoom).unwrap_err();
}
#[test]
@ -195,7 +232,7 @@ fn unassigned_candidate() {
let input = r#""Constraint 1" "Group 1" 0 3 1 2 3
"Constraint 1" "Group 2" 0 3 4 5 6"#;
let constraints = Constraints::from_con(input.lines()).unwrap();
constraints.validate_constraints(7).unwrap_err();
constraints.validate_constraints(7, ConstraintMode::GuardDoom).unwrap_err();
}
/// Read an optionally quoted string, returning the string without quotes
@ -262,6 +299,10 @@ pub enum ConstraintError {
NoConformantResult,
}
// ----------------------
// GUARD/DOOM CONSTRAINTS
// ----------------------
/// Cell in a [ConstraintMatrix]
#[derive(Clone)]
pub struct ConstraintMatrixCell {
@ -332,6 +373,10 @@ impl ConstraintMatrix {
}
for (i, candidate) in election.candidates.iter().enumerate() {
if candidate.is_dummy {
continue;
}
let idx: Vec<usize> = constraints.0.iter().map(|c| {
for (j, group) in c.groups.iter().enumerate() {
if group.candidates.contains(&i) {
@ -607,8 +652,8 @@ 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: &[&Candidate], candidate_state: CandidateState) -> Result<(), ConstraintError> {
/// Clone and update the constraints matrix, with the state of the given candidates set to candidate_state check if a conformant result is possible
pub fn test_constraints_any_time<N: Number>(state: &CountState<N>, candidates: &[&Candidate], candidate_state: CandidateState) -> Result<(), ConstraintError> {
if state.constraint_matrix.is_none() {
return Ok(());
}
@ -697,10 +742,312 @@ pub fn update_constraints<N: Number>(state: &mut CountState<N>, opts: &STVOption
return guarded_or_doomed;
}
_ => { todo!() }
ConstraintMode::RepeatCount => { return false; } // No action needed here: elect_hopefuls checks test_constraints_immediate
}
}
// ----------------------------------
// FOR --constraint-mode repeat_count
// ----------------------------------
/// Check constraints, with the state of the given candidates set to candidate_state check if this immediately violates constraints
pub fn test_constraints_immediate<'a, N: Number>(state: &CountState<'a, N>, candidates: &[&Candidate], candidate_state: CandidateState) -> Result<(), (&'a Constraint, &'a ConstrainedGroup)> {
if state.election.constraints.is_none() {
return Ok(());
}
//return false;
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;
}
if let Some((a, b)) = state.election.constraints.as_ref().unwrap().exceeds_maximum(state.election, trial_candidates) {
return Err((a, b));
}
return Ok(());
}
/// Initialise the [Election] as required for --constraint-mode repeat_count
pub fn init_repeat_count<N: Number>(election: &mut Election<N>) {
// Add dummy candidates
let mut new_candidates = Vec::new();
for candidate in &election.candidates {
let mut new_candidate = candidate.clone();
new_candidate.is_dummy = true;
new_candidates.push(new_candidate);
}
election.candidates.append(&mut new_candidates);
}
/// Initialise the rollback for [ConstraintMode::TwoStage]
pub fn init_repeat_count_rollback<'a, N: Number>(state: &mut CountState<'a, N>, constraint: &'a Constraint, group: &'a ConstrainedGroup) {
let mut rollback_candidates = HashMap::new();
let rollback_exhausted = state.exhausted.clone();
// Copy ballot papers to rollback state
for (candidate, count_card) in state.candidates.iter_mut() {
rollback_candidates.insert(*candidate, count_card.clone());
}
state.rollback_state = RollbackState::NeedsRollback { candidates: Some(rollback_candidates), exhausted: Some(rollback_exhausted), constraint, group };
}
/// Process one stage of rollback for [ConstraintMode::TwoStage]
pub fn rollback_one_stage<N: Number>(state: &mut CountState<N>, opts: &STVOptions) -> Result<bool, STVError>
where
for<'r> &'r N: ops::Add<&'r N, Output=N>,
for<'r> &'r N: ops::Sub<&'r N, Output=N>,
for<'r> &'r N: ops::Mul<&'r N, Output=N>,
for<'r> &'r N: ops::Div<&'r N, Output=N>,
for<'r> &'r N: ops::Neg<Output=N>
{
if let RollbackState::NeedsRollback { candidates, exhausted, constraint, group } = &mut state.rollback_state {
let mut candidates = candidates.take().unwrap();
// Exclude candidates who cannot be elected due to constraint violations
let order_excluded = state.num_excluded + 1;
let mut excluded_candidates = Vec::new();
for candidate_idx in &group.candidates {
let count_card = state.candidates.get_mut(&state.election.candidates[*candidate_idx]).unwrap();
if count_card.state == CandidateState::Hopeful {
count_card.state = CandidateState::Excluded;
count_card.finalised = true;
state.num_excluded += 1;
count_card.order_elected = -(order_excluded as isize);
excluded_candidates.push(state.election.candidates[*candidate_idx].name.as_str());
}
}
// Prepare dummy candidates, etc.
for candidate in &state.election.candidates {
if candidate.is_dummy {
continue;
}
// Move ballot papers to dummy candidate
let dummy_candidate = state.election.candidates.iter().find(|c| c.name == candidate.name && c.is_dummy).unwrap();
let dummy_count_card = state.candidates.get_mut(dummy_candidate).unwrap();
dummy_count_card.parcels.append(&mut candidates.get_mut(candidate).unwrap().parcels);
dummy_count_card.votes = candidates[candidate].votes.clone();
// Reset count
let count_card = state.candidates.get_mut(candidate).unwrap();
count_card.parcels.clear();
count_card.votes = N::new();
count_card.transfers = N::new();
if candidates[candidate].state == CandidateState::Elected {
if &candidates[candidate].votes > state.quota.as_ref().unwrap() {
count_card.votes = state.quota.as_ref().unwrap().clone();
} else {
count_card.votes = candidates[candidate].votes.clone();
}
}
}
state.title = StageKind::Rollback;
state.logger.log_smart(
"Rolled back to apply constraints. {} is excluded.",
"Rolled back to apply constraints. {} are excluded.",
excluded_candidates.into_iter().sorted().collect()
);
state.rollback_state = RollbackState::RollingBack { candidates: Some(candidates), exhausted: exhausted.take(), candidate_distributing: None, constraint: Some(constraint), group: Some(group) };
return Ok(true);
}
if let RollbackState::RollingBack { candidates, exhausted, candidate_distributing, constraint, group } = &mut state.rollback_state {
let candidates = candidates.take().unwrap();
let mut exhausted = exhausted.take().unwrap();
let mut candidate_distributing = candidate_distributing.take();
let constraint = constraint.take().unwrap();
let group = group.take().unwrap();
// --------------------
// Distribute surpluses
let has_surplus: Vec<&Candidate> = state.election.candidates.iter() // Present in order in case of tie
.filter(|c| {
let cc = &candidates[c];
if !c.is_dummy && cc.state == CandidateState::Elected && !cc.finalised {
let dummy_candidate = state.election.candidates.iter().find(|x| x.name == c.name && x.is_dummy).unwrap();
!state.candidates[dummy_candidate].finalised
} else {
false
}
})
.collect();
if !has_surplus.is_empty() {
// Distribute top candidate's surplus
let max_cands = match opts.surplus_order {
SurplusOrder::BySize => {
ties::multiple_max_by(&has_surplus, |c| &candidates[c].votes)
}
SurplusOrder::ByOrder => {
ties::multiple_min_by(&has_surplus, |c| candidates[c].order_elected)
}
};
let elected_candidate = if max_cands.len() > 1 {
stv::choose_highest(state, opts, &max_cands, "Which candidate's surplus to distribute?")?
} else {
max_cands[0]
};
let dummy_candidate = state.election.candidates.iter().find(|c| c.name == elected_candidate.name && c.is_dummy).unwrap();
match opts.surplus {
SurplusMethod::WIG | SurplusMethod::UIG | SurplusMethod::EG => { gregory::distribute_surplus(state, opts, dummy_candidate); }
SurplusMethod::IHare | SurplusMethod::Hare => { sample::distribute_surplus(state, opts, dummy_candidate)?; }
_ => unreachable!()
}
state.rollback_state = RollbackState::RollingBack { candidates: Some(candidates), exhausted: Some(exhausted), candidate_distributing, constraint: Some(constraint), group: Some(group) };
return Ok(true);
}
// ----------------------------------
// Distribute exhausted ballot papers
// FIXME: Untested!
if exhausted.parcels.iter().any(|p| !p.votes.is_empty()) {
// Use arbitrary dummy candidate
let dummy_candidate = state.election.candidates.iter().find(|c| c.is_dummy).unwrap();
let dummy_count_card = state.candidates.get_mut(dummy_candidate).unwrap();
dummy_count_card.parcels.append(&mut exhausted.parcels);
state.title = StageKind::RollbackExhausted;
state.logger.log_literal(String::from("Distributing exhausted ballots."));
stv::exclude_candidates(state, opts, vec![dummy_candidate])?;
let dummy_count_card = state.candidates.get_mut(dummy_candidate).unwrap();
exhausted.parcels.append(&mut dummy_count_card.parcels);
state.rollback_state = RollbackState::RollingBack { candidates: Some(candidates), exhausted: Some(exhausted), candidate_distributing: None, constraint: Some(constraint), group: Some(group) };
return Ok(true);
}
// ------------------------------------------------
// Distribute ballot papers of electable candidates
let electable_candidates_old: Vec<&Candidate> = state.election.candidates.iter() // Present in order in case of multiple
.filter(|c| {
let cc = &candidates[c];
let cand_idx = state.election.candidates.iter().position(|x| x == *c).unwrap();
if !c.is_dummy && !group.candidates.contains(&cand_idx) && !cc.finalised {
let dummy_candidate = state.election.candidates.iter().find(|x| x.name == c.name && x.is_dummy).unwrap();
!state.candidates[dummy_candidate].finalised
} else {
false
}
})
.collect();
if !electable_candidates_old.is_empty() {
if candidate_distributing.is_none() || !electable_candidates_old.contains(candidate_distributing.as_ref().unwrap()) {
if electable_candidates_old.len() > 1 {
// Determine or prompt for which candidate to distribute
for strategy in opts.ties.iter() {
match strategy {
TieStrategy::Random(_) | TieStrategy::Prompt => {
candidate_distributing = Some(strategy.choose_lowest(state, opts, &electable_candidates_old, "Which candidate's ballots to distribute?").unwrap());
break;
}
TieStrategy::Forwards | TieStrategy::Backwards => {}
}
}
} else {
candidate_distributing = Some(electable_candidates_old[0]);
}
}
let candidate_distributing = candidate_distributing.unwrap();
let dummy_candidate = state.election.candidates.iter().find(|c| c.name == candidate_distributing.name && c.is_dummy).unwrap();
state.title = StageKind::BallotsOf(candidate_distributing);
state.logger.log_smart(
"Distributing ballot papers of {}.",
"Distributing ballot papers of {}.",
vec![candidate_distributing.name.as_str()]
);
stv::exclude_candidates(state, opts, vec![dummy_candidate])?;
state.rollback_state = RollbackState::RollingBack { candidates: Some(candidates), exhausted: Some(exhausted), candidate_distributing: Some(candidate_distributing), constraint: Some(constraint), group: Some(group) };
return Ok(true);
}
// --------------------------------------------------
// Distribute ballot papers of unelectable candidates
let unelectable_candidates_old: Vec<&Candidate> = state.election.candidates.iter() // Present in order in case of multiple
.filter(|c| {
let cc = &candidates[c];
let cand_idx = state.election.candidates.iter().position(|x| x == *c).unwrap();
if !c.is_dummy && group.candidates.contains(&cand_idx) && !cc.finalised {
let dummy_candidate = state.election.candidates.iter().find(|x| x.name == c.name && x.is_dummy).unwrap();
!state.candidates[dummy_candidate].finalised
} else {
false
}
})
.collect();
if !unelectable_candidates_old.is_empty() {
if candidate_distributing.is_none() || !unelectable_candidates_old.contains(candidate_distributing.as_ref().unwrap()) {
if unelectable_candidates_old.len() > 1 {
// Determine or prompt for which candidate to distribute
for strategy in opts.ties.iter() {
match strategy {
TieStrategy::Random(_) | TieStrategy::Prompt => {
candidate_distributing = Some(strategy.choose_lowest(state, opts, &unelectable_candidates_old, "Which candidate's ballots to distribute?").unwrap());
break;
}
TieStrategy::Forwards | TieStrategy::Backwards => {}
}
}
} else {
candidate_distributing = Some(unelectable_candidates_old[0]);
}
}
let candidate_distributing = candidate_distributing.unwrap();
let dummy_candidate = state.election.candidates.iter().find(|c| c.name == candidate_distributing.name && c.is_dummy).unwrap();
state.title = StageKind::BallotsOf(candidate_distributing);
state.logger.log_smart(
"Distributing ballot papers of {}.",
"Distributing ballot papers of {}.",
vec![candidate_distributing.name.as_str()]
);
stv::exclude_candidates(state, opts, vec![dummy_candidate])?;
state.rollback_state = RollbackState::RollingBack { candidates: Some(candidates), exhausted: Some(exhausted), candidate_distributing: Some(candidate_distributing), constraint: Some(constraint), group: Some(group) };
return Ok(true);
}
// ---------------------------
// Rollback complete: finalise
// Delete dummy candidates
for (candidate, count_card) in state.candidates.iter_mut() {
if candidate.is_dummy {
count_card.state = CandidateState::Withdrawn;
count_card.parcels.clear();
count_card.votes = N::new();
count_card.transfers = N::new();
}
}
state.logger.log_literal(String::from("Rollback complete."));
state.rollback_state = RollbackState::Normal;
state.num_excluded = state.candidates.values().filter(|cc| cc.state == CandidateState::Excluded).count();
return Ok(false);
}
unreachable!();
}
#[cfg(test)]

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo)
* Copyright © 20212022 Lee Yingtong Li (RunasSudo)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::constraints::{Constraints, ConstraintMatrix};
use crate::constraints::{Constraint, Constraints, ConstrainedGroup, ConstraintMatrix};
use crate::logger::Logger;
use crate::numbers::Number;
use crate::sharandom::SHARandom;
@ -36,6 +36,7 @@ use std::fmt;
/// An election to be counted
#[cfg_attr(not(target_arch = "wasm32"), derive(Archive, Deserialize, Serialize))]
#[derive(Clone)]
pub struct Election<N> {
/// Name of the election
pub name: String,
@ -92,14 +93,17 @@ impl<N: Number> Election<N> {
}
/// A candidate in an [Election]
#[derive(Eq, Hash, PartialEq)]
#[derive(Clone, Eq, Hash, PartialEq)]
#[cfg_attr(not(target_arch = "wasm32"), derive(Archive, Deserialize, Serialize))]
pub struct Candidate {
/// Name of the candidate
pub name: String,
/// If this candidate is a dummy candidate (e.g. for --constraint-mode repeat_count)
pub is_dummy: bool,
}
/// The current state of counting an [Election]
#[derive(Clone)]
pub struct CountState<'a, N: Number> {
/// Pointer to the [Election] being counted
pub election: &'a Election<N>,
@ -135,6 +139,8 @@ pub struct CountState<'a, N: Number> {
/// [ConstraintMatrix] for constrained elections
pub constraint_matrix: Option<ConstraintMatrix>,
/// [RollbackState] when using [ConstraintMode::Rollback]
pub rollback_state: RollbackState<'a, N>,
/// Transfer table for this surplus/exclusion
pub transfer_table: Option<TransferTable<'a, N>>,
@ -162,6 +168,7 @@ impl<'a, N: Number> CountState<'a, N> {
num_elected: 0,
num_excluded: 0,
constraint_matrix: None,
rollback_state: RollbackState::Normal,
transfer_table: None,
title: StageKind::FirstPreferences,
logger: Logger { entries: Vec::new() },
@ -169,7 +176,11 @@ impl<'a, N: Number> CountState<'a, N> {
// Init candidate count cards
for candidate in election.candidates.iter() {
state.candidates.insert(candidate, CountCard::new());
let mut count_card = CountCard::new();
if candidate.is_dummy {
count_card.state = CandidateState::Withdrawn;
}
state.candidates.insert(candidate, count_card);
}
// Set withdrawn candidates state
@ -241,6 +252,10 @@ impl<'a, N: Number> CountState<'a, N> {
let mut result = String::new();
for (candidate, count_card) in candidates {
if candidate.is_dummy {
continue;
}
match count_card.state {
CandidateState::Hopeful => {
result.push_str(&format!("- {}: {:.dps$} ({:.dps$})\n", candidate.name, count_card.votes, count_card.transfers, dps=opts.pp_decimals));
@ -281,7 +296,7 @@ impl<'a, N: Number> CountState<'a, N> {
result.push_str(&format!("Exhausted: {:.dps$} ({:.dps$})\n", self.exhausted.votes, self.exhausted.transfers, dps=opts.pp_decimals));
result.push_str(&format!("Loss by fraction: {:.dps$} ({:.dps$})\n", self.loss_fraction.votes, self.loss_fraction.transfers, dps=opts.pp_decimals));
let mut total_vote = self.candidates.values().fold(N::zero(), |acc, cc| { acc + &cc.votes });
let mut total_vote = self.candidates.iter().filter_map(|(c, cc)| if c.is_dummy { None } else { Some(cc) }).fold(N::zero(), |acc, cc| { acc + &cc.votes });
total_vote += &self.exhausted.votes;
total_vote += &self.loss_fraction.votes;
result.push_str(&format!("Total votes: {:.dps$}\n", total_vote, dps=opts.pp_decimals));
@ -306,6 +321,12 @@ pub enum StageKind<'a> {
SurplusOf(&'a Candidate),
/// Exclusion of ...
ExclusionOf(Vec<&'a Candidate>),
/// Rolled back (--constraint-mode repeat_count)
Rollback,
/// Exhausted ballots (--constraint-mode repeat_count)
RollbackExhausted,
/// Ballots of ... (--constraint-mode repeat_count)
BallotsOf(&'a Candidate),
/// Surpluses distributed (Meek)
SurplusesDistributed,
/// Bulk election
@ -319,6 +340,9 @@ impl<'a> StageKind<'a> {
StageKind::FirstPreferences => "",
StageKind::SurplusOf(_) => "Surplus of",
StageKind::ExclusionOf(_) => "Exclusion of",
StageKind::Rollback => "",
StageKind::RollbackExhausted => "",
StageKind::BallotsOf(_) => "Ballots of",
StageKind::SurplusesDistributed => "",
StageKind::BulkElection => "",
};
@ -337,6 +361,15 @@ impl<'a> fmt::Display for StageKind<'a> {
StageKind::ExclusionOf(candidates) => {
return f.write_fmt(format_args!("{} {}", self.kind_as_string(), candidates.iter().map(|c| &c.name).sorted().join(", ")));
}
StageKind::Rollback => {
return f.write_str("Constraints applied");
}
StageKind::RollbackExhausted => {
return f.write_str("Exhausted ballots");
}
StageKind::BallotsOf(candidate) => {
return f.write_fmt(format_args!("{} {}", self.kind_as_string(), candidate.name));
}
StageKind::SurplusesDistributed => {
return f.write_str("Surpluses distributed");
}
@ -465,6 +498,7 @@ impl<'a, N> Vote<'a, N> {
}
/// A record of a voter's preferences
#[derive(Clone)]
#[cfg_attr(not(target_arch = "wasm32"), derive(Archive, Deserialize, Serialize))]
pub struct Ballot<N> {
/// Original value/weight of the ballot
@ -530,3 +564,15 @@ pub enum CandidateState {
/// Declared excluded
Excluded,
}
/// If --constraint-mode repeat_count and redistribution is required, tracks the ballot papers being redistributed
#[allow(missing_docs)]
#[derive(Clone)]
pub enum RollbackState<'a, N> {
/// Not rolling back
Normal,
/// Start rolling back next stage
NeedsRollback { candidates: Option<HashMap<&'a Candidate, CountCard<'a, N>>>, exhausted: Option<CountCard<'a, N>>, constraint: &'a Constraint, group: &'a ConstrainedGroup },
/// Rolling back
RollingBack { candidates: Option<HashMap<&'a Candidate, CountCard<'a, N>>>, exhausted: Option<CountCard<'a, N>>, candidate_distributing: Option<&'a Candidate>, constraint: Option<&'a Constraint>, group: Option<&'a ConstrainedGroup> },
}

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo)
* Copyright © 20212022 Lee Yingtong Li (RunasSudo)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -16,6 +16,7 @@
*/
/// Smart logger used in election counts
#[derive(Clone)]
pub struct Logger<'a> {
/// [Vec] of log entries for the current stage
pub entries: Vec<LogEntry<'a>>,
@ -71,6 +72,7 @@ impl<'a> Logger<'a> {
}
/// Represents either a literal or smart log entry
#[derive(Clone)]
pub enum LogEntry<'a> {
/// Smart log entry - see [SmartLogEntry]
Smart(SmartLogEntry<'a>),
@ -79,6 +81,7 @@ pub enum LogEntry<'a> {
}
/// Smart log entry
#[derive(Clone)]
pub struct SmartLogEntry<'a> {
template1: &'a str,
template2: &'a str,

View File

@ -233,12 +233,21 @@ impl ops::Mul for Rational {
impl ops::Div for Rational {
type Output = Self;
fn div(self, rhs: Self) -> Self::Output { Self(self.0 / rhs.0) }
fn div(self, rhs: Self) -> Self::Output {
if rhs.0.cmp0() == Ordering::Equal {
panic!("Divide by zero");
}
return Self(self.0 / rhs.0);
}
}
impl ops::Rem for Rational {
type Output = Self;
fn rem(self, rhs: Self) -> Self::Output {
if rhs.0.cmp0() == Ordering::Equal {
panic!("Divide by zero");
}
// TODO: Is there a cleaner way of implementing this?
let mut quotient = self.0 / &rhs.0;
quotient.rem_trunc_mut();
@ -276,12 +285,20 @@ impl ops::Mul<&Self> for Rational {
impl ops::Div<&Self> for Rational {
type Output = Self;
fn div(self, rhs: &Self) -> Self::Output { Self(self.0 / &rhs.0) }
fn div(self, rhs: &Self) -> Self::Output {
if rhs.0.cmp0() == Ordering::Equal {
panic!("Divide by zero");
}
return Self(self.0 / &rhs.0);
}
}
impl ops::Rem<&Self> for Rational {
type Output = Self;
fn rem(self, rhs: &Self) -> Self::Output {
if rhs.0.cmp0() == Ordering::Equal {
panic!("Divide by zero");
}
let mut quotient = self.0 / &rhs.0;
quotient.rem_trunc_mut();
quotient *= &rhs.0;
@ -314,11 +331,19 @@ impl ops::MulAssign for Rational {
}
impl ops::DivAssign for Rational {
fn div_assign(&mut self, rhs: Self) { self.0 /= rhs.0; }
fn div_assign(&mut self, rhs: Self) {
if rhs.0.cmp0() == Ordering::Equal {
panic!("Divide by zero");
}
self.0 /= rhs.0;
}
}
impl ops::RemAssign for Rational {
fn rem_assign(&mut self, rhs: Self) {
if rhs.0.cmp0() == Ordering::Equal {
panic!("Divide by zero");
}
self.0 /= &rhs.0;
self.0.rem_trunc_mut();
self.0 *= rhs.0;
@ -350,11 +375,19 @@ impl ops::MulAssign<&Self> for Rational {
}
impl ops::DivAssign<&Self> for Rational {
fn div_assign(&mut self, rhs: &Self) { self.0 /= &rhs.0; }
fn div_assign(&mut self, rhs: &Self) {
if rhs.0.cmp0() == Ordering::Equal {
panic!("Divide by zero");
}
self.0 /= &rhs.0;
}
}
impl ops::RemAssign<&Self> for Rational {
fn rem_assign(&mut self, rhs: &Self) {
if rhs.0.cmp0() == Ordering::Equal {
panic!("Divide by zero");
}
self.0 /= &rhs.0;
self.0.rem_trunc_mut();
self.0 *= &rhs.0;
@ -395,12 +428,20 @@ impl ops::Mul<Self> for &Rational {
impl ops::Div<Self> for &Rational {
type Output = Rational;
fn div(self, rhs: Self) -> Self::Output { Rational(rug::Rational::from(&self.0 / &rhs.0)) }
fn div(self, rhs: Self) -> Self::Output {
if rhs.0.cmp0() == Ordering::Equal {
panic!("Divide by zero");
}
return Rational(rug::Rational::from(&self.0 / &rhs.0));
}
}
impl ops::Rem<Self> for &Rational {
type Output = Rational;
fn rem(self, rhs: Self) -> Self::Output {
if rhs.0.cmp0() == Ordering::Equal {
panic!("Divide by zero");
}
let mut quotient = rug::Rational::from(&self.0 / &rhs.0);
quotient.rem_trunc_mut();
quotient *= &rhs.0;

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo)
* Copyright © 20212022 Lee Yingtong Li (RunasSudo)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -178,7 +178,8 @@ impl<N: Number, I: Iterator<Item=char>> BLTParser<N, I> {
for _ in 0..self.num_candidates {
let name = self.string()?;
self.election.candidates.push(Candidate {
name
name,
is_dummy: false,
});
}
let name = self.string()?;

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo)
* Copyright © 20212022 Lee Yingtong Li (RunasSudo)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -44,6 +44,7 @@ pub fn parse_reader<R: Read, N: Number>(reader: R, require_1: bool, require_sequ
col_map.insert(i, candidates.len());
candidates.push(Candidate {
name: cand_name.to_string(),
is_dummy: false,
});
}

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo)
* Copyright © 20212022 Lee Yingtong Li (RunasSudo)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -19,6 +19,7 @@ use ibig::UBig;
use sha2::{Digest, Sha256};
/// Deterministic random number generator using SHA256
#[derive(Clone)]
pub struct SHARandom<'r> {
seed: &'r str,
counter: usize,

View File

@ -201,7 +201,7 @@ where
}
/// Distribute the surplus of a given candidate according to the Gregory method, based on [STVOptions::surplus]
fn distribute_surplus<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, elected_candidate: &'a Candidate)
pub fn distribute_surplus<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, elected_candidate: &'a Candidate)
where
for<'r> &'r N: ops::Add<&'r N, Output=N>,
for<'r> &'r N: ops::Sub<&'r N, Output=N>,

View File

@ -28,6 +28,7 @@ use std::cmp::max;
use std::collections::HashMap;
/// Table describing vote transfers during a surplus distribution or exclusion
#[derive(Clone)]
pub struct TransferTable<'e, N: Number> {
/// Continuing candidates
pub hopefuls: Vec<&'e Candidate>,
@ -565,6 +566,7 @@ fn multiply_surpfrac<N: Number>(mut number: N, surpfrac_numer: &Option<N>, surpf
}
/// Column in a [TransferTable]
#[derive(Clone)]
pub struct TransferTableColumn<'e, N: Number> {
/// Value fraction of ballots counted in this column
pub value_fraction: N,
@ -599,6 +601,7 @@ impl<'e, N: Number> TransferTableColumn<'e, N> {
}
/// Cell in a [TransferTable], representing transfers to one candidate at a particular value
#[derive(Clone)]
pub struct TransferTableCell<N: Number> {
/// Ballots expressing a next preference for the continuing candidate
pub ballots: N,

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo)
* Copyright © 20212022 Lee Yingtong Li (RunasSudo)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -35,6 +35,7 @@ struct BallotInTree<'b, N: Number> {
}
/// Tree-packed ballot representation
#[derive(Clone)]
pub struct BallotTree<'t, N: Number> {
num_ballots: N,
ballots: Vec<BallotInTree<'t, N>>,

View File

@ -29,9 +29,8 @@ pub mod sample;
pub mod wasm;
use crate::constraints;
use crate::election::Election;
use crate::numbers::Number;
use crate::election::{Candidate, CandidateState, CountCard, CountState, StageKind, Vote};
use crate::election::{Candidate, CandidateState, CountCard, CountState, Election, RollbackState, StageKind, Vote};
use crate::sharandom::SHARandom;
use crate::ties::{self, TieStrategy};
@ -221,6 +220,7 @@ impl STVOptions {
if self.quota_mode != QuotaMode::DynamicByTotal { return Err(STVError::InvalidOptions("--surplus meek requires --quota-mode dynamic_by_total")); }
if self.transferable_only { return Err(STVError::InvalidOptions("--surplus meek is incompatible with --transferable-only")); }
if self.exclusion != ExclusionMethod::SingleStage { return Err(STVError::InvalidOptions("--surplus meek requires --exclusion single_stage")); }
if self.constraints_path.is_some() && self.constraint_mode == ConstraintMode::RepeatCount { return Err(STVError::InvalidOptions("--constraint-mode repeat_count requires a Gregory method for --surplus")); } // TODO: NYI?
}
if self.surplus == SurplusMethod::IHare || self.surplus == SurplusMethod::Hare {
if self.round_quota != Some(0) { return Err(STVError::InvalidOptions("--surplus ihare and --surplus hare require --round-quota 0")); }
@ -228,6 +228,7 @@ impl STVOptions {
if self.sample == SampleMethod::StratifyLR && self.sample_per_ballot { return Err(STVError::InvalidOptions("--sample stratify is incompatible with --sample-per-ballot")); }
//if self.sample == SampleMethod::StratifyFloor && self.sample_per_ballot { return Err(STVError::InvalidOptions("--sample stratify_floor is incompatible with --sample-per-ballot")); }
if self.sample_per_ballot && !self.immediate_elect { return Err(STVError::InvalidOptions("--sample-per-ballot is incompatible with --no-immediate-elect")); }
if self.constraints_path.is_some() && self.constraint_mode == ConstraintMode::RepeatCount { return Err(STVError::InvalidOptions("--constraint-mode repeat_count requires a Gregory method for --surplus")); } // TODO: NYI?
}
if self.subtract_nontransferable && !self.transferable_only { return Err(STVError::InvalidOptions("--subtract-nontransferable requires --transferable-only")) }
if self.min_threshold != "0" && self.defer_surpluses { return Err(STVError::InvalidOptions("--min-threshold is incompatible with --defer-surpluses (not yet implemented)")); } // TODO: NYI
@ -567,8 +568,8 @@ impl<S: AsRef<str>> From<S> for SampleMethod {
pub enum ConstraintMode {
/// Guard or doom candidates as soon as required to secure a conformant result
GuardDoom,
/// TODO: NYI
Rollback,
/// If constraints violated, exclude/reintroduce candidates as required and redistribute ballot papers
RepeatCount,
}
impl ConstraintMode {
@ -576,7 +577,7 @@ impl ConstraintMode {
fn describe(self) -> String {
match self {
ConstraintMode::GuardDoom => "--constraint-mode guard_doom",
ConstraintMode::Rollback => "--constraint-mode rollback",
ConstraintMode::RepeatCount => "--constraint-mode repeat_count",
}.to_string()
}
}
@ -585,7 +586,7 @@ impl<S: AsRef<str>> From<S> for ConstraintMode {
fn from(s: S) -> Self {
match s.as_ref() {
"guard_doom" => ConstraintMode::GuardDoom,
"rollback" => ConstraintMode::Rollback,
"repeat_count" => ConstraintMode::RepeatCount,
_ => panic!("Invalid --constraint-mode"),
}
}
@ -674,6 +675,13 @@ where
return Ok(true);
}
if let RollbackState::Normal = state.rollback_state {
} else if constraints::rollback_one_stage(state, opts)? {
elect_hopefuls(state, opts, true)?;
update_tiebreaks(state, opts);
return Ok(false);
}
// Attempt early bulk election
if opts.early_bulk_elect {
if bulk_elect(state, opts)? {
@ -727,20 +735,25 @@ where
}
/// See [next_preferences]
struct NextPreferencesResult<'a, N> {
candidates: HashMap<&'a Candidate, NextPreferencesEntry<'a, N>>,
exhausted: NextPreferencesEntry<'a, N>,
total_ballots: N,
pub struct NextPreferencesResult<'a, N> {
/// [NextPreferencesEntry] for each [Candidate]
pub candidates: HashMap<&'a Candidate, NextPreferencesEntry<'a, N>>,
/// [NextPreferencesEntry] for exhausted ballots
pub exhausted: NextPreferencesEntry<'a, N>,
/// Total weight of ballots examined
pub total_ballots: N,
}
/// See [next_preferences]
struct NextPreferencesEntry<'a, N> {
votes: Vec<Vote<'a, N>>,
num_ballots: N,
pub struct NextPreferencesEntry<'a, N> {
/// Votes recording a next preference for the candidate
pub votes: Vec<Vote<'a, N>>,
/// Weight of such ballots
pub num_ballots: N,
}
/// Count the given votes, grouping according to next available preference
fn next_preferences<'a, N: Number>(state: &CountState<'a, N>, votes: Vec<Vote<'a, N>>) -> NextPreferencesResult<'a, N> {
pub fn next_preferences<'a, N: Number>(state: &CountState<'a, N>, votes: Vec<Vote<'a, N>>) -> NextPreferencesResult<'a, N> {
let mut result = NextPreferencesResult {
candidates: HashMap::new(),
exhausted: NextPreferencesEntry {
@ -1015,6 +1028,12 @@ fn meets_vre<N: Number>(state: &CountState<N>, count_card: &CountCard<N>, opts:
///
/// Returns `true` if any candidates were elected.
fn elect_sure_winners<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> Result<bool, STVError> {
// Do not interrupt rolling back!!
if let RollbackState::Normal = state.rollback_state {
} else {
return Ok(false);
}
if state.num_elected >= state.election.seats {
return Ok(false);
}
@ -1057,7 +1076,7 @@ fn elect_sure_winners<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOp
let mut leading_hopefuls: Vec<&Candidate> = hopefuls.iter().take(num_vacancies).map(|(c, _)| *c).collect();
match constraints::try_constraints(state, &leading_hopefuls, CandidateState::Elected) {
match constraints::test_constraints_any_time(state, &leading_hopefuls, CandidateState::Elected) {
Ok(_) => {}
Err(_) => { return Ok(false); } // Bulk election conflicts with constraints
}
@ -1087,7 +1106,7 @@ fn elect_sure_winners<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOp
state.logger.log_smart(
"As they cannot now be overtaken, {} is elected to fill the remaining vacancy.",
"As they cannot now be overtaken, {} are elected to fill the remaining vacancies.",
vec![&candidate.name]
vec![candidate.name.as_str()] // rust-analyzer doesn't understand &String -> &str
);
leading_hopefuls.remove(leading_hopefuls.iter().position(|c| *c == candidate).unwrap());
@ -1127,6 +1146,22 @@ fn elect_hopefuls<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOption
max_cands[0]
};
if opts.constraint_mode == ConstraintMode::RepeatCount && state.election.constraints.is_some() {
if let Err((constraint, group)) = constraints::test_constraints_immediate(state, &[candidate], CandidateState::Elected) {
// This election would violate a constraint, so stop here
state.logger.log_smart(
"The election of {} now would violate constraints.",
"The election of {} now would violate constraints.",
vec![candidate.name.as_str()]
);
// Trigger rollback
constraints::init_repeat_count_rollback(state, constraint, group);
return Ok(elected);
}
}
let count_card = state.candidates.get_mut(candidate).unwrap();
count_card.state = CandidateState::Elected;
state.num_elected += 1;
@ -1139,7 +1174,7 @@ fn elect_hopefuls<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOption
state.logger.log_smart(
"{} meets the quota and is elected.",
"{} meet the quota and are elected.",
vec![&candidate.name]
vec![candidate.name.as_str()]
);
} else {
// Elected with vote required
@ -1147,7 +1182,7 @@ fn elect_hopefuls<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOption
state.logger.log_smart(
"{} meets the vote required and is elected.",
"{} meet the vote required and are elected.",
vec![&candidate.name]
vec![candidate.name.as_str()]
);
}
@ -1291,7 +1326,7 @@ fn do_bulk_elect<N: Number>(state: &mut CountState<N>, opts: &STVOptions, templa
state.logger.log_smart(
template1,
template2,
vec![&candidate.name]
vec![candidate.name.as_str()]
);
if constraints::update_constraints(state, opts) {
@ -1396,7 +1431,7 @@ fn hopefuls_below_threshold<'a, N: Number>(state: &CountState<'a, N>, opts: &STV
.collect();
// Do not exclude if this violates constraints
match constraints::try_constraints(state, &excluded_candidates, CandidateState::Excluded) {
match constraints::test_constraints_any_time(state, &excluded_candidates, CandidateState::Excluded) {
Ok(_) => { return excluded_candidates; }
Err(_) => { return Vec::new(); } // Bulk exclusion conflicts with constraints
}
@ -1438,7 +1473,7 @@ fn hopefuls_to_bulk_exclude<'a, N: Number>(state: &CountState<'a, N>, _opts: &ST
let try_exclude: Vec<&Candidate> = try_exclude.iter().map(|(c, _)| **c).collect();
// Do not exclude if this violates constraints
match constraints::try_constraints(state, &try_exclude, CandidateState::Excluded) {
match constraints::test_constraints_any_time(state, &try_exclude, CandidateState::Excluded) {
Ok(_) => {}
Err(_) => { break; } // Bulk exclusion conflicts with constraints
}
@ -1556,7 +1591,7 @@ where
}
/// Perform one stage of a candidate exclusion, according to [STVOptions::exclusion]
fn exclude_candidates<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, excluded_candidates: Vec<&'a Candidate>) -> Result<(), STVError>
pub fn exclude_candidates<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions, excluded_candidates: Vec<&'a Candidate>) -> Result<(), STVError>
where
for<'r> &'r N: ops::Sub<&'r N, Output=N>,
for<'r> &'r N: ops::Mul<&'r N, Output=N>,

View File

@ -18,7 +18,7 @@
#![allow(rustdoc::private_intra_doc_links)]
#![allow(unused_unsafe)] // Confuses cargo check
use crate::constraints::Constraints;
use crate::constraints::{self, Constraints};
use crate::election::{CandidateState, CountState, Election, StageKind};
//use crate::numbers::{DynNum, Fixed, GuardedFixed, NativeFloat64, Number, NumKind, Rational};
use crate::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational};
@ -91,16 +91,21 @@ macro_rules! impl_type {
/// Call [Constraints::from_con] and set [Election::constraints]
#[cfg_attr(feature = "wasm", wasm_bindgen)]
#[allow(non_snake_case)]
pub fn [<election_load_constraints_$type>](election: &mut [<Election$type>], text: String) {
pub fn [<election_load_constraints_$type>](election: &mut [<Election$type>], text: String, opts: &STVOptions) {
election.0.constraints = match Constraints::from_con(text.lines()) {
Ok(c) => Some(c),
Err(err) => wasm_error!("Constraint Syntax Error", err),
};
// Validate constraints
if let Err(err) = election.0.constraints.as_ref().unwrap().validate_constraints(election.0.candidates.len()) {
if let Err(err) = election.0.constraints.as_ref().unwrap().validate_constraints(election.0.candidates.len(), opts.0.constraint_mode) {
wasm_error!("Constraint Validation Error", err);
}
// Add dummy candidates if required
if opts.0.constraint_mode == stv::ConstraintMode::RepeatCount {
constraints::init_repeat_count(&mut election.0);
}
}
/// Wrapper for [stv::preprocess_election]
@ -327,7 +332,7 @@ pub fn describe_count<N: Number>(filename: String, election: &Election<N>, opts:
let mut result = String::from("<p>Count computed by OpenTally (revision ");
result.push_str(crate::VERSION);
let total_ballots = election.ballots.iter().fold(N::zero(), |acc, b| { acc + &b.orig_value });
result.push_str(&format!(r#"). Read {:.0} ballots from &lsquo;{}&rsquo; for election &lsquo;{}&rsquo;. There are {} candidates for {} vacancies. "#, total_ballots, filename, election.name, election.candidates.len(), election.seats));
result.push_str(&format!(r#"). Read {:.0} ballots from &lsquo;{}&rsquo; for election &lsquo;{}&rsquo;. There are {} candidates for {} vacancies. "#, total_ballots, filename, election.name, election.candidates.iter().filter(|c| !c.is_dummy).count(), election.seats));
let opts_str = opts.describe::<N>();
if !opts_str.is_empty() {
@ -348,6 +353,10 @@ pub fn init_results_table<N: Number>(election: &Election<N>, opts: &stv::STVOpti
}
for candidate in election.candidates.iter() {
if candidate.is_dummy {
continue;
}
if report_style == "votes_transposed" {
result.push_str(&format!(r#"<tr class="candidate transfers"><td class="candidate-name">{}</td></tr>"#, candidate.name));
} else {
@ -381,7 +390,7 @@ pub fn update_results_table<N: Number>(stage_num: usize, state: &CountState<N>,
// Insert borders to left of new exclusions in Wright STV
let classes_o; // Outer version
let classes_i; // Inner version
if opts.exclusion == stv::ExclusionMethod::Wright && matches!(state.title, StageKind::ExclusionOf(_)) {
if (opts.exclusion == stv::ExclusionMethod::Wright && matches!(state.title, StageKind::ExclusionOf(_))) || matches!(state.title, StageKind::Rollback) {
classes_o = r#" class="blw""#;
classes_i = r#"blw "#;
} else {
@ -395,6 +404,10 @@ pub fn update_results_table<N: Number>(stage_num: usize, state: &CountState<N>,
hide_xfers_trsp = true;
} else if opts.exclusion == stv::ExclusionMethod::Wright && matches!(state.title, StageKind::ExclusionOf(_)) {
hide_xfers_trsp = true;
} else if let StageKind::Rollback = state.title {
hide_xfers_trsp = true;
} else if let StageKind::BulkElection = state.title {
hide_xfers_trsp = true;
} else {
hide_xfers_trsp = false;
}
@ -403,7 +416,7 @@ pub fn update_results_table<N: Number>(stage_num: usize, state: &CountState<N>,
let kind_str = state.title.kind_as_string();
let title_str;
match &state.title {
StageKind::FirstPreferences | StageKind::SurplusesDistributed | StageKind::BulkElection => {
StageKind::FirstPreferences | StageKind::Rollback | StageKind::RollbackExhausted | StageKind::SurplusesDistributed | StageKind::BulkElection => {
title_str = format!("{}", state.title);
}
StageKind::SurplusOf(candidate) => {
@ -417,6 +430,9 @@ pub fn update_results_table<N: Number>(stage_num: usize, state: &CountState<N>,
title_str = candidates.iter().map(|c| &c.name).join(",<br>");
}
}
StageKind::BallotsOf(candidate) => {
title_str = candidate.name.clone();
}
};
match report_style {
@ -447,6 +463,10 @@ pub fn update_results_table<N: Number>(stage_num: usize, state: &CountState<N>,
}
for candidate in state.election.candidates.iter() {
if candidate.is_dummy {
continue;
}
let count_card = &state.candidates[candidate];
// TODO: REFACTOR THIS!!
@ -585,7 +605,7 @@ pub fn update_results_table<N: Number>(stage_num: usize, state: &CountState<N>,
}
// Calculate total votes
let mut total_vote = state.candidates.values().fold(N::zero(), |acc, cc| { acc + &cc.votes });
let mut total_vote = state.candidates.iter().filter_map(|(c, cc)| if c.is_dummy { None } else { Some(cc) }).fold(N::zero(), |acc, cc| { acc + &cc.votes });
total_vote += &state.exhausted.votes;
total_vote += &state.loss_fraction.votes;
@ -667,6 +687,10 @@ pub fn finalise_results_table<N: Number>(state: &CountState<N>, report_style: &s
// Candidate states
for candidate in state.election.candidates.iter() {
if candidate.is_dummy {
continue;
}
let count_card = &state.candidates[candidate];
if count_card.state == stv::CandidateState::Elected {
result.push(&format!(r#"<td{} class="bb elected">ELECTED {}</td>"#, rowspan, count_card.order_elected).into());

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo)
* Copyright © 20212022 Lee Yingtong Li (RunasSudo)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@ -220,7 +220,7 @@ where
/// Prompt the candidate for input, depending on CLI or WebAssembly target
#[cfg(not(target_arch = "wasm32"))]
fn prompt<'c, N: Number>(state: &CountState<N>, opts: &STVOptions, candidates: &[&'c Candidate], prompt_text: &str) -> Result<&'c Candidate, STVError> {
pub fn prompt<'c, N: Number>(state: &CountState<N>, opts: &STVOptions, candidates: &[&'c Candidate], prompt_text: &str) -> Result<&'c Candidate, STVError> {
// Show intrastage progress if required
if !state.logger.entries.is_empty() {
// Print stage details
@ -269,8 +269,9 @@ extern "C" {
fn get_user_input(s: &str) -> Option<String>;
}
/// Prompt the candidate for input, depending on CLI or WebAssembly target
#[cfg(target_arch = "wasm32")]
fn prompt<'c, N: Number>(state: &CountState<N>, opts: &STVOptions, candidates: &[&'c Candidate], prompt_text: &str) -> Result<&'c Candidate, STVError> {
pub fn prompt<'c, N: Number>(state: &CountState<N>, opts: &STVOptions, candidates: &[&'c Candidate], prompt_text: &str) -> Result<&'c Candidate, STVError> {
let mut message = String::new();
// Show intrastage progress if required