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 # 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/>. # 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.model
import pyRCV2.numbers 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', '-q', choices=['droop', 'droop_exact', 'hare', 'hare_exact'], default='droop', help='quota calculation (default: droop)')
parser.add_argument('--quota-criterion', '-c', choices=['geq', 'gt'], default='geq', help='quota criterion (default: geq)') parser.add_argument('--quota-criterion', '-c', choices=['geq', 'gt'], default='geq', help='quota criterion (default: geq)')
parser.add_argument('--quota-mode', choices=['static', 'progressive', 'ers97'], default='static', help='whether to apply a form of progressive quota (default: static)') 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('--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('--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('--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('--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('--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-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-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-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('--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('--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('--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('--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('--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('--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('--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('--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('--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)') 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) state = 'ELECTED {}'.format(count_card.order_elected)
elif count_card.state == pyRCV2.model.CandidateState.EXCLUDED or count_card.state == pyRCV2.model.CandidateState.EXCLUDING: elif count_card.state == pyRCV2.model.CandidateState.EXCLUDED or count_card.state == pyRCV2.model.CandidateState.EXCLUDING:
state = 'Excluded {}'.format(-count_card.order_elected) 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: elif count_card.state == pyRCV2.model.CandidateState.WITHDRAWN:
state = 'Withdrawn' state = 'Withdrawn'
@ -120,9 +132,6 @@ def main(args):
else: else:
counter = WIGSTVCounter(election, vars(args)) counter = WIGSTVCounter(election, vars(args))
#if args.no_round_quota:
# counter.options['round_quota'] = None
if args.ties is None: if args.ties is None:
args.ties = ['prompt'] args.ties = ['prompt']
@ -143,6 +152,10 @@ def main(args):
counter.options['bulk_elect'] = not args.no_bulk_elect counter.options['bulk_elect'] = not args.no_bulk_elect
counter.options['papers'] = 'transferable' if args.transferable_only else 'both' 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 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('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() print()
@ -164,5 +177,5 @@ def main(args):
print_step(args, stage, result) print_step(args, stage, result)
print('Count complete. The winning candidates are, in order of election:') 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)) 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 # 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/>. # 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: class ConstraintMatrix:
"""
Represents a constraint matrix/table, as described by Otten
"""
def __init__(self, dimensions): def __init__(self, dimensions):
self.dimensions = dimensions self.dimensions = dimensions
@ -46,7 +56,7 @@ class ConstraintMatrixCell:
self.cands = 0 self.cands = 0
def __repr__(self): 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): def ndrange(dimensions):
# n-dimensional range function # n-dimensional range function
@ -58,16 +68,87 @@ def ndrange(dimensions):
yield from ([n] for n in range(dimensions[0])) yield from ([n] for n in range(dimensions[0]))
return return
__pragma__('opov')
yield from ([n] + j for n in range(dimensions[0]) for j in ndrange(dimensions[1:])) yield from ([n] + j for n in range(dimensions[0]) for j in ndrange(dimensions[1:]))
__pragma__('noopov')
def init_matrix(counter): 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): 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): 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 cm = counter._constraint_matrix
# Rule 1 # Rule 1
@ -79,66 +160,125 @@ def step_matrix(counter):
cell.max = cell.cands cell.max = cell.cands
return False return False
if cell.min > cell.max: 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 # Rule 2/3
for address in ndrange(cm.dimensions): for address in ndrange(cm.dimensions):
cell = cm.get(address) cell = cm.get(address)
for dimension in range(len(cm.dimensions)): for dimension1 in range(len(cm.dimensions)): # Which margin total
tot_min_others = 0 for dimension2 in range(len(cm.dimensions)): # Which axis to check along
tot_max_others = 0 if dimension1 == dimension2:
addr2 = [x for x in address]
for i in range(cm.dimensions[dimension]):
if i == address[dimension]:
continue continue
addr2[dimension] = i
cell2 = cm.get(addr2) tot_min_others = 0
tot_min_others += cell2.min tot_max_others = 0
tot_max_others += cell2.max
addr2[dimension] = -1 addr2 = [x for x in address]
cell_dimension = cm.get(addr2) for i in range(cm.dimensions[dimension2]):
min_dimension = cell_dimension.min if i == address[dimension2]:
max_dimension = cell_dimension.max continue
addr2[dimension2] = i
# This many must be elected from this cell at least cell2 = cm.get(addr2)
this_cell_min = min_dimension - tot_max_others tot_min_others += cell2.min
this_cell_max = max_dimension - tot_min_others tot_max_others += cell2.max
# Rule 2 #addr2[dimension] = -1
if this_cell_min > cell.min: addr2 = [-1 for _ in range(len(cm.dimensions))]
cell.min = this_cell_min addr2[dimension1] = address[dimension1]
return False cell_dimension = cm.get(addr2)
min_dimension = cell_dimension.min
max_dimension = cell_dimension.max
# Rule 3 # This many must be elected from this cell at least
if this_cell_max < cell.max: this_cell_min = min_dimension - tot_max_others
cell.max = this_cell_max this_cell_max = max_dimension - tot_min_others
return False
# 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 # Rule 4/5
for dimension in range(len(cm.dimensions)): for dimension in range(len(cm.dimensions)):
for addr1 in ndrange(cm.dimensions[:dimension]): for n in range(cm.dimensions[dimension]):
for addr2 in ndrange(cm.dimensions[dimension+1:]): tot_min = 0
tot_min = 0 tot_max = 0
tot_max = 0
for addr_d in range(cm.dimensions[dimension]): for addr1 in ndrange(cm.dimensions[:dimension]):
address = addr1 + [addr_d] + addr2 for addr2 in ndrange(cm.dimensions[dimension+1:]):
__pragma__('opov')
address = addr1 + [n] + addr2
__pragma__('noopov')
tot_min += cm.get(address).min tot_min += cm.get(address).min
tot_max += cm.get(address).max tot_max += cm.get(address).max
address = addr1 + [-1] + addr2 address = [-1 for _ in range(len(cm.dimensions))]
cell = cm.get(address) address[dimension] = n
cell = cm.get(address)
# Rule 4 # Rule 4
if cell.min < tot_min: if cell.min < tot_min:
cell.min = tot_min cell.min = tot_min
# Rule 5 # Rule 5
if cell.max > tot_max: if cell.max > tot_max:
cell.max = tot_max cell.max = tot_max
return True 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 __pragma__ = lambda x: None
from pyRCV2 import constraints
from pyRCV2.model import BallotInCount, CandidateState, CountCard, CountCompleted, CountStepResult from pyRCV2.model import BallotInCount, CandidateState, CountCard, CountCompleted, CountStepResult
import pyRCV2.numbers import pyRCV2.numbers
from pyRCV2.numbers import Num from pyRCV2.numbers import Num
@ -37,20 +38,21 @@ class BaseSTVCounter:
# Default options # Default options
self.options = { self.options = {
'bulk_elect': True, # Bulk election? 'bulk_elect': True, # Bulk election?
'bulk_exclude': False, # Bulk exclusion? 'bulk_exclude': False, # Bulk exclusion?
'defer_surpluses': False, # Defer surpluses? 'defer_surpluses': False, # Defer surpluses?
'quota': 'droop', # 'droop', 'droop_exact', 'hare' or 'hare_exact' 'quota': 'droop', # 'droop', 'droop_exact', 'hare' or 'hare_exact'
'quota_criterion': 'geq', # 'geq' or 'gt' 'quota_criterion': 'geq', # 'geq' or 'gt'
'quota_mode': 'static', # 'static', 'progressive' or 'ers97' 'quota_mode': 'static', # 'static', 'progressive' or 'ers97'
'surplus_order': 'size', # 'size' or 'order' 'surplus_order': 'size', # 'size' or 'order'
'papers': 'both', # 'both' or 'transferable' 'papers': 'both', # 'both' or 'transferable'
'exclusion': 'one_round', # 'one_round', 'parcels_by_order', 'by_value' or 'wright' 'exclusion': 'one_round', # 'one_round', 'parcels_by_order', 'by_value' or 'wright'
'ties': [], # List of tie strategies (e.g. TiesRandom) 'ties': [], # List of tie strategies (e.g. TiesRandom)
'round_quota': None, # Number of decimal places or None 'round_quota': None, # Number of decimal places or None
'round_votes': 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_tvs': None, # Number of decimal places or None
'round_weights': 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: if options is not None:
@ -84,6 +86,11 @@ class BaseSTVCounter:
self._exclusion = None # Optimisation to avoid re-collating/re-sorting ballots 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.distribute_first_preferences()
self.logs.append('First preferences distributed.') self.logs.append('First preferences distributed.')
@ -104,7 +111,7 @@ class BaseSTVCounter:
for i, candidate in enumerate(ballot.preferences): for i, candidate in enumerate(ballot.preferences):
count_card = self.candidates[candidate] 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 count_card.transfers += ballot.value
if len(count_card.parcels) == 0: if len(count_card.parcels) == 0:
count_card.parcels.append([BallotInCount(ballot, Num(ballot.value), i)]) count_card.parcels.append([BallotInCount(ballot, Num(ballot.value), i)])
@ -131,6 +138,11 @@ class BaseSTVCounter:
if result: if result:
return result return result
# Exclude doomed candidates
result = self.exclude_doomed()
if result:
return result
# Distribute surpluses # Distribute surpluses
result = self.distribute_surpluses() result = self.distribute_surpluses()
if result: if result:
@ -143,7 +155,7 @@ class BaseSTVCounter:
# Insufficient winners and no surpluses to distribute # Insufficient winners and no surpluses to distribute
# Exclude the lowest ranked hopeful(s) # Exclude the lowest ranked hopeful(s)
result = self.exclude_candidates() result = self.exclude_hopefuls()
if result: if result:
return result return result
@ -184,7 +196,7 @@ class BaseSTVCounter:
# Include EXCLUDING to avoid interrupting an exclusion # 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: 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 # 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: if len(candidates_elected) == 1:
self.logs.append(candidates_elected[0][0].name + ' is elected to fill the remaining vacancy.') self.logs.append(candidates_elected[0][0].name + ' is elected to fill the remaining vacancy.')
else: else:
@ -195,6 +207,11 @@ class BaseSTVCounter:
self.num_elected += 1 self.num_elected += 1
count_card.order_elected = self.num_elected 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') return self.make_result('Bulk election')
def can_defer_surpluses(self, has_surplus): def can_defer_surpluses(self, has_surplus):
@ -206,7 +223,7 @@ class BaseSTVCounter:
__pragma__('opov') __pragma__('opov')
total_surpluses = sum((cc.votes - self.quota for c, cc in has_surplus), Num(0)) total_surpluses = sum((cc.votes - self.quota for c, cc in has_surplus), Num(0))
__pragma__('noopov') __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) hopefuls.sort(key=lambda x: x[1].votes)
__pragma__('opov') __pragma__('opov')
if total_surpluses > hopefuls[1][1].votes - hopefuls[0][1].votes: if total_surpluses > hopefuls[1][1].votes - hopefuls[0][1].votes:
@ -302,7 +319,7 @@ class BaseSTVCounter:
if not self.options['bulk_elect']: if not self.options['bulk_elect']:
if len(self.election.candidates) - self.num_withdrawn - self.num_excluded <= self.election.seats: if len(self.election.candidates) - self.num_withdrawn - self.num_excluded <= self.election.seats:
# Declare elected one remaining candidate at a time # 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) hopefuls.sort(key=lambda x: x[1].votes, reverse=True)
order_elected = [] order_elected = []
@ -314,6 +331,11 @@ class BaseSTVCounter:
self.num_elected += 1 self.num_elected += 1
x[1].order_elected = self.num_elected 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) order_elected.append(x[0].name)
hopefuls.remove(x) hopefuls.remove(x)
@ -324,18 +346,54 @@ class BaseSTVCounter:
return self.make_result('Bulk election') 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) Exclude the lowest ranked hopeful(s)
""" """
candidates_excluded = self.candidates_to_exclude() 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: for candidate, count_card in candidates_excluded:
if count_card.state != CandidateState.EXCLUDING: if count_card.state != CandidateState.EXCLUDING:
count_card.state = CandidateState.EXCLUDING count_card.state = CandidateState.EXCLUDING
self.num_excluded += 1 self.num_excluded += 1
count_card.order_elected = -self.num_excluded 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 # Handle Wright STV
if self.options['exclusion'] == 'wright': if self.options['exclusion'] == 'wright':
for candidate, count_card in candidates_excluded: for candidate, count_card in candidates_excluded:
@ -441,14 +499,8 @@ class BaseSTVCounter:
candidates_excluded = self.candidates_to_bulk_exclude(hopefuls) candidates_excluded = self.candidates_to_bulk_exclude(hopefuls)
if len(candidates_excluded) > 0: 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:
candidates_excluded = [self.choose_lowest(hopefuls)] candidates_excluded = [self.choose_lowest(hopefuls)]
self.logs.append('No surpluses to distribute, so ' + candidates_excluded[0][0].name + ' is excluded.')
return candidates_excluded return candidates_excluded
@ -495,7 +547,7 @@ class BaseSTVCounter:
__pragma__('opov') __pragma__('opov')
orig_vre = self.vote_required_election orig_vre = self.vote_required_election
total_active_vote = \ 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')) 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) self.vote_required_election = total_active_vote / Num(self.election.seats - self.num_elected + 1)
if self.options['round_votes'] is not None: if self.options['round_votes'] is not None:
@ -526,13 +578,14 @@ class BaseSTVCounter:
""" """
# Does a candidate meet the quota? # 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: if len(meets_quota) > 0:
meets_quota.sort(key=lambda x: x[1].votes, reverse=True) meets_quota.sort(key=lambda x: x[1].votes, reverse=True)
if len(meets_quota) == 1: if len(meets_quota) == 1:
self.logs.append(meets_quota[0][0].name + ' meets the quota and is elected.') self.logs.append(meets_quota[0][0].name + ' meets the quota and is elected.')
else: 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.') 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 # Declare elected any candidate who meets the quota
@ -544,7 +597,13 @@ class BaseSTVCounter:
self.num_elected += 1 self.num_elected += 1
count_card.order_elected = self.num_elected 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': if self.options['quota_mode'] == 'ers97':
self.compute_quota() # Vote required for election may have changed self.compute_quota() # Vote required for election may have changed
@ -635,7 +694,7 @@ class BaseSTVCounter:
candidate = bc.ballot.preferences[i] candidate = bc.ballot.preferences[i]
count_card = self.candidates[candidate] 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)) #next_preferences[candidate][0].append(BallotInCount(bc.ballot, bc.ballot_value, i))
bc.last_preference = i bc.last_preference = i
next_preferences[candidate][0].append(bc) next_preferences[candidate][0].append(bc)

View File

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

View File

@ -29,13 +29,15 @@ class Candidate:
return repr(self) return repr(self)
class CandidateState: class CandidateState:
HOPEFUL = 0 HOPEFUL = 0 # Continuing candidate
PROVISIONALLY_ELECTED = 10 GUARDED = 10 # Due to constraints, cannot be excluded - await quota for election
DISTRIBUTING_SURPLUS = 20 PROVISIONALLY_ELECTED = 20 # Declared elected, awaiting surplus transfer (FIXME: Remove this)
ELECTED = 30 DISTRIBUTING_SURPLUS = 30 # Distributing the surplus of this candidate (FIXME: Remove this)
EXCLUDING = 40 ELECTED = 40 # Declared elected and no further surplus to transfer
EXCLUDED = 50 DOOMED = 50 # Due to constraints, cannot be elected - exclude at next opportunity
WITHDRAWN = 60 EXCLUDING = 60 # Distributing the votes of this excluded candidate (FIXME: Remove this)
EXCLUDED = 70 # Declared excluded
WITHDRAWN = 80 # Withdrawn before count
class Ballot: class Ballot:
""" """
@ -63,17 +65,39 @@ class BallotInCount:
# Optimisation: Record the most-recently used preference so earlier preferences do not need to be later examined # Optimisation: Record the most-recently used preference so earlier preferences do not need to be later examined
self.last_preference = last_preference 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: class Election:
""" """
Represents a BLT 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.name = name
self.seats = seats self.seats = seats
self.candidates = candidates if candidates is not None else [] self.candidates = candidates if candidates is not None else []
self.ballots = ballots if ballots is not None else [] self.ballots = ballots if ballots is not None else []
self.withdrawn = withdrawn if withdrawn 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: class CountCard:
""" """
@ -86,7 +110,7 @@ class CountCard:
self.order_elected = None # Negative for order of exclusion self.order_elected = None # Negative for order of exclusion
# self.parcels = List[Parcel] # self.parcels = List[Parcel]
# Parcel = List[Tuple[Ballot, Num]] # Parcel = List[BallotInCount]
# The exhausted/loss to fraction piles will have only one parcel # The exhausted/loss to fraction piles will have only one parcel
self.parcels = [] self.parcels = []
self._parcels_sorted = False # Optimisation to avoid re-sorting in exclusion by_value self._parcels_sorted = False # Optimisation to avoid re-sorting in exclusion by_value
@ -107,7 +131,7 @@ class CountCard:
result = CountCard() result = CountCard()
result.orig_votes = self.orig_votes result.orig_votes = self.orig_votes
result.transfers = self.transfers 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.state = self.state
result.order_elected = self.order_elected result.order_elected = self.order_elected
return result return result

View File

@ -30,6 +30,12 @@ class SafeDict:
def __setitem__(self, key, value): def __setitem__(self, key, value):
self.impl.set(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): def __contains__(self, key):
return self.impl.has(key) return self.impl.has(key)

View File

@ -29,6 +29,12 @@ class SafeDict:
def __setitem__(self, key, value): def __setitem__(self, key, value):
self.impl[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): def __contains__(self, key):
return key in self.impl 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) == False assert constraints.step_matrix(counter) == False
assert constraints.step_matrix(counter) == False
assert constraints.step_matrix(counter) == True assert constraints.step_matrix(counter) == True
assert cm.get([0, 0]).max == 4 assert cm.get([0, 0]).max == 4