From ac1a532a9ed1c05c23143c47e85b4b06aabca2d5 Mon Sep 17 00:00:00 2001 From: Felix Sargent Date: Fri, 17 Jul 2026 23:04:18 +0100 Subject: [PATCH] Fix IRV results contract for strategy polling Co-authored-by: Cursor --- src/vse_sim/methods.py | 42 ++++++++++++++++----------------------- tests/test_regressions.py | 18 ++++++++++++++++- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/vse_sim/methods.py b/src/vse_sim/methods.py index 33a3cae..8270a2f 100644 --- a/src/vse_sim/methods.py +++ b/src/vse_sim/methods.py @@ -525,37 +525,27 @@ def runIrv(self, remaining, ncand): remaining = self.eliminateCandidate(remaining, toEliminate) return results - def resultsFor(self, voters, chooser, tally=None, **kwargs): - """Tabulate the rank-vector ballots produced by this simulator. - - IRV's public ``results`` API accepts candidate IDs in preference - order, including partial rankings. The simulator's honest and - strategic ballot producers instead return rank vectors, so translate - those ballots before invoking the tabulator. - """ - def orderingChooser(cls, voter, chooserTally): - ballot = chooser(cls, voter, chooserTally) - return sorted(range(len(ballot)), key=lambda candidate: ballot[candidate], - reverse=True) - - orderingChooser.__name__ = chooser.__name__ - return super().resultsFor(voters, orderingChooser, tally, **kwargs) - @staticmethod - def winner(results): - """Return the winner from IRV's winner-first finish ordering. + 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) - Unlike score methods, :meth:`runIrv` returns candidate IDs ordered by - finish, with the winner at index zero. - """ - return results[0] + @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"] - [2, 1, 0] - >>> Irv().winner([2,0,1]) + [0, 1, 2] + >>> Irv().results([[0,1,2]])[2] 2 >>> Irv().results([[0,1,2],[2,1,0]])[1] 0 @@ -564,7 +554,9 @@ def results(self, ballots, **kwargs): """ if type(ballots) is not list: ballots = list(ballots) - return self.runIrv(self.buildPreferenceSchedule(ballots), len(ballots[0])) + 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 diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 7b0b890..21030a6 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -8,7 +8,7 @@ from scripts.recalculate_irv_pages import recalculate from vse_sim.core import SideTally from vse_sim.diagnostics import TRACE, setDebug, trace -from vse_sim.methods import Mav, Schulze, Score +from vse_sim.methods import Irv, Mav, Schulze, Score from vse_sim.simulation import CsvBatch, seedRandomGenerators from vse_sim.strategies import ProbChooser, beHon, beStrat from vse_sim.voter_models import Electorate, Voter @@ -75,6 +75,22 @@ def test_mav_cutoffs_are_scoped_to_generated_ballot_function(): assert low_ballot(Mav, Voter([-2, -1]), SideTally()) == expected +def test_irv_results_keep_simulator_score_contract_for_strategy(): + method = Irv() + voters = Electorate( + [Voter([0, 1, 2])] * 4 + + [Voter([2, 1, 0])] * 3 + + [Voter([1, 2, 0])] * 2 + ) + + results = method.resultsFor(voters, method.honBallot)["results"] + polls = sorted(enumerate(results), key=lambda candidate_result: -candidate_result[1]) + + assert results == [2, 0, 1] + assert method.winner(results) == 0 + assert polls[0][0] == 0 + + @pytest.mark.parametrize( "probabilities", [