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