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’.
## 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)
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">
Progressive quota
</label>
<label>
<input type="checkbox" id="chkBulkElection" checked>
Bulk election
</label>
<label style="display: none;">
<input type="checkbox" id="chkBulkExclusion">
Bulk exclusion (NYI)

View File

@ -31,6 +31,7 @@ function changePreset() {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false;
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
@ -41,24 +42,27 @@ function changePreset() {
document.getElementById('selQuotaCriterion').value = 'gt';
document.getElementById('selQuota').value = 'droop_exact';
document.getElementById('chkProgQuota').checked = true;
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('selNumbers').value = 'rational';
document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wig';
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('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false;
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('selNumbers').value = 'int';
document.getElementById('selSurplus').value = 'order';
document.getElementById('selTransfers').value = 'uig';
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('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false;
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
@ -274,6 +278,7 @@ async function clickCount() {
'quota_criterion': document.getElementById('selQuotaCriterion').value,
'quota': document.getElementById('selQuota').value,
'prog_quota': document.getElementById('chkProgQuota').checked,
'bulk_elect': document.getElementById('chkBulkElection').checked,
'bulk_exclude': document.getElementById('chkBulkExclusion').checked,
'surplus_order': document.getElementById('selSurplus').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-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('--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('--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)')
@ -100,6 +101,8 @@ def main(args):
sys.exit(1)
counter.options['ties'].append(TiesRandom(args.random_seed))
counter.options['bulk_elect'] = not args.no_bulk_election
# Reset
result = counter.reset()

View File

@ -36,6 +36,7 @@ class BaseSTVCounter:
# Default options
self.options = {
'prog_quota': False, # Progressively reducing quota?
'bulk_elect': True, # Bulk election?
'quota': 'droop', # 'droop', 'droop_exact', 'hare' or 'hare_exact'
'quota_criterion': 'geq', # 'geq' or 'gt'
'surplus_order': 'size', # 'size' or 'order'
@ -107,7 +108,7 @@ class BaseSTVCounter:
self.step_count_cards()
# Check if done
result = self.check_if_done()
result = self.before_surpluses()
if result:
return result
@ -116,6 +117,11 @@ class BaseSTVCounter:
if result:
return result
# Check if done (2)
result = self.before_exclusion()
if result:
return result
# Insufficient winners and no surpluses to distribute
# Exclude the lowest ranked hopeful
result = self.exclude_candidate()
@ -134,39 +140,40 @@ class BaseSTVCounter:
self.exhausted.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?
if self.num_elected >= self.election.seats:
return CountCompleted()
# 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:
# TODO: Sort by size
# 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
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
# Are there just enough candidates to fill all the seats?
if 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:
# TODO: Sort by size
# 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
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 distribute_surpluses(self):
"""
@ -227,6 +234,37 @@ class BaseSTVCounter:
"""
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):
"""
Exclude the lowest ranked hopeful