diff --git a/html/index.js b/html/index.js index 87f14c6..bdb2549 100644 --- a/html/index.js +++ b/html/index.js @@ -70,6 +70,13 @@ worker.onmessage = function(evt) { } else if (evt.data.type === 'finalResultSummary') { divLogs2.insertAdjacentHTML('beforeend', evt.data.summary); document.getElementById('printPane').style.display = 'block'; + + } else if (evt.data.type === 'requireInput') { + let response = window.prompt(evt.data.message); + while (response === null) { + response = window.prompt(evt.data.message); + } + worker.postMessage({'type': 'userInput', 'response': response}); } } diff --git a/html/worker.js b/html/worker.js index f766283..67a7888 100644 --- a/html/worker.js +++ b/html/worker.js @@ -8,9 +8,10 @@ async function initWasm() { } initWasm(); +var numbers, election, opts, state, stageNum; + onmessage = function(evt) { if (evt.data.type === 'countElection') { - let numbers; if (evt.data.numbers === 'fixed') { numbers = 'Fixed'; wasm.fixed_set_dps(evt.data.decimals); @@ -23,14 +24,14 @@ onmessage = function(evt) { } // Init election - let election = wasm['election_from_blt_' + numbers](evt.data.electionData); + election = wasm['election_from_blt_' + numbers](evt.data.electionData); if (evt.data.normaliseBallots) { wasm['election_normalise_ballots_' + numbers](election); } // Init STV options - let opts = wasm.STVOptions.new.apply(null, evt.data.optsStr); + opts = wasm.STVOptions.new.apply(null, evt.data.optsStr); // Describe count postMessage({'type': 'describeCount', 'content': wasm['describe_count_' + numbers](evt.data.filePath, election, opts)}); @@ -39,23 +40,54 @@ onmessage = function(evt) { postMessage({'type': 'initResultsTable', 'content': wasm['init_results_table_' + numbers](election, opts)}); // Step election - let state = wasm['CountState' + numbers].new(election); + state = wasm['CountState' + numbers].new(election); wasm['count_init_' + numbers](state, opts); postMessage({'type': 'updateResultsTable', 'result': wasm['update_results_table_' + numbers](1, state, opts)}); postMessage({'type': 'updateStageComments', 'comment': wasm['update_stage_comments_' + numbers](state)}); - for (let stageNum = 2;; stageNum++) { + stageNum = 2; + + resume_count(); + + } else if (evt.data.type == 'userInput') { + user_input_buffer = evt.data.response; + resume_count(); + } +} + +function resume_count() { + for (;; stageNum++) { + try { let isDone = wasm['count_one_stage_' + numbers](state, opts); if (isDone) { break; } - - postMessage({'type': 'updateResultsTable', 'result': wasm['update_results_table_' + numbers](stageNum, state, opts)}); - postMessage({'type': 'updateStageComments', 'comment': wasm['update_stage_comments_' + numbers](state)}); + } catch (ex) { + if (ex === "RequireInput") { + return; + } else { + throw ex; + } } - postMessage({'type': 'updateResultsTable', 'result': wasm['finalise_results_table_' + numbers](state)}); - postMessage({'type': 'finalResultSummary', 'summary': wasm['final_result_summary_' + numbers](state)}); + postMessage({'type': 'updateResultsTable', 'result': wasm['update_results_table_' + numbers](stageNum, state, opts)}); + postMessage({'type': 'updateStageComments', 'comment': wasm['update_stage_comments_' + numbers](state)}); + } + + postMessage({'type': 'updateResultsTable', 'result': wasm['finalise_results_table_' + numbers](state)}); + postMessage({'type': 'finalResultSummary', 'summary': wasm['final_result_summary_' + numbers](state)}); +} + +var user_input_buffer = null; + +function read_user_input_buffer(message) { + if (user_input_buffer === null) { + postMessage({'type': 'requireInput', 'message': message}); + return null; + } else { + let user_input = user_input_buffer; + user_input_buffer = null; + return user_input; } } diff --git a/src/lib.rs b/src/lib.rs index 0d2bd97..efda35e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod election; pub mod logger; pub mod numbers; pub mod stv; +pub mod ties; use git_version::git_version; use wasm_bindgen::prelude::wasm_bindgen; diff --git a/src/main.rs b/src/main.rs index 187591e..90a1048 100644 --- a/src/main.rs +++ b/src/main.rs @@ -208,7 +208,7 @@ where loop { let is_done = stv::count_one_stage(&mut state, &stv_opts); - if is_done { + if is_done.unwrap() { break; } stage_num += 1; diff --git a/src/stv/mod.rs b/src/stv/mod.rs index cf3c42d..5335347 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -22,6 +22,7 @@ pub mod wasm; use crate::numbers::Number; use crate::election::{Candidate, CandidateState, CountCard, CountState, Parcel, Vote}; +use crate::ties::TieStrategy; use itertools::Itertools; use wasm_bindgen::prelude::wasm_bindgen; @@ -264,13 +265,19 @@ impl ExclusionMethod { } } +#[wasm_bindgen] +#[derive(Debug)] +pub enum STVError { + RequireInput, +} + pub fn count_init(mut state: &mut CountState<'_, N>, opts: &STVOptions) { distribute_first_preferences(&mut state); calculate_quota(&mut state, opts); elect_meeting_quota(&mut state, opts); } -pub fn count_one_stage<'a, N: Number>(mut state: &mut CountState<'a, N>, opts: &STVOptions) -> bool +pub fn count_one_stage<'a, N: Number>(mut state: &mut CountState<'a, N>, opts: &STVOptions) -> Result where for<'r> &'r N: ops::Sub<&'r N, Output=N>, for<'r> &'r N: ops::Div<&'r N, Output=N>, @@ -281,36 +288,36 @@ where // Finish count if finished_before_stage(&state) { - return true; + return Ok(true); } // Continue exclusions if continue_exclusion(&mut state, &opts) { calculate_quota(&mut state, opts); elect_meeting_quota(&mut state, opts); - return false; + return Ok(false); } // Distribute surpluses - if distribute_surpluses(&mut state, &opts) { + if distribute_surpluses(&mut state, &opts)? { calculate_quota(&mut state, opts); elect_meeting_quota(&mut state, opts); - return false; + return Ok(false); } // Attempt bulk election if bulk_elect(&mut state) { - return false; + return Ok(false); } // Exclude lowest hopeful - if exclude_hopefuls(&mut state, &opts) { + if exclude_hopefuls(&mut state, &opts)? { calculate_quota(&mut state, opts); elect_meeting_quota(&mut state, opts); - return false; + return Ok(false); } - todo!(); + panic!("Count incomplete but unable to proceed"); } struct NextPreferencesResult<'a, N> { @@ -596,14 +603,15 @@ where return true; } -fn distribute_surpluses(state: &mut CountState, opts: &STVOptions) -> bool +fn distribute_surpluses(state: &mut CountState, opts: &STVOptions) -> Result where for<'r> &'r N: ops::Sub<&'r N, Output=N>, for<'r> &'r N: ops::Div<&'r N, Output=N>, for<'r> &'r N: ops::Neg { let quota = state.quota.as_ref().unwrap(); - let mut has_surplus: Vec<(&&Candidate, &CountCard)> = state.candidates.iter() + let mut has_surplus: Vec<(&Candidate, &CountCard)> = state.election.candidates.iter() // Present in order in case of tie + .map(|c| (c, state.candidates.get(c).unwrap())) .filter(|(_, cc)| &cc.votes > quota) .collect(); let total_surpluses = has_surplus.iter() @@ -614,28 +622,37 @@ where if opts.defer_surpluses { if can_defer_surpluses(state, opts, &total_surpluses) { state.logger.log_literal(format!("Distribution of surpluses totalling {:.dps$} votes will be deferred.", total_surpluses, dps=opts.pp_decimals)); - return false; + return Ok(false); } } match opts.surplus_order { SurplusOrder::BySize => { // Compare b with a to sort high-low - has_surplus.sort_unstable_by(|a, b| b.1.votes.partial_cmp(&a.1.votes).unwrap()); + has_surplus.sort_by(|a, b| b.1.votes.partial_cmp(&a.1.votes).unwrap()); } SurplusOrder::ByOrder => { - has_surplus.sort_unstable_by(|a, b| a.1.order_elected.partial_cmp(&b.1.order_elected).unwrap()); + has_surplus.sort_by(|a, b| a.1.order_elected.partial_cmp(&b.1.order_elected).unwrap()); } } // Distribute top candidate's surplus - // TODO: Handle ties - let elected_candidate = has_surplus.first_mut().unwrap().0; + let elected_candidate; + + // Handle ties + if has_surplus.len() > 1 && has_surplus[0].1.votes == has_surplus[1].1.votes { + let max_votes = &has_surplus[0].1.votes; + let has_surplus = has_surplus.into_iter().filter_map(|(c, cc)| if &cc.votes == max_votes { Some(c) } else { None }).collect(); + elected_candidate = TieStrategy::Prompt.choose_highest(has_surplus)?; + } else { + elected_candidate = has_surplus.first_mut().unwrap().0; + } + distribute_surplus(state, &opts, elected_candidate); - return true; + return Ok(true); } - return false; + return Ok(false); } /// Return the denominator of the transfer value @@ -945,7 +962,7 @@ fn hopefuls_to_bulk_exclude<'a, N: Number>(state: &CountState<'a, N>, _opts: &ST return excluded_candidates; } -fn exclude_hopefuls<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> bool +fn exclude_hopefuls<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> Result where for<'r> &'r N: ops::Div<&'r N, Output=N>, { @@ -958,15 +975,22 @@ where // Exclude lowest ranked candidate if excluded_candidates.len() == 0 { - let mut hopefuls: Vec<(&&Candidate, &CountCard)> = state.candidates.iter() + let mut hopefuls: Vec<(&Candidate, &CountCard)> = state.election.candidates.iter() // Present in order in case of tie + .map(|c| (c, state.candidates.get(c).unwrap())) .filter(|(_, cc)| cc.state == CandidateState::Hopeful) .collect(); // Sort by votes - // TODO: Handle ties - hopefuls.sort_unstable_by(|a, b| a.1.votes.partial_cmp(&b.1.votes).unwrap()); + hopefuls.sort_by(|a, b| a.1.votes.partial_cmp(&b.1.votes).unwrap()); - excluded_candidates = vec![&hopefuls.first().unwrap().0]; + // Handle ties + if hopefuls.len() > 1 && hopefuls[0].1.votes == hopefuls[1].1.votes { + let min_votes = &hopefuls[0].1.votes; + let hopefuls = hopefuls.into_iter().filter_map(|(c, cc)| if &cc.votes == min_votes { Some(c) } else { None }).collect(); + excluded_candidates = vec![TieStrategy::Prompt.choose_lowest(hopefuls)?]; + } else { + excluded_candidates = vec![&hopefuls.first().unwrap().0]; + } } let mut names: Vec<&str> = excluded_candidates.iter().map(|c| c.name.as_str()).collect(); @@ -981,7 +1005,7 @@ where exclude_candidates(state, opts, excluded_candidates); - return true; + return Ok(true); } fn continue_exclusion<'a, N: Number>(state: &mut CountState<'a, N>, opts: &STVOptions) -> bool @@ -990,7 +1014,7 @@ where { // Cannot filter by raw vote count, as candidates may have 0.00 votes but still have recorded ballot papers let mut excluded_with_votes: Vec<(&&Candidate, &CountCard)> = state.candidates.iter() - //.filter(|(_, cc)| cc.state == CandidateState::Excluded && !cc.votes.is_zero()) + //.filter(|(_, cc)| cc.state == CandidateState::EXCLUDED && !cc.votes.is_zero()) .filter(|(_, cc)| cc.state == CandidateState::Excluded && cc.parcels.iter().any(|p| p.len() > 0)) .collect(); diff --git a/src/stv/wasm.rs b/src/stv/wasm.rs index 9c7efcf..d9213db 100644 --- a/src/stv/wasm.rs +++ b/src/stv/wasm.rs @@ -22,7 +22,7 @@ use crate::stv; extern crate console_error_panic_hook; use js_sys::Array; -use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; // Init @@ -61,8 +61,11 @@ macro_rules! impl_type { #[wasm_bindgen] #[allow(non_snake_case)] - pub fn [](state: &mut [], opts: &stv::STVOptions) -> bool { - return stv::count_one_stage(&mut state.0, &opts); + pub fn [](state: &mut [], opts: &stv::STVOptions) -> Result { + match stv::count_one_stage(&mut state.0, &opts) { + Ok(v) => Ok(v), + Err(stv::STVError::RequireInput) => Err("RequireInput".into()), + } } // Reporting diff --git a/src/ties.rs b/src/ties.rs new file mode 100644 index 0000000..b011abe --- /dev/null +++ b/src/ties.rs @@ -0,0 +1,124 @@ +/* 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 crate::election::Candidate; +use crate::stv::STVError; + +#[allow(unused_imports)] +use wasm_bindgen::prelude::wasm_bindgen; + +#[allow(unused_imports)] +use std::io::{stdin, stdout, Write}; + +pub enum TieStrategy<'s> { + Forwards, + Backwards, + Random(&'s str), + Prompt, +} + +impl<'s> TieStrategy<'s> { + pub fn choose_highest<'c>(&self, candidates: Vec<&'c Candidate>) -> Result<&'c Candidate, STVError> { + match self { + Self::Forwards => { todo!() } + Self::Backwards => { todo!() } + Self::Random(_seed) => { todo!() } + Self::Prompt => { + return prompt(candidates); + } + } + } + + pub fn choose_lowest<'c>(&self, candidates: Vec<&'c Candidate>) -> Result<&'c Candidate, STVError> { + match self { + Self::Forwards => { todo!() } + Self::Backwards => { todo!() } + Self::Random(_seed) => { + return self.choose_highest(candidates); + } + Self::Prompt => { + return self.choose_highest(candidates); + } + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn prompt<'c>(candidates: Vec<&'c Candidate>) -> Result<&'c Candidate, STVError> { + println!("Multiple tied candidates:"); + for (i, candidate) in candidates.iter().enumerate() { + println!("{}. {}", i + 1, candidate.name); + } + let mut buffer = String::new(); + loop { + print!("Which candidate to select? [1-{}] ", candidates.len()); + stdout().flush().expect("IO Error"); + stdin().read_line(&mut buffer).expect("IO Error"); + match buffer.trim().parse::() { + Ok(val) => { + if val >= 1 && val <= candidates.len() { + println!(); + return Ok(candidates[val - 1]); + } else { + println!("Invalid selection"); + continue; + } + } + Err(_) => { + println!("Invalid selection"); + continue; + } + } + } +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +extern "C" { + fn read_user_input_buffer(s: &str) -> Option; +} + +#[cfg(target_arch = "wasm32")] +fn prompt<'c>(candidates: Vec<&'c Candidate>) -> Result<&'c Candidate, STVError> { + let mut message = String::from("Multiple tied candidates:\n"); + for (i, candidate) in candidates.iter().enumerate() { + message.push_str(&format!("{}. {}\n", i + 1, candidate.name)); + } + message.push_str(&format!("Which candidate to select? [1-{}] ", candidates.len())); + + match read_user_input_buffer(&message) { + Some(response) => { + match response.trim().parse::() { + Ok(val) => { + if val >= 1 && val <= candidates.len() { + return Ok(candidates[val - 1]); + } else { + let _ = read_user_input_buffer(&message); + return Err(STVError::RequireInput); + } + } + Err(_) => { + let _ = read_user_input_buffer(&message); + return Err(STVError::RequireInput); + } + } + } + None => { + return Err(STVError::RequireInput); + } + } +} diff --git a/tests/ers97.rs b/tests/ers97.rs index d2c21c1..b7fd519 100644 --- a/tests/ers97.rs +++ b/tests/ers97.rs @@ -83,7 +83,7 @@ fn ers97_rational() { while stage_num < stage { // Step through stages // Assert count not yet done - assert_eq!(stv::count_one_stage(&mut state, &stv_opts), false); + assert_eq!(stv::count_one_stage(&mut state, &stv_opts).unwrap(), false); stage_num += 1; } diff --git a/tests/scotland.rs b/tests/scotland.rs index d815218..dd6a40e 100644 --- a/tests/scotland.rs +++ b/tests/scotland.rs @@ -119,11 +119,11 @@ fn scotland_linn07_fixed5() { } } - assert_eq!(stv::count_one_stage(&mut state, &stv_opts), false); + assert_eq!(stv::count_one_stage(&mut state, &stv_opts).unwrap(), false); stage_num += 1; } - assert_eq!(stv::count_one_stage(&mut state, &stv_opts), true); + assert_eq!(stv::count_one_stage(&mut state, &stv_opts).unwrap(), true); } fn get_cand_stage(candidate: &Element, idx: usize) -> &Element { diff --git a/tests/utils/mod.rs b/tests/utils/mod.rs index 81b9bff..df76276 100644 --- a/tests/utils/mod.rs +++ b/tests/utils/mod.rs @@ -69,7 +69,7 @@ where while stage_num < stage { // Step through stages // Assert count not yet done - assert_eq!(stv::count_one_stage(&mut state, &stv_opts), false); + assert_eq!(stv::count_one_stage(&mut state, &stv_opts).unwrap(), false); stage_num += 1; } validate_stage(idx, &state, &records);