Add detailed comments per stage

This commit is contained in:
RunasSudo 2021-01-09 04:33:13 +11:00
parent bd5910e7f6
commit ede1da73ad
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
8 changed files with 209 additions and 138 deletions

View File

@ -216,6 +216,8 @@
<table id="result"></table>
<div id="resultLogs"></div>
<script src="vendor/BigInt_BigRat-a5f89e2.min.js"></script>
<script src="vendor/big-6.0.0.min.js"></script>
<script src="vendor/sjcl-1.0.8.min.js"></script>

View File

@ -183,10 +183,14 @@ async function clickCount() {
let tblResults = document.getElementById('result');
tblResults.innerHTML = '';
let candMap = {}; // candidate name -> rows
let divResultLogs = document.getElementById('resultLogs');
divResultLogs.innerHTML = '';
// Step election
let worker = new Worker('worker.js');
let election, elComment, elExhausted1, elExhausted2, elLTF1, elLTF2, elTotal, elQuota, elVRE;
let election;
let trComment, trExhausted1, trExhausted2, trLTF1, trLTF2, trTotal, trQuota, trVRE;
let olLogs;
worker.onmessage = function(evt) {
if (evt.data.type === 'require_input') {
@ -204,10 +208,10 @@ async function clickCount() {
election = evt.data.election;
// Comment row
elComment = document.createElement('tr');
trComment = document.createElement('tr');
let elTd = document.createElement('td');
elComment.appendChild(elTd);
tblResults.appendChild(elComment);
trComment.appendChild(elTd);
tblResults.appendChild(trComment);
// Candidates
for (let candidate of election.candidates) {
@ -227,65 +231,72 @@ async function clickCount() {
}
// Exhausted votes row
elExhausted1 = document.createElement('tr');
elExhausted1.classList.add('info');
elExhausted2 = document.createElement('tr');
elExhausted2.classList.add('info');
trExhausted1 = document.createElement('tr');
trExhausted1.classList.add('info');
trExhausted2 = document.createElement('tr');
trExhausted2.classList.add('info');
elTd = document.createElement('td');
elTd.setAttribute('rowspan', '2');
elTd.style.borderTop = '1px solid black';
elTd.innerText = 'Exhausted';
elExhausted1.appendChild(elTd);
trExhausted1.appendChild(elTd);
tblResults.appendChild(elExhausted1);
tblResults.appendChild(elExhausted2);
tblResults.appendChild(trExhausted1);
tblResults.appendChild(trExhausted2);
// Loss to fraction row
elLTF1 = document.createElement('tr');
elLTF1.classList.add('info');
elLTF2 = document.createElement('tr');
elLTF2.classList.add('info');
trLTF1 = document.createElement('tr');
trLTF1.classList.add('info');
trLTF2 = document.createElement('tr');
trLTF2.classList.add('info');
elTd = document.createElement('td');
elTd.setAttribute('rowspan', '2');
elTd.style.borderTop = '1px solid black';
elTd.innerText = 'Loss to fraction';
elLTF1.appendChild(elTd);
trLTF1.appendChild(elTd);
tblResults.appendChild(elLTF1);
tblResults.appendChild(elLTF2);
tblResults.appendChild(trLTF1);
tblResults.appendChild(trLTF2);
// Total row
elTotal = document.createElement('tr');
elTotal.classList.add('info');
trTotal = document.createElement('tr');
trTotal.classList.add('info');
elTd = document.createElement('td');
elTd.style.borderTop = '1px solid black';
elTd.innerText = 'Total';
elTotal.appendChild(elTd);
tblResults.appendChild(elTotal);
trTotal.appendChild(elTd);
tblResults.appendChild(trTotal);
// Quota row
elQuota = document.createElement('tr');
elQuota.classList.add('info');
trQuota = document.createElement('tr');
trQuota.classList.add('info');
elTd = document.createElement('td');
elTd.style.borderTop = '1px solid black';
elTd.style.borderBottom = '1px solid black';
elTd.innerText = 'Quota';
elQuota.appendChild(elTd);
tblResults.appendChild(elQuota);
trQuota.appendChild(elTd);
tblResults.appendChild(trQuota);
// Vote required for election row
if (document.getElementById('selQuotaMode').value === 'ers97') {
elVRE = document.createElement('tr');
elVRE.classList.add('info');
trVRE = document.createElement('tr');
trVRE.classList.add('info');
elTd = document.createElement('td');
elTd.style.borderTop = '1px solid black';
elTd.style.borderBottom = '1px solid black';
elTd.innerText = 'Vote required for election';
elVRE.appendChild(elTd);
tblResults.appendChild(elVRE);
trVRE.appendChild(elTd);
tblResults.appendChild(trVRE);
}
// Result logs
let elP = document.createElement('p');
elP.innerText = 'Stage comments:';
divResultLogs.appendChild(elP);
olLogs = document.createElement('ol');
divResultLogs.appendChild(olLogs);
}
if (evt.data.type === 'result') {
@ -294,7 +305,7 @@ async function clickCount() {
// Display results
elTd = document.createElement('td');
elTd.innerText = result.stage + '. ' + result.comment;
elComment.appendChild(elTd);
trComment.appendChild(elTd);
for (let [candidate, countCard] of result.candidates) {
[elTr1, elTr2] = candMap[candidate];
@ -342,31 +353,31 @@ async function clickCount() {
elTd.classList.add('count');
elTd.style.borderTop = '1px solid black';
elTd.innerHTML = ppVotes(result.exhausted.transfers);
elExhausted1.appendChild(elTd);
trExhausted1.appendChild(elTd);
elTd = document.createElement('td');
elTd.classList.add('count');
elTd.innerHTML = ppVotes(result.exhausted.votes);
elExhausted2.appendChild(elTd);
trExhausted2.appendChild(elTd);
// Display loss to fraction
elTd = document.createElement('td');
elTd.classList.add('count');
elTd.style.borderTop = '1px solid black';
elTd.innerHTML = ppVotes(result.loss_fraction.transfers);
elLTF1.appendChild(elTd);
trLTF1.appendChild(elTd);
elTd = document.createElement('td');
elTd.classList.add('count');
elTd.innerHTML = ppVotes(result.loss_fraction.votes);
elLTF2.appendChild(elTd);
trLTF2.appendChild(elTd);
// Display total
elTd = document.createElement('td');
elTd.classList.add('count');
elTd.style.borderTop = '1px solid black';
elTd.innerHTML = ppVotes(result.total);
elTotal.appendChild(elTd);
trTotal.appendChild(elTd);
// Display quota
elTd = document.createElement('td');
@ -374,7 +385,7 @@ async function clickCount() {
elTd.style.borderTop = '1px solid black';
elTd.style.borderBottom = '1px solid black';
elTd.innerHTML = ppVotes(result.quota);
elQuota.appendChild(elTd);
trQuota.appendChild(elTd);
// Display vote required for election
if (result.vote_required_election !== null) {
@ -383,8 +394,13 @@ async function clickCount() {
elTd.style.borderTop = '1px solid black';
elTd.style.borderBottom = '1px solid black';
elTd.innerHTML = ppVotes(result.vote_required_election);
elVRE.appendChild(elTd);
trVRE.appendChild(elTd);
}
// Result logs
let elLi = document.createElement('li');
elLi.innerText = result.logs.join(' ');
olLogs.appendChild(elLi);
}
if (evt.data.type === 'done') {

View File

@ -124,6 +124,7 @@ function resultToJS(result) {
return {
'stage': stage,
'comment': result.comment,
'logs': result.logs,
'candidates': result.candidates.py_items().map(([c, cc]) => [c.py_name, {
'transfers': cc.transfers.pp(ppDPs),
'votes': cc.votes.pp(ppDPs),

View File

@ -54,6 +54,9 @@ def add_parser(subparsers):
def print_step(args, stage, result):
print('{}. {}'.format(stage, result.comment))
if result.logs:
print(' '.join(result.logs))
results = list(result.candidates.items())
if args.sort_votes:
results.sort(key=lambda x: x[1].votes, reverse=True)
@ -61,7 +64,10 @@ def print_step(args, stage, result):
for candidate, count_card in results:
state = None
if count_card.state == pyRCV2.model.CandidateState.ELECTED or count_card.state == pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED or count_card.state == pyRCV2.model.CandidateState.DISTRIBUTING_SURPLUS:
state = 'ELECTED {}'.format(count_card.order_elected)
if args.method == 'meek':
state = 'ELECTED {} (kv = {})'.format(count_card.order_elected, count_card.keep_value.pp(args.pp_decimals))
else:
state = 'ELECTED {}'.format(count_card.order_elected)
elif count_card.state == pyRCV2.model.CandidateState.EXCLUDED or count_card.state == pyRCV2.model.CandidateState.EXCLUDING:
state = 'Excluded {}'.format(-count_card.order_elected)
elif count_card.state == pyRCV2.model.CandidateState.WITHDRAWN:

View File

@ -60,6 +60,7 @@ class BaseSTVCounter:
self.total_orig = sum((b.value for b in self.election.ballots), Num('0'))
self.logs = []
self.step_results = []
self.num_elected = 0
self.num_excluded = 0
@ -80,26 +81,14 @@ class BaseSTVCounter:
self._exclusion = None # Optimisation to avoid re-collating/re-sorting ballots
self.distribute_first_preferences()
self.logs.append('First preferences distributed.')
self.quota = None
self.vote_required_election = None # For ERS97
self.compute_quota()
self.elect_meeting_quota()
__pragma__('opov')
result = CountStepResult(
'First preferences',
self.candidates,
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota,
self.vote_required_election,
)
__pragma__('noopov')
self.step_results = [result]
return result
return self.make_result('First preferences')
def distribute_first_preferences(self):
"""
@ -173,6 +162,7 @@ class BaseSTVCounter:
__pragma__('opov')
return CountCompleted(
'Count complete',
self.logs,
self.candidates,
self.exhausted,
self.loss_fraction,
@ -187,26 +177,18 @@ class BaseSTVCounter:
# Include EXCLUDING to avoid interrupting an exclusion
if len(self.election.candidates) - self.num_excluded + sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.EXCLUDING) <= 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
self.num_elected += 1
count_card.order_elected = self.num_elected
candidates_elected = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL]
if len(candidates_elected) == 1:
self.logs.append(candidates_elected[0][0].name + ' is elected to fill the remaining vacancy.')
else:
self.logs.append(self.pretty_join([c.name for c, cc in candidates_elected]) + ' are elected to fill the remaining vacancies.')
__pragma__('opov')
result = CountStepResult(
'Bulk election',
self.candidates,
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota,
self.vote_required_election,
)
__pragma__('noopov')
for candidate, count_card in candidates_elected:
count_card.state = CandidateState.PROVISIONALLY_ELECTED
self.num_elected += 1
count_card.order_elected = self.num_elected
self.step_results.append(result)
return result
return self.make_result('Bulk election')
def can_defer_surpluses(self, has_surplus):
"""
@ -235,6 +217,7 @@ class BaseSTVCounter:
__pragma__('opov')
# Can defer surpluses
self.logs.append('Distribution of surpluses totalling ' + total_surpluses.pp(2) + ' votes will be deferred.')
return True
def distribute_surpluses(self):
@ -287,6 +270,7 @@ class BaseSTVCounter:
__pragma__('noopov')
# Transfer surplus
self.logs.append('Surplus of ' + candidate_surplus.name + ' distributed.')
self.do_surplus(candidate_surplus, count_card, surplus)
# Declare elected any candidates meeting the quota as a result of surpluses
@ -294,20 +278,7 @@ class BaseSTVCounter:
self.elect_meeting_quota()
__pragma__('opov')
result = CountStepResult(
'Surplus of ' + candidate_surplus.name,
self.candidates,
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota,
self.vote_required_election,
)
__pragma__('noopov')
self.step_results.append(result)
return result
return self.make_result('Surplus of ' + candidate_surplus.name)
def do_surplus(self, candidate_surplus, count_card, surplus):
"""
@ -333,20 +304,7 @@ class BaseSTVCounter:
self.num_elected += 1
count_card.order_elected = self.num_elected
__pragma__('opov')
result = CountStepResult(
'Bulk election',
self.candidates,
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota,
self.vote_required_election,
)
__pragma__('noopov')
self.step_results.append(result)
return result
return self.make_result('Bulk election')
def exclude_candidates(self):
"""
@ -400,20 +358,7 @@ class BaseSTVCounter:
self.elect_meeting_quota()
__pragma__('opov')
result = CountStepResult(
'Exclusion of ' + ', '.join([c.name for c, cc in candidates_excluded]),
self.candidates,
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota,
self.vote_required_election,
)
__pragma__('noopov')
self.step_results.append(result)
return result
return self.make_result('Exclusion of ' + ', '.join([c.name for c, cc in candidates_excluded]))
def candidates_to_bulk_exclude(self, hopefuls):
"""
@ -460,6 +405,7 @@ class BaseSTVCounter:
# Continue current exclusion if applicable
if self._exclusion is not None:
self.logs.append('Continuing exclusion of ' + self.pretty_join([c.name for c, cc in self._exclusion[0]]) + '.')
__pragma__('opov')
return self._exclusion[0]
__pragma__('noopov')
@ -477,8 +423,14 @@ class BaseSTVCounter:
candidates_excluded = self.candidates_to_bulk_exclude(hopefuls)
if len(candidates_excluded) == 0:
if len(candidates_excluded) > 0:
if len(candidates_excluded) == 1:
self.logs.append('No surpluses to distribute, so ' + candidates_excluded[0][0].name + ' is excluded.')
else:
self.logs.append('No surpluses to distribute, so ' + self.pretty_join([c.name for c, cc in candidates_excluded]) + ' are excluded.')
else:
candidates_excluded = [self.choose_lowest(hopefuls)]
self.logs.append('No surpluses to distribute, so ' + candidates_excluded[0][0].name + ' is excluded.')
return candidates_excluded
@ -516,17 +468,22 @@ class BaseSTVCounter:
else:
# Round up (preserving the original quota if exact)
self.quota = self.quota.round(self.options['round_quota'], self.quota.ROUND_UP)
self.logs.append(self.total.pp(2) + ' usable votes, so the quota is ' + self.quota.pp(2) + '.')
__pragma__('noopov')
if self.options['quota_mode'] == 'ers97' and self.num_elected < self.election.seats:
# Calculate the total active vote
__pragma__('opov')
orig_vre = self.vote_required_election
total_active_vote = \
sum((cc.votes for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.EXCLUDING), Num('0')) + \
sum((cc.votes - self.quota for c, cc in self.candidates.items() if cc.votes > self.quota), Num('0'))
self.vote_required_election = total_active_vote / Num(self.election.seats - self.num_elected + 1)
if self.options['round_votes'] is not None:
self.vote_required_election = self.vote_required_election.round(self.options['round_votes'], self.vote_required_election.ROUND_UP)
if (orig_vre is None or self.vote_required_election != orig_vre) and self.vote_required_election < self.quota:
self.logs.append('Total active vote is ' + total_active_vote.pp(2) + ', so the vote required for election is ' + self.vote_required_election.pp(2) + '.')
__pragma__('noopov')
def meets_quota(self, count_card):
@ -555,6 +512,10 @@ class BaseSTVCounter:
if len(meets_quota) > 0:
meets_quota.sort(key=lambda x: x[1].votes, reverse=True)
if len(meets_quota) == 1:
self.logs.append(meets_quota[0][0].name + ' meets the quota and is elected.')
else:
self.logs.append(self.pretty_join([c.name for c, cc in meets_quota]) + ' meet the quota and are elected.')
# Declare elected any candidate who meets the quota
while len(meets_quota) > 0:
@ -566,11 +527,11 @@ class BaseSTVCounter:
count_card.order_elected = self.num_elected
meets_quota.remove(x)
if self.options['quota_mode'] == 'ers97':
self.compute_quota()
self.elect_meeting_quota() # Repeat as the vote required for election may have changed
return
if self.options['quota_mode'] == 'ers97':
self.compute_quota()
self.elect_meeting_quota() # Repeat as the vote required for election may have changed
return
# -----------------
# UTILITY FUNCTIONS
@ -671,3 +632,29 @@ class BaseSTVCounter:
if self.options['round_tvs'] is None:
return num
return num.round(self.options['round_tvs'], num.ROUND_DOWN)
def make_result(self, comment):
__pragma__('opov')
result = CountStepResult(
comment,
self.logs,
self.candidates,
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota,
self.vote_required_election,
)
__pragma__('noopov')
self.logs = []
self.step_results.append(result)
return result
def pretty_join(self, strs):
if len(strs) == 0:
return ''
if len(strs) == 1:
return strs[0]
if len(strs) == 2:
return strs[0] + ' and ' + strs[1]
return ', '.join(strs[0:-1]) + ' and ' + strs[len(strs)-1]

View File

@ -155,14 +155,18 @@ class WIGSTVCounter(BaseSTVCounter):
else:
raise STVException('Invalid exclusion mode')
#print([[bv / b.value for c, bb in stage for b, bv in bb] for stage in self._exclusion[1]])
this_exclusion = self._exclusion[1][0]
self._exclusion[1].remove(this_exclusion)
# Transfer votes
next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences([bb for c, bb in this_exclusion])
if self.options['exclusion'] != 'one_round':
__pragma__('opov')
self.logs.append('Transferring ' + total_ballots.pp(0) + ' ballot papers, totalling ' + total_votes.pp(2) + ' votes, received at value ' + (this_exclusion[0][1][0][1] / this_exclusion[0][1][0][0].value).pp(2) + '.')
__pragma__('noopov')
for candidate, x in next_preferences.items():
cand_ballots, num_ballots, num_votes = x[0], x[1], x[2]
@ -199,6 +203,9 @@ class WIGSTVCounter(BaseSTVCounter):
__pragma__('noopov')
if len(self._exclusion[1]) == 0:
if self.options['exclusion'] != 'one_round':
self.logs.append('Exclusion complete.')
for candidate_excluded, count_card in candidates_excluded:
__pragma__('opov')
count_card.transfers -= count_card.votes

View File

@ -17,7 +17,7 @@
__pragma__ = lambda x: None
from pyRCV2.method.base_stv import BaseSTVCounter, STVException
from pyRCV2.model import CandidateState, CountCard, CountStepResult
from pyRCV2.model import CandidateState, CountCard
from pyRCV2.numbers import Num
from pyRCV2.safedict import SafeDict
@ -56,9 +56,25 @@ class MeekSTVCounter(BaseSTVCounter):
raise STVException('Meek method is incompatible with --transferable-only')
if self.options['exclusion'] != 'one_round':
raise STVException('Meek method requires --exclusion one_round')
if self.options['round_votes'] is not None:
raise STVException('Meek method is incompatible with --round-votes')
if self.options['round_tvs'] is not None:
raise STVException('Meek method is incompatible with --round-tvs')
return BaseSTVCounter.reset(self)
if self.options['round_weights'] is not None:
raise STVException('Meek method is incompatible with --round-weights')
self._exclusion = None # Optimisation to avoid re-collating/re-sorting ballots
self.distribute_first_preferences()
self.logs.append('First preferences distributed.')
self.quota = None
self.vote_required_election = None # For ERS97
self.compute_quota()
self.logs.append(self.total.pp(2) + ' usable votes, so the quota is ' + self.quota.pp(2) + '.')
self.elect_meeting_quota()
return self.make_result('First preferences')
def distribute_first_preferences(self):
"""
@ -137,7 +153,11 @@ class MeekSTVCounter(BaseSTVCounter):
__pragma__('noopov')
if len(has_surplus) > 0:
num_iterations = 0
orig_quota = self.quota
while len(has_surplus) > 0:
num_iterations += 1
# Recompute keep values
for candidate, count_card in has_surplus:
__pragma__('opov')
@ -154,24 +174,21 @@ class MeekSTVCounter(BaseSTVCounter):
has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.ELECTED and cc.votes / self.quota > self._quota_tolerance_ub]
__pragma__('noopov')
if num_iterations == 1:
self.logs.append('Surpluses distributed, requiring 1 iteration.')
else:
self.logs.append('Surpluses distributed, requiring ' + str(num_iterations) + ' iterations.')
self.logs.append('Keep values of elected candidates are: ' + ', '.join([c.name + ' (' + cc.keep_value.pp(2) + ')' for c, cc in self.candidates.items() if cc.state == CandidateState.ELECTED]) + '.')
if self.quota != orig_quota:
self.logs.append(self.total.pp(2) + ' usable votes, so the quota is ' + self.quota.pp(2) + '.')
# Declare elected any candidates meeting the quota as a result of surpluses
# NB: We could do this earlier, but this shows the flow of the election more clearly in the count sheet
self.elect_meeting_quota()
__pragma__('opov')
result = CountStepResult(
'Surpluses distributed',
self.candidates,
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota,
self.vote_required_election,
)
__pragma__('noopov')
self.step_results.append(result)
return result
return self.make_result('Surpluses distributed')
def do_exclusion(self, candidates_excluded):
"""
@ -194,6 +211,10 @@ class MeekSTVCounter(BaseSTVCounter):
if len(meets_quota) > 0:
meets_quota.sort(key=lambda x: x[1].votes, reverse=True)
if len(meets_quota) == 1:
self.logs.append(meets_quota[0][0].name + ' meets the quota and is elected.')
else:
self.logs.append(self.pretty_join([c.name for c, cc in meets_quota]) + ' meet the quota and are elected.')
# Declare elected any candidate who meets the quota
while len(meets_quota) > 0:
@ -205,3 +226,33 @@ class MeekSTVCounter(BaseSTVCounter):
count_card.order_elected = self.num_elected
meets_quota.remove(x)
def compute_quota(self):
"""
Overrides BaseSTVCounter.compute_quota
Do not log quota changes
"""
__pragma__('opov')
self.total = sum((cc.votes for c, cc in self.candidates.items()), Num('0'))
self.loss_fraction.transfers += (self.total_orig - self.total - self.exhausted.votes) - self.loss_fraction.votes
if self.options['quota'] == 'droop' or self.options['quota'] == 'droop_exact':
self.quota = self.total / Num(self.election.seats + 1)
elif self.options['quota'] == 'hare' or self.options['quota'] == 'hare_exact':
self.quota = self.total / Num(self.election.seats)
else:
raise STVException('Invalid quota option')
if self.options['round_quota'] is not None:
if self.options['quota'] == 'droop' or self.options['quota'] == 'hare':
# Increment to next available increment
factor = Num(10).__pow__(self.options['round_quota'])
__pragma__('opov')
self.quota = ((self.quota * factor).__floor__() + Num(1)) / factor
__pragma__('noopov')
else:
# Round up (preserving the original quota if exact)
self.quota = self.quota.round(self.options['round_quota'], self.quota.ROUND_UP)
__pragma__('noopov')

View File

@ -103,8 +103,9 @@ class CountCard:
__pragma__('noopov')
class CountStepResult:
def __init__(self, comment, candidates, exhausted, loss_fraction, total, quota, vote_required_election):
def __init__(self, comment, logs, candidates, exhausted, loss_fraction, total, quota, vote_required_election):
self.comment = comment
self.logs = logs
self.candidates = candidates # SafeDict: Candidate -> CountCard
self.exhausted = exhausted # CountCard