From 66f07343549270652351c17d0354ee18761a0304 Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Sun, 16 May 2021 22:32:49 +1000 Subject: [PATCH] Implement constrained guarding/dooming of candidates --- pyRCV2/cli/stv.py | 25 +++- pyRCV2/con.py | 67 +++++++++ pyRCV2/constraints.py | 248 +++++++++++++++++++++++++------- pyRCV2/method/base_stv.py | 121 ++++++++++++---- pyRCV2/method/meek.py | 20 ++- pyRCV2/model.py | 44 ++++-- pyRCV2/safedict/safedict_js.py | 6 + pyRCV2/safedict/safedict_py.py | 6 + tests/data/prsa1_constr1.con | 2 + tests/data/prsa1_constr2.con | 2 + tests/test_constraints_otten.py | 1 + 11 files changed, 437 insertions(+), 105 deletions(-) create mode 100644 pyRCV2/con.py create mode 100644 tests/data/prsa1_constr1.con create mode 100644 tests/data/prsa1_constr2.con diff --git a/pyRCV2/cli/stv.py b/pyRCV2/cli/stv.py index 24c24ad..dee8f5b 100644 --- a/pyRCV2/cli/stv.py +++ b/pyRCV2/cli/stv.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -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)) diff --git a/pyRCV2/con.py b/pyRCV2/con.py new file mode 100644 index 0000000..0c657a9 --- /dev/null +++ b/pyRCV2/con.py @@ -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 . + +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 diff --git a/pyRCV2/constraints.py b/pyRCV2/constraints.py index 3c58823..cd86d4c 100644 --- a/pyRCV2/constraints.py +++ b/pyRCV2/constraints.py @@ -14,9 +14,19 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -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 ''.format(self.elected, self.min, self.max, self.cands) + return ''.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 diff --git a/pyRCV2/method/base_stv.py b/pyRCV2/method/base_stv.py index f394771..ddfa839 100644 --- a/pyRCV2/method/base_stv.py +++ b/pyRCV2/method/base_stv.py @@ -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) diff --git a/pyRCV2/method/meek.py b/pyRCV2/method/meek.py index 1af62b4..c6608d2 100644 --- a/pyRCV2/method/meek.py +++ b/pyRCV2/method/meek.py @@ -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): """ diff --git a/pyRCV2/model.py b/pyRCV2/model.py index fdc7ccb..167e070 100644 --- a/pyRCV2/model.py +++ b/pyRCV2/model.py @@ -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 diff --git a/pyRCV2/safedict/safedict_js.py b/pyRCV2/safedict/safedict_js.py index fe78603..7d678f3 100644 --- a/pyRCV2/safedict/safedict_js.py +++ b/pyRCV2/safedict/safedict_js.py @@ -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) diff --git a/pyRCV2/safedict/safedict_py.py b/pyRCV2/safedict/safedict_py.py index 351d746..2d7e1cb 100644 --- a/pyRCV2/safedict/safedict_py.py +++ b/pyRCV2/safedict/safedict_py.py @@ -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 diff --git a/tests/data/prsa1_constr1.con b/tests/data/prsa1_constr1.con new file mode 100644 index 0000000..7933815 --- /dev/null +++ b/tests/data/prsa1_constr1.con @@ -0,0 +1,2 @@ +"Gender" "Men" 0 2 2 3 4 6 +"Gender" "Women" 2 99 1 5 7 diff --git a/tests/data/prsa1_constr2.con b/tests/data/prsa1_constr2.con new file mode 100644 index 0000000..fd3710e --- /dev/null +++ b/tests/data/prsa1_constr2.con @@ -0,0 +1,2 @@ +"Gender" "Men" 0 99 2 3 4 6 +"Gender" "Women" 2 99 1 5 7 diff --git a/tests/test_constraints_otten.py b/tests/test_constraints_otten.py index cf65ec7..955df42 100644 --- a/tests/test_constraints_otten.py +++ b/tests/test_constraints_otten.py @@ -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