From 59f6f266880d8177790abfa1250e1e0ab483cc72 Mon Sep 17 00:00:00 2001 From: Felix Sargent Date: Fri, 17 Jul 2026 23:19:22 +0100 Subject: [PATCH 1/2] Split voting methods into package modules Co-authored-by: Cursor --- src/vse_sim/methods.py | 1047 ----------------------- src/vse_sim/methods/__init__.py | 32 + src/vse_sim/methods/borda.py | 74 ++ src/vse_sim/methods/bullety_approval.py | 39 + src/vse_sim/methods/irnr.py | 58 ++ src/vse_sim/methods/irv.py | 161 ++++ src/vse_sim/methods/irv_prime.py | 98 +++ src/vse_sim/methods/mav.py | 154 ++++ src/vse_sim/methods/mj.py | 45 + src/vse_sim/methods/plurality.py | 29 + src/vse_sim/methods/ranked_pairs.py | 26 + src/vse_sim/methods/schulze.py | 137 +++ src/vse_sim/methods/score.py | 83 ++ src/vse_sim/methods/srv.py | 41 + src/vse_sim/methods/v321.py | 145 ++++ 15 files changed, 1122 insertions(+), 1047 deletions(-) delete mode 100644 src/vse_sim/methods.py create mode 100644 src/vse_sim/methods/__init__.py create mode 100644 src/vse_sim/methods/borda.py create mode 100644 src/vse_sim/methods/bullety_approval.py create mode 100644 src/vse_sim/methods/irnr.py create mode 100644 src/vse_sim/methods/irv.py create mode 100644 src/vse_sim/methods/irv_prime.py create mode 100644 src/vse_sim/methods/mav.py create mode 100644 src/vse_sim/methods/mj.py create mode 100644 src/vse_sim/methods/plurality.py create mode 100644 src/vse_sim/methods/ranked_pairs.py create mode 100644 src/vse_sim/methods/schulze.py create mode 100644 src/vse_sim/methods/score.py create mode 100644 src/vse_sim/methods/srv.py create mode 100644 src/vse_sim/methods/v321.py diff --git a/src/vse_sim/methods.py b/src/vse_sim/methods.py deleted file mode 100644 index 8270a2f..0000000 --- a/src/vse_sim/methods.py +++ /dev/null @@ -1,1047 +0,0 @@ - -import random - -from numpy import argsort, floor, mean, percentile, sign - -from .core import CandidateWithCount, Method, rememberBallot, rememberBallots -from .voter_models import DeterministicModel, Voter # noqa: F401 - - -# Election methods -class Borda(Method): - candScore = staticmethod(mean) - - nRanks = 999 # infinity - - @staticmethod - def fillPrefOrder(voter, ballot, - whichCands=None, #None means "all"; otherwise, an iterable of cand indexes - lowSlot=0, - nSlots=None, #again, None means "all" - remainderScore=None #what to give candidates that don't fit in nSlots - ): - - venum = list(enumerate(voter)) - if whichCands: - venum = [venum[c] for c in whichCands] - prefOrder = sorted(venum,key=lambda x:-x[1]) #high to low - Borda.fillCands(ballot, prefOrder, lowSlot, nSlots, remainderScore) - #modifies ballot argument, returns nothing. - - @staticmethod - def fillCands(ballot, - whichCands, #list of tuples starting with cand id, in descending order - lowSlot=0, - nSlots=None, #again, None means "all" - remainderScore=None #what to give candidates that don't fit in nSlots - ): - if nSlots is None: - nSlots = len(whichCands) - cur = lowSlot + nSlots - 1 - for i in range(nSlots): - ballot[whichCands[i][0]] = cur - cur -= 1 - if remainderScore is not None: - i += 1 - while i < len(whichCands): - ballot[whichCands[i][0]] = remainderScore - i += 1 - #modifies ballot argument, returns nothing. - - @staticmethod #cls is provided explicitly, not through binding - @rememberBallot - def honBallot(cls, utils): - ballot = [0] * len(utils) - cls.fillPrefOrder(utils, ballot) - return ballot - - - @classmethod - def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, - frontId, frontResult, targId, targResult): - """Mutates the `ballot` argument to be a strategic ballot. - - >>> Borda().stratBallotFor([4,5,2,1])(Borda, Voter([-4,-5,-2,-1])) - [3, 0, 1, 2] - """ - nRanks = min(cls.nRanks,n) - if stratGap <= 0: - ballot[frontId], ballot[targId] = (nRanks - 1), 0 - else: - ballot[frontId], ballot[targId] = 0, (nRanks - 1) - nRanks -= 2 - if nRanks > 0: - cls.fillCands(ballot, places[2:][::-1], - lowSlot=1, nSlots=nRanks, remainderScore=0) - -RankedMethod = Borda #alias -RatedMethod = RankedMethod #Should have same strategies available, plus more - -class Plurality(RankedMethod): - - nRanks = 2 - - @staticmethod - def oneVote(utils, forWhom): - ballot = [0] * len(utils) - ballot[forWhom] = 1 - return ballot - - @staticmethod #cls is provided explicitly, not through binding - @rememberBallot - def honBallot(cls, utils): - """Takes utilities and returns an honest ballot. - - >>> Plurality.honBallot(Plurality, Voter([-3,-2,-1])) - [0, 0, 1] - >>> Plurality().stratBallotFor([3,2,1])(Plurality, Voter([-3,-2,-1])) - [0, 1, 0] - """ - ballot = [0] * len(utils) - cls.fillPrefOrder(utils, ballot, - nSlots = 1, lowSlot=1, remainderScore=0) - return ballot - - - -def Score(topRank=10, asClass=False): - - class Score0to(Method): - """Score voting, 0-10. - - - Strategy establishes pivots - >>> Score().stratBallotFor([0,1,2])(Score, Voter([5,6,7])) - [0, 0, 10] - >>> Score().stratBallotFor([2,1,0])(Score, Voter([5,6,7])) - [0, 10, 10] - >>> Score().stratBallotFor([1,0,2])(Score, Voter([5,6,7])) - [0, 5.0, 10] - - Strategy (kinda) works for ties - >>> Score().stratBallotFor([1,0,2])(Score, Voter([5,6,6])) - [0, 10, 10] - >>> Score().stratBallotFor([1,0,2])(Score, Voter([6,6,7])) - [0, 0, 10] - >>> Score().stratBallotFor([1,0,2])(Score, Voter([6,7,6])) - [10, 10, 10] - >>> Score().stratBallotFor([1,0,2])(Score, Voter([6,5,6])) - [10, 0, 10] - - """ - - bias2 = 2.770135393419682 - bias5 = 2.3536762480634343 - candScore = staticmethod(mean) - - - def __str__(self): - if self.topRank == 1: - return "IdealApproval" - return self.__class__.__name__ + str(self.topRank) - - @staticmethod #cls is provided explicitly, not through binding - @rememberBallot - def honBallot(cls, utils): - """Takes utilities and returns an honest ballot (on 0..10). - - - honest ballots work as expected - >>> Score().honBallot(Score, Voter([5,6,7])) - [0.0, 5.0, 10.0] - >>> Score().resultsFor(DeterministicModel(3)(5,3),Score().honBallot)["results"] - [4.0, 6.0, 5.0] - """ - bot = min(utils) - scale = max(utils)-bot - if scale == 0: - return [cls.topRank] * len(utils) - return [floor((cls.topRank + .99) * (util-bot) / scale) for util in utils] - - - @classmethod - def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, - frontId, frontResult, targId, targResult): - """Returns a (function which takes utilities and returns a strategic ballot) - for the given "polling" info.""" - - cuts = [voter[frontId], voter[targId]] - if stratGap > 0: - #sort cuts high to low - cuts = (cuts[1], cuts[0]) - if cuts[0] == cuts[1]: - strat = [(cls.topRank if (util >= cuts[0]) else 0) for util in voter] - else: - strat = [max(0,min(cls.topRank,floor( - (cls.topRank + .99) * (util-cuts[1]) / (cuts[0]-cuts[1]) - ))) - for util in voter] - for i in range(n): - ballot[i] = strat[i] - - Score0to.topRank = topRank - return Score0to if asClass else Score0to() - -def BulletyApprovalWith(bullets=0.5, asClass=False): - - - - class BulletyApproval((Score(1,True))): - - bulletiness = bullets - - def __str__(self): - return f"BulletyApproval{str(round(self.bulletiness * 100))}" - - - - @staticmethod #cls is provided explicitly, not through binding - @rememberBallot - def honBallot(cls, utils): - """Takes utilities and returns an honest ballot (on 0..10). - - - honest ballots work as expected - >>> Score().honBallot(Score, Voter([5,6,7])) - [0.0, 5.0, 10.0] - >>> Score().resultsFor(DeterministicModel(3)(5,3),Score().honBallot)["results"] - [4.0, 6.0, 5.0] - """ - if random.random() > cls.bulletiness: - return cls.__bases__[0].honBallot(cls, utils) - best = max(utils) - return [1 if util==best else 0 for util in utils] - - - return BulletyApproval if asClass else BulletyApproval() - - -def Srv(topRank=10): - """Score Runoff Voting - >>> Srv().resultsFor(DeterministicModel(3)(5,3),Irv().honBallot)["results"] - [0.8, 1.2, 1.21] - >>> Srv().results([[0,1,2]])[2] - 2.0 - >>> Srv().results([[0,1,2],[2,1,0]])[1] - 1.0 - >>> Srv().results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2) - [0.8888888888888888, 1.2222222222222223, 0.8888888888888888] - >>> Srv().results([[2,1,0]] * 100 + [[1,0,2]] + [[0,2,1]] * 100) - [1.502537313432836, 1.492537313432836, 0.5074626865671642] - >>> Srv().results([[1,2,0]] * 8 + [[2,0,1]] * 6 + [[0,1,2]] * 5) - [1.0526315789473684, 1.105263157894737, 0.8421052631578947] - >>> Srv().results([[0,4,3,1,2]] * 5 + [[1,4,3,2,1]] * 4 + [[2,3,4,0,1]] * 6) - [1.0666666666666667, 3.6, 3.4, 0.8666666666666667, 1.3333333333333333] - """ - - score0to = Score(topRank,True) - - class Srv0to(score0to): - - stratTargetFor = Method.stratTarget3 - - def results(self, ballots, **kwargs): - """Srv results.""" - baseResults = super(Srv0to, self).results(ballots, **kwargs) - (runnerUp,top) = sorted(range(len(baseResults)), key=lambda i: baseResults[i])[-2:] - upset = sum(sign(ballot[runnerUp] - ballot[top]) for ballot in ballots) - if upset > 0: - baseResults[runnerUp] = baseResults[top] + 0.01 - return [result.item() if hasattr(result, "item") else result for result in baseResults] - return Srv0to() - - -def toVote(cutoffs, util): - """Maps one util to a vote, using cutoffs. - - Used by Mav, but declared outside to avoid method binding overhead.""" - for vote in range(len(cutoffs)): - if util <= cutoffs[vote]: - return vote - return vote + 1 - - -class Mav(Method): - """Majority Approval Voting. - """ - - bias5 = 1.0970202515275356 - - - baseCuts = [-0.8, 0, 0.8, 1.6] - specificPercentiles = [25,50,75,90] - - def candScore(self, scores): - """For now, only works correctly for odd nvot. - - Basic tests - >>> Mav().candScore([1,2,3,4,5]) - 3.0 - >>> Mav().candScore([1,2,3,3,3]) - 2.5 - >>> Mav().candScore([1,2,3,4]) - 2.5 - >>> Mav().candScore([1,2,3,3]) - 2.5 - >>> Mav().candScore([1,2,2,2]) - 1.5 - >>> Mav().candScore([1,2,3,3,5]) - 2.7 - """ - scores = sorted(scores) - nvot = len(scores) - nGrades = (len(self.baseCuts) + 1) - i = int((nvot - 1) / 2) - base = scores[i] - while i < nvot and scores[i] == base: - i += 1 - upper = (base + 0.5) - (i - nvot/2) * nGrades / nvot - lower = (base) - (i - nvot/2) / nvot - return max(upper, lower) - - def honBallotFor(self, voters): - """Return an honest ballot function with election-scoped cutoffs.""" - cuts = percentile(voters, self.specificPercentiles) - - def honBallot(cls, voter, tally=None): - ballot = cls._honBallotWithCuts(voter, cuts) - setattr(voter, f"{cls.__name__}_hon", ballot) - return ballot - - honBallot.__name__ = "honBallot" - honBallot.allTallyKeys = lambda: [] - return honBallot - - @staticmethod - def _honBallotWithCuts(voter, cuts): - cuts = [min(cut, max(voter) - 0.001) for cut in cuts] - return [toVote(cuts, util) for util in voter] - - @staticmethod #cls is provided explicitly, not through binding - @rememberBallot - def honBallot(cls, voter): - """Takes utilities and returns an honest ballot (on 0..4). - - honest ballot works as intended, gives highest grade to highest utility: - >>> Mav().honBallot(Mav, Voter([-1,-0.5,0.5,1,1.1])) - [0, 1, 2, 3, 4] - - Even if they don't rate at least an honest "B": - >>> Mav().honBallot(Mav, Voter([-1,-0.5,0.5])) - [0, 1, 4] - """ - return cls._honBallotWithCuts(voter, cls.baseCuts) - - - def stratBallotFor(self, polls): - """Returns a function which takes utilities and returns a dict( - strat=, - extraStrat=, - isStrat=, - stratGap= - ) - for the given "polling" info. - - - - Strategic tests: - >>> Mav().stratBallotFor([0,1.1,1.9,0,0])(Mav, Voter([-1,-0.5,0.5,1,2])) - [0, 1, 2, 3, 4] - >>> Mav().stratBallotFor([0,2.1,2.9,0,0])(Mav, Voter([-1,-0.5,0.5,1,2])) - [0, 1, 3, 3, 4] - >>> Mav().stratBallotFor([0,2.1,1.9,0,0])(Mav, Voter([-1,0.4,0.5,1,2])) - [0, 1, 3, 3, 4] - >>> Mav().stratBallotFor([1,0,2])(Mav, Voter([6,7,6])) - [4, 4, 4] - >>> Mav().stratBallotFor([1,0,2])(Mav, Voter([6,5,6])) - [4, 0, 4] - >>> Mav().stratBallotFor([2.1,0,3])(Mav, Voter([6,5,6])) - [4, 0, 4] - >>> Mav().stratBallotFor([2.1,0,3])(Mav, Voter([6,5,6.1])) - [2, 2, 4] - """ - places = sorted(enumerate(polls),key=lambda x:-x[1]) #from high to low - ((frontId,frontResult), (targId, targResult)) = places[:2] - - @rememberBallots - def stratBallot(cls, voter): - frontUtils = [voter[frontId], voter[targId]] #utils of frontrunners - stratGap = frontUtils[1] - frontUtils[0] - if stratGap == 0: - strat = extraStrat = [(4 if (util >= frontUtils[0]) else 0) - for util in voter] - isStrat = True - - else: - if stratGap < 0: - #winner is preferred; be complacent. - isStrat = False - else: - #runner-up is preferred; be strategic in iss run - isStrat = True - #sort cuts high to low - frontUtils = (frontUtils[1], frontUtils[0]) - top = max(voter) - cutoffs = [( (min(frontUtils[0], self.baseCuts[i])) - if (i < floor(targResult)) else - ( (frontUtils[1]) - if (i < floor(frontResult) + 1) else - min(top, self.baseCuts[i]) - )) - for i in range(len(self.baseCuts))] - strat = [toVote(cutoffs, util) for util in voter] - extraStrat = [max(0,min(10,floor( - 4.99 * (util-frontUtils[1]) / (frontUtils[0]-frontUtils[1]) - ))) - for util in voter] - return dict(strat=strat, extraStrat=extraStrat, isStrat=isStrat, - stratGap = stratGap) - - return stratBallot - - -class Mj(Mav): - def candScore(self, scores): - """This formula will always give numbers within 0.5 of the raw median. - Unfortunately, with 5 grade levels, these will tend to be within 0.1 of - the raw median, leaving scores further from the integers mostly unused. - This is only a problem aesthetically. - - For now, only works correctly for odd nvot - - tests: - >>> Mj().candScore([1,2,3,4,5]) - 3 - >>> Mj().candScore([1,2,3,3,5]) - 2.7 - >>> Mj().candScore([1,3,3,3,5]) - 3 - >>> Mj().candScore([1,3,3,4,5]) - 3.3 - >>> Mj().candScore([1,3,3,3,3]) - 2.9 - >>> Mj().candScore([3] * 24 + [1]) - 2.98 - >>> Mj().candScore([3] * 24 + [4]) - 3.02 - >>> Mj().candScore([3] * 13 + [4] * 12) - 3.46 - """ - scores = sorted(scores) - nvot = len(scores) - lo = hi = mid = nvot // 2 - base = scores[mid] - while (hi < nvot and scores[hi] == base): - hi += 1 - while (lo >= 0 and scores[lo] == base): - lo -= 1 - - if (hi-mid) == (mid-lo): - return base - elif (hi-mid) < (mid-lo): - return base + 0.5 - (hi-mid) / nvot - else: - return base - 0.5 + (mid-lo) / nvot - -class Irv(Method): - """ - IRV. - - High numbers are good for both results and votes (pretty sure). - """ - - stratTargetFor = Method.stratTarget3 - - def buildPreferenceSchedule(self, ballots): - """Gets a dictionary of the form {ranking as tuple, vote count}.""" - - prefs = {} - for b in ballots: - key = tuple(b) - if key in prefs: - prefs[key] += 1 - else: - prefs[key] = 1 - return prefs - - def eliminateCandidate(self, inputPrefs, toEliminate): - """Gets a dictionary of the form {ranking as tuple, vote count} with toEliminate removed.""" - - if not isinstance(toEliminate, CandidateWithCount): - return inputPrefs - - prefs = {} - for ranking, votes in inputPrefs.items(): - newranking = [ - candidate - for candidate in ranking - if candidate != toEliminate.candidate - ] - - if not newranking: - continue - newkey = tuple(newranking) - if newkey in prefs: - prefs[newkey] += votes - else: - prefs[newkey] = votes - return prefs - - def candidateVotes(self, prefSchedule): - """Gets a list of CandidateWithCount, from highest to lowest.""" - candidates = {} - for ranking, votes in prefSchedule.items(): - candidate = ranking[0] - if candidate in candidates: - candidates[candidate].votes += votes - else: - candidates[candidate] = CandidateWithCount(candidate, votes) - - # Simply for VSE which requires ranking of non-winners; in real election we don't really - # care - alternates = [] - trackedalt = set() - for ranking, _votes in prefSchedule.items(): - for alternate in ranking[1:]: - if (alternate not in candidates) and alternate not in trackedalt: - alternates.append(CandidateWithCount(alternate, 0)) - trackedalt.add(alternate) - - return sorted(candidates.values(), key=lambda c: (c.votes, c.candidate), reverse = True) + alternates - - def getLeast(self, voteRanking, keep = {}): - for candidate in reversed(voteRanking): - if candidate.candidate not in keep: - return candidate - - def runIrv(self, remaining, ncand): - """IRV results.""" - results = [-1] * ncand - for i in range(ncand): - votes = self.candidateVotes(remaining) - toEliminate = self.getLeast(votes) - results[ncand - i - 1] = toEliminate.candidate - remaining = self.eliminateCandidate(remaining, toEliminate) - return results - - @staticmethod - def rankVectorToPreference(ballot): - """Return candidate IDs in descending preference order from a rank vector.""" - return sorted(range(len(ballot)), key=lambda candidate: ballot[candidate], - reverse=True) - - @staticmethod - def finishOrderToResults(finishOrder): - """Convert winner-first finish order to high-is-better candidate scores.""" - ncand = len(finishOrder) - results = [-1] * ncand - for score, candidate in enumerate(reversed(finishOrder)): - results[candidate] = score - return results - - def results(self, ballots, **kwargs): - """IRV results. - - >>> Irv().resultsFor(DeterministicModel(3)(5,3),Irv().honBallot)["results"] - [0, 1, 2] - >>> Irv().results([[0,1,2]])[2] - 2 - >>> Irv().results([[0,1,2],[2,1,0]])[1] - 0 - >>> Irv().results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2) - [2, 0, 1] - """ - if type(ballots) is not list: - ballots = list(ballots) - rankings = [self.rankVectorToPreference(ballot) for ballot in ballots] - finishOrder = self.runIrv(self.buildPreferenceSchedule(rankings), len(ballots[0])) - return self.finishOrderToResults(finishOrder) - - @staticmethod #cls is provided explicitly, not through binding - @rememberBallot - def honBallot(cls, voter): - """Takes utilities and returns an honest ballot. - - >>> Irv.honBallot(Irv,Voter([4,1,6,3])) - [2, 0, 3, 1] - """ - ballot = [-1] * len(voter) - order = sorted(enumerate(voter), key=lambda x:x[1]) - for i, cand in enumerate(order): - ballot[cand[0]] = i - return ballot - - - @classmethod - def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, - frontId, frontResult, targId, targResult): - """ - >>> Irv().stratBallotFor([3,2,1,0])(Irv,Voter([3,6,5,2])) - [1, 2, 3, 0] - """ - i = n - 1 - winnerQ = voter[frontId] - targQ = voter[targId] - placesToFill = list(range(n-1,0,-1)) - if targQ > winnerQ: - ballot[targId] = i - i -= 1 - del placesToFill[-2] - for j in placesToFill: - nextLoser, loserScore = places[j] #all but winner, low to high - if voter[nextLoser] > winnerQ: - ballot[nextLoser] = i - i -= 1 - ballot[frontId] = i - i -= 1 - for j in placesToFill: - nextLoser, loserScore = places[j] - if voter[nextLoser] <= winnerQ: - ballot[nextLoser] = i - i -= 1 - assert i == -1 - -class IrvPrime(Irv): - """ - IRV Prime. - - See https://electowiki.org/wiki/IRV_Prime - """ - - stratTargetFor = Method.stratTarget3 - - def results(self, ballots, **kwargs): - """IRV Prime results. - - >>> IrvPrime().results([[0,1,2]])[2] - 2 - >>> IrvPrime().results([[0,1,2],[2,1,0]])[1] - 0 - >>> IrvPrime().results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2) - [1, 2, 0] - >>> IrvPrime().results([[2,1,0]] * 100 + [[1,0,2]] + [[0,2,1]] * 100) - [1, 0, 2] - >>> # Favorite betrayal example from http://rangevoting.org/IncentToExagg.html - >>> IrvPrime().results([[1,2,0]] * 8 + [[2,0,1]] * 6 + [[0,1,2]] * 5) - [0, 1, 2] - >>> IrvPrime().results([[0,4,3,1,2]] * 5 + [[1,4,3,2,1]] * 4 + [[2,3,4,0,1]] * 6) - [4, 2, 3, 0, 1] - >>> # Elections 3-5 from http://votingmatters.org.uk/ISSUE6/P4.HTM - >>> IrvPrime().results([[0,1,2,3,4,5]] * 12 + [[2,0,1,3,4,5]] * 11 + [[1,2,0,3,4,5]] * 10 + - ... [[3,4,5]] * 27) - [1, 2, 3, 0, 4, 5] - >>> IrvPrime().results([[0,1]] * 11 + [[1]] * 7 + [[2]] * 12) - [1, 2, 0] - >>> IrvPrime().results([[0,3,2,1]] * 5 + [[1,2,0,3]] * 5 + [[2,0,1,3]] * 8 + - ... [[3,0,1,2]] * 4 + [[3,1,2,0]] * 8) - [0, 3, 2, 1] - >>> IrvPrime().results([[0,2,1,3]] * 6 + [[0,3,1,2]] * 3 + [[0,3,2,1]] * 3 + - ... [[1,2,0,3]] * 4 + [[2,0,1,3]] * 4 + [[3,1,2,0]] * 5) - [2, 0, 3, 1] - >>> # Failure of later-no-harm - >>> IrvPrime().results([[0, 1, 2]] * 32 + [[0, 2, 1]] * 20 + [[1,2,0]] * 30 + - ... [[1,0,2]] * 21 + [[2,0,1]] * 30 + [[2,1,0]] * 20) - [2, 0, 1] - >>> IrvPrime().results([[0, 1, 2]] * 32 + [[0, 2, 1]] * 20 + [[1,2,0]] * 30 + - ... [[1,0,2]] * 21 + [[2,1,0]] * 30 + [[2,1,0]] * 20) - [1, 0, 2] - """ - - if type(ballots) is not list: - ballots = list(ballots) - - remaining = self.buildPreferenceSchedule(ballots) - ncand = len(self.candidateVotes(remaining)) - classic = self.runIrv(remaining, ncand) - - # Keep the winner from the classic IRV - winners = {classic[0]} - - # Find all candidates that can beat classic IRV winner; this may be a superset - # of schwartz/smith, but it's all that matters - winnersPrime = set() - for possibleWinner in range(ncand): - if possibleWinner in winners: - continue - - numWins = 0 - numLosses = 0 - for ranking, votes in remaining.items(): - possibleWinnerRanking = winnerRanking = len(ranking) + 1 - for pos in range(len(ranking)): - if ranking[pos] == possibleWinner: - possibleWinnerRanking = pos - # We can change this to a loop if there's > 1 winner - elif ranking[pos] == next(iter(winners)): - winnerRanking = pos - if possibleWinnerRanking < winnerRanking: - numWins += votes - elif winnerRanking < possibleWinnerRanking: - numLosses += votes - if numWins > numLosses: - winnersPrime.add(possibleWinner) - - # Now re-run IRV preserving all winners + winners prime - keepers = winners.union(winnersPrime) - results = [-1] * ncand - for i in range(ncand): - votes = self.candidateVotes(remaining) - toEliminate = self.getLeast(votes, keepers) - if not isinstance(toEliminate, CandidateWithCount): - # Begin "step 4", i.e. continue elimination without preserving anyone - keepers = {} - toEliminate = self.getLeast(votes) - results[ncand - i - 1] = toEliminate.candidate - remaining = self.eliminateCandidate(remaining, toEliminate) - - return results - -class V321(Mav): - baseCuts = [-.1,.8] - specificPercentiles = [45, 75] - - stratTargetFor = Method.stratTarget3 - - def results(self, ballots, isHonest=False, **kwargs): - """3-2-1 Voting results. - - >>> V321().resultsFor(DeterministicModel(3)(5,3),V321().honBallot)["results"] - [-0.75, 2, 1] - >>> V321().results([[0,1,2]])[2] - 2 - >>> V321().results([[0,1,2],[2,1,0]])[1] - 2.5 - >>> V321().results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2) - [1, 1.5, -0.25] - >>> V321().results([[0,1,2,1]]*29 + [[1,2,0,1]]*30 + [[2,0,1,1]]*31 + [[1,1,1,2]]*10) - [3, 0.5, 1, 0] - >>> V321().results([[1,0,2,1]]*29 + [[0,2,1,1]]*30 + [[2,1,0,1]]*31 + [[1,1,1,2]]*10) - [3.375, 2.875, 0.25, 0] - """ - candScores = list(zip(*ballots, strict=False)) - n2s = [sum(1 if s>1 else 0 for s in c) for c in candScores] - o2s = argsort(n2s) #order - r2s = [-1] * len(n2s) #ranks - for r,i in enumerate(o2s): - r2s[i] = r - semifinalists = o2s[-3:] #[third, second, first] by top ranks - n1s = [sum(1 if s>0 else 0 for s in candScores[sf]) for sf in semifinalists] - o1s = argsort(n1s) - r2s[semifinalists[o1s[0]]] -= (o1s[0] +1) * .75 #non-finalist below finalists - (runnerUp,top) = semifinalists[o1s[1]], semifinalists[o1s[2]] - upset = sum(sign(ballot[runnerUp] - ballot[top]) for ballot in ballots) - if upset > 0: - runnerUp, top = top, runnerUp - r2s[runnerUp], r2s[top] = r2s[top] - .125, r2s[runnerUp] + .125 - r2s[top] = max(r2s[top], r2s[runnerUp] + 0.5) - if isHonest: - self.extraEvents.update({"3beats1": False, "3beats2": False, "4beats1": False}) - upset2 = sum(sign(ballot[semifinalists[o1s[0]]] - ballot[semifinalists[o1s[2]]]) for ballot in ballots) - self.extraEvents["3beats1"] = upset2 > 0 - upset3 = sum(sign(ballot[semifinalists[o1s[0]]] - ballot[semifinalists[o1s[1]]]) for ballot in ballots) - self.extraEvents["3beats2"] = upset3 > 0 - if len(o2s) > 3: - fourth = o2s[-4] - fourthNotLasts = sum(1 if s>1 else 0 for s in candScores[fourth]) - fourthWin = (fourthNotLasts > n1s[o1s[1]] and - sum(sign(ballot[fourth] - ballot[semifinalists[o1s[2]]]) - for ballot in ballots) - > 0) - self.extraEvents["4beats1"] = fourthWin - - return [result.item() if hasattr(result, "item") else result for result in r2s] - - def stratBallotFor(self, polls): - """Returns a function which takes utilities and returns a dict( - isStrat= - for the given "polling" info. - - - >>> Irv().stratBallotFor([3,2,1,0])(Irv,Voter([3,6,5,2])) - [1, 2, 3, 0] - """ - len(polls) - - places = sorted(enumerate(polls),key=lambda x:-x[1]) #high to low - top3 = [c for c,r in places[:3]] - - def stratBallot(cls, voter): - stratGap = voter[top3[1]] - voter[top3[0]] - myPrefs = [c for c,v in sorted(enumerate(voter),key=lambda x:-x[1])] #high to low - my3order = [myPrefs.index(c) for c in top3] - rating = 2 - ballot = [0] * len(voter) - if my3order[0] == min(my3order): #agree on winner - for i in range(my3order[0]+1): - ballot[myPrefs[i]] = 2 - if my3order[1] <= my3order[2]: - for i in range(my3order[0]+1,my3order[1]+1): - ballot[myPrefs[i]] = 1 - return dict(strat=ballot, isStrat=False, stratGap=stratGap) - for c in myPrefs: - ballot[c] = rating - if rating and (c in top3): - if c == top3[0]: - rating = 0 - else: - rating -= 1 - - return dict(strat=ballot, isStrat=True, stratGap=stratGap) - if self.extraEvents["3beats1"]: - @rememberBallots - def stratBallo2(cls, voter): - stratGap = voter[top3[1]] - voter[top3[0]] - myprefs = sorted(enumerate(voter),key=lambda x:-x[1]) #high to low - rating = 2 - ballot = [None] * len(voter) - isStrat=False - stratGap = 0 - for c, _util in myprefs: - ballot[c] = rating - if rating and (c in top3): - if (c == top3[2]): - isStrat= (rating == 2) - rating = 0 - else: - rating -= 1 - isStrat = (voter[top3[0]] == max(voter[c] for c in top3)) - return dict(strat=ballot, isStrat=isStrat, stratGap=stratGap) - stratBallo2.__name__ = "stratBallot" #God, that's ugly. - return stratBallo2 - - if self.extraEvents["4beats1"]: - fourth = places[3][1] - first = top3[1] - @rememberBallots - def stratBallo3(cls, voter): - stratGap = voter[top3[1]] - voter[top3[0]] - myprefs = sorted(enumerate(voter),key=lambda x:-x[1]) #high to low - - rating = 2 - ballot = [None] * len(voter) - if voter[fourth] > voter[first]: - - for c, _util in myprefs: - ballot[c] = rating - if rating and (c == fourth): - rating -= 2 - return dict(strat=ballot, isStrat=True, stratGap=stratGap) - - return stratBallot(cls,voter) - stratBallo3.__name__ = "stratBallot" #God, that's ugly. - return stratBallo3 - - - return rememberBallots(stratBallot) - -class Schulze(RankedMethod): - def resolveCycle(self, cmat, n): - - beatStrength = [[0] * n for _ in range(n)] - numWins = [0] * n - for i in range(n): - for j in range(n): - if i != j: - beatStrength[i][j] = cmat[i][j] if cmat[i][j] > cmat[j][i] else 0 - - for i in range(n): - for j in range(n): - if i != j: - for k in range(n): - if i != k and j != k: - beatStrength[j][k] = max( - beatStrength[j][k], - min(beatStrength[j][i], beatStrength[i][k]), - ) - - for i in range(n): - for j in range(n): - if i != j: - if beatStrength[i][j]>beatStrength[j][i]: - numWins[i] += 1 - if beatStrength[i][j]==beatStrength[j][i] and i>> schulze = Schulze() - >>> schulze.resultsFor(DeterministicModel(3)(5,3),schulze.honBallot,isHonest=True)["results"] - [1, 2, 0] - >>> schulze.extraEvents - {'scenario': 'cycle'} - >>> schulze.results([[0,1,2]],isHonest=True)[2] - 2 - >>> schulze.extraEvents - {'scenario': 'easy'} - >>> schulze.results([[0,1,2],[2,1,0]],isHonest=True)[1] - 1 - >>> schulze.extraEvents - {'scenario': 'easy'} - >>> schulze.results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2,isHonest=True) - [1, 2, 0] - >>> schulze.extraEvents - {'scenario': 'chicken'} - >>> schulze.results([[0,1,2]] * 4 + [[2,1,0]] * 2 + [[1,2,0]] * 3,isHonest=True) - [1, 2, 0] - >>> schulze.extraEvents - {'scenario': 'squeeze'} - >>> schulze.results([[3,2,1,0]] * 5 + [[2,3,1,0]] * 2 + [[0,1,0,3]] * 6 + [[0,0,3,0]] * 3,isHonest=True) - [2, 3, 1, 0] - >>> schulze.extraEvents - {'scenario': 'other'} - >>> schulze.results([[3,0,0,0]] * 5 + [[2,3,0,0]] * 2 + [[0,0,0,3]] * 6 + [[0,0,3,0]] * 3,isHonest=True) - [3, 0, 1, 2] - >>> schulze.extraEvents - {'scenario': 'spoiler'} - """ - n = len(ballots[0]) - cmat = [[0 for _ in range(n)] for _ in range(n)] - numWins = [0] * n - for i in range(n): - for j in range(n): - if i != j: - cmat[i][j] = sum(sign(ballot[i] - ballot[j]) for ballot in ballots) - if cmat[i][j]>0: - numWins[i] += 1 - elif cmat[i][j]==0 and i 0: - others = [c for (c, r) in places[2:]] - notTooBad = min(voter[frontId], voter[targId]) - decentOnes = [c for c in others if voter[c] >= notTooBad] - cls.fillPrefOrder(voter, ballot, - whichCands=decentOnes, - lowSlot=n-len(decentOnes)) - ballot[frontId], ballot[targId] = 0, n-len(decentOnes)-1 - cls.fillPrefOrder(voter, ballot, - whichCands=[c for c in others if voter[c] < notTooBad], - lowSlot=1) - else: - ballot[frontId] = n - 1 - cls.fillPrefOrder(voter, ballot, - whichCands=[c for (c, r) in places[1:]], - lowSlot=0) - -class Rp(Schulze): - def resolveCycle(self, cmat, n): - """Note: mutates cmat destructively. - - >>> Rp().resultsFor(DeterministicModel(3)(5,3),Rp().honBallot,isHonest=True)["results"] - [1, 2, 0] - """ - matches = [(i, j, cmat[i][j]) for i in range(n) for j in range(i,n) if i != j] - rps = sorted(matches,key=lambda x:-abs(x[2])) - for (i, j, margin) in rps: - if margin < 0: - i, j = j, i - if cmat[j][i] is not True: - cmat[i][j] = True - for k in range(n): - if k not in (i, j): - if cmat[j][k] is True: - cmat[i][k] = True - if cmat[k][i] is True: - cmat[k][j] = True - - return [sum(cmat[i][j] is True for j in range(n)) for i in range(n)] - - -class IRNR(RankedMethod): - stratMax = 10 - - stratTargetFor = Method.stratTarget3 # strategize in favor of third place, because second place is pointless (can't change pairwise) - def results(self, ballots, **kwargs): - enabled = [True] * len(ballots[0]) - numEnabled = sum(enabled) - results = [None] * len(enabled) - while numEnabled > 1: - tsum = [0.0] * len(enabled) - for bal in ballots: - vsum = 0.0 - for i, v in enumerate(bal): - if enabled[i]: - vsum += abs(v) - if vsum == 0.0: - # TODO: count spoiled ballot - continue - for i, v in enumerate(bal): - if enabled[i]: - tsum[i] += v / vsum - mini = None - minv = None - for i, _v in enumerate(tsum): - if enabled[i] and ((minv is None) or (tsum[i] < minv)): - minv = tsum[i] - mini = i - enabled[mini] = False - results[mini] = minv - numEnabled -= 1 - for i, _v in enumerate(tsum): - if enabled[i]: - results[i] = tsum[i] - return results - - @staticmethod #cls is provided explicitly, not through binding - @rememberBallot - def honBallot(cls, utils): - """Takes utilities and returns an honest ballot. - """ - return utils - - - - @classmethod - def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, - frontId, frontResult, targId, targResult): - if stratGap <= 0: - ballot[frontId], ballot[targId] = cls.stratMax, 0 - else: - ballot[frontId], ballot[targId] = 0, cls.stratMax - cls.fillPrefOrder(voter, ballot, - whichCands=[c for (c, r) in places[2:]], - nSlots = 1, lowSlot=1, remainderScore=0) diff --git a/src/vse_sim/methods/__init__.py b/src/vse_sim/methods/__init__.py new file mode 100644 index 0000000..e913cd2 --- /dev/null +++ b/src/vse_sim/methods/__init__.py @@ -0,0 +1,32 @@ +from .borda import Borda, RankedMethod, RatedMethod +from .bullety_approval import BulletyApprovalWith +from .irnr import IRNR +from .irv import Irv +from .irv_prime import IrvPrime +from .mav import Mav, toVote +from .mj import Mj +from .plurality import Plurality +from .ranked_pairs import Rp +from .schulze import Schulze +from .score import Score +from .srv import Srv +from .v321 import V321 + +__all__ = [ + "Borda", + "BulletyApprovalWith", + "IRNR", + "Irv", + "IrvPrime", + "Mav", + "Mj", + "Plurality", + "RankedMethod", + "RatedMethod", + "Rp", + "Schulze", + "Score", + "Srv", + "V321", + "toVote", +] diff --git a/src/vse_sim/methods/borda.py b/src/vse_sim/methods/borda.py new file mode 100644 index 0000000..e19bab0 --- /dev/null +++ b/src/vse_sim/methods/borda.py @@ -0,0 +1,74 @@ +from numpy import mean + +from ..core import Method, rememberBallot +from ..voter_models import Voter # noqa: F401 + + +class Borda(Method): + candScore = staticmethod(mean) + + nRanks = 999 # infinity + + @staticmethod + def fillPrefOrder(voter, ballot, + whichCands=None, #None means "all"; otherwise, an iterable of cand indexes + lowSlot=0, + nSlots=None, #again, None means "all" + remainderScore=None #what to give candidates that don't fit in nSlots + ): + + venum = list(enumerate(voter)) + if whichCands: + venum = [venum[c] for c in whichCands] + prefOrder = sorted(venum,key=lambda x:-x[1]) #high to low + Borda.fillCands(ballot, prefOrder, lowSlot, nSlots, remainderScore) + #modifies ballot argument, returns nothing. + + @staticmethod + def fillCands(ballot, + whichCands, #list of tuples starting with cand id, in descending order + lowSlot=0, + nSlots=None, #again, None means "all" + remainderScore=None #what to give candidates that don't fit in nSlots + ): + if nSlots is None: + nSlots = len(whichCands) + cur = lowSlot + nSlots - 1 + for i in range(nSlots): + ballot[whichCands[i][0]] = cur + cur -= 1 + if remainderScore is not None: + i += 1 + while i < len(whichCands): + ballot[whichCands[i][0]] = remainderScore + i += 1 + #modifies ballot argument, returns nothing. + + @staticmethod #cls is provided explicitly, not through binding + @rememberBallot + def honBallot(cls, utils): + ballot = [0] * len(utils) + cls.fillPrefOrder(utils, ballot) + return ballot + + + @classmethod + def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, + frontId, frontResult, targId, targResult): + """Mutates the `ballot` argument to be a strategic ballot. + + >>> Borda().stratBallotFor([4,5,2,1])(Borda, Voter([-4,-5,-2,-1])) + [3, 0, 1, 2] + """ + nRanks = min(cls.nRanks,n) + if stratGap <= 0: + ballot[frontId], ballot[targId] = (nRanks - 1), 0 + else: + ballot[frontId], ballot[targId] = 0, (nRanks - 1) + nRanks -= 2 + if nRanks > 0: + cls.fillCands(ballot, places[2:][::-1], + lowSlot=1, nSlots=nRanks, remainderScore=0) + +RankedMethod = Borda #alias +RatedMethod = RankedMethod #Should have same strategies available, plus more diff --git a/src/vse_sim/methods/bullety_approval.py b/src/vse_sim/methods/bullety_approval.py new file mode 100644 index 0000000..a3dc7ba --- /dev/null +++ b/src/vse_sim/methods/bullety_approval.py @@ -0,0 +1,39 @@ +import random + +from ..core import rememberBallot +from ..voter_models import DeterministicModel, Voter # noqa: F401 +from .score import Score + + +def BulletyApprovalWith(bullets=0.5, asClass=False): + + + + class BulletyApproval((Score(1,True))): + + bulletiness = bullets + + def __str__(self): + return f"BulletyApproval{str(round(self.bulletiness * 100))}" + + + + @staticmethod #cls is provided explicitly, not through binding + @rememberBallot + def honBallot(cls, utils): + """Takes utilities and returns an honest ballot (on 0..10). + + + honest ballots work as expected + >>> Score().honBallot(Score, Voter([5,6,7])) + [0.0, 5.0, 10.0] + >>> Score().resultsFor(DeterministicModel(3)(5,3),Score().honBallot)["results"] + [4.0, 6.0, 5.0] + """ + if random.random() > cls.bulletiness: + return cls.__bases__[0].honBallot(cls, utils) + best = max(utils) + return [1 if util==best else 0 for util in utils] + + + return BulletyApproval if asClass else BulletyApproval() diff --git a/src/vse_sim/methods/irnr.py b/src/vse_sim/methods/irnr.py new file mode 100644 index 0000000..6a7a881 --- /dev/null +++ b/src/vse_sim/methods/irnr.py @@ -0,0 +1,58 @@ +from ..core import Method, rememberBallot +from .borda import RankedMethod + + +class IRNR(RankedMethod): + stratMax = 10 + + stratTargetFor = Method.stratTarget3 # strategize in favor of third place, because second place is pointless (can't change pairwise) + def results(self, ballots, **kwargs): + enabled = [True] * len(ballots[0]) + numEnabled = sum(enabled) + results = [None] * len(enabled) + while numEnabled > 1: + tsum = [0.0] * len(enabled) + for bal in ballots: + vsum = 0.0 + for i, v in enumerate(bal): + if enabled[i]: + vsum += abs(v) + if vsum == 0.0: + # TODO: count spoiled ballot + continue + for i, v in enumerate(bal): + if enabled[i]: + tsum[i] += v / vsum + mini = None + minv = None + for i, _v in enumerate(tsum): + if enabled[i] and ((minv is None) or (tsum[i] < minv)): + minv = tsum[i] + mini = i + enabled[mini] = False + results[mini] = minv + numEnabled -= 1 + for i, _v in enumerate(tsum): + if enabled[i]: + results[i] = tsum[i] + return results + + @staticmethod #cls is provided explicitly, not through binding + @rememberBallot + def honBallot(cls, utils): + """Takes utilities and returns an honest ballot. + """ + return utils + + + + @classmethod + def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, + frontId, frontResult, targId, targResult): + if stratGap <= 0: + ballot[frontId], ballot[targId] = cls.stratMax, 0 + else: + ballot[frontId], ballot[targId] = 0, cls.stratMax + cls.fillPrefOrder(voter, ballot, + whichCands=[c for (c, r) in places[2:]], + nSlots = 1, lowSlot=1, remainderScore=0) diff --git a/src/vse_sim/methods/irv.py b/src/vse_sim/methods/irv.py new file mode 100644 index 0000000..b3f4775 --- /dev/null +++ b/src/vse_sim/methods/irv.py @@ -0,0 +1,161 @@ +from ..core import CandidateWithCount, Method, rememberBallot +from ..voter_models import DeterministicModel, Voter # noqa: F401 + + +class Irv(Method): + """ + IRV. + + High numbers are good for both results and votes (pretty sure). + """ + + stratTargetFor = Method.stratTarget3 + + def buildPreferenceSchedule(self, ballots): + """Gets a dictionary of the form {ranking as tuple, vote count}.""" + + prefs = {} + for b in ballots: + key = tuple(b) + if key in prefs: + prefs[key] += 1 + else: + prefs[key] = 1 + return prefs + + def eliminateCandidate(self, inputPrefs, toEliminate): + """Gets a dictionary of the form {ranking as tuple, vote count} with toEliminate removed.""" + + if not isinstance(toEliminate, CandidateWithCount): + return inputPrefs + + prefs = {} + for ranking, votes in inputPrefs.items(): + newranking = [ + candidate + for candidate in ranking + if candidate != toEliminate.candidate + ] + + if not newranking: + continue + newkey = tuple(newranking) + if newkey in prefs: + prefs[newkey] += votes + else: + prefs[newkey] = votes + return prefs + + def candidateVotes(self, prefSchedule): + """Gets a list of CandidateWithCount, from highest to lowest.""" + candidates = {} + for ranking, votes in prefSchedule.items(): + candidate = ranking[0] + if candidate in candidates: + candidates[candidate].votes += votes + else: + candidates[candidate] = CandidateWithCount(candidate, votes) + + # Simply for VSE which requires ranking of non-winners; in real election we don't really + # care + alternates = [] + trackedalt = set() + for ranking, _votes in prefSchedule.items(): + for alternate in ranking[1:]: + if (alternate not in candidates) and alternate not in trackedalt: + alternates.append(CandidateWithCount(alternate, 0)) + trackedalt.add(alternate) + + return sorted(candidates.values(), key=lambda c: (c.votes, c.candidate), reverse = True) + alternates + + def getLeast(self, voteRanking, keep = {}): + for candidate in reversed(voteRanking): + if candidate.candidate not in keep: + return candidate + + def runIrv(self, remaining, ncand): + """IRV results.""" + results = [-1] * ncand + for i in range(ncand): + votes = self.candidateVotes(remaining) + toEliminate = self.getLeast(votes) + results[ncand - i - 1] = toEliminate.candidate + remaining = self.eliminateCandidate(remaining, toEliminate) + return results + + @staticmethod + def rankVectorToPreference(ballot): + """Return candidate IDs in descending preference order from a rank vector.""" + return sorted(range(len(ballot)), key=lambda candidate: ballot[candidate], + reverse=True) + + @staticmethod + def finishOrderToResults(finishOrder): + """Convert winner-first finish order to high-is-better candidate scores.""" + ncand = len(finishOrder) + results = [-1] * ncand + for score, candidate in enumerate(reversed(finishOrder)): + results[candidate] = score + return results + + def results(self, ballots, **kwargs): + """IRV results. + + >>> Irv().resultsFor(DeterministicModel(3)(5,3),Irv().honBallot)["results"] + [0, 1, 2] + >>> Irv().results([[0,1,2]])[2] + 2 + >>> Irv().results([[0,1,2],[2,1,0]])[1] + 0 + >>> Irv().results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2) + [2, 0, 1] + """ + if type(ballots) is not list: + ballots = list(ballots) + rankings = [self.rankVectorToPreference(ballot) for ballot in ballots] + finishOrder = self.runIrv(self.buildPreferenceSchedule(rankings), len(ballots[0])) + return self.finishOrderToResults(finishOrder) + + @staticmethod #cls is provided explicitly, not through binding + @rememberBallot + def honBallot(cls, voter): + """Takes utilities and returns an honest ballot. + + >>> Irv.honBallot(Irv,Voter([4,1,6,3])) + [2, 0, 3, 1] + """ + ballot = [-1] * len(voter) + order = sorted(enumerate(voter), key=lambda x:x[1]) + for i, cand in enumerate(order): + ballot[cand[0]] = i + return ballot + + + @classmethod + def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, + frontId, frontResult, targId, targResult): + """ + >>> Irv().stratBallotFor([3,2,1,0])(Irv,Voter([3,6,5,2])) + [1, 2, 3, 0] + """ + i = n - 1 + winnerQ = voter[frontId] + targQ = voter[targId] + placesToFill = list(range(n-1,0,-1)) + if targQ > winnerQ: + ballot[targId] = i + i -= 1 + del placesToFill[-2] + for j in placesToFill: + nextLoser, loserScore = places[j] #all but winner, low to high + if voter[nextLoser] > winnerQ: + ballot[nextLoser] = i + i -= 1 + ballot[frontId] = i + i -= 1 + for j in placesToFill: + nextLoser, loserScore = places[j] + if voter[nextLoser] <= winnerQ: + ballot[nextLoser] = i + i -= 1 + assert i == -1 diff --git a/src/vse_sim/methods/irv_prime.py b/src/vse_sim/methods/irv_prime.py new file mode 100644 index 0000000..555be71 --- /dev/null +++ b/src/vse_sim/methods/irv_prime.py @@ -0,0 +1,98 @@ +from ..core import CandidateWithCount, Method +from .irv import Irv + + +class IrvPrime(Irv): + """ + IRV Prime. + + See https://electowiki.org/wiki/IRV_Prime + """ + + stratTargetFor = Method.stratTarget3 + + def results(self, ballots, **kwargs): + """IRV Prime results. + + >>> IrvPrime().results([[0,1,2]])[2] + 2 + >>> IrvPrime().results([[0,1,2],[2,1,0]])[1] + 0 + >>> IrvPrime().results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2) + [1, 2, 0] + >>> IrvPrime().results([[2,1,0]] * 100 + [[1,0,2]] + [[0,2,1]] * 100) + [1, 0, 2] + >>> # Favorite betrayal example from http://rangevoting.org/IncentToExagg.html + >>> IrvPrime().results([[1,2,0]] * 8 + [[2,0,1]] * 6 + [[0,1,2]] * 5) + [0, 1, 2] + >>> IrvPrime().results([[0,4,3,1,2]] * 5 + [[1,4,3,2,1]] * 4 + [[2,3,4,0,1]] * 6) + [4, 2, 3, 0, 1] + >>> # Elections 3-5 from http://votingmatters.org.uk/ISSUE6/P4.HTM + >>> IrvPrime().results([[0,1,2,3,4,5]] * 12 + [[2,0,1,3,4,5]] * 11 + [[1,2,0,3,4,5]] * 10 + + ... [[3,4,5]] * 27) + [1, 2, 3, 0, 4, 5] + >>> IrvPrime().results([[0,1]] * 11 + [[1]] * 7 + [[2]] * 12) + [1, 2, 0] + >>> IrvPrime().results([[0,3,2,1]] * 5 + [[1,2,0,3]] * 5 + [[2,0,1,3]] * 8 + + ... [[3,0,1,2]] * 4 + [[3,1,2,0]] * 8) + [0, 3, 2, 1] + >>> IrvPrime().results([[0,2,1,3]] * 6 + [[0,3,1,2]] * 3 + [[0,3,2,1]] * 3 + + ... [[1,2,0,3]] * 4 + [[2,0,1,3]] * 4 + [[3,1,2,0]] * 5) + [2, 0, 3, 1] + >>> # Failure of later-no-harm + >>> IrvPrime().results([[0, 1, 2]] * 32 + [[0, 2, 1]] * 20 + [[1,2,0]] * 30 + + ... [[1,0,2]] * 21 + [[2,0,1]] * 30 + [[2,1,0]] * 20) + [2, 0, 1] + >>> IrvPrime().results([[0, 1, 2]] * 32 + [[0, 2, 1]] * 20 + [[1,2,0]] * 30 + + ... [[1,0,2]] * 21 + [[2,1,0]] * 30 + [[2,1,0]] * 20) + [1, 0, 2] + """ + + if type(ballots) is not list: + ballots = list(ballots) + + remaining = self.buildPreferenceSchedule(ballots) + ncand = len(self.candidateVotes(remaining)) + classic = self.runIrv(remaining, ncand) + + # Keep the winner from the classic IRV + winners = {classic[0]} + + # Find all candidates that can beat classic IRV winner; this may be a superset + # of schwartz/smith, but it's all that matters + winnersPrime = set() + for possibleWinner in range(ncand): + if possibleWinner in winners: + continue + + numWins = 0 + numLosses = 0 + for ranking, votes in remaining.items(): + possibleWinnerRanking = winnerRanking = len(ranking) + 1 + for pos in range(len(ranking)): + if ranking[pos] == possibleWinner: + possibleWinnerRanking = pos + # We can change this to a loop if there's > 1 winner + elif ranking[pos] == next(iter(winners)): + winnerRanking = pos + if possibleWinnerRanking < winnerRanking: + numWins += votes + elif winnerRanking < possibleWinnerRanking: + numLosses += votes + if numWins > numLosses: + winnersPrime.add(possibleWinner) + + # Now re-run IRV preserving all winners + winners prime + keepers = winners.union(winnersPrime) + results = [-1] * ncand + for i in range(ncand): + votes = self.candidateVotes(remaining) + toEliminate = self.getLeast(votes, keepers) + if not isinstance(toEliminate, CandidateWithCount): + # Begin "step 4", i.e. continue elimination without preserving anyone + keepers = {} + toEliminate = self.getLeast(votes) + results[ncand - i - 1] = toEliminate.candidate + remaining = self.eliminateCandidate(remaining, toEliminate) + + return results diff --git a/src/vse_sim/methods/mav.py b/src/vse_sim/methods/mav.py new file mode 100644 index 0000000..d40a710 --- /dev/null +++ b/src/vse_sim/methods/mav.py @@ -0,0 +1,154 @@ +from numpy import floor, percentile + +from ..core import Method, rememberBallot, rememberBallots +from ..voter_models import Voter # noqa: F401 + + +def toVote(cutoffs, util): + """Maps one util to a vote, using cutoffs. + + Used by Mav, but declared outside to avoid method binding overhead.""" + for vote in range(len(cutoffs)): + if util <= cutoffs[vote]: + return vote + return vote + 1 + + +class Mav(Method): + """Majority Approval Voting. + """ + + bias5 = 1.0970202515275356 + + + baseCuts = [-0.8, 0, 0.8, 1.6] + specificPercentiles = [25,50,75,90] + + def candScore(self, scores): + """For now, only works correctly for odd nvot. + + Basic tests + >>> Mav().candScore([1,2,3,4,5]) + 3.0 + >>> Mav().candScore([1,2,3,3,3]) + 2.5 + >>> Mav().candScore([1,2,3,4]) + 2.5 + >>> Mav().candScore([1,2,3,3]) + 2.5 + >>> Mav().candScore([1,2,2,2]) + 1.5 + >>> Mav().candScore([1,2,3,3,5]) + 2.7 + """ + scores = sorted(scores) + nvot = len(scores) + nGrades = (len(self.baseCuts) + 1) + i = int((nvot - 1) / 2) + base = scores[i] + while i < nvot and scores[i] == base: + i += 1 + upper = (base + 0.5) - (i - nvot/2) * nGrades / nvot + lower = (base) - (i - nvot/2) / nvot + return max(upper, lower) + + def honBallotFor(self, voters): + """Return an honest ballot function with election-scoped cutoffs.""" + cuts = percentile(voters, self.specificPercentiles) + + def honBallot(cls, voter, tally=None): + ballot = cls._honBallotWithCuts(voter, cuts) + setattr(voter, f"{cls.__name__}_hon", ballot) + return ballot + + honBallot.__name__ = "honBallot" + honBallot.allTallyKeys = lambda: [] + return honBallot + + @staticmethod + def _honBallotWithCuts(voter, cuts): + cuts = [min(cut, max(voter) - 0.001) for cut in cuts] + return [toVote(cuts, util) for util in voter] + + @staticmethod #cls is provided explicitly, not through binding + @rememberBallot + def honBallot(cls, voter): + """Takes utilities and returns an honest ballot (on 0..4). + + honest ballot works as intended, gives highest grade to highest utility: + >>> Mav().honBallot(Mav, Voter([-1,-0.5,0.5,1,1.1])) + [0, 1, 2, 3, 4] + + Even if they don't rate at least an honest "B": + >>> Mav().honBallot(Mav, Voter([-1,-0.5,0.5])) + [0, 1, 4] + """ + return cls._honBallotWithCuts(voter, cls.baseCuts) + + + def stratBallotFor(self, polls): + """Returns a function which takes utilities and returns a dict( + strat=, + extraStrat=, + isStrat=, + stratGap= + ) + for the given "polling" info. + + + + Strategic tests: + >>> Mav().stratBallotFor([0,1.1,1.9,0,0])(Mav, Voter([-1,-0.5,0.5,1,2])) + [0, 1, 2, 3, 4] + >>> Mav().stratBallotFor([0,2.1,2.9,0,0])(Mav, Voter([-1,-0.5,0.5,1,2])) + [0, 1, 3, 3, 4] + >>> Mav().stratBallotFor([0,2.1,1.9,0,0])(Mav, Voter([-1,0.4,0.5,1,2])) + [0, 1, 3, 3, 4] + >>> Mav().stratBallotFor([1,0,2])(Mav, Voter([6,7,6])) + [4, 4, 4] + >>> Mav().stratBallotFor([1,0,2])(Mav, Voter([6,5,6])) + [4, 0, 4] + >>> Mav().stratBallotFor([2.1,0,3])(Mav, Voter([6,5,6])) + [4, 0, 4] + >>> Mav().stratBallotFor([2.1,0,3])(Mav, Voter([6,5,6.1])) + [2, 2, 4] + """ + places = sorted(enumerate(polls),key=lambda x:-x[1]) #from high to low + ((frontId,frontResult), (targId, targResult)) = places[:2] + + @rememberBallots + def stratBallot(cls, voter): + frontUtils = [voter[frontId], voter[targId]] #utils of frontrunners + stratGap = frontUtils[1] - frontUtils[0] + if stratGap == 0: + strat = extraStrat = [(4 if (util >= frontUtils[0]) else 0) + for util in voter] + isStrat = True + + else: + if stratGap < 0: + #winner is preferred; be complacent. + isStrat = False + else: + #runner-up is preferred; be strategic in iss run + isStrat = True + #sort cuts high to low + frontUtils = (frontUtils[1], frontUtils[0]) + top = max(voter) + cutoffs = [( (min(frontUtils[0], self.baseCuts[i])) + if (i < floor(targResult)) else + ( (frontUtils[1]) + if (i < floor(frontResult) + 1) else + min(top, self.baseCuts[i]) + )) + for i in range(len(self.baseCuts))] + strat = [toVote(cutoffs, util) for util in voter] + extraStrat = [max(0,min(10,floor( + 4.99 * (util-frontUtils[1]) / (frontUtils[0]-frontUtils[1]) + ))) + for util in voter] + return dict(strat=strat, extraStrat=extraStrat, isStrat=isStrat, + stratGap = stratGap) + + return stratBallot diff --git a/src/vse_sim/methods/mj.py b/src/vse_sim/methods/mj.py new file mode 100644 index 0000000..76ef612 --- /dev/null +++ b/src/vse_sim/methods/mj.py @@ -0,0 +1,45 @@ +from .mav import Mav + + +class Mj(Mav): + def candScore(self, scores): + """This formula will always give numbers within 0.5 of the raw median. + Unfortunately, with 5 grade levels, these will tend to be within 0.1 of + the raw median, leaving scores further from the integers mostly unused. + This is only a problem aesthetically. + + For now, only works correctly for odd nvot + + tests: + >>> Mj().candScore([1,2,3,4,5]) + 3 + >>> Mj().candScore([1,2,3,3,5]) + 2.7 + >>> Mj().candScore([1,3,3,3,5]) + 3 + >>> Mj().candScore([1,3,3,4,5]) + 3.3 + >>> Mj().candScore([1,3,3,3,3]) + 2.9 + >>> Mj().candScore([3] * 24 + [1]) + 2.98 + >>> Mj().candScore([3] * 24 + [4]) + 3.02 + >>> Mj().candScore([3] * 13 + [4] * 12) + 3.46 + """ + scores = sorted(scores) + nvot = len(scores) + lo = hi = mid = nvot // 2 + base = scores[mid] + while (hi < nvot and scores[hi] == base): + hi += 1 + while (lo >= 0 and scores[lo] == base): + lo -= 1 + + if (hi-mid) == (mid-lo): + return base + elif (hi-mid) < (mid-lo): + return base + 0.5 - (hi-mid) / nvot + else: + return base - 0.5 + (mid-lo) / nvot diff --git a/src/vse_sim/methods/plurality.py b/src/vse_sim/methods/plurality.py new file mode 100644 index 0000000..3b0d848 --- /dev/null +++ b/src/vse_sim/methods/plurality.py @@ -0,0 +1,29 @@ +from ..core import rememberBallot +from ..voter_models import Voter # noqa: F401 +from .borda import RankedMethod + + +class Plurality(RankedMethod): + + nRanks = 2 + + @staticmethod + def oneVote(utils, forWhom): + ballot = [0] * len(utils) + ballot[forWhom] = 1 + return ballot + + @staticmethod #cls is provided explicitly, not through binding + @rememberBallot + def honBallot(cls, utils): + """Takes utilities and returns an honest ballot. + + >>> Plurality.honBallot(Plurality, Voter([-3,-2,-1])) + [0, 0, 1] + >>> Plurality().stratBallotFor([3,2,1])(Plurality, Voter([-3,-2,-1])) + [0, 1, 0] + """ + ballot = [0] * len(utils) + cls.fillPrefOrder(utils, ballot, + nSlots = 1, lowSlot=1, remainderScore=0) + return ballot diff --git a/src/vse_sim/methods/ranked_pairs.py b/src/vse_sim/methods/ranked_pairs.py new file mode 100644 index 0000000..9e61edc --- /dev/null +++ b/src/vse_sim/methods/ranked_pairs.py @@ -0,0 +1,26 @@ +from ..voter_models import DeterministicModel # noqa: F401 +from .schulze import Schulze + + +class Rp(Schulze): + def resolveCycle(self, cmat, n): + """Note: mutates cmat destructively. + + >>> Rp().resultsFor(DeterministicModel(3)(5,3),Rp().honBallot,isHonest=True)["results"] + [1, 2, 0] + """ + matches = [(i, j, cmat[i][j]) for i in range(n) for j in range(i,n) if i != j] + rps = sorted(matches,key=lambda x:-abs(x[2])) + for (i, j, margin) in rps: + if margin < 0: + i, j = j, i + if cmat[j][i] is not True: + cmat[i][j] = True + for k in range(n): + if k not in (i, j): + if cmat[j][k] is True: + cmat[i][k] = True + if cmat[k][i] is True: + cmat[k][j] = True + + return [sum(cmat[i][j] is True for j in range(n)) for i in range(n)] diff --git a/src/vse_sim/methods/schulze.py b/src/vse_sim/methods/schulze.py new file mode 100644 index 0000000..50a533f --- /dev/null +++ b/src/vse_sim/methods/schulze.py @@ -0,0 +1,137 @@ +from numpy import sign + +from ..voter_models import DeterministicModel # noqa: F401 +from .borda import RankedMethod + + +class Schulze(RankedMethod): + def resolveCycle(self, cmat, n): + + beatStrength = [[0] * n for _ in range(n)] + numWins = [0] * n + for i in range(n): + for j in range(n): + if i != j: + beatStrength[i][j] = cmat[i][j] if cmat[i][j] > cmat[j][i] else 0 + + for i in range(n): + for j in range(n): + if i != j: + for k in range(n): + if i != k and j != k: + beatStrength[j][k] = max( + beatStrength[j][k], + min(beatStrength[j][i], beatStrength[i][k]), + ) + + for i in range(n): + for j in range(n): + if i != j: + if beatStrength[i][j]>beatStrength[j][i]: + numWins[i] += 1 + if beatStrength[i][j]==beatStrength[j][i] and i>> schulze = Schulze() + >>> schulze.resultsFor(DeterministicModel(3)(5,3),schulze.honBallot,isHonest=True)["results"] + [1, 2, 0] + >>> schulze.extraEvents + {'scenario': 'cycle'} + >>> schulze.results([[0,1,2]],isHonest=True)[2] + 2 + >>> schulze.extraEvents + {'scenario': 'easy'} + >>> schulze.results([[0,1,2],[2,1,0]],isHonest=True)[1] + 1 + >>> schulze.extraEvents + {'scenario': 'easy'} + >>> schulze.results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2,isHonest=True) + [1, 2, 0] + >>> schulze.extraEvents + {'scenario': 'chicken'} + >>> schulze.results([[0,1,2]] * 4 + [[2,1,0]] * 2 + [[1,2,0]] * 3,isHonest=True) + [1, 2, 0] + >>> schulze.extraEvents + {'scenario': 'squeeze'} + >>> schulze.results([[3,2,1,0]] * 5 + [[2,3,1,0]] * 2 + [[0,1,0,3]] * 6 + [[0,0,3,0]] * 3,isHonest=True) + [2, 3, 1, 0] + >>> schulze.extraEvents + {'scenario': 'other'} + >>> schulze.results([[3,0,0,0]] * 5 + [[2,3,0,0]] * 2 + [[0,0,0,3]] * 6 + [[0,0,3,0]] * 3,isHonest=True) + [3, 0, 1, 2] + >>> schulze.extraEvents + {'scenario': 'spoiler'} + """ + n = len(ballots[0]) + cmat = [[0 for _ in range(n)] for _ in range(n)] + numWins = [0] * n + for i in range(n): + for j in range(n): + if i != j: + cmat[i][j] = sum(sign(ballot[i] - ballot[j]) for ballot in ballots) + if cmat[i][j]>0: + numWins[i] += 1 + elif cmat[i][j]==0 and i 0: + others = [c for (c, r) in places[2:]] + notTooBad = min(voter[frontId], voter[targId]) + decentOnes = [c for c in others if voter[c] >= notTooBad] + cls.fillPrefOrder(voter, ballot, + whichCands=decentOnes, + lowSlot=n-len(decentOnes)) + ballot[frontId], ballot[targId] = 0, n-len(decentOnes)-1 + cls.fillPrefOrder(voter, ballot, + whichCands=[c for c in others if voter[c] < notTooBad], + lowSlot=1) + else: + ballot[frontId] = n - 1 + cls.fillPrefOrder(voter, ballot, + whichCands=[c for (c, r) in places[1:]], + lowSlot=0) diff --git a/src/vse_sim/methods/score.py b/src/vse_sim/methods/score.py new file mode 100644 index 0000000..e44f868 --- /dev/null +++ b/src/vse_sim/methods/score.py @@ -0,0 +1,83 @@ +from numpy import floor, mean + +from ..core import Method, rememberBallot +from ..voter_models import DeterministicModel, Voter # noqa: F401 + + +def Score(topRank=10, asClass=False): + + class Score0to(Method): + """Score voting, 0-10. + + + Strategy establishes pivots + >>> Score().stratBallotFor([0,1,2])(Score, Voter([5,6,7])) + [0, 0, 10] + >>> Score().stratBallotFor([2,1,0])(Score, Voter([5,6,7])) + [0, 10, 10] + >>> Score().stratBallotFor([1,0,2])(Score, Voter([5,6,7])) + [0, 5.0, 10] + + Strategy (kinda) works for ties + >>> Score().stratBallotFor([1,0,2])(Score, Voter([5,6,6])) + [0, 10, 10] + >>> Score().stratBallotFor([1,0,2])(Score, Voter([6,6,7])) + [0, 0, 10] + >>> Score().stratBallotFor([1,0,2])(Score, Voter([6,7,6])) + [10, 10, 10] + >>> Score().stratBallotFor([1,0,2])(Score, Voter([6,5,6])) + [10, 0, 10] + + """ + + bias2 = 2.770135393419682 + bias5 = 2.3536762480634343 + candScore = staticmethod(mean) + + + def __str__(self): + if self.topRank == 1: + return "IdealApproval" + return self.__class__.__name__ + str(self.topRank) + + @staticmethod #cls is provided explicitly, not through binding + @rememberBallot + def honBallot(cls, utils): + """Takes utilities and returns an honest ballot (on 0..10). + + + honest ballots work as expected + >>> Score().honBallot(Score, Voter([5,6,7])) + [0.0, 5.0, 10.0] + >>> Score().resultsFor(DeterministicModel(3)(5,3),Score().honBallot)["results"] + [4.0, 6.0, 5.0] + """ + bot = min(utils) + scale = max(utils)-bot + if scale == 0: + return [cls.topRank] * len(utils) + return [floor((cls.topRank + .99) * (util-bot) / scale) for util in utils] + + + @classmethod + def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, + frontId, frontResult, targId, targResult): + """Returns a (function which takes utilities and returns a strategic ballot) + for the given "polling" info.""" + + cuts = [voter[frontId], voter[targId]] + if stratGap > 0: + #sort cuts high to low + cuts = (cuts[1], cuts[0]) + if cuts[0] == cuts[1]: + strat = [(cls.topRank if (util >= cuts[0]) else 0) for util in voter] + else: + strat = [max(0,min(cls.topRank,floor( + (cls.topRank + .99) * (util-cuts[1]) / (cuts[0]-cuts[1]) + ))) + for util in voter] + for i in range(n): + ballot[i] = strat[i] + + Score0to.topRank = topRank + return Score0to if asClass else Score0to() diff --git a/src/vse_sim/methods/srv.py b/src/vse_sim/methods/srv.py new file mode 100644 index 0000000..9679e27 --- /dev/null +++ b/src/vse_sim/methods/srv.py @@ -0,0 +1,41 @@ +from numpy import sign + +from ..core import Method +from ..voter_models import DeterministicModel # noqa: F401 +from .irv import Irv # noqa: F401 +from .score import Score + + +def Srv(topRank=10): + """Score Runoff Voting + >>> Srv().resultsFor(DeterministicModel(3)(5,3),Irv().honBallot)["results"] + [0.8, 1.2, 1.21] + >>> Srv().results([[0,1,2]])[2] + 2.0 + >>> Srv().results([[0,1,2],[2,1,0]])[1] + 1.0 + >>> Srv().results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2) + [0.8888888888888888, 1.2222222222222223, 0.8888888888888888] + >>> Srv().results([[2,1,0]] * 100 + [[1,0,2]] + [[0,2,1]] * 100) + [1.502537313432836, 1.492537313432836, 0.5074626865671642] + >>> Srv().results([[1,2,0]] * 8 + [[2,0,1]] * 6 + [[0,1,2]] * 5) + [1.0526315789473684, 1.105263157894737, 0.8421052631578947] + >>> Srv().results([[0,4,3,1,2]] * 5 + [[1,4,3,2,1]] * 4 + [[2,3,4,0,1]] * 6) + [1.0666666666666667, 3.6, 3.4, 0.8666666666666667, 1.3333333333333333] + """ + + score0to = Score(topRank,True) + + class Srv0to(score0to): + + stratTargetFor = Method.stratTarget3 + + def results(self, ballots, **kwargs): + """Srv results.""" + baseResults = super(Srv0to, self).results(ballots, **kwargs) + (runnerUp,top) = sorted(range(len(baseResults)), key=lambda i: baseResults[i])[-2:] + upset = sum(sign(ballot[runnerUp] - ballot[top]) for ballot in ballots) + if upset > 0: + baseResults[runnerUp] = baseResults[top] + 0.01 + return [result.item() if hasattr(result, "item") else result for result in baseResults] + return Srv0to() diff --git a/src/vse_sim/methods/v321.py b/src/vse_sim/methods/v321.py new file mode 100644 index 0000000..22b3ef1 --- /dev/null +++ b/src/vse_sim/methods/v321.py @@ -0,0 +1,145 @@ +from numpy import argsort, sign + +from ..core import Method, rememberBallots +from ..voter_models import DeterministicModel, Voter # noqa: F401 +from .irv import Irv # noqa: F401 +from .mav import Mav + + +class V321(Mav): + baseCuts = [-.1,.8] + specificPercentiles = [45, 75] + + stratTargetFor = Method.stratTarget3 + + def results(self, ballots, isHonest=False, **kwargs): + """3-2-1 Voting results. + + >>> V321().resultsFor(DeterministicModel(3)(5,3),V321().honBallot)["results"] + [-0.75, 2, 1] + >>> V321().results([[0,1,2]])[2] + 2 + >>> V321().results([[0,1,2],[2,1,0]])[1] + 2.5 + >>> V321().results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2) + [1, 1.5, -0.25] + >>> V321().results([[0,1,2,1]]*29 + [[1,2,0,1]]*30 + [[2,0,1,1]]*31 + [[1,1,1,2]]*10) + [3, 0.5, 1, 0] + >>> V321().results([[1,0,2,1]]*29 + [[0,2,1,1]]*30 + [[2,1,0,1]]*31 + [[1,1,1,2]]*10) + [3.375, 2.875, 0.25, 0] + """ + candScores = list(zip(*ballots, strict=False)) + n2s = [sum(1 if s>1 else 0 for s in c) for c in candScores] + o2s = argsort(n2s) #order + r2s = [-1] * len(n2s) #ranks + for r,i in enumerate(o2s): + r2s[i] = r + semifinalists = o2s[-3:] #[third, second, first] by top ranks + n1s = [sum(1 if s>0 else 0 for s in candScores[sf]) for sf in semifinalists] + o1s = argsort(n1s) + r2s[semifinalists[o1s[0]]] -= (o1s[0] +1) * .75 #non-finalist below finalists + (runnerUp,top) = semifinalists[o1s[1]], semifinalists[o1s[2]] + upset = sum(sign(ballot[runnerUp] - ballot[top]) for ballot in ballots) + if upset > 0: + runnerUp, top = top, runnerUp + r2s[runnerUp], r2s[top] = r2s[top] - .125, r2s[runnerUp] + .125 + r2s[top] = max(r2s[top], r2s[runnerUp] + 0.5) + if isHonest: + self.extraEvents.update({"3beats1": False, "3beats2": False, "4beats1": False}) + upset2 = sum(sign(ballot[semifinalists[o1s[0]]] - ballot[semifinalists[o1s[2]]]) for ballot in ballots) + self.extraEvents["3beats1"] = upset2 > 0 + upset3 = sum(sign(ballot[semifinalists[o1s[0]]] - ballot[semifinalists[o1s[1]]]) for ballot in ballots) + self.extraEvents["3beats2"] = upset3 > 0 + if len(o2s) > 3: + fourth = o2s[-4] + fourthNotLasts = sum(1 if s>1 else 0 for s in candScores[fourth]) + fourthWin = (fourthNotLasts > n1s[o1s[1]] and + sum(sign(ballot[fourth] - ballot[semifinalists[o1s[2]]]) + for ballot in ballots) + > 0) + self.extraEvents["4beats1"] = fourthWin + + return [result.item() if hasattr(result, "item") else result for result in r2s] + + def stratBallotFor(self, polls): + """Returns a function which takes utilities and returns a dict( + isStrat= + for the given "polling" info. + + + >>> Irv().stratBallotFor([3,2,1,0])(Irv,Voter([3,6,5,2])) + [1, 2, 3, 0] + """ + len(polls) + + places = sorted(enumerate(polls),key=lambda x:-x[1]) #high to low + top3 = [c for c,r in places[:3]] + + def stratBallot(cls, voter): + stratGap = voter[top3[1]] - voter[top3[0]] + myPrefs = [c for c,v in sorted(enumerate(voter),key=lambda x:-x[1])] #high to low + my3order = [myPrefs.index(c) for c in top3] + rating = 2 + ballot = [0] * len(voter) + if my3order[0] == min(my3order): #agree on winner + for i in range(my3order[0]+1): + ballot[myPrefs[i]] = 2 + if my3order[1] <= my3order[2]: + for i in range(my3order[0]+1,my3order[1]+1): + ballot[myPrefs[i]] = 1 + return dict(strat=ballot, isStrat=False, stratGap=stratGap) + for c in myPrefs: + ballot[c] = rating + if rating and (c in top3): + if c == top3[0]: + rating = 0 + else: + rating -= 1 + + return dict(strat=ballot, isStrat=True, stratGap=stratGap) + if self.extraEvents["3beats1"]: + @rememberBallots + def stratBallo2(cls, voter): + stratGap = voter[top3[1]] - voter[top3[0]] + myprefs = sorted(enumerate(voter),key=lambda x:-x[1]) #high to low + rating = 2 + ballot = [None] * len(voter) + isStrat=False + stratGap = 0 + for c, _util in myprefs: + ballot[c] = rating + if rating and (c in top3): + if (c == top3[2]): + isStrat= (rating == 2) + rating = 0 + else: + rating -= 1 + isStrat = (voter[top3[0]] == max(voter[c] for c in top3)) + return dict(strat=ballot, isStrat=isStrat, stratGap=stratGap) + stratBallo2.__name__ = "stratBallot" #God, that's ugly. + return stratBallo2 + + if self.extraEvents["4beats1"]: + fourth = places[3][1] + first = top3[1] + @rememberBallots + def stratBallo3(cls, voter): + stratGap = voter[top3[1]] - voter[top3[0]] + myprefs = sorted(enumerate(voter),key=lambda x:-x[1]) #high to low + + rating = 2 + ballot = [None] * len(voter) + if voter[fourth] > voter[first]: + + for c, _util in myprefs: + ballot[c] = rating + if rating and (c == fourth): + rating -= 2 + return dict(strat=ballot, isStrat=True, stratGap=stratGap) + + return stratBallot(cls,voter) + stratBallo3.__name__ = "stratBallot" #God, that's ugly. + return stratBallo3 + + + return rememberBallots(stratBallot) From 01c41e190f8645bacf5a47b9b52f59622f8bfe5d Mon Sep 17 00:00:00 2001 From: Felix Sargent Date: Fri, 17 Jul 2026 23:20:10 +0100 Subject: [PATCH 2/2] Update methods package documentation Co-authored-by: Cursor --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d70663b..b12609a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,8 @@ module and NumPy. - `src/vse_sim/simulation.py`: orchestration, method presets, and CSV output. - `src/vse_sim/core.py`: core method API, tallies, ballot caching, and VSE rows. -- `src/vse_sim/methods.py`: voting method and ballot implementations. +- `src/vse_sim/methods/`: voting method and ballot implementations, one module + per method. - `src/vse_sim/voter_models.py`: voter and electorate models. - `src/vse_sim/strategies.py`: strategic ballot choosers and media models. - `src/vse_sim/decorators.py`: local decorators used by the package.