Implement constrained guarding/dooming of candidates
This commit is contained in:
parent
64f698e182
commit
66f0734354
@ -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
67
pyRCV2/con.py
Normal 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
|
@ -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,27 +160,33 @@ 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)):
|
||||
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
|
||||
|
||||
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 i in range(cm.dimensions[dimension2]):
|
||||
if i == address[dimension2]:
|
||||
continue
|
||||
addr2[dimension] = i
|
||||
addr2[dimension2] = i
|
||||
|
||||
cell2 = cm.get(addr2)
|
||||
tot_min_others += cell2.min
|
||||
tot_max_others += cell2.max
|
||||
|
||||
addr2[dimension] = -1
|
||||
#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
|
||||
@ -120,17 +207,20 @@ def step_matrix(counter):
|
||||
|
||||
# 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:]):
|
||||
for n in range(cm.dimensions[dimension]):
|
||||
tot_min = 0
|
||||
tot_max = 0
|
||||
|
||||
for addr_d in range(cm.dimensions[dimension]):
|
||||
address = addr1 + [addr_d] + addr2
|
||||
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
|
||||
address = [-1 for _ in range(len(cm.dimensions))]
|
||||
address[dimension] = n
|
||||
cell = cm.get(address)
|
||||
|
||||
# Rule 4
|
||||
@ -142,3 +232,53 @@ def step_matrix(counter):
|
||||
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
|
||||
|
@ -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
|
||||
@ -51,6 +52,7 @@ class BaseSTVCounter:
|
||||
'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:
|
||||
@ -195,6 +207,11 @@ class BaseSTVCounter:
|
||||
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')
|
||||
|
||||
def can_defer_surpluses(self, has_surplus):
|
||||
@ -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,18 +346,54 @@ 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':
|
||||
for candidate, count_card in candidates_excluded:
|
||||
@ -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)
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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:
|
||||
"""
|
||||
@ -63,17 +65,39 @@ 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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
2
tests/data/prsa1_constr1.con
Normal file
2
tests/data/prsa1_constr1.con
Normal file
@ -0,0 +1,2 @@
|
||||
"Gender" "Men" 0 2 2 3 4 6
|
||||
"Gender" "Women" 2 99 1 5 7
|
2
tests/data/prsa1_constr2.con
Normal file
2
tests/data/prsa1_constr2.con
Normal file
@ -0,0 +1,2 @@
|
||||
"Gender" "Men" 0 99 2 3 4 6
|
||||
"Gender" "Women" 2 99 1 5 7
|
@ -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
|
||||
|
Reference in New Issue
Block a user