diff --git a/README.md b/README.md index 1048edf..0d72cb7 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ OpenTally is highly customisable, including options for: * calculations using fixed-point arithmetic, guarded fixed-point ([quasi-exact](http://www.votingmatters.org.uk/ISSUE24/I24P2.pdf)) or exact rational numbers * different tie breaking rules (backwards, random, manual) with auditable deterministic random number generation * multiple constraints (e.g. affirmative action rules) +* equal rankings ## Online usage diff --git a/docs/blt.md b/docs/blt.md index 12db92a..e5a5a51 100644 --- a/docs/blt.md +++ b/docs/blt.md @@ -9,9 +9,9 @@ The file format is as follows: -2 3 1 3 4 0 4 1 3 2 0 -2 4 1 3 0 +2 4 1=3 0 1 2 0 -2 2 4 3 1 0 +2 2=4=3 1 0 1 3 4 2 0 0 "Adam" @@ -25,7 +25,7 @@ The first line (`4 2`) indicates that there are 4 candidates for 2 vacancies. The second line (`-2`), which is optional, indicates that the 2nd candidate (Basil) has withdrawn. Multiple withdrawn candidates may be specified on this line, e.g. `-2 -3 -4`. -The third line (second, if there are no withdrawn candidates) begins the ballot data. `3 1 3 4 0` indicates that there were 3 ballots which voted, in order of preference, for the 1st candidate (Adam), then the 3rd candidate (Charlotte), then the 4th candidate (Donald). A `0` optionally indicates the end of the list of preferences. +The third line (second, if there are no withdrawn candidates) begins the ballot data. `3 1 3 4 0` indicates that there were 3 ballots which voted, in order of preference, for the 1st candidate (Adam), then the 3rd candidate (Charlotte), then the 4th candidate (Donald). An `=` indicates that multiple candidates were ranked at the same preference. A `0` optionally indicates the end of the list of preferences. The end of the list of ballots must be indicated with a single `0`. diff --git a/html/index.js b/html/index.js index 647ef49..a4e17e6 100644 --- a/html/index.js +++ b/html/index.js @@ -29,7 +29,7 @@ var tblResult = document.getElementById('result'); var divLogs2 = document.getElementById('resultLogs2'); var olStageComments; -var worker = new Worker('worker.js'); +var worker = new Worker('worker.js?v=GITVERSION'); worker.onmessage = function(evt) { if (evt.data.type === 'init') { diff --git a/html/worker.js b/html/worker.js index 3234e3c..1279bf2 100644 --- a/html/worker.js +++ b/html/worker.js @@ -1,4 +1,4 @@ -importScripts('opentally.js'); +importScripts('opentally.js?v=GITVERSION'); var wasm = wasm_bindgen; var wasmRaw; @@ -9,7 +9,7 @@ const DATA_START = DATA_ADDR + 8; const DATA_END = 50 * 1024; // Needs to be increased compared with Asyncify default async function initWasm() { - wasmRaw = await wasm_bindgen('opentally_async.wasm'); + wasmRaw = await wasm_bindgen('opentally_async.wasm?v=GITVERSION'); new Int32Array(wasmRaw.memory.buffer, DATA_ADDR).set([DATA_START, DATA_END]); diff --git a/src/election.rs b/src/election.rs index fd67ab2..0d8b46d 100644 --- a/src/election.rs +++ b/src/election.rs @@ -24,6 +24,8 @@ use crate::stv::{QuotaMode, STVOptions}; #[cfg(not(target_arch = "wasm32"))] use rkyv::{Archive, Archived, Deserialize, Fallible, Resolver, Serialize, with::{ArchiveWith, DeserializeWith, SerializeWith}}; +use itertools::Itertools; + use std::cmp::max; use std::collections::HashMap; @@ -414,11 +416,27 @@ impl Ballot { for preference in self.preferences.iter() { if preference.len() == 1 { + // Single preference so just add to the end of existing preferences for minivoter in minivoters.iter_mut() { minivoter.push(preference.clone()); } } else { - todo!(); + // Equal ranking + // Get all possible permutations + let permutations: Vec> = preference.iter().copied().permutations(preference.len()).collect(); + + // Split into new "minivoters" for each possible permutation + let mut new_minivoters = Vec::with_capacity(minivoters.len() * permutations.len()); + for permutation in permutations { + for minivoter in minivoters.iter() { + let mut new_minivoter = minivoter.clone(); + for p in permutation.iter() { + new_minivoter.push(vec![*p]); + } + new_minivoters.push(new_minivoter); + } + } + minivoters = new_minivoters; } } diff --git a/src/parser/blt.rs b/src/parser/blt.rs index 505aafd..a58e3d2 100644 --- a/src/parser/blt.rs +++ b/src/parser/blt.rs @@ -144,13 +144,19 @@ impl> BLTParser { self.delimiter_not_nl(); // Read preferences - let mut preferences = Vec::new(); + let mut preferences: Vec> = Vec::new(); loop { if self.lookahead() == '0' || self.lookahead() == '\n' { // End of preferences self.accept(); break; + } else if self.lookahead() == '=' { + // Equal preference + self.accept(); + preferences.last_mut().unwrap().push(self.usize()? - 1); + self.delimiter_not_nl(); } else { + // No equal preference preferences.push(vec![self.usize()? - 1]); self.delimiter_not_nl(); } diff --git a/src/parser/csp.rs b/src/parser/csp.rs index 30d233a..9761caa 100644 --- a/src/parser/csp.rs +++ b/src/parser/csp.rs @@ -19,6 +19,7 @@ use crate::election::{Ballot, Candidate, Election}; use crate::numbers::Number; use csv::ReaderBuilder; +use itertools::Itertools; use std::collections::HashMap; use std::io::Read; @@ -82,12 +83,21 @@ pub fn parse_reader(reader: R) -> Election { } // Sort by ranking - // FIXME: Handle equal rankings and skipped rankings - preferences.sort_by(|a, b| a.0.cmp(&b.0)); + let mut unique_rankings: Vec = preferences.iter().map(|(r, _)| *r).unique().collect(); + unique_rankings.sort(); + + let mut sorted_preferences = Vec::with_capacity(preferences.len()); + for ranking in unique_rankings { + // Filter for preferences at this ranking + let prefs_this_ranking: Vec = preferences.iter() + .filter_map(|(r, i)| if *r == ranking { Some(**i) } else { None }) + .collect(); + sorted_preferences.push(prefs_this_ranking); + } ballots.push(Ballot { orig_value: value, - preferences: preferences.into_iter().map(|(_, i)| vec![*i]).collect(), + preferences: sorted_preferences, }); } diff --git a/tests/data/equal_ranks.blt b/tests/data/equal_ranks.blt new file mode 100644 index 0000000..c83ac79 --- /dev/null +++ b/tests/data/equal_ranks.blt @@ -0,0 +1,15 @@ +# Comment: Test data featuring equal rankings +# Source: RunasSudo +# Contributor: RunasSudo +7 4 +1 1 2=3=4 5 6=7 0 +1 5=6=7 0 +0 +"Candidate 1" +"Candidate 2" +"Candidate 3" +"Candidate 4" +"Candidate 5" +"Candidate 6" +"Candidate 7" +"Test data featuring equal rankings" diff --git a/tests/equal_ranks.rs b/tests/equal_ranks.rs new file mode 100644 index 0000000..4b6ed6e --- /dev/null +++ b/tests/equal_ranks.rs @@ -0,0 +1,28 @@ +/* OpenTally: Open-source election vote counting + * Copyright © 2021 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 . + */ + +use opentally::election::Election; +use opentally::numbers::Rational; +use opentally::parser; + +#[test] +fn parse_equal_ranks() { + let mut election: Election = parser::blt::parse_path("tests/data/equal_ranks.blt").expect("Parse Error"); + election.realise_equal_rankings(); + + assert_eq!(election.ballots.len(), 18); +}