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 @@
+
+