Implement constrained guarding/dooming of candidates

This commit is contained in:
RunasSudo 2021-05-16 22:32:49 +10:00
parent 64f698e182
commit 66f0734354
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
11 changed files with 437 additions and 105 deletions

View File

@ -14,7 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import pyRCV2.blt
import pyRCV2.blt, pyRCV2.con
import pyRCV2.model
import pyRCV2.numbers
@ -32,22 +32,30 @@ 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('--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', 'gfixed', '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', 'meek'], default='wig', help='method of transferring surpluses (default: wig - weighted inclusive Gregory)')
parser.add_argument('--transferable-only', action='store_true', help='examine only transferable papers during surplus distributions')
parser.add_argument('--exclusion', choices=['one_round', 'parcels_by_order', 'by_value', 'wright'], default='one_round', help='how to perform exclusions (default: one_round)')
parser.add_argument('--ties', '-t', action='append', choices=['backwards', 'forwards', 'prompt', 'random'], default=None, help='how to resolve ties (default: prompt)')
parser.add_argument('--random-seed', default=None, help='arbitrary string used to seed the RNG for random tie breaking')
parser.add_argument('--constraints', default=None, help='CON file specifying constraints for the election')
#parser.add_argument('--constraint-mode', choices=['matrix', 'guarding'], default=None, help='mode of dealing with constraints (default: matrix)')
parser.add_argument('--hide-excluded', action='store_true', help='hide excluded candidates from results report')
parser.add_argument('--sort-votes', action='store_true', help='sort candidates by votes in results report')
parser.add_argument('--pp-decimals', type=int, default=2, help='print votes to specified decimal places in results report (default: 2)')
@ -71,6 +79,10 @@ def print_step(args, stage, result):
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.DOOMED:
state = 'Doomed'
elif count_card.state == pyRCV2.model.CandidateState.GUARDED:
state = 'Guarded'
elif count_card.state == pyRCV2.model.CandidateState.WITHDRAWN:
state = 'Withdrawn'
@ -120,9 +132,6 @@ 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']
@ -143,6 +152,10 @@ def main(args):
counter.options['bulk_elect'] = not args.no_bulk_elect
counter.options['papers'] = 'transferable' if args.transferable_only else 'both'
if args.constraints is not None:
with open(args.constraints, 'r') as f:
election.constraints = pyRCV2.con.readCON(f.read(), election)
# Print report
print('Count computed by pyRCV2 (development version). Read {} ballots from "{}" for election "{}". There are {} candidates for {} vacancies. Counting using options "{}".'.format(sum((b.value for b in election.ballots), pyRCV2.numbers.Num(0)).pp(0), args.file, election.name, len(election.candidates), election.seats, counter.describe_options()))
print()
@ -164,5 +177,5 @@ def main(args):
print_step(args, stage, result)
print('Count complete. The winning candidates are, in order of election:')
for x in sorted(((c, cc) for c, cc in result.candidates.items() if cc.state == CandidateState.ELECTED), key=lambda x: x[1].order_elected):
for x in sorted(((c, cc) for c, cc in result.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED or cc.state == CandidateState.DISTRIBUTING_SURPLUS or cc.state == CandidateState.ELECTED), key=lambda x: x[1].order_elected):
print('{}. {}'.format(x[1].order_elected , x[0].name))

67
pyRCV2/con.py Normal file
View File

@ -0,0 +1,67 @@
# pyRCV2: Preferential vote counting
# 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from pyRCV2.model import Constraint
def CONException(Exception):
pass
def readCON(data, election):
lines = data.split('\n')
constraints = {}
for line in lines:
if not line:
continue
bits = line.split(' ')
# Read constraint category
if bits[0].startswith('"'):
idx = next(i for i in range(len(bits)) if bits[i].endswith('"'))
category = ' '.join(bits[0:idx+1]).strip('"')
else:
idx = 0
category = bits[0]
# Read constraint name
idx += 1
if bits[idx].startswith('"'):
idx2 = next(i for i in range(idx, len(bits)) if bits[i].endswith('"'))
name = ' '.join(bits[idx:idx2+1]).strip('"')
idx = idx2
else:
name = bits[idx]
# Read min, max
n_min = int(bits[idx + 1])
n_max = int(bits[idx + 2])
# Read candidates
candidates = []
for v in bits[idx+3:]:
candidates.append(election.candidates[int(v)-1])
if category not in constraints:
constraints[category] = {}
if name in constraints[category]:
raise CONException('Duplicate constraint "' + name + '" in category "' + category + '"')
constraints[category][name] = Constraint(category, name, n_min, n_max, candidates)
return constraints

View File

@ -14,9 +14,19 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from pyRCV2.method.base_stv import STVException
__pragma__ = lambda x: None
from pyRCV2.model import CandidateState
from pyRCV2.safedict import SafeDict
class ConstraintException(Exception):
pass
class ConstraintMatrix:
"""
Represents a constraint matrix/table, as described by Otten
"""
def __init__(self, dimensions):
self.dimensions = dimensions
@ -46,7 +56,7 @@ class ConstraintMatrixCell:
self.cands = 0
def __repr__(self):
return '<ConstraintMatrixCell: E={}, Max={}, Min={}, C={}>'.format(self.elected, self.min, self.max, self.cands)
return '<ConstraintMatrixCell: E={}, Min={}, Max={}, C={}>'.format(self.elected, self.min, self.max, self.cands)
def ndrange(dimensions):
# n-dimensional range function
@ -58,16 +68,87 @@ def ndrange(dimensions):
yield from ([n] for n in range(dimensions[0]))
return
__pragma__('opov')
yield from ([n] + j for n in range(dimensions[0]) for j in ndrange(dimensions[1:]))
__pragma__('noopov')
def init_matrix(counter):
counter._constraint_matrix = ConstraintMatrix([len(category) for category_name, category in counter.election.constraints.items()])
"""Initialise matrix with initial constraints"""
cm = ConstraintMatrix([len(category) for category_name, category in counter.election.constraints.items()])
counter._constraint_matrix = cm
update_matrix(counter)
# Add min/max to margins
for i, x in enumerate(counter.election.constraints.items()):
category_name, category = x
for j, y in enumerate(category.items()):
group_name, constraint = y
address = [-1 for _ in range(len(cm.dimensions))]
address[i] = j
cell = cm.get(address)
cell.min = constraint.min
cell.max = constraint.max
def candidate_to_address(constraints, candidate):
address = []
for category_name, category in constraints.items():
for i, x in enumerate(category.items()):
group_name, constraint = x
if candidate in constraint.candidates:
address.append(i)
return address
def update_matrix(counter):
# Update matrix with elected numbers
...
"""Update matrix with elected/hopeful numbers"""
cm = counter._constraint_matrix
# Reset elected/cands
for address in ndrange(cm.dimensions):
cell = cm.get(address)
cell.elected = 0
cell.cands = 0
# Update grand total cell as well
cell_gt = cm.get([-1 for _ in range(len(cm.dimensions))])
cell_gt.elected = 0
cell_gt.cands = 0
# Count elected/cands
for candidate, count_card in counter.candidates.items():
if count_card.state in (CandidateState.HOPEFUL, CandidateState.GUARDED, CandidateState.PROVISIONALLY_ELECTED, CandidateState.DISTRIBUTING_SURPLUS, CandidateState.ELECTED):
address = candidate_to_address(counter.election.constraints, candidate)
cm.get(address).cands += 1
cell_gt.cands += 1
if len(cm.dimensions) > 1:
# If only 1 dimension, this is equivalent to cell_gt
for dimension in range(len(cm.dimensions)):
addr2 = [x for x in address]
addr2[dimension] = -1
cm.get(addr2).cands += 1
if count_card.state in (CandidateState.PROVISIONALLY_ELECTED, CandidateState.DISTRIBUTING_SURPLUS, CandidateState.ELECTED):
address = candidate_to_address(counter.election.constraints, candidate)
cm.get(address).elected += 1
cell_gt.elected += 1
if len(cm.dimensions) > 1:
# If only 1 dimension, this is equivalent to cell_gt
for dimension in range(len(cm.dimensions)):
addr2 = [x for x in address]
addr2[dimension] = -1
cm.get(addr2).elected += 1
cell_gt.max = cell_gt.cands
def step_matrix(counter):
"""
Adjust the matrix one step towards stability
Return True if stable, or False if an adjustment was required
"""
cm = counter._constraint_matrix
# Rule 1
@ -79,66 +160,125 @@ def step_matrix(counter):
cell.max = cell.cands
return False
if cell.min > cell.max:
raise STVException('No result conformant with the constraints is possible')
raise ConstraintException('No result conformant with the constraints is possible')
# Rule 2/3
for address in ndrange(cm.dimensions):
cell = cm.get(address)
for dimension in range(len(cm.dimensions)):
tot_min_others = 0
tot_max_others = 0
addr2 = [x for x in address]
for i in range(cm.dimensions[dimension]):
if i == address[dimension]:
for dimension1 in range(len(cm.dimensions)): # Which margin total
for dimension2 in range(len(cm.dimensions)): # Which axis to check along
if dimension1 == dimension2:
continue
addr2[dimension] = i
cell2 = cm.get(addr2)
tot_min_others += cell2.min
tot_max_others += cell2.max
addr2[dimension] = -1
cell_dimension = cm.get(addr2)
min_dimension = cell_dimension.min
max_dimension = cell_dimension.max
# This many must be elected from this cell at least
this_cell_min = min_dimension - tot_max_others
this_cell_max = max_dimension - tot_min_others
# Rule 2
if this_cell_min > cell.min:
cell.min = this_cell_min
return False
# Rule 3
if this_cell_max < cell.max:
cell.max = this_cell_max
return False
tot_min_others = 0
tot_max_others = 0
addr2 = [x for x in address]
for i in range(cm.dimensions[dimension2]):
if i == address[dimension2]:
continue
addr2[dimension2] = i
cell2 = cm.get(addr2)
tot_min_others += cell2.min
tot_max_others += cell2.max
#addr2[dimension] = -1
addr2 = [-1 for _ in range(len(cm.dimensions))]
addr2[dimension1] = address[dimension1]
cell_dimension = cm.get(addr2)
min_dimension = cell_dimension.min
max_dimension = cell_dimension.max
# This many must be elected from this cell at least
this_cell_min = min_dimension - tot_max_others
this_cell_max = max_dimension - tot_min_others
# Rule 2
if this_cell_min > cell.min:
cell.min = this_cell_min
return False
# Rule 3
if this_cell_max < cell.max:
cell.max = this_cell_max
return False
# Rule 4/5
for dimension in range(len(cm.dimensions)):
for addr1 in ndrange(cm.dimensions[:dimension]):
for addr2 in ndrange(cm.dimensions[dimension+1:]):
tot_min = 0
tot_max = 0
for addr_d in range(cm.dimensions[dimension]):
address = addr1 + [addr_d] + addr2
for n in range(cm.dimensions[dimension]):
tot_min = 0
tot_max = 0
for addr1 in ndrange(cm.dimensions[:dimension]):
for addr2 in ndrange(cm.dimensions[dimension+1:]):
__pragma__('opov')
address = addr1 + [n] + addr2
__pragma__('noopov')
tot_min += cm.get(address).min
tot_max += cm.get(address).max
address = addr1 + [-1] + addr2
cell = cm.get(address)
# Rule 4
if cell.min < tot_min:
cell.min = tot_min
# Rule 5
if cell.max > tot_max:
cell.max = tot_max
address = [-1 for _ in range(len(cm.dimensions))]
address[dimension] = n
cell = cm.get(address)
# Rule 4
if cell.min < tot_min:
cell.min = tot_min
# Rule 5
if cell.max > tot_max:
cell.max = tot_max
return True
def stabilise_matrix(counter):
"""Run step_matrix until entering a stable state"""
while True:
result = step_matrix(counter)
if result == True:
return
def guard_or_doom(counter):
"""Guard or doom candidates as required"""
cm = counter._constraint_matrix
logs = []
for address in ndrange(cm.dimensions):
cell = cm.get(address)
if cell.elected == cell.max:
# Doom remaining candidates in this group
cands_doomed = SafeDict([(c, cc) for c, cc in counter.candidates.items() if cc.state == CandidateState.HOPEFUL])
for i, x in enumerate(counter.election.constraints.items()):
category_name, category = x
for candidate, count_card in list(cands_doomed.items()):
if candidate not in category[list(category.keys())[address[i]]].candidates:
#del cands_doomed[candidate] # Transcrypt NYI
cands_doomed.__delitem__(candidate)
for candidate, count_card in cands_doomed.items():
count_card.state = CandidateState.DOOMED
if len(cands_doomed) > 0:
logs.append(counter.pretty_join([c.name for c, cc in cands_doomed.items()]) + ' must be doomed to comply with constraints.')
if cell.cands == cell.min:
# Guard remaining candidates in this group
cands_guarded = SafeDict([(c, cc) for c, cc in counter.candidates.items() if cc.state == CandidateState.HOPEFUL])
for i, x in enumerate(counter.election.constraints.items()):
category_name, category = x
for candidate, count_card in list(cands_guarded.items()):
if candidate not in category[list(category.keys())[address[i]]].candidates:
#del cands_guarded[candidate] # Transcrypt NYI
cands_guarded.__delitem__(candidate)
for candidate, count_card in cands_guarded.items():
count_card.state = CandidateState.GUARDED
if len(cands_guarded) > 0:
logs.append(counter.pretty_join([c.name for c, cc in cands_guarded.items()]) + ' must be guarded to comply with constraints.')
return logs

View File

@ -16,6 +16,7 @@
__pragma__ = lambda x: None
from pyRCV2 import constraints
from pyRCV2.model import BallotInCount, CandidateState, CountCard, CountCompleted, CountStepResult
import pyRCV2.numbers
from pyRCV2.numbers import Num
@ -37,20 +38,21 @@ class BaseSTVCounter:
# Default options
self.options = {
'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
'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
'constraint_mode': 'guard_doom', # 'guard_doom' or 'rollback' (NYI)
}
if options is not None:
@ -84,6 +86,11 @@ class BaseSTVCounter:
self._exclusion = None # Optimisation to avoid re-collating/re-sorting ballots
if self.election.constraints:
constraints.init_matrix(self)
constraints.stabilise_matrix(self)
self.logs.extend(constraints.guard_or_doom(self))
self.distribute_first_preferences()
self.logs.append('First preferences distributed.')
@ -104,7 +111,7 @@ class BaseSTVCounter:
for i, candidate in enumerate(ballot.preferences):
count_card = self.candidates[candidate]
if count_card.state == CandidateState.HOPEFUL:
if count_card.state == CandidateState.HOPEFUL or count_card.state == CandidateState.GUARDED:
count_card.transfers += ballot.value
if len(count_card.parcels) == 0:
count_card.parcels.append([BallotInCount(ballot, Num(ballot.value), i)])
@ -131,6 +138,11 @@ class BaseSTVCounter:
if result:
return result
# Exclude doomed candidates
result = self.exclude_doomed()
if result:
return result
# Distribute surpluses
result = self.distribute_surpluses()
if result:
@ -143,7 +155,7 @@ class BaseSTVCounter:
# Insufficient winners and no surpluses to distribute
# Exclude the lowest ranked hopeful(s)
result = self.exclude_candidates()
result = self.exclude_hopefuls()
if result:
return result
@ -184,7 +196,7 @@ class BaseSTVCounter:
# Include EXCLUDING to avoid interrupting an exclusion
if len(self.election.candidates) - self.num_withdrawn - 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
candidates_elected = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL]
candidates_elected = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.GUARDED]
if len(candidates_elected) == 1:
self.logs.append(candidates_elected[0][0].name + ' is elected to fill the remaining vacancy.')
else:
@ -194,6 +206,11 @@ class BaseSTVCounter:
count_card.state = CandidateState.PROVISIONALLY_ELECTED
self.num_elected += 1
count_card.order_elected = self.num_elected
if self.election.constraints:
constraints.update_matrix(self)
constraints.stabilise_matrix(self)
self.logs.extend(constraints.guard_or_doom(self))
return self.make_result('Bulk election')
@ -206,7 +223,7 @@ class BaseSTVCounter:
__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 = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.GUARDED]
hopefuls.sort(key=lambda x: x[1].votes)
__pragma__('opov')
if total_surpluses > hopefuls[1][1].votes - hopefuls[0][1].votes:
@ -302,7 +319,7 @@ class BaseSTVCounter:
if not self.options['bulk_elect']:
if len(self.election.candidates) - self.num_withdrawn - self.num_excluded <= 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 = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.GUARDED]
hopefuls.sort(key=lambda x: x[1].votes, reverse=True)
order_elected = []
@ -314,6 +331,11 @@ class BaseSTVCounter:
self.num_elected += 1
x[1].order_elected = self.num_elected
if self.election.constraints:
constraints.update_matrix(self)
constraints.stabilise_matrix(self)
self.logs.extend(constraints.guard_or_doom(self))
order_elected.append(x[0].name)
hopefuls.remove(x)
@ -324,17 +346,53 @@ class BaseSTVCounter:
return self.make_result('Bulk election')
def exclude_candidates(self):
def exclude_doomed(self):
"""
Exclude doomed candidate(s)
"""
# Do not interrupt an exclusion
if any(cc.state == CandidateState.EXCLUDING for c, cc in self.candidates.items()):
return
candidates_excluded = {(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.DOOMED}
if len(candidates_excluded) > 0:
if len(candidates_excluded) == 1:
self.logs.append('Excluding doomed candidate ' + candidates_excluded[0][0].name + '.')
else:
self.logs.append('Excluding doomed candidates ' + self.pretty_join([c.name for c, cc in candidates_excluded]) + '.')
return self.exclude_candidates(candidates_excluded)
def exclude_hopefuls(self):
"""
Exclude the lowest ranked hopeful(s)
"""
candidates_excluded = self.candidates_to_exclude()
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.')
return self.exclude_candidates(candidates_excluded)
def exclude_candidates(self, candidates_excluded):
"""
Exclude the given candidate(s)
"""
for candidate, count_card in candidates_excluded:
if count_card.state != CandidateState.EXCLUDING:
count_card.state = CandidateState.EXCLUDING
self.num_excluded += 1
count_card.order_elected = -self.num_excluded
if self.election.constraints:
constraints.update_matrix(self)
constraints.stabilise_matrix(self)
self.logs.extend(constraints.guard_or_doom(self))
# Handle Wright STV
if self.options['exclusion'] == 'wright':
@ -441,14 +499,8 @@ class BaseSTVCounter:
candidates_excluded = self.candidates_to_bulk_exclude(hopefuls)
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:
if len(candidates_excluded) == 0:
candidates_excluded = [self.choose_lowest(hopefuls)]
self.logs.append('No surpluses to distribute, so ' + candidates_excluded[0][0].name + ' is excluded.')
return candidates_excluded
@ -495,7 +547,7 @@ class BaseSTVCounter:
__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 for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.GUARDED or cc.state == CandidateState.EXCLUDING), Num('0')) + \
sum((cc.votes - self.quota for c, cc in self.candidates.items() if (cc.state == CandidateState.PROVISIONALLY_ELECTED or cc.state == CandidateState.DISTRIBUTING_SURPLUS or cc.state == CandidateState.ELECTED) and 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:
@ -526,13 +578,14 @@ class BaseSTVCounter:
"""
# Does a candidate meet the quota?
meets_quota = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL and self.meets_quota(cc)]
meets_quota = [(c, cc) for c, cc in self.candidates.items() if (cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.GUARDED) and self.meets_quota(cc)]
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:
# FIXME: Fix the messages when one of these candidates is doomed
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
@ -544,7 +597,13 @@ class BaseSTVCounter:
self.num_elected += 1
count_card.order_elected = self.num_elected
meets_quota.remove(x)
if self.election.constraints:
constraints.update_matrix(self)
constraints.stabilise_matrix(self)
self.logs.extend(constraints.guard_or_doom(self))
meets_quota = [(c, cc) for c, cc in self.candidates.items() if (cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.GUARDED) and self.meets_quota(cc)]
meets_quota.sort(key=lambda x: x[1].votes, reverse=True)
if self.options['quota_mode'] == 'ers97':
self.compute_quota() # Vote required for election may have changed
@ -635,7 +694,7 @@ class BaseSTVCounter:
candidate = bc.ballot.preferences[i]
count_card = self.candidates[candidate]
if count_card.state == CandidateState.HOPEFUL:
if count_card.state == CandidateState.HOPEFUL or count_card.state == CandidateState.GUARDED:
#next_preferences[candidate][0].append(BallotInCount(bc.ballot, bc.ballot_value, i))
bc.last_preference = i
next_preferences[candidate][0].append(bc)

View File

@ -18,6 +18,7 @@ DEBUG_MEEK = False
__pragma__ = lambda x: None
from pyRCV2 import constraints
from pyRCV2.method.base_stv import BaseSTVCounter, STVException
from pyRCV2.model import CandidateState, CountCard
from pyRCV2.numbers import Num
@ -125,6 +126,11 @@ class MeekSTVCounter(BaseSTVCounter):
self._exclusion = None # Optimisation to avoid re-collating/re-sorting ballots
if self.election.constraints:
constraints.init_matrix(self)
constraints.stabilise_matrix(self)
self.logs.extend(constraints.guard_or_doom(self))
self.distribute_first_preferences()
self.logs.append('First preferences distributed.')
@ -146,12 +152,12 @@ class MeekSTVCounter(BaseSTVCounter):
count_card = self.candidates[candidate]
__pragma__('noopov')
if count_card.state == CandidateState.HOPEFUL:
if count_card.state == CandidateState.HOPEFUL or count_card.state == CandidateState.GUARDED:
# Hopeful candidate has keep value 1, so transfer entire remaining value
__pragma__('opov')
count_card.transfers += remaining_multiplier * cand_tree.num
__pragma__('noopov')
elif count_card.state == CandidateState.EXCLUDED or count_card.state == CandidateState.WITHDRAWN:
elif count_card.state == CandidateState.EXCLUDED or count_card.state == CandidateState.WITHDRAWN or count_card.state == CandidateState.DOOMED:
# Excluded candidate has keep value 0, so skip over this candidate
# Recurse
self.distribute_recursively(cand_tree, remaining_multiplier)
@ -278,7 +284,7 @@ class MeekSTVCounter(BaseSTVCounter):
"""
# Does a candidate meet the quota?
meets_quota = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL and self.meets_quota(cc)]
meets_quota = [(c, cc) for c, cc in self.candidates.items() if (cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.GUARDED) and self.meets_quota(cc)]
if len(meets_quota) > 0:
meets_quota.sort(key=lambda x: x[1].votes, reverse=True)
@ -296,7 +302,13 @@ class MeekSTVCounter(BaseSTVCounter):
self.num_elected += 1
count_card.order_elected = self.num_elected
meets_quota.remove(x)
if self.election.constraints:
constraints.update_matrix(self)
constraints.stabilise_matrix(self)
self.logs.extend(constraints.guard_or_doom(self))
meets_quota = [(c, cc) for c, cc in self.candidates.items() if (cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.GUARDED) and self.meets_quota(cc)]
meets_quota.sort(key=lambda x: x[1].votes, reverse=True)
def compute_quota(self):
"""

View File

@ -29,13 +29,15 @@ class Candidate:
return repr(self)
class CandidateState:
HOPEFUL = 0
PROVISIONALLY_ELECTED = 10
DISTRIBUTING_SURPLUS = 20
ELECTED = 30
EXCLUDING = 40
EXCLUDED = 50
WITHDRAWN = 60
HOPEFUL = 0 # Continuing candidate
GUARDED = 10 # Due to constraints, cannot be excluded - await quota for election
PROVISIONALLY_ELECTED = 20 # Declared elected, awaiting surplus transfer (FIXME: Remove this)
DISTRIBUTING_SURPLUS = 30 # Distributing the surplus of this candidate (FIXME: Remove this)
ELECTED = 40 # Declared elected and no further surplus to transfer
DOOMED = 50 # Due to constraints, cannot be elected - exclude at next opportunity
EXCLUDING = 60 # Distributing the votes of this excluded candidate (FIXME: Remove this)
EXCLUDED = 70 # Declared excluded
WITHDRAWN = 80 # Withdrawn before count
class Ballot:
"""
@ -62,18 +64,40 @@ class BallotInCount:
# Optimisation: Record the most-recently used preference so earlier preferences do not need to be later examined
self.last_preference = last_preference
def clone(self):
return BallotInCount(self.ballot, self.ballot_value, self.last_preference)
class Constraint:
"""
Represents a constraint within a constraint group
"""
def __init__(self, category_name, name, min, max, candidates):
self.category_name = category_name
self.name = name
self.min = min
self.max = max
self.candidates = candidates
class Election:
"""
Represents a BLT election
"""
def __init__(self, name='', seats=0, candidates=None, ballots=None, withdrawn=None):
def __init__(self, name='', seats=0, candidates=None, ballots=None, withdrawn=None, constraints=None):
self.name = name
self.seats = seats
self.candidates = candidates if candidates is not None else []
self.ballots = ballots if ballots is not None else []
self.withdrawn = withdrawn if withdrawn is not None else []
self.constraints = constraints if constraints is not None else {}
def validate_constraints(self):
"""Confirm that each constraint features each candidate once and only once"""
# TODO
return True
class CountCard:
"""
@ -86,7 +110,7 @@ class CountCard:
self.order_elected = None # Negative for order of exclusion
# self.parcels = List[Parcel]
# Parcel = List[Tuple[Ballot, Num]]
# Parcel = List[BallotInCount]
# The exhausted/loss to fraction piles will have only one parcel
self.parcels = []
self._parcels_sorted = False # Optimisation to avoid re-sorting in exclusion by_value
@ -107,7 +131,7 @@ class CountCard:
result = CountCard()
result.orig_votes = self.orig_votes
result.transfers = self.transfers
result.parcels = [[(b[0].clone(), b[1]) for b in p] for p in self.parcels]
result.parcels = [[b.clone() for b in p] for p in self.parcels]
result.state = self.state
result.order_elected = self.order_elected
return result

View File

@ -30,6 +30,12 @@ class SafeDict:
def __setitem__(self, key, value):
self.impl.set(key, value)
def __delitem__(self, key):
self.impl.delete(key)
def __len__(self):
return self.impl.size
def __contains__(self, key):
return self.impl.has(key)

View File

@ -29,6 +29,12 @@ class SafeDict:
def __setitem__(self, key, value):
self.impl[key] = value
def __delitem__(self, key):
del self.impl[key]
def __len__(self):
return len(self.impl)
def __contains__(self, key):
return key in self.impl

View File

@ -0,0 +1,2 @@
"Gender" "Men" 0 2 2 3 4 6
"Gender" "Women" 2 99 1 5 7

View File

@ -0,0 +1,2 @@
"Gender" "Men" 0 99 2 3 4 6
"Gender" "Women" 2 99 1 5 7

View File

@ -49,6 +49,7 @@ def test_constraints_otten():
assert constraints.step_matrix(counter) == False
assert constraints.step_matrix(counter) == False
assert constraints.step_matrix(counter) == False
assert constraints.step_matrix(counter) == False
assert constraints.step_matrix(counter) == True
assert cm.get([0, 0]).max == 4