Basic election counting logic

This commit is contained in:
RunasSudo 2020-10-17 22:20:13 +11:00
parent cca71614b1
commit 7d1635269a
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
13 changed files with 500 additions and 17 deletions

View File

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

View File

@ -14,8 +14,8 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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:

View File

@ -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 <https://www.gnu.org/licenses/>.
__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

View File

View File

@ -14,9 +14,26 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
__pragma__ = lambda x: None
from pyRCV2.numbers import Num
class Candidate:
def __init__(self, name):
self.name = name
def __repr__(self):
return '<Candidate ' + self.name + '>'
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

View File

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

View File

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

View File

@ -14,15 +14,30 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
__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

View File

@ -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 <https://www.gnu.org/licenses/>.
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)')

View File

@ -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 <https://www.gnu.org/licenses/>.
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()

View File

@ -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};')

117
test.html
View File

@ -8,15 +8,130 @@
<input type="file" id="bltFile">
<button onclick="clickBtn();">OK</button>
<table id="result"></table>
<script src="http://peterolson.github.com/BigRational.js/BigInt_BigRat.min.js"></script>
<script src="bundle.js"></script>
<script>
async function clickBtn() {
// Read BLT file
let bltFile = document.getElementById('bltFile').files[0];
let text = await bltFile.text();
let election = py.pyRCV2.blt.readBLT(text);
console.log(election);
// Create counter
let counter = py.pyRCV2.method.STVCCounter.STVCCounter(election);
// Reset
let result = counter.reset();
// Initialise table rows
let tblResults = document.getElementById('result');
let candMap = new Map(); // Map Candidate -> rows
// Comment row
let elComment = document.createElement('tr');
let elTd = document.createElement('td');
elComment.appendChild(elTd);
tblResults.appendChild(elComment);
// Candidates
for (let candidate of election.candidates) {
let elTr1 = document.createElement('tr');
let elTr2 = document.createElement('tr');
elTd = document.createElement('td');
elTd.setAttribute('rowspan', '2');
elTd.style.borderTop = '1px solid black';
elTd.innerText = candidate.py_name;
elTr1.appendChild(elTd);
tblResults.appendChild(elTr1);
tblResults.appendChild(elTr2);
candMap.set(candidate, [elTr1, elTr2]);
}
// Exhausted votes row
let elExhausted1 = document.createElement('tr');
let elExhausted2 = document.createElement('tr');
elTd = document.createElement('td');
elTd.setAttribute('rowspan', '2');
elTd.style.borderTop = '1px solid black';
elTd.innerText = 'Exhausted';
elExhausted1.appendChild(elTd);
tblResults.appendChild(elExhausted1);
tblResults.appendChild(elExhausted2);
// Quota row
let elQuota = document.createElement('tr');
elTd = document.createElement('td');
elTd.style.borderTop = '1px solid black';
elTd.innerText = 'Quota';
elQuota.appendChild(elTd);
tblResults.appendChild(elQuota);
// Step election
result = counter.reset();
do {
// Display results
elTd = document.createElement('td');
elTd.innerText = result.comment;
elComment.appendChild(elTd);
for (let [candidate, countCard] of result.candidates.impl) {
[elTr1, elTr2] = candMap.get(candidate);
elTd = document.createElement('td');
elTd.style.borderTop = '1px solid black';
if (!countCard.transfers.impl.equals(0)) {
elTd.innerText = countCard.transfers.pp(2);
}
elTr1.appendChild(elTd);
elTd = document.createElement('td');
if (countCard.state == py.pyRCV2.model.CandidateState.WITHDRAWN) {
elTd.innerText = 'WD';
} else if (countCard.state == py.pyRCV2.model.CandidateState.ELECTED || countCard.state == py.pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED) {
elTd.innerText = countCard.votes.pp(2);
elTd.style.fontWeight = 'bold';
} else if (countCard.state == py.pyRCV2.model.CandidateState.EXCLUDED) {
elTd.innerText = 'EX';
} else {
elTd.innerText = countCard.votes.pp(2);
}
elTr2.appendChild(elTd);
}
// Display exhausted votes
elTd = document.createElement('td');
elTd.style.borderTop = '1px solid black';
if (!result.exhausted.transfers.impl.equals(0)) {
elTd.innerText = result.exhausted.transfers.pp(2);
}
elExhausted1.appendChild(elTd);
elTd = document.createElement('td');
elTd.innerText = result.exhausted.votes.pp(2);
elExhausted2.appendChild(elTd);
// Display quota
elTd = document.createElement('td');
elTd.style.borderTop = '1px solid black';
elTd.innerText = result.quota.pp(2);
elQuota.appendChild(elTd);
// Step election
result = counter.step();
if (py.isinstance(result, py.pyRCV2.model.CountCompleted)) {
break;
}
} while (true);
}
</script>
</body>