Implement ERS97

This commit is contained in:
RunasSudo 2021-01-01 22:26:57 +11:00
parent 27c0638f98
commit fb33a96d37
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
17 changed files with 509 additions and 155 deletions

View File

@ -48,6 +48,7 @@
<option value="senate">Australian Senate STV</option>
<option value="wright">Wright STV</option>
<option value="prsa77">PRSA 1977</option>
<option value="ers97">ERS97</option>
</select>
</label>
<i class="sep"></i>
@ -58,6 +59,20 @@
</div>
<div id="divAdvancedOptions" style="display: none;">
<label>
Numbers:
<select id="selNumbers">
<option value="native">Native</option>
<option value="rational">Rational</option>
<option value="fixed" selected>Fixed</option>
</select>
</label>
<i class="sep"></i>
<label>
Decimal places (if Numbers = Fixed):
<input type="number" id="txtDP" value="5" style="width: 3em;">
</label>
<br>
<label>
Quota:
<select id="selQuotaCriterion">
@ -73,33 +88,36 @@
<option value="hare_exact">Hare (exact)</option>
</select>
</label>
<i class="sep"></i>
<label>
<input type="checkbox" id="chkProgQuota">
Progressive quota
<select id="selQuotaMode">
<option value="static" selected>Static quota</option>
<option value="progressive">Progressive quota</option>
<option value="ers97">Static with ERS97 rules</option>
</select>
</label>
<i class="sep"></i>
<label>
<input type="checkbox" id="chkBulkElection" checked>
Bulk election
</label>
<label style="display: none;">
<input type="checkbox" id="chkBulkExclusion">
Bulk exclusion (NYI)
</label>
<br>
<i class="sep"></i>
<label>
Numbers:
<select id="selNumbers">
<option value="native">Native</option>
<option value="rational">Rational</option>
<option value="fixed" selected>Fixed</option>
</select>
<input type="checkbox" id="chkBulkExclusion">
Bulk exclusion
</label>
<i class="sep"></i>
<label>
Decimal places (if Numbers = Fixed):
<input type="number" id="txtDP" value="5" style="width: 3em;">
<input type="checkbox" id="chkDeferSurpluses">
Defer surpluses
</label>
<br>
<label>
<input type="checkbox" id="chkRoundQuota" checked>
Round quota to
</label>
<label>
<input type="number" id="txtRoundQuota" value="0" style="width: 3em;">
d.p.
</label>
<i class="sep"></i>
<label>
@ -111,6 +129,15 @@
d.p.
</label>
<i class="sep"></i>
<label>
<input type="checkbox" id="chkRoundTVs">
Round transfer values to
</label>
<label>
<input type="number" id="txtRoundTVs" value="0" style="width: 3em;">
d.p.
</label>
<i class="sep"></i>
<label>
<input type="checkbox" id="chkRoundWeights">
Round ballot weights to

View File

@ -1,6 +1,6 @@
/*
pyRCV2: Preferential vote counting
Copyright © 2020 Lee Yingtong Li (RunasSudo)
Copyright © 20202021 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
@ -30,12 +30,16 @@ function changePreset() {
if (document.getElementById('selPreset').value === 'scottish') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false;
document.getElementById('selQuotaMode').value = 'static';
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('chkDeferSurpluses').checked = false;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '0';
document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundTVs').checked = false;
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wig';
@ -45,11 +49,14 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'stvc') {
document.getElementById('selQuotaCriterion').value = 'gt';
document.getElementById('selQuota').value = 'droop_exact';
document.getElementById('chkProgQuota').checked = true;
document.getElementById('selQuotaMode').value = 'progressive';
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('chkDeferSurpluses').checked = false;
document.getElementById('selNumbers').value = 'rational';
document.getElementById('chkRoundQuota').checked = false;
document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundTVs').checked = false;
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wig';
@ -59,13 +66,17 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'senate') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false;
document.getElementById('selQuotaMode').value = 'static';
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('chkDeferSurpluses').checked = false;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '0';
document.getElementById('chkRoundVotes').checked = true;
document.getElementById('txtRoundVotes').value = '0';
document.getElementById('chkRoundTVs').checked = false;
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'order';
document.getElementById('selTransfers').value = 'uig';
@ -75,12 +86,16 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'wright') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false;
document.getElementById('selQuotaMode').value = 'static';
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('chkDeferSurpluses').checked = false;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '0';
document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundTVs').checked = false;
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wig';
@ -90,19 +105,46 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'prsa77') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false;
document.getElementById('selQuotaMode').value = 'static';
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('chkDeferSurpluses').checked = true;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '0';
document.getElementById('chkRoundVotes').checked = true;
document.getElementById('txtRoundVotes').value = '0';
document.getElementById('chkRoundTVs').checked = true;
document.getElementById('txtRoundTVs').value = '0';
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'order';
document.getElementById('selTransfers').value = 'eg';
document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'parcels_by_order';
document.getElementById('selTies').value = 'backwards_random';
} else if (document.getElementById('selPreset').value === 'ers97') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop_exact';
document.getElementById('selQuotaMode').value = 'ers97';
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('chkDeferSurpluses').checked = true;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '2';
document.getElementById('chkRoundVotes').checked = true;
document.getElementById('txtRoundVotes').value = '2';
document.getElementById('chkRoundTVs').checked = true;
document.getElementById('txtRoundTVs').value = '2';
document.getElementById('chkRoundWeights').checked = true;
document.getElementById('txtRoundTVs').value = '2';
document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'eg';
document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'by_value';
document.getElementById('selTies').value = 'backwards_random';
}
}
@ -308,14 +350,17 @@ async function clickCount() {
'options': {
'quota_criterion': document.getElementById('selQuotaCriterion').value,
'quota': document.getElementById('selQuota').value,
'prog_quota': document.getElementById('chkProgQuota').checked,
'quota_mode': document.getElementById('selQuotaMode').value,
'bulk_elect': document.getElementById('chkBulkElection').checked,
'bulk_exclude': document.getElementById('chkBulkExclusion').checked,
'defer_surpluses': document.getElementById('chkDeferSurpluses').checked,
'surplus_order': document.getElementById('selSurplus').value,
'papers': document.getElementById('selPapers').value,
'exclusion': document.getElementById('selExclusion').value,
'ties': document.getElementById('selTies').value,
'round_quota': document.getElementById('chkRoundQuota').checked ? parseInt(document.getElementById('txtRoundQuota').value) : null,
'round_votes': document.getElementById('chkRoundVotes').checked ? parseInt(document.getElementById('txtRoundVotes').value) : null,
'round_tvs': document.getElementById('chkRoundTVs').checked ? parseInt(document.getElementById('txtRoundTVs').value) : null,
'round_weights': document.getElementById('chkRoundWeights').checked ? parseInt(document.getElementById('txtRoundWeights').value) : null,
},
'seed': document.getElementById('txtSeed').value,

View File

@ -29,11 +29,16 @@ 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('--quota-mode', choices=['static', 'progressive', 'ers97'], default='static', help='whether to apply a form of progressive quota (default: static)')
parser.add_argument('--no-bulk-elect', action='store_true', help='disable bulk election unless absolutely required')
parser.add_argument('--bulk-exclude', action='store_true', help='use bulk exclusion')
parser.add_argument('--defer-surpluses', action='store_true', help='defer surplus transfers if possible')
parser.add_argument('--numbers', '-n', choices=['fixed', 'rational', '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('--no-round-quota', action='store_true', help='do not round the quota')
parser.add_argument('--round-quota', type=int, help='round quota to specified decimal places')
parser.add_argument('--round-votes', type=int, help='round votes to specified decimal places')
parser.add_argument('--round-tvs', type=int, help='round transfer values to specified decimal places')
parser.add_argument('--round-weights', type=int, help='round ballot weights to specified decimal places')
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('--method', '-m', choices=['wig', 'uig', 'eg'], default='wig', help='method of transferring surpluses (default: wig - weighted inclusive Gregory)')
@ -72,7 +77,11 @@ def print_step(args, stage, result):
print('Exhausted: {} ({})'.format(result.exhausted.votes.pp(2), result.exhausted.transfers.pp(2)))
print('Loss to fraction: {} ({})'.format(result.loss_fraction.votes.pp(2), result.loss_fraction.transfers.pp(2)))
print('Total votes: {}'.format(result.total.pp(2)))
print('Quota: {}'.format(result.quota.pp(2)))
if args.quota_mode == 'ers97' and result.vote_required_election < result.quota:
print('Vote required for election: {}'.format(result.vote_required_election.pp(2)))
else:
print('Quota: {}'.format(result.quota.pp(2)))
print()
@ -97,6 +106,9 @@ def main(args):
else:
counter = WIGSTVCounter(election, vars(args))
if args.no_round_quota:
counter.options['round_quota'] = None
if args.ties is None:
args.ties = ['prompt']
@ -108,11 +120,11 @@ def main(args):
counter.options['ties'].append(TiesPrompt())
elif t == 'random':
if args.random_seed is None:
print('A --random-seed is required to use random tie breaking')
print('A --random-seed is required to use random tie breaking.')
sys.exit(1)
counter.options['ties'].append(TiesRandom(args.random_seed))
counter.options['bulk_elect'] = not args.no_bulk_election
counter.options['bulk_elect'] = not args.no_bulk_elect
counter.options['papers'] = 'transferable' if args.transferable_only else 'both'
# Reset

View File

@ -61,15 +61,19 @@ class BaseSTVCounter:
# Default options
self.options = {
'prog_quota': False, # Progressively reducing quota?
'bulk_elect': True, # Bulk election?
'bulk_exclude': False, # Bulk exclusion?
'defer_surpluses': False, # Defer surpluses?
'quota': 'droop', # 'droop', 'droop_exact', 'hare' or 'hare_exact'
'quota_criterion': 'geq', # 'geq' or 'gt'
'quota_mode': 'static', # 'static', 'progressive' or 'ers97'
'surplus_order': 'size', # 'size' or 'order'
'papers': 'both', # 'both' or 'transferable'
'exclusion': 'one_round', # 'one_round', 'parcels_by_order', 'by_value' or 'wright'
'ties': [], # List of tie strategies (e.g. TiesRandom)
'round_quota': None, # Number of decimal places or None
'round_votes': None, # Number of decimal places or None
'round_tvs': None, # Number of decimal places or None
'round_weights': None, # Number of decimal places or None
}
@ -97,9 +101,12 @@ class BaseSTVCounter:
Does not reset the states of candidates, etc.
"""
self._exclusion = None # Optimisation to avoid re-collating/re-sorting ballots
self.distribute_first_preferences()
self.quota = None
self.vote_required_election = None # For ERS97
self.compute_quota()
self.elect_meeting_quota()
@ -110,7 +117,8 @@ class BaseSTVCounter:
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota
self.quota,
self.vote_required_election,
)
__pragma__('noopov')
@ -162,8 +170,8 @@ class BaseSTVCounter:
return result
# Insufficient winners and no surpluses to distribute
# Exclude the lowest ranked hopeful
result = self.exclude_candidate()
# Exclude the lowest ranked hopeful(s)
result = self.exclude_candidates()
if result:
return result
@ -205,13 +213,43 @@ class BaseSTVCounter:
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota
self.quota,
self.vote_required_election,
)
__pragma__('noopov')
self.step_results.append(result)
return result
def can_defer_surpluses(self, has_surplus):
"""
Determine if the specified surpluses can be deferred
"""
# Do not defer if this could change the last 2 candidates
__pragma__('opov')
total_surpluses = sum((cc.votes - self.quota for c, cc in has_surplus), Num(0))
__pragma__('noopov')
hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL]
hopefuls.sort(key=lambda x: x[1].votes)
__pragma__('opov')
if total_surpluses > hopefuls[1][1].votes - hopefuls[0][1].votes:
return False
__pragma__('noopov')
# Do not defer if this could affect a bulk exclusion
if self.options['bulk_exclude']:
to_bulk_exclude = self.candidates_to_bulk_exclude(hopefuls)
if len(to_bulk_exclude) > 0:
total_excluded = sum((cc.votes for c, cc in to_bulk_exclude), Num(0))
__pragma__('opov')
if total_surpluses > hopefuls[len(to_bulk_exclude) + 1][1].votes - total_excluded:
return False
__pragma__('opov')
# Can defer surpluses
return True
def distribute_surpluses(self):
"""
Distribute surpluses, if any
@ -221,26 +259,40 @@ class BaseSTVCounter:
if any(cc.state == CandidateState.EXCLUDING for c, cc in self.candidates.items()):
return
candidate_surplus, count_card = None, None
# Are we distributing a surplus?
has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.DISTRIBUTING_SURPLUS]
# Do surpluses need to be distributed?
if len(has_surplus) == 0:
if len(has_surplus) > 0:
candidate_surplus, count_card = has_surplus[0]
else:
# 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 specified order
if self.options['surplus_order'] == 'size':
has_surplus.sort(key=lambda x: x[1].votes, reverse=True)
candidate_surplus, count_card = self.choose_highest(has_surplus) # May need to break ties
elif self.options['surplus_order'] == 'order':
has_surplus.sort(key=lambda x: x[1].order_elected)
candidate_surplus, count_card = has_surplus[0] # Ties were already broken when these were assigned
else:
raise STVException('Invalid surplus order option')
if len(has_surplus) > 0:
# Distribute surpluses in specified order
if self.options['surplus_order'] == 'size':
has_surplus.sort(key=lambda x: x[1].votes, reverse=True)
elif self.options['surplus_order'] == 'order':
has_surplus.sort(key=lambda x: x[1].order_elected)
else:
raise STVException('Invalid surplus order option')
# Attempt to defer all remaining surpluses if possible
if self.options['defer_surpluses']:
if self.can_defer_surpluses(has_surplus):
has_surplus = []
if len(has_surplus) > 0:
# Cannot defer any surpluses
if self.options['surplus_order'] == 'size':
candidate_surplus, count_card = self.choose_highest(has_surplus) # May need to break ties
elif self.options['surplus_order'] == 'order':
candidate_surplus, count_card = has_surplus[0] # Ties were already broken when these were assigned
if candidate_surplus is not None:
count_card.state = CandidateState.DISTRIBUTING_SURPLUS
__pragma__('opov')
@ -262,7 +314,8 @@ class BaseSTVCounter:
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota
self.quota,
self.vote_required_election,
)
__pragma__('noopov')
@ -300,24 +353,27 @@ class BaseSTVCounter:
self.exhausted,
self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota
self.quota,
self.vote_required_election,
)
__pragma__('noopov')
self.step_results.append(result)
return result
def exclude_candidate(self):
def exclude_candidates(self):
"""
Exclude the lowest ranked hopeful
Exclude the lowest ranked hopeful(s)
"""
candidate_excluded, count_card = self.candidate_to_exclude()
count_card.state = CandidateState.EXCLUDING
candidates_excluded = self.candidates_to_exclude()
for candidate, count_card in candidates_excluded:
count_card.state = CandidateState.EXCLUDING
# Handle Wright STV
if self.options['exclusion'] == 'wright':
count_card.state = CandidateState.EXCLUDED
for candidate, count_card in candidates_excluded:
count_card.state = CandidateState.EXCLUDED
# Reset the count
# Carry over certain candidate states
@ -342,12 +398,12 @@ class BaseSTVCounter:
step_results = self.step_results # Carry over step results
result = self.reset()
self.step_results = step_results
result.comment = 'Exclusion of ' + candidate_excluded.name
result.comment = 'Exclusion of ' + ', '.join([c.name for c, cc in candidates_excluded])
return result
# Exclude this candidate
self.do_exclusion(candidate_excluded, count_card)
self.do_exclusion(candidates_excluded)
# Declare any candidates meeting the quota as a result of exclusion
self.compute_quota()
@ -356,36 +412,87 @@ class BaseSTVCounter:
__pragma__('opov')
result = CountStepResult(
'Exclusion of ' + candidate_excluded.name,
'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.quota,
self.vote_required_election,
)
__pragma__('noopov')
self.step_results.append(result)
return result
def candidate_to_exclude(self):
def candidates_to_bulk_exclude(self, hopefuls):
"""
Determine the candidate to exclude
Determine which candidates can be bulk excluded
Returns List[Tuple[Candidate, CountCard]]
"""
remaining_candidates = self.num_elected + sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL)
__pragma__('opov')
total_surpluses = sum((cc.votes - self.quota for c, cc in self.candidates.items() if cc.votes > self.quota), Num(0))
__pragma__('noopov')
# Attempt to exclude as many candidates as possible
for i in range(0, len(hopefuls)):
try_exclude = hopefuls[0:len(hopefuls)-i]
# Do not exclude if this splits tied candidates
__pragma__('opov')
if i != 0 and try_exclude[len(hopefuls)-i-1][1].votes == hopefuls[len(hopefuls)-i][1].votes:
continue
__pragma__('noopov')
# Do not exclude if this leaves insufficient candidates
if remaining_candidates - len(try_exclude) < self.election.seats:
continue
# Do not exclude if this could change the order of exclusion
total_votes = sum((cc.votes for c, cc in try_exclude), Num(0))
__pragma__('opov')
if i != 0 and total_votes + total_surpluses > hopefuls[len(hopefuls)-i][1].votes:
continue
__pragma__('noopov')
# Can bulk exclude
return try_exclude
return []
def candidates_to_exclude(self):
"""
Determine the candidate(s) to exclude
Returns List[Tuple[Candidate, CountCard]]
"""
# Continue current exclusion if applicable
excluding = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.EXCLUDING]
if len(excluding) > 0:
return excluding[0][0], excluding[0][1]
if self._exclusion is not None:
__pragma__('opov')
return self._exclusion[0]
__pragma__('noopov')
hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL]
hopefuls.sort(key=lambda x: x[1].votes)
candidate_excluded, count_card = self.choose_lowest(hopefuls)
candidates_excluded = []
return candidate_excluded, count_card
# Bulk exclusion
if self.options['bulk_exclude']:
if self.options['exclusion'] == 'parcels_by_order':
# Ordering of parcels is not defined in this case
raise STVException('Cannot use bulk_exclude with parcels_by_order')
def do_exclusion(self, candidate_excluded, count_card):
candidates_excluded = self.candidates_to_bulk_exclude(hopefuls)
if len(candidates_excluded) == 0:
candidates_excluded = [self.choose_lowest(hopefuls)]
return candidates_excluded
def do_exclusion(self, candidates_excluded):
"""
Exclude the given candidate and transfer the votes
Subclasses must override this function
@ -401,19 +508,33 @@ class BaseSTVCounter:
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.quota is None or self.options['prog_quota']:
if self.options['quota'] == 'droop':
self.quota = (self.total / Num(self.election.seats + 1)).__floor__() + Num('1')
elif self.options['quota'] == 'droop_exact':
if self.quota is None or self.options['quota_mode'] == 'progressive':
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':
self.quota = (self.total / Num(self.election.seats)).__floor__() + Num('1')
elif self.options['quota'] == 'hare_exact':
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')
if self.options['quota_mode'] == 'ers97':
# Calculate the total active vote
__pragma__('opov')
total_active_vote = sum((cc.votes for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL), 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)
__pragma__('noopov')
def meets_quota(self, count_card):
"""
Determine if the given candidate meets the quota
@ -421,11 +542,11 @@ class BaseSTVCounter:
if self.options['quota_criterion'] == 'geq':
__pragma__('opov')
return count_card.votes >= self.quota
return count_card.votes >= self.quota or (self.options['quota_mode'] == 'ers97' and count_card.votes >= self.vote_required_election)
__pragma__('noopov')
elif self.options['quota_criterion'] == 'gt':
__pragma__('opov')
return count_card.votes > self.quota
return count_card.votes > self.quota or (self.options['quota_mode'] == 'ers97' and count_card.votes > self.vote_required_election)
__pragma__('noopov')
else:
raise STVException('Invalid quota criterion')
@ -452,13 +573,16 @@ class BaseSTVCounter:
meets_quota.remove(x)
if self.options['quota_mode'] == 'ers97':
self.elect_meeting_quota() # Repeat as the vote required for election may have changed
# -----------------
# UTILITY FUNCTIONS
# -----------------
def next_preferences(self, parcels):
"""
Examine the specified parcels and group ballot papers by next available preference
Examine the specified ballots and group ballot papers by next available preference
"""
# SafeDict: Candidate -> [List[Ballot], ballots, votes]
next_preferences = SafeDict([(c, [[], Num('0'), Num('0')]) for c, cc in self.candidates.items()])
@ -547,6 +671,11 @@ class BaseSTVCounter:
return num
return num.round(self.options['round_weights'], num.ROUND_DOWN)
def round_tv(self, num):
if self.options['round_tvs'] is None:
return num
return num.round(self.options['round_tvs'], num.ROUND_DOWN)
class WIGSTVCounter(BaseSTVCounter):
"""
Basic weighted inclusive Gregory STV counter
@ -569,28 +698,43 @@ class WIGSTVCounter(BaseSTVCounter):
if len(cand_ballots) > 0:
__pragma__('opov')
self.candidates[candidate].parcels.append(new_parcel)
self.candidates[candidate]._parcels_sorted = False
__pragma__('noopov')
__pragma__('opov')
if self.options['papers'] == 'transferable':
if transferable_votes > surplus:
self.candidates[candidate].transfers += self.round_votes((num_votes * surplus) / transferable_votes)
if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_votes * surplus) / transferable_votes)
else:
tv = self.round_tv(surplus / transferable_votes)
self.candidates[candidate].transfers += self.round_votes(num_votes * tv)
else:
self.candidates[candidate].transfers += self.round_votes(num_votes) # Do not allow weight to increase
else:
self.candidates[candidate].transfers += self.round_votes((num_votes * surplus) / total_votes)
if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_votes * surplus) / total_votes)
else:
tv = self.round_tv(surplus / total_votes)
self.candidates[candidate].transfers += self.round_votes(num_votes * tv)
__pragma__('noopov')
for ballot, ballot_value in cand_ballots:
__pragma__('opov')
if self.options['papers'] == 'transferable':
if transferable_votes > surplus:
new_value = (ballot_value * surplus) / transferable_votes
if self.options['round_tvs'] is None:
new_value = (ballot_value * surplus) / transferable_votes
else:
tv = self.round_tv(surplus / transferable_votes)
new_value = ballot_value * tv
else:
new_value = ballot_value
else:
new_value = (ballot_value * surplus) / total_votes
if self.options['round_tvs'] is None:
new_value = (ballot_value * surplus) / total_votes
else:
tv = self.round_tv(surplus / total_votes)
new_value = ballot_value * tv
new_parcel.append((ballot, self.round_weight(new_value)))
__pragma__('noopov')
@ -611,48 +755,49 @@ class WIGSTVCounter(BaseSTVCounter):
count_card.state = CandidateState.ELECTED
def do_exclusion(self, candidate_excluded, count_card):
if self.options['exclusion'] == 'parcels_by_order':
if len(count_card.parcels) > 0:
parcel = count_card.parcels[0]
next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences([parcel])
else:
# TODO: Skip this entirely if this is the case
parcel = []
count_card.parcels.remove(parcel)
elif self.options['exclusion'] == 'by_value':
# Sort the ballots by value
if not count_card._parcels_sorted:
ballots = [(b, bv) for p in count_card.parcels for b, bv in p]
def do_exclusion(self, candidates_excluded):
# Optimisation: Pre-sort exclusion ballots if applicable
# self._exclusion[1] -> list of ballots-per-stage, ballots-per-stage = List[Tuple[Candidate,List[Ballot+Value]]]
if self._exclusion is None:
if self.options['exclusion'] == 'one_round':
self._exclusion = (candidates_excluded, [[(c, [b for p in cc.parcels for b in p]) for c, cc in candidates_excluded]])
elif self.options['exclusion'] == 'parcels_by_order':
c, cc = candidates_excluded[0]
self._exclusion = (candidates_excluded, [[(c, p)] for p in cc.parcels])
elif self.options['exclusion'] == 'by_value':
ballots = [(c, b, bv) for c, cc in candidates_excluded for p in cc.parcels for b, bv in p]
# Sort ballots by value
__pragma__('opov')
ballots.sort(key=lambda x: x[1] / x[0].value, reverse=True)
ballots.sort(key=lambda x: x[2] / x[1].value, reverse=True)
# Round to 8 decimal places to consider equality
# FIXME: Work out a better way of doing this
count_card.parcels = groupby(ballots, lambda x: (x[1] / x[0].value).round(8, x[1].ROUND_DOWN))
count_card._parcels_sorted = True
if self.options['round_tvs']:
ballots_by_value = groupby(ballots, lambda x: self.round_tv(x[2] / x[1].value))
else:
ballots_by_value = groupby(ballots, lambda x: (x[2] / x[1].value).round(8, x[2].ROUND_DOWN))
__pragma__('noopov')
if len(count_card.parcels) > 0:
parcel = count_card.parcels[0]
count_card.parcels.remove(parcel)
# TODO: Can we combine ballots for each candidate within each stage?
self._exclusion = (candidates_excluded, [[(c, [(b, bv)]) for c, b, bv in x] for x in ballots_by_value])
else:
parcel = []
raise STVException('Invalid exclusion mode')
next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences([parcel])
else: # one_round
next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences(count_card.parcels)
count_card.parcels = []
#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])
for candidate, x in next_preferences.items():
cand_ballots = x[0]
num_ballots = x[1]
num_votes = x[2]
cand_ballots, num_ballots, num_votes = x[0], x[1], x[2]
new_parcel = []
if len(cand_ballots) > 0:
__pragma__('opov')
self.candidates[candidate].parcels.append(new_parcel)
self.candidates[candidate]._parcels_sorted = False
__pragma__('noopov')
__pragma__('opov')
@ -664,19 +809,30 @@ class WIGSTVCounter(BaseSTVCounter):
new_parcel.append((ballot, ballot_value))
__pragma__('noopov')
# Subtract votes
__pragma__('opov')
self.exhausted.transfers += self.round_votes(exhausted_votes)
__pragma__('noopov')
__pragma__('opov')
count_card.transfers -= total_votes
__pragma__('noopov')
for candidate, ballots in this_exclusion:
total_votes = Num(0)
for ballot, ballot_value in ballots:
__pragma__('opov')
total_votes += ballot_value
__pragma__('noopov')
if len(count_card.parcels) == 0:
__pragma__('opov')
count_card.transfers -= count_card.votes
self.candidates[candidate].transfers -= total_votes
__pragma__('noopov')
count_card.state = CandidateState.EXCLUDED
if len(self._exclusion[1]) == 0:
for candidate_excluded, count_card in candidates_excluded:
__pragma__('opov')
count_card.transfers -= count_card.votes
__pragma__('noopov')
count_card.state = CandidateState.EXCLUDED
self._exclusion = None
class UIGSTVCounter(WIGSTVCounter):
"""
@ -704,28 +860,43 @@ class UIGSTVCounter(WIGSTVCounter):
if len(cand_ballots) > 0:
__pragma__('opov')
self.candidates[candidate].parcels.append(new_parcel)
self.candidates[candidate]._parcels_sorted = False
__pragma__('noopov')
__pragma__('opov')
if self.options['papers'] == 'transferable':
if transferable_votes > surplus:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / transferable_ballots)
if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / transferable_ballots)
else:
tv = self.round_tv(surplus / transferable_ballots)
self.candidates[candidate].transfers += self.round_votes(num_ballots * tv)
else:
self.candidates[candidate].transfers += self.round_votes(num_votes)
else:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / total_ballots)
if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / total_ballots)
else:
tv = self.round_tv(surplus / total_ballots)
self.candidates[candidate].transfers += self.round_votes(num_ballots * tv)
__pragma__('noopov')
for ballot, ballot_value in cand_ballots:
__pragma__('opov')
if self.options['papers'] == 'transferable':
if transferable_votes > surplus:
new_value = (ballot.value * surplus) / transferable_ballots
if self.options['round_tvs'] is None:
new_value = (ballot.value * surplus) / transferable_ballots
else:
tv = self.round_tv(surplus / transferable_ballots)
new_value = ballot.value * tv
else:
new_value = ballot_value
else:
new_value = (ballot.value * surplus) / total_ballots
if self.options['round_tvs'] is None:
new_value = (ballot.value * surplus) / total_ballots
else:
tv = self.round_tv(surplus / total_ballots)
new_value = ballot.value * tv
new_parcel.append((ballot, self.round_weight(new_value)))
__pragma__('noopov')
@ -772,28 +943,43 @@ class EGSTVCounter(UIGSTVCounter):
if len(cand_ballots) > 0:
__pragma__('opov')
self.candidates[candidate].parcels.append(new_parcel)
self.candidates[candidate]._parcels_sorted = False
__pragma__('noopov')
__pragma__('opov')
if self.options['papers'] == 'transferable':
if transferable_votes > surplus:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / transferable_ballots)
if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / transferable_ballots)
else:
tv = self.round_tv(surplus / transferable_ballots)
self.candidates[candidate].transfers += self.round_votes(num_ballots * tv)
else:
self.candidates[candidate].transfers += self.round_votes(num_votes)
else:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / total_ballots)
if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / total_ballots)
else:
tv = self.round_tv(surplus / total_ballots)
self.candidates[candidate].transfers += self.round_votes(num_ballots * tv)
__pragma__('noopov')
for ballot, ballot_value in cand_ballots:
__pragma__('opov')
if self.options['papers'] == 'transferable':
if transferable_votes > surplus:
new_value = (ballot.value * surplus) / transferable_ballots
if self.options['round_tvs'] is None:
new_value = (ballot.value * surplus) / transferable_ballots
else:
tv = self.round_tv(surplus / transferable_ballots)
new_value = ballot.value * tv
else:
new_value = ballot_value
else:
new_value = (ballot.value * surplus) / total_ballots
if self.options['round_tvs'] is None:
new_value = (ballot.value * surplus) / total_ballots
else:
tv = self.round_tv(surplus / total_ballots)
new_value = ballot.value * tv
new_parcel.append((ballot, self.round_weight(new_value)))
__pragma__('noopov')

View File

@ -1,5 +1,5 @@
# pyRCV2: Preferential vote counting
# Copyright © 2020 Lee Yingtong Li (RunasSudo)
# Copyright © 2020–2021 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
@ -98,7 +98,7 @@ class CountCompleted:
pass
class CountStepResult:
def __init__(self, comment, candidates, exhausted, loss_fraction, total, quota):
def __init__(self, comment, candidates, exhausted, loss_fraction, total, quota, vote_required_election):
self.comment = comment
self.candidates = candidates # SafeDict: Candidate -> CountCard
@ -107,6 +107,7 @@ class CountStepResult:
self.total = total
self.quota = quota
self.vote_required_election = vote_required_election
def clone(self):
"""Return a clone of this result as a record of this stage"""

View File

@ -1,5 +1,5 @@
# pyRCV2: Preferential vote counting
# Copyright © 2020 Lee Yingtong Li (RunasSudo)
# Copyright © 2020–2021 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
@ -22,12 +22,12 @@ __pragma__('noskip')
if is_py:
__pragma__('skip')
from pyRCV2.numbers.fixed_py import Fixed, set_dps
from pyRCV2.numbers.fixed_py import Fixed, set_dps, get_dps
from pyRCV2.numbers.native_py import Native
from pyRCV2.numbers.rational_py import Rational
__pragma__('noskip')
else:
from pyRCV2.numbers.fixed_js import Fixed, set_dps
from pyRCV2.numbers.fixed_js import Fixed, set_dps, get_dps
from pyRCV2.numbers.native_js import Native
from pyRCV2.numbers.rational_js import Rational

View File

@ -116,6 +116,9 @@ class BaseNum:
def __le__(self, other):
raise NotImplementedError('Method not implemented')
def __pow__(self, power):
raise NotImplementedError('Method not implemented')
def round(self, dps, mode):
"""
Round to the specified number of decimal places, using the ROUND_* mode specified
@ -214,6 +217,10 @@ class BasePyNum(BaseNum):
"""Implements BaseNum.__le__"""
return self.impl <= other.impl
def __pow__(self, power):
"""Implements BaseNum.__pow__"""
return self._from_impl(self.impl ** power)
@compatible_types
def __iadd__(self, other):
"""Overrides BaseNum.__iadd__"""

View File

@ -21,6 +21,9 @@ Big.DP = 6
def set_dps(dps):
Big.DP = dps
def get_dps():
return Big.DP
class Fixed(BaseNum):
"""
Wrapper for big.js (fixed-point arithmetic)
@ -73,6 +76,10 @@ class Fixed(BaseNum):
"""Implements BaseNum.__le__"""
return self.impl.lte(other.impl)
def __pow__(self, power):
"""Implements BaseNum.__pow__"""
return Fixed._from_impl(self.impl.pow(power))
def round(self, dps, mode):
"""Implements BaseNum.round"""
return Fixed(self.impl.round(dps, mode))
return Fixed._from_impl(self.impl.round(dps, mode))

View File

@ -18,12 +18,17 @@ from pyRCV2.numbers.base import BasePyNum, compatible_types
import decimal
_quantize_exp = 6
_dps = 6
_quantize_exp = decimal.Decimal('10') ** -_dps
def set_dps(dps):
global _quantize_exp
global _dps, _quantize_exp
_dps = dps
_quantize_exp = decimal.Decimal('10') ** -dps
def get_dps():
return _dps
class Fixed(BasePyNum):
"""
Wrapper for Python Decimal (for fixed-point arithmetic)
@ -43,4 +48,4 @@ class Fixed(BasePyNum):
def round(self, dps, mode):
"""Implements BaseNum.round"""
return Fixed(self.impl.quantize(decimal.Decimal('10') ** -dps, mode))
return Fixed._from_impl(self.impl.quantize(decimal.Decimal('10') ** -dps, mode))

View File

@ -74,17 +74,21 @@ class Native(BaseNum):
def __floor__(self):
"""Overrides BaseNum.__floor__"""
return Native(Math.floor(self.impl))
return Native._from_impl(Math.floor(self.impl))
def __pow__(self, power):
"""Implements BaseNum.__pow__"""
return Native._from_impl(Math.pow(self.impl, power))
def round(self, dps, mode):
"""Implements BaseNum.round"""
if mode == Native.ROUND_DOWN:
return Native(Math.floor(self.impl * Math.pow(10, dps)) / Math.pow(10, dps))
return Native._from_impl(Math.floor(self.impl * Math.pow(10, dps)) / Math.pow(10, dps))
elif mode == Native.ROUND_HALF_UP:
return Native(Math.round(self.impl * Math.pow(10, dps)) / Math.pow(10, dps))
return Native._from_impl(Math.round(self.impl * Math.pow(10, dps)) / Math.pow(10, dps))
elif mode == Native.ROUND_HALF_EVEN:
raise NotImplementedError('ROUND_HALF_EVEN is not implemented in JS Native context')
elif mode == Native.ROUND_UP:
return Native(Math.ceil(self.impl * Math.pow(10, dps)) / Math.pow(10, dps))
return Native._from_impl(Math.ceil(self.impl * Math.pow(10, dps)) / Math.pow(10, dps))
else:
raise ValueError('Invalid rounding mode')

View File

@ -29,12 +29,12 @@ class Native(BasePyNum):
"""Implements BaseNum.round"""
factor = 10 ** dps
if mode == Native.ROUND_DOWN:
return Native(math.floor(self.impl * factor) / factor)
return Native._from_impl(math.floor(self.impl * factor) / factor)
elif mode == Native.ROUND_HALF_UP:
raise NotImplementedError('ROUND_HALF_UP is not implemented in Python Native context')
elif mode == Native.ROUND_HALF_EVEN:
return Native(round(self.impl * factor) / factor)
return Native._from_impl(round(self.impl * factor) / factor)
elif mode == Native.ROUND_UP:
return Native(math.ceil(self.impl * factor) / factor)
return Native._from_impl(math.ceil(self.impl * factor) / factor)
else:
raise ValueError('Invalid rounding mode')

View File

@ -71,18 +71,22 @@ class Rational(BaseNum):
def __floor__(self):
"""Overrides BaseNum.__floor__"""
return Rational(self.impl.floor())
return Rational._from_impl(self.impl.floor())
def __pow__(self, power):
"""Implements BaseNum.__pow__"""
return Rational._from_impl(self.impl.pow(power))
def round(self, dps, mode):
"""Implements BaseNum.round"""
factor = bigRat(10).pow(dps)
if mode == Rational.ROUND_DOWN:
return Rational(self.impl.multiply(factor).floor().divide(factor))
return Rational._from_impl(self.impl.multiply(factor).floor().divide(factor))
elif mode == Rational.ROUND_HALF_UP:
return Rational(self.impl.multiply(factor).round().divide(factor))
return Rational._from_impl(self.impl.multiply(factor).round().divide(factor))
elif mode == Rational.ROUND_HALF_EVEN:
raise NotImplementedError('ROUND_HALF_EVEN is not implemented in JS Native context')
elif mode == Rational.ROUND_UP:
return Rational(self.impl.multiply(factor).ceil().divide(factor))
return Rational._from_impl(self.impl.multiply(factor).ceil().divide(factor))
else:
raise ValueError('Invalid rounding mode')

View File

@ -35,12 +35,12 @@ class Rational(BasePyNum):
"""Implements BaseNum.round"""
factor = Fraction(10) ** dps
if mode == Rational.ROUND_DOWN:
return Rational(math.floor(self.impl * factor) / factor)
return Rational._from_impl(math.floor(self.impl * factor) / factor)
elif mode == Rational.ROUND_HALF_UP:
raise NotImplementedError('ROUND_HALF_UP is not implemented in Python Rational context')
elif mode == Rational.ROUND_HALF_EVEN:
return Rational(round(self.impl * factor) / factor)
return Rational._from_impl(round(self.impl * factor) / factor)
elif mode == Rational.ROUND_UP:
return Rational(math.ceil(self.impl * factor) / factor)
return Rational._from_impl(math.ceil(self.impl * factor) / factor)
else:
raise ValueError('Invalid rounding mode')

51
tests/data/ers97.blt Normal file
View File

@ -0,0 +1,51 @@
11 6
35 1 2
3 1 3 9
11 1 3 6
5 1 3 7
6 1 3 8
20 1 4 0
7 1 4 9
4 1 4 11
8 1 5
4 1 8
1 1 10 5 7
1 1 10 9
18 1 11
11 1 0
81 2
23 3 1 2 0
2 3 1 2 7
1 3 1 2 8
1 3 1 0
3 4 1 3 7
1 4 8
11 4 5 6
3 4 2 0
5 4 2 11
1 4 3 0
105 5
91 6
64 7
5 8
42 8 9
12 8 7
55 9
2 10 5
5 10 8
14 10 9
2 10 1 11
90 11
0
"Smith"
"Carpenter"
"Wright"
"Glazier"
"Duke"
"Prince"
"Baron"
"Abbot"
"Vicar"
"Monk"
"Freeman"
"ERS97 Model Election"

View File

@ -1,5 +1,5 @@
# pyRCV2: Preferential vote counting
# Copyright © 2020 Lee Yingtong Li (RunasSudo)
# Copyright © 2020–2021 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
@ -47,6 +47,7 @@ def test_aec_tas19():
counter = UIGSTVCounter(election, {
'surplus_order': 'order',
'exclusion': 'by_value',
'round_quota': 0,
'round_votes': 0,
})
result = counter.reset()

View File

@ -43,7 +43,10 @@ def test_csm15():
cands = {c.name: c for c in election.candidates}
counter = WIGSTVCounter(election, {'exclusion': 'wright'})
counter = WIGSTVCounter(election, {
'exclusion': 'wright',
'round_quota': 0,
})
# Round 1
result = counter.reset()

View File

@ -1,5 +1,5 @@
# pyRCV2: Preferential vote counting
# Copyright © 2020 Lee Yingtong Li (RunasSudo)
# Copyright © 2020–2021 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
@ -45,7 +45,8 @@ def test_prsa1():
counter = EGSTVCounter(election, {
'surplus_order': 'order',
'papers': 'transferable',
'exclusion': 'parcels_by_order'
'exclusion': 'parcels_by_order',
'round_quota': 0,
})
# Stage 1