Implement option to disable bulk election

This commit is contained in:
RunasSudo 2020-12-27 18:27:41 +11:00
parent ae6bb3e09f
commit d1528ee987
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
5 changed files with 85 additions and 29 deletions

View File

@ -33,6 +33,12 @@ When this option is disabled (default), the quota is calculated once after all f
When this option is enabled, the quota is recalculated at the end of every stage, and may decrease throughout the count as votes are exhausted. OpaVote calls this a ‘dynamic threshold’. When this option is enabled, the quota is recalculated at the end of every stage, and may decrease throughout the count as votes are exhausted. OpaVote calls this a ‘dynamic threshold’.
## Bulk election (--no-bulk-election)
When bulk election is enabled (default), all remaining candidates are declared elected in a single round once the number of not-excluded candidates exactly equals the number of vacancies to fill. Further surplus distributions are not performed. This is typical of most STV rules.
When bulk election is disabled, surpluses continue to be distributed even once the number of not-excluded candidates exactly equals the number of vacancies to fill. Once there are no more surpluses to distribute, the candidates are declared elected, one by one, in descending order of votes. This ensures that only one candidate is ever elected in each round and the order of election is well-defined, which is required e.g. for some affirmative action rules.
## Numbers (-n/--numbers), Decimal places (--decimals) ## Numbers (-n/--numbers), Decimal places (--decimals)
This dropdown allows you to select how numbers (vote totals, etc.) are represented internally in memory. The options are: This dropdown allows you to select how numbers (vote totals, etc.) are represented internally in memory. The options are:

View File

@ -68,6 +68,10 @@
<input type="checkbox" id="chkProgQuota"> <input type="checkbox" id="chkProgQuota">
Progressive quota Progressive quota
</label> </label>
<label>
<input type="checkbox" id="chkBulkElection" checked>
Bulk election
</label>
<label style="display: none;"> <label style="display: none;">
<input type="checkbox" id="chkBulkExclusion"> <input type="checkbox" id="chkBulkExclusion">
Bulk exclusion (NYI) Bulk exclusion (NYI)

View File

@ -31,6 +31,7 @@ function changePreset() {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop'; document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false; document.getElementById('chkProgQuota').checked = false;
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false; document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('selNumbers').value = 'fixed'; document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5'; document.getElementById('txtDP').value = '5';
@ -41,24 +42,27 @@ function changePreset() {
document.getElementById('selQuotaCriterion').value = 'gt'; document.getElementById('selQuotaCriterion').value = 'gt';
document.getElementById('selQuota').value = 'droop_exact'; document.getElementById('selQuota').value = 'droop_exact';
document.getElementById('chkProgQuota').checked = true; document.getElementById('chkProgQuota').checked = true;
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true; document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('selNumbers').value = 'rational'; document.getElementById('selNumbers').value = 'rational';
document.getElementById('selSurplus').value = 'size'; document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wig'; document.getElementById('selTransfers').value = 'wig';
document.getElementById('selTies').value = 'backwards_random'; document.getElementById('selTies').value = 'backwards_random';
} else if (document.getElementById('selPreset').value === 'senate') { } /* else if (document.getElementById('selPreset').value === 'senate') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop'; document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false; document.getElementById('chkProgQuota').checked = false;
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true; document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('selNumbers').value = 'int'; document.getElementById('selNumbers').value = 'int';
document.getElementById('selSurplus').value = 'order'; document.getElementById('selSurplus').value = 'order';
document.getElementById('selTransfers').value = 'uig'; document.getElementById('selTransfers').value = 'uig';
document.getElementById('selTies').value = 'backwards_random'; document.getElementById('selTies').value = 'backwards_random';
} else if (document.getElementById('selPreset').value === 'wright') { } */ else if (document.getElementById('selPreset').value === 'wright') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop'; document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false; document.getElementById('chkProgQuota').checked = false;
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true; document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('selNumbers').value = 'fixed'; document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5'; document.getElementById('txtDP').value = '5';
@ -274,6 +278,7 @@ async function clickCount() {
'quota_criterion': document.getElementById('selQuotaCriterion').value, 'quota_criterion': document.getElementById('selQuotaCriterion').value,
'quota': document.getElementById('selQuota').value, 'quota': document.getElementById('selQuota').value,
'prog_quota': document.getElementById('chkProgQuota').checked, 'prog_quota': document.getElementById('chkProgQuota').checked,
'bulk_elect': document.getElementById('chkBulkElection').checked,
'bulk_exclude': document.getElementById('chkBulkExclusion').checked, 'bulk_exclude': document.getElementById('chkBulkExclusion').checked,
'surplus_order': document.getElementById('selSurplus').value, 'surplus_order': document.getElementById('selSurplus').value,
'ties': document.getElementById('selTies').value 'ties': document.getElementById('selTies').value

View File

@ -31,6 +31,7 @@ def add_parser(subparsers):
parser.add_argument('--quota', '-q', choices=['droop', 'droop_exact', 'hare', 'hare_exact'], default='droop', help='quota calculation (default: droop)') parser.add_argument('--quota', '-q', choices=['droop', 'droop_exact', 'hare', 'hare_exact'], default='droop', help='quota calculation (default: droop)')
parser.add_argument('--quota-criterion', '-c', choices=['geq', 'gt'], default='geq', help='quota criterion (default: geq)') parser.add_argument('--quota-criterion', '-c', choices=['geq', 'gt'], default='geq', help='quota criterion (default: geq)')
parser.add_argument('--prog-quota', action='store_true', help='progressively reducing quota') parser.add_argument('--prog-quota', action='store_true', help='progressively reducing quota')
parser.add_argument('--no-bulk-election', action='store_true', help='disable bulk election unless absolutely required')
parser.add_argument('--numbers', '-n', choices=['fixed', 'rational', 'int', 'native'], default='fixed', help='numbers mode (default: fixed)') parser.add_argument('--numbers', '-n', choices=['fixed', 'rational', 'int', 'native'], default='fixed', help='numbers mode (default: fixed)')
parser.add_argument('--decimals', type=int, default=5, help='decimal places if --numbers fixed (default: 5)') parser.add_argument('--decimals', type=int, default=5, help='decimal places if --numbers fixed (default: 5)')
parser.add_argument('--surplus-order', '-s', choices=['size', 'order'], default='size', help='whether to distribute surpluses by size or by order of election (default: size)') parser.add_argument('--surplus-order', '-s', choices=['size', 'order'], default='size', help='whether to distribute surpluses by size or by order of election (default: size)')
@ -100,6 +101,8 @@ def main(args):
sys.exit(1) sys.exit(1)
counter.options['ties'].append(TiesRandom(args.random_seed)) counter.options['ties'].append(TiesRandom(args.random_seed))
counter.options['bulk_elect'] = not args.no_bulk_election
# Reset # Reset
result = counter.reset() result = counter.reset()

View File

@ -36,6 +36,7 @@ class BaseSTVCounter:
# Default options # Default options
self.options = { self.options = {
'prog_quota': False, # Progressively reducing quota? 'prog_quota': False, # Progressively reducing quota?
'bulk_elect': True, # Bulk election?
'quota': 'droop', # 'droop', 'droop_exact', 'hare' or 'hare_exact' 'quota': 'droop', # 'droop', 'droop_exact', 'hare' or 'hare_exact'
'quota_criterion': 'geq', # 'geq' or 'gt' 'quota_criterion': 'geq', # 'geq' or 'gt'
'surplus_order': 'size', # 'size' or 'order' 'surplus_order': 'size', # 'size' or 'order'
@ -107,7 +108,7 @@ class BaseSTVCounter:
self.step_count_cards() self.step_count_cards()
# Check if done # Check if done
result = self.check_if_done() result = self.before_surpluses()
if result: if result:
return result return result
@ -116,6 +117,11 @@ class BaseSTVCounter:
if result: if result:
return result return result
# Check if done (2)
result = self.before_exclusion()
if result:
return result
# Insufficient winners and no surpluses to distribute # Insufficient winners and no surpluses to distribute
# Exclude the lowest ranked hopeful # Exclude the lowest ranked hopeful
result = self.exclude_candidate() result = self.exclude_candidate()
@ -134,39 +140,40 @@ class BaseSTVCounter:
self.exhausted.step() self.exhausted.step()
self.loss_fraction.step() self.loss_fraction.step()
def check_if_done(self): def before_surpluses(self):
""" """
Check if the count can be completed Check if the count can be completed before distributing surpluses
""" """
# Have sufficient candidates been elected? # Have sufficient candidates been elected?
if self.num_elected >= self.election.seats: if self.num_elected >= self.election.seats:
return CountCompleted() return CountCompleted()
# Are there just enough candidates to fill all the seats # Are there just enough candidates to fill all the seats?
if self.num_elected + sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL) <= self.election.seats: if self.options['bulk_elect']:
# TODO: Sort by size if self.num_elected + sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL) <= self.election.seats:
# TODO: Sort by size
# Declare elected all remaining candidates
for candidate, count_card in self.candidates.items(): # Declare elected all remaining candidates
if count_card.state == CandidateState.HOPEFUL: for candidate, count_card in self.candidates.items():
count_card.state = CandidateState.PROVISIONALLY_ELECTED if count_card.state == CandidateState.HOPEFUL:
count_card.order_elected = self.num_elected count_card.state = CandidateState.PROVISIONALLY_ELECTED
self.num_elected += 1 count_card.order_elected = self.num_elected
self.num_elected += 1
__pragma__('opov')
result = CountStepResult( __pragma__('opov')
'Bulk election', result = CountStepResult(
self.candidates, 'Bulk election',
self.exhausted, self.candidates,
self.loss_fraction, self.exhausted,
self.total + self.exhausted.votes + self.loss_fraction.votes, self.loss_fraction,
self.quota self.total + self.exhausted.votes + self.loss_fraction.votes,
) self.quota
__pragma__('noopov') )
__pragma__('noopov')
self.step_results.append(result)
return result self.step_results.append(result)
return result
def distribute_surpluses(self): def distribute_surpluses(self):
""" """
@ -227,6 +234,37 @@ class BaseSTVCounter:
""" """
raise NotImplementedError('Method not implemented') raise NotImplementedError('Method not implemented')
def before_exclusion(self):
"""
Check before excluding a candidate
"""
# If we did not perform bulk election in before_surpluses: Are there just enough candidates to fill all the seats?
if not self.options['bulk_elect']:
if self.num_elected + sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL) <= self.election.seats:
# Declare elected one remaining candidate at a time
hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL]
hopefuls.sort(key=lambda x: x[1].votes, reverse=True)
candidate_elected, count_card = self.choose_highest(hopefuls)
count_card.state = CandidateState.PROVISIONALLY_ELECTED
count_card.order_elected = self.num_elected
self.num_elected += 1
__pragma__('opov')
result = CountStepResult(
'Bulk election',
self.candidates,
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota
)
__pragma__('noopov')
self.step_results.append(result)
return result
def exclude_candidate(self): def exclude_candidate(self):
""" """
Exclude the lowest ranked hopeful Exclude the lowest ranked hopeful