diff --git a/build.sh b/build.sh index 02ac805..4d3d409 100755 --- a/build.sh +++ b/build.sh @@ -1,2 +1,16 @@ #!/bin/env bash -transcrypt pyRCV2.transcrypt && rollup __target__/pyRCV2.transcrypt.js -o bundle.js --name py -f iife --no-treeshake +# Transcrypt +transcrypt $@ --nomin pyRCV2.transcrypt || exit 1 + +# Patch next() to accept optional argument +perl -0777 -i -pe 's#function py_next \(iterator\) {#function py_next \(iterator, def = undefined\) { try { return py_next_orig \(iterator\); } catch \(exception\) { if \(def !== "undefined"\) { return def; } else { throw exception; } } } function py_next_orig \(iterator\) {#g' __target__/org.transcrypt.__runtime__.js || exit 1 +# Patch sum() to accept optional argument +perl -0777 -i -pe 's#export function sum \(iterable\) {\n let result = 0;#export function sum \(iterable, result = 0\) { for \(let item of iterable\) { result = __add__\(result, item\); } return result;#g' __target__/org.transcrypt.__runtime__.js || exit 1 +# Patch sort() to use operator overloading +perl -0777 -i -pe 's#key \(a\) > key \(b\) \?#__gt__\(key \(a\), key \(b\)\) \?#g' __target__/org.transcrypt.__runtime__.js || exit 1 + +# Roll up +rollup __target__/pyRCV2.transcrypt.js -o bundle.js --name py -f iife --no-treeshake || exit 1 + +# Patch +perl -0777 -i -pe "s#'__index0__'#__index0__#g" bundle.js || exit 1 diff --git a/pyRCV2/blt.py b/pyRCV2/blt.py index 7eb754b..2747936 100644 --- a/pyRCV2/blt.py +++ b/pyRCV2/blt.py @@ -14,8 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from pyRCV2.model import * -from pyRCV2.numbers import * +from pyRCV2.model import Ballot, Candidate, Election +from pyRCV2.numbers import Num class BLTException(Exception): pass @@ -47,11 +47,11 @@ def readBLT(data): # Read candidates for k in range(j + 1, j + 1 + num_candidates): - election.candidates.append(Candidate(lines[k].strip('"'))) + election.candidates.append(Candidate(lines[k][1:-1])) # Read name if j + 1 + num_candidates < len(lines): - election.name = lines[j + 1 + num_candidates].strip('"') + election.name = lines[j + 1 + num_candidates][1:-1] # Any additional data? if len(lines) > j + 2 + num_candidates and len(lines[j + 2 + num_candidates]) > 0: diff --git a/pyRCV2/method/STVCCounter.py b/pyRCV2/method/STVCCounter.py new file mode 100644 index 0000000..c77e722 --- /dev/null +++ b/pyRCV2/method/STVCCounter.py @@ -0,0 +1,180 @@ +# pyRCV2: Preferential vote counting +# Copyright © 2020 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 . + +__pragma__ = lambda x: None + +from pyRCV2.model import CandidateState, CountCard, CountCompleted, CountStepResult +from pyRCV2.numbers import Num +from pyRCV2.safedict import SafeDict + +class STVCCounter: + """Count an STV election using pyRCV STV-C rules""" + + def __init__(self, election): + self.election = election + + def reset(self): + self.candidates = SafeDict([(c, CountCard()) for c in self.election.candidates]) + self.exhausted = CountCard() + + # Withdraw candidates + for candidate in self.election.withdrawn: + __pragma__('opov') + self.candidates[candidate].state = CandidateState.WITHDRAWN + __pragma__('noopov') + + # Distribute first preferences + for ballot in self.election.ballots: + __pragma__('opov') + candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None) + + if candidate is not None: + self.candidates[candidate].transfers += ballot.value + self.candidates[candidate].ballots.append((ballot, ballot.value)) + else: + self.exhausted.transfers += ballot.value + self.exhausted.ballots.append((ballot, ballot.value)) + __pragma__('noopov') + + self.compute_quota() + self.elect_meeting_quota() + + return CountStepResult( + 'First preferences', + self.candidates, + self.exhausted, + self.quota + ) + + def step(self): + # Step count cards + for candidate, count_card in self.candidates.items(): + count_card.step() + self.exhausted.step() + + # Have sufficient candidates been elected? + if sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED or cc.state == CandidateState.ELECTED) >= self.election.seats: + return CountCompleted() + + # Are there just enough candidates to fill all the seats + if sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED or cc.state == CandidateState.ELECTED or cc.state == CandidateState.HOPEFUL) <= self.election.seats: + # Declare elected all remaining candidates + for candidate, count_card in self.candidates.items(): + if count_card.state == CandidateState.HOPEFUL: + count_card.state = CandidateState.PROVISIONALLY_ELECTED + + return CountStepResult( + 'Bulk election', + self.candidates, + self.exhausted, + self.quota + ) + + # Do surpluses need to be distributed? + __pragma__('opov') + has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED and cc.votes > self.quota] + __pragma__('noopov') + + if len(has_surplus) > 0: + # Distribute surpluses in order of size + has_surplus.sort(lambda x: x[1].votes, True) + candidate, count_card = has_surplus[0] + count_card.state = CandidateState.ELECTED + + __pragma__('opov') + surplus = count_card.votes - self.quota + transfer_value = surplus / self.quota + count_card.transfers -= surplus + __pragma__('noopov') + + # Transfer surplus + for ballot, ballot_value in count_card.ballots: + __pragma__('opov') + new_value = ballot_value * transfer_value + candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None) + + if candidate is not None: + self.candidates[candidate].transfers += new_value + self.candidates[candidate].ballots.append((ballot, new_value)) + else: + self.exhausted.transfers += new_value + self.exhausted.ballots.append((ballot, new_value)) + __pragma__('noopov') + + # Declare any candidates meeting the quota as a result of surpluses + self.compute_quota() + self.elect_meeting_quota() + + return CountStepResult( + 'Surplus', + self.candidates, + self.exhausted, + self.quota + ) + + # Insufficient winners and no surpluses to distribute + # Exclude the lowest ranked hopeful + hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL] + hopefuls.sort(lambda x: x[1].votes) + + # TODO: Handle ties + candidate, count_card = hopefuls[0] + count_card.state = CandidateState.EXCLUDED + __pragma__('opov') + count_card.transfers -= count_card.votes + __pragma__('noopov') + + # Exclude this candidate + for ballot, ballot_value in count_card.ballots: + __pragma__('opov') + candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None) + + if candidate is not None: + self.candidates[candidate].transfers += ballot_value + self.candidates[candidate].ballots.append((ballot, ballot_value)) + else: + self.exhausted.transfers += ballot_value + self.exhausted.ballots.append((ballot, ballot_value)) + __pragma__('noopov') + + # Declare any candidates meeting the quota as a result of exclusion + self.compute_quota() + self.elect_meeting_quota() + + return CountStepResult( + 'Exclusion', + self.candidates, + self.exhausted, + self.quota + ) + + def compute_quota(self): + # Compute quota + __pragma__('opov') + total = sum((cc.votes for c, cc in self.candidates.items()), Num('0')) + self.quota = total / Num(self.election.seats) + __pragma__('noopov') + + def elect_meeting_quota(self): + # Does a candidate meet the quota? + __pragma__('opov') + meets_quota = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL and cc.votes > self.quota] + __pragma__('noopov') + + if len(meets_quota) > 0: + # Declare elected any candidate who meets the quota + for candidate, count_card in meets_quota: + count_card.state = CandidateState.PROVISIONALLY_ELECTED diff --git a/pyRCV2/method/__init__.py b/pyRCV2/method/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyRCV2/model.py b/pyRCV2/model.py index b8fb4e6..a6322a5 100644 --- a/pyRCV2/model.py +++ b/pyRCV2/model.py @@ -14,9 +14,26 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +__pragma__ = lambda x: None + +from pyRCV2.numbers import Num + class Candidate: def __init__(self, name): self.name = name + + def __repr__(self): + return '' + + def toString(self): + return repr(self) + +class CandidateState: + HOPEFUL = 0 + PROVISIONALLY_ELECTED = 1 + ELECTED = 2 + EXCLUDED = 3 + WITHDRAWN = 4 class Ballot: def __init__(self, value, preferences): @@ -34,3 +51,36 @@ class Election: self.candidates = candidates if candidates is not None else [] self.ballots = ballots if ballots is not None else [] self.withdrawn = withdrawn if withdrawn is not None else [] + +class CountCard: + """ + Represents a Candidate's (or exhausted pile) current progress in the count + """ + def __init__(self): + self.orig_votes = Num('0') + self.transfers = Num('0') + self.ballots = [] + self.state = CandidateState.HOPEFUL + + @property + def votes(self): + __pragma__('opov') + return self.orig_votes + self.transfers + __pragma__('noopov') + + def step(self): + """Roll over previous round transfers in preparation for next round""" + self.orig_votes = self.votes + self.transfers = Num('0') + +class CountCompleted: + pass + +class CountStepResult: + def __init__(self, comment, candidates, exhausted, quota): + self.comment = comment + + self.candidates = candidates # SafeDict: Candidate -> CountCard + self.exhausted = exhausted # CountCard + + self.quota = quota diff --git a/pyRCV2/numbers/__init__.py b/pyRCV2/numbers/__init__.py index 074aca9..c5c925f 100644 --- a/pyRCV2/numbers/__init__.py +++ b/pyRCV2/numbers/__init__.py @@ -25,7 +25,7 @@ if is_py: from pyRCV2.numbers.rational_py import Rational __pragma__('noskip') else: - from pyRCV2.numbers.rational_py import Rational + from pyRCV2.numbers.rational_js import Rational _numclass = Rational diff --git a/pyRCV2/numbers/rational_js.py b/pyRCV2/numbers/rational_js.py index b0e7158..bfb942c 100644 --- a/pyRCV2/numbers/rational_js.py +++ b/pyRCV2/numbers/rational_js.py @@ -18,11 +18,23 @@ class Rational: def __init__(self, val): self.impl = bigRat(val) + def pp(self, dp): + return self.impl.valueOf().toFixed(dp) + def __add__(self, other): - return self.impl.add(other.impl) + return Rational(self.impl.add(other.impl)) def __sub__(self, other): - return self.impl.subtract(other.impl) + return Rational(self.impl.subtract(other.impl)) def __mul__(self, other): - return self.impl.multiply(other.impl) + return Rational(self.impl.multiply(other.impl)) def __div__(self, other): - return self.impl.divide(other.impl) + return Rational(self.impl.divide(other.impl)) + + def __gt__(self, other): + return self.impl.greater(other.impl) + def __ge__(self, other): + return self.impl.greaterOrEquals(other.impl) + def __lt__(self, other): + return self.impl.lesser(other.impl) + def __le__(self, other): + return self.impl.lesserOrEquals(other.impl) diff --git a/pyRCV2/numbers/rational_py.py b/pyRCV2/numbers/rational_py.py index 67b90c3..ee6072b 100644 --- a/pyRCV2/numbers/rational_py.py +++ b/pyRCV2/numbers/rational_py.py @@ -14,15 +14,30 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from fractions import Fraction + class Rational: def __init__(self, val): - self.impl = int(val) + self.impl = Fraction(val) + + def pp(self, dp): + """Pretty print to specified number of decimal places""" + return format(self.impl, '.{}f'.format(dp)) def __add__(self, other): - return self.impl + other.impl + return Rational(self.impl + other.impl) def __sub__(self, other): - return self.impl - other.impl + return Rational(self.impl - other.impl) def __mul__(self, other): - return self.impl * other.impl + return Rational(self.impl * other.impl) def __div__(self, other): - return self.impl / other.impl + return Rational(self.impl / other.impl) + + def __gt__(self, other): + return self.impl > other.impl + def __ge__(self, other): + return self.impl >= other.impl + def __lt__(self, other): + return self.impl < other.impl + def __le__(self, other): + return self.impl <= other.impl diff --git a/pyRCV2/safedict/__init__.py b/pyRCV2/safedict/__init__.py new file mode 100644 index 0000000..40b3b7e --- /dev/null +++ b/pyRCV2/safedict/__init__.py @@ -0,0 +1,28 @@ +# pyRCV2: Preferential vote counting +# Copyright © 2020 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 . + +__pragma__ = lambda x: None +is_py = False +__pragma__('skip') +is_py = True +__pragma__('noskip') + +if is_py: + __pragma__('skip') + from pyRCV2.safedict.safedict_py import SafeDict + __pragma__('noskip') +else: + from pyRCV2.safedict.safedict_js import SafeDict diff --git a/pyRCV2/safedict/safedict_js.py b/pyRCV2/safedict/safedict_js.py new file mode 100644 index 0000000..d8041e7 --- /dev/null +++ b/pyRCV2/safedict/safedict_js.py @@ -0,0 +1,35 @@ +# pyRCV2: Preferential vote counting +# Copyright © 2020 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 . + +class SafeDict: + """Dictionary which can contain non-string keys in both Python and JS""" + + def __init__(self, params=None): + self.impl = __new__(Map) + + if params: + for k, v in params: + self.impl.set(k, v) + + def __getitem__(self, key): + return self.impl.js_get(key) + + def __setitem__(self, key, value): + self.impl.set(key, value) + + def items(self): + entries = self.impl.entries() # Returns an Iterator + return __pragma__('js', 'Array.from(entries)') diff --git a/pyRCV2/safedict/safedict_py.py b/pyRCV2/safedict/safedict_py.py new file mode 100644 index 0000000..1aafc8f --- /dev/null +++ b/pyRCV2/safedict/safedict_py.py @@ -0,0 +1,33 @@ +# pyRCV2: Preferential vote counting +# Copyright © 2020 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 . + +class SafeDict: + """Dictionary which can contain non-string keys in both Python and JS""" + + def __init__(self, params=None): + if params: + self.impl = {k: v for k, v in params} + else: + self.impl = {} + + def __getitem__(self, key): + return self.impl[key] + + def __setitem__(self, key, value): + self.impl[key] = value + + def items(self): + return self.impl.items() diff --git a/pyRCV2/transcrypt.py b/pyRCV2/transcrypt.py index 40b73cb..c0228e0 100644 --- a/pyRCV2/transcrypt.py +++ b/pyRCV2/transcrypt.py @@ -16,6 +16,7 @@ import pyRCV2.blt import pyRCV2.model +import pyRCV2.method, pyRCV2.method.STVCCounter import pyRCV2.numbers -__pragma__('js', '{}', 'export {pyRCV2, str, repr};') +__pragma__('js', '{}', 'export {pyRCV2, isinstance, repr, str};') diff --git a/test.html b/test.html index 6c9223a..5f794e8 100644 --- a/test.html +++ b/test.html @@ -8,15 +8,130 @@ +
+