# 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 . __pragma__ = lambda x: None from pyRCV2.numbers import Num class Candidate: def __init__(self, name): self.name = name def __repr__(self): return '' def toString(self): # pragma: no cover return repr(self) class CandidateState: 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: """ Represents a voter's (or if weighted, multiple voters') preferences """ def __init__(self, value, preferences): self.value = value self.preferences = preferences def clone(self): return Ballot(self.value, self.preferences) class BallotInCount: """ Represents a Ballot held by a candidate at a particular value during the count """ __slots__ = ['ballot', 'ballot_value', 'last_preference'] def __init__(self, ballot, ballot_value, last_preference): self.ballot = ballot self.ballot_value = ballot_value # 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, 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""" from pyRCV2.constraints import ConstraintException for category_name, category in self.constraints.items(): candidates = [x for x in self.candidates] for group_name, group in category.items(): for candidate in group.candidates: if candidate in candidates: candidates.remove(candidate) else: raise ConstraintException('Candidate "' + candidate.name + '" duplicated in category "' + category_name + '"') if len(candidates) > 0: raise ConstraintException('Candidate "' + candidates[0].name + '" not assigned to a group in category "' + category_name + '"') class CountCard: """ Represents a Candidate's (or exhausted pile) current progress in the count """ def __init__(self): self.orig_votes = Num('0') self.transfers = Num('0') self.state = CandidateState.HOPEFUL self.order_elected = None # Negative for order of exclusion # self.parcels = List[Parcel] # 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 @property def votes(self): __pragma__('opov') return self.orig_votes + self.transfers __pragma__('noopov') def step(self): """Roll over previous round transfers in preparation for next round""" self.orig_votes = self.votes self.transfers = Num('0') def clone(self): """Return a clone of this count card (including cloning ballots) as a record of this stage""" result = CountCard() result.orig_votes = self.orig_votes result.transfers = self.transfers 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 def continue_from(self, previous): """Adjust this count card's transfers, etc. so its total votes continue on from the previous values""" votes = self.votes self.orig_votes = previous.votes __pragma__('opov') self.transfers = votes - self.orig_votes __pragma__('noopov') class CountStepResult: def __init__(self, stage_kind, comment, logs, candidates, exhausted, loss_fraction, total, quota, vote_required_election): self.stage_kind = stage_kind self.comment = comment self.logs = logs self.candidates = candidates # SafeDict: Candidate -> CountCard self.exhausted = exhausted # CountCard self.loss_fraction = loss_fraction # CountCard self.total = total self.quota = quota self.vote_required_election = vote_required_election def clone(self): """Return a clone of this result as a record of this stage""" candidates = SafeDict() for c, cc in self.candidates.items(): __pragma__('opov') candidates[c] = cc.clone() __pragma__('noopov') return CountStepResult(self.stage_kind, self.comment, candidates, self.exhausted.clone(), self.loss_fraction.clone(), self.total, self.quota) class CountCompleted(CountStepResult): pass