From 2793088e12181317db99e095b9da66e788e76057 Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Sun, 30 May 2021 18:28:39 +1000 Subject: [PATCH] Initial WebAssembly implementation --- .gitignore | 2 + Cargo.lock | 91 ++++++++++++++++++++++- Cargo.toml | 17 +++-- pkg/test.html | 49 +++++++++++++ src/election.rs | 8 +-- src/main.rs | 4 +- src/numbers/mod.rs | 11 ++- src/numbers/native.rs | 3 +- src/numbers/rational.rs | 5 +- src/stv/mod.rs | 3 + src/stv/wasm.rs | 156 ++++++++++++++++++++++++++++++++++++++++ tests/aec.rs | 2 +- 12 files changed, 331 insertions(+), 20 deletions(-) create mode 100644 pkg/test.html create mode 100644 src/stv/wasm.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..54954c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +/pkg/opentally.js +/pkg/opentally_bg.wasm diff --git a/Cargo.lock b/Cargo.lock index 7a559d4..540f068 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,12 +36,24 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c59e7af012c713f529e7a3ee57ce9b31ddd858d4b512923602f74608b009631" + [[package]] name = "byteorder" version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.0" @@ -74,13 +86,23 @@ dependencies = [ "syn", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d976903543e0c48546a91908f21588a680a8c8f984df9a5d69feccb2b2a211" +dependencies = [ + "cfg-if 0.1.10", + "wasm-bindgen", +] + [[package]] name = "crc32fast" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81156fece84ab6a9f2afdb109ce3ae577e42b1228441eded99bd77f627953b1a" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", ] [[package]] @@ -111,7 +133,7 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd3aec53de10fe96d7d8c565eb17f2c687bb5518a2ec453b5b1252964526abe0" dependencies = [ - "cfg-if", + "cfg-if 1.0.0", "crc32fast", "libc", "miniz_oxide", @@ -192,6 +214,15 @@ version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "789da6d93f1b866ffe175afc5322a4d76c038605a1c3319bb57b06967ca98a36" +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if 1.0.0", +] + [[package]] name = "memchr" version = "2.4.0" @@ -222,11 +253,13 @@ name = "opentally" version = "0.1.0" dependencies = [ "clap", + "console_error_panic_hook", "csv", "flate2", "git-version", "num-traits", "rug", + "wasm-bindgen", ] [[package]] @@ -356,6 +389,60 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +[[package]] +name = "wasm-bindgen" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54ee1d4ed486f78874278e63e4069fc1ab9f6a18ca492076ffb90c5eb2997fd" +dependencies = [ + "cfg-if 1.0.0", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b33f6a0694ccfea53d94db8b2ed1c3a8a4c86dd936b13b9f0a15ec4a451b900" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088169ca61430fe1e58b8096c24975251700e7b1f6fd91cc9d59b04fb9b18bd4" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2241542ff3d9f241f5e2cb6dd09b37efe786df8851c54957683a49f0987a97" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff876b8f18eed75a66cf49b65e7f967cb354a7aa16003fb55dbfd25b44b4f" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index c58b054..4762b3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,18 +4,27 @@ version = "0.1.0" authors = ["Lee Yingtong Li "] edition = "2018" +[lib] +crate-type = ["lib", "cdylib"] + [dependencies] -csv = "1.1.6" -flate2 = "1.0" git-version = "0.3.4" num-traits = "0.2" +wasm-bindgen = "0.2.74" -[dependencies.rug] +# Only for WebAssembly - include here for syntax highlighting +console_error_panic_hook = "0.1.6" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +csv = "1.1.6" +flate2 = "1.0" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.rug] version = "1.12" default-features = false features = ["integer", "rational", "float"] -[dependencies.clap] +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.clap] #version = "3.0.0-beta.2" # Bug 2279 git = "https://github.com/clap-rs/clap" branch = "master" diff --git a/pkg/test.html b/pkg/test.html new file mode 100644 index 0000000..7131010 --- /dev/null +++ b/pkg/test.html @@ -0,0 +1,49 @@ + + +
+ + + + + diff --git a/src/election.rs b/src/election.rs index c65a5bd..80220be 100644 --- a/src/election.rs +++ b/src/election.rs @@ -19,7 +19,6 @@ use crate::logger::Logger; use crate::numbers::Number; use std::collections::HashMap; -use std::io::{BufRead, Lines}; pub struct Election { pub seats: usize, @@ -28,9 +27,9 @@ pub struct Election { } impl Election { - pub fn from_blt(mut lines: Lines) -> Self { + pub fn from_blt>(mut lines: I) -> Self { // Read first line - let line = lines.next().expect("Unexpected EOF").expect("IO Error"); + let line = lines.next().expect("Unexpected EOF"); let mut bits = line.split(" "); let num_candidates = bits.next().expect("Syntax Error").parse().expect("Syntax Error"); let seats: usize = bits.next().expect("Syntax Error").parse().expect("Syntax Error"); @@ -44,7 +43,6 @@ impl Election { // Read ballots for line in &mut lines { - let line = line.expect("IO Error"); if line == "0" { break; } @@ -69,7 +67,7 @@ impl Election { // Read candidates for line in lines.take(num_candidates) { - let mut line = &line.expect("IO Error")[..]; + let mut line = &line[..]; if line.starts_with("\"") && line.ends_with("\"") { line = &line[1..line.len()-1]; } diff --git a/src/main.rs b/src/main.rs index 06ef182..6887d9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,10 +92,10 @@ fn main() { // Create and count election according to --numbers if cmd_opts.numbers == "rational" { - let election: Election = Election::from_blt(lines); + let election: Election = Election::from_blt(lines.map(|r| r.expect("IO Error").to_string()).into_iter()); count_election(election, cmd_opts); } else if cmd_opts.numbers == "float64" { - let election: Election = Election::from_blt(lines); + let election: Election = Election::from_blt(lines.map(|r| r.expect("IO Error").to_string()).into_iter()); count_election(election, cmd_opts); } } diff --git a/src/numbers/mod.rs b/src/numbers/mod.rs index 8e1281e..5240f81 100644 --- a/src/numbers/mod.rs +++ b/src/numbers/mod.rs @@ -16,17 +16,22 @@ */ mod native; + +#[cfg(not(target_arch = "wasm32"))] mod rational; use num_traits::{NumAssignRef, NumRef}; -use rug::{self, Assign}; use std::cmp::Ord; use std::fmt; use std::ops; +pub trait Assign { + fn assign(&mut self, src: Src); +} + //pub trait Number: NumRef + NumAssignRef + PartialOrd + Assign + Clone + fmt::Display where for<'a> &'a Self: RefNum<&'a Self> { -pub trait Number: NumRef + NumAssignRef + ops::Neg + Ord + Assign + Clone + fmt::Display where for<'a> Self: Assign<&'a Self>{ +pub trait Number: NumRef + NumAssignRef + ops::Neg + Ord + Assign + Clone + fmt::Display where for<'a> Self: Assign<&'a Self> { fn new() -> Self; fn from(n: usize) -> Self; @@ -42,4 +47,6 @@ pub trait Number: NumRef + NumAssignRef + ops::Neg + Ord + Assign + } pub use self::native::NativeFloat64; + +#[cfg(not(target_arch = "wasm32"))] pub use self::rational::Rational; diff --git a/src/numbers/native.rs b/src/numbers/native.rs index 083f753..d83c51e 100644 --- a/src/numbers/native.rs +++ b/src/numbers/native.rs @@ -15,10 +15,9 @@ * along with this program. If not, see . */ -use super::Number; +use super::{Assign, Number}; use num_traits::{Num, One, Zero}; -use rug::Assign; use std::cmp::{Ord, Ordering, PartialEq, PartialOrd}; use std::num::ParseIntError; diff --git a/src/numbers/rational.rs b/src/numbers/rational.rs index db4318e..8c5fb5f 100644 --- a/src/numbers/rational.rs +++ b/src/numbers/rational.rs @@ -15,10 +15,11 @@ * along with this program. If not, see . */ -use super::Number; +use super::{Assign, Number}; use num_traits::{Num, One, Zero}; -use rug::{self, Assign, ops::Pow, rational::ParseRationalError}; +use rug::{self, ops::Pow, rational::ParseRationalError}; +use rug::Assign as RugAssign; use std::cmp::{Ord, Ordering, PartialEq, PartialOrd}; use std::fmt; diff --git a/src/stv/mod.rs b/src/stv/mod.rs index 3cd5134..cb04448 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -17,6 +17,9 @@ #![allow(mutable_borrow_reservation_conflict)] +//#[cfg(target_arch = "wasm32")] +pub mod wasm; + use crate::numbers::Number; use crate::election::{Candidate, CandidateState, CountCard, CountState, Parcel, Vote}; diff --git a/src/stv/wasm.rs b/src/stv/wasm.rs new file mode 100644 index 0000000..e1d1908 --- /dev/null +++ b/src/stv/wasm.rs @@ -0,0 +1,156 @@ +/* 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, CandidateState, CountCard, CountState, CountStateOrRef, Election, StageResult}; +use crate::numbers::{NativeFloat64, Number}; +use crate::stv; + +extern crate console_error_panic_hook; + +use wasm_bindgen::prelude::wasm_bindgen; + +// Logging + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console)] + fn log(s: &str); +} +macro_rules! cprintln { + ($($t:tt)*) => ( + #[allow(unused_unsafe)] + unsafe { log(&format_args!($($t)*).to_string()) } + ) +} + +// Exported functions + +#[wasm_bindgen] +pub fn election_from_blt_float64(text: String) -> ElectionFloat64 { + // Install panic! hook + console_error_panic_hook::set_once(); + + let election: Election = Election::from_blt(text.lines().map(|s| s.to_string()).into_iter()); + return ElectionFloat64(election); +} + +#[wasm_bindgen] +pub fn count_init_float64(state: &mut CountStateFloat64, opts: &STVOptions) { + stv::count_init(&mut state.0, &opts.0); +} + +#[wasm_bindgen] +pub fn count_one_stage_float64(state: &mut CountStateFloat64, opts: &STVOptions) -> bool { + return stv::count_one_stage(&mut state.0, &opts.0); +} + +// Reporting + +fn print_candidates<'a, N: 'a + Number, I: Iterator)>>(candidates: I) { + for (candidate, count_card) in candidates { + if count_card.state == CandidateState::ELECTED { + cprintln!("- {}: {:.dps$} ({:.dps$}) - ELECTED {}", candidate.name, count_card.votes, count_card.transfers, count_card.order_elected, dps=2); + } else if count_card.state == CandidateState::EXCLUDED { + cprintln!("- {}: {:.dps$} ({:.dps$}) - Excluded {}", candidate.name, count_card.votes, count_card.transfers, -count_card.order_elected, dps=2); + } else { + cprintln!("- {}: {:.dps$} ({:.dps$})", candidate.name, count_card.votes, count_card.transfers, dps=2); + } + } +} + +fn print_stage(stage_num: usize, result: &StageResult) { + // Print stage details + match result.kind { + None => { cprintln!("{}. {}", stage_num, result.title); } + Some(kind) => { cprintln!("{}. {} {}", stage_num, kind, result.title); } + }; + cprintln!("{}", result.logs.join(" ")); + + let state = result.state.as_ref(); + + // Print candidates + let candidates = state.election.candidates.iter() + .map(|c| (c, state.candidates.get(c).unwrap())); + print_candidates(candidates); + + // Print summary rows + cprintln!("Exhausted: {:.dps$} ({:.dps$})", state.exhausted.votes, state.exhausted.transfers, dps=2); + cprintln!("Loss by fraction: {:.dps$} ({:.dps$})", state.loss_fraction.votes, state.loss_fraction.transfers, dps=2); + + let mut total_vote = state.candidates.values().fold(N::zero(), |acc, cc| { acc + &cc.votes }); + total_vote += &state.exhausted.votes; + total_vote += &state.loss_fraction.votes; + cprintln!("Total votes: {:.dps$}", total_vote, dps=2); + + cprintln!("Quota: {:.dps$}", state.quota, dps=2); + + cprintln!(""); +} + +#[wasm_bindgen] +pub fn make_and_print_result_float64(stage_num: usize, state: &CountStateFloat64) { + let result = StageResult { + kind: state.0.kind, + title: &state.0.title, + logs: state.0.logger.render(), + state: CountStateOrRef::from(&state.0), + }; + print_stage(stage_num, &result); +} + +// Wrapper structs +// Required as we cannot specify &'static in wasm-bindgen: issue #1187 + +#[wasm_bindgen] +pub struct CountStateFloat64(CountState<'static, NativeFloat64>); +#[wasm_bindgen] +impl CountStateFloat64 { + pub fn new(election: &ElectionFloat64) -> Self { + return CountStateFloat64(CountState::new(election.as_static())); + } +} + +#[wasm_bindgen] +pub struct ElectionFloat64(Election); +#[wasm_bindgen] +impl ElectionFloat64 { + pub fn seats(&self) -> usize { self.0.seats } + + fn as_static(&self) -> &'static Election { + // Need to have this as we cannot specify &'static in wasm-bindgen: issue #1187 + unsafe { + let ptr = &self.0 as *const Election; + &*ptr + } + } +} + +#[wasm_bindgen] +pub struct STVOptions(stv::STVOptions<'static>); +#[wasm_bindgen] +impl STVOptions { + pub fn new(round_votes: Option, exclusion: String) -> Self { + if exclusion == "one_round" { + return STVOptions(stv::STVOptions { + round_votes: round_votes, + exclusion: &"one_round", + }); + } else { + panic!("Unknown --exclusion"); + } + } +} diff --git a/tests/aec.rs b/tests/aec.rs index 7b240bd..17d90a9 100644 --- a/tests/aec.rs +++ b/tests/aec.rs @@ -48,7 +48,7 @@ fn aec_tas19_rational() { let lines = gz_reader.lines(); // Read BLT - let election: Election = Election::from_blt(lines); + let election: Election = Election::from_blt(lines.map(|r| r.expect("IO Error").to_string()).into_iter()); // TODO: Validate candidate names