diff --git a/docs/options.md b/docs/options.md
index f0882eb..89cdde0 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -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:
diff --git a/html/index.html b/html/index.html
index 7156d87..80ffebe 100644
--- a/html/index.html
+++ b/html/index.html
@@ -68,6 +68,10 @@
Progressive quota
+
+
+ Bulk election
+
Bulk exclusion (NYI)
diff --git a/html/index.js b/html/index.js
index 14b9783..e0c75e4 100644
--- a/html/index.js
+++ b/html/index.js
@@ -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
diff --git a/pyRCV2/cli/stv.py b/pyRCV2/cli/stv.py
index 402748b..dfcbb70 100644
--- a/pyRCV2/cli/stv.py
+++ b/pyRCV2/cli/stv.py
@@ -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()
diff --git a/pyRCV2/method/base_stv.py b/pyRCV2/method/base_stv.py
index 90d0363..9ef7485 100644
--- a/pyRCV2/method/base_stv.py
+++ b/pyRCV2/method/base_stv.py
@@ -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