This repository has been archived on 2021-05-25. You can view files and clone it, but cannot push or open issues or pull requests.
pyRCV2/pyRCV2/model.py

184 lines
6.2 KiB
Python

# 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/>.
__pragma__ = lambda x: None
from pyRCV2.numbers import Num
class Candidate:
def __init__(self, name):
self.name = name
def __repr__(self):
return '<Candidate ' + self.name + '>'
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