diff --git a/testData/expected-multiwinner-faqs.json b/testData/expected-multiwinner-faqs.json index 6c413be5..11222545 100644 --- a/testData/expected-multiwinner-faqs.json +++ b/testData/expected-multiwinner-faqs.json @@ -16,7 +16,7 @@ }, { "question": "Why was Write-In eliminated?", - "answer": "Write-In had the fewest votes in Round 1. Since Write-In was eliminated, the voters who supported Write-In had their votes count for their next choices in Round 2. Transferring votes ensures that every voter can be included in choosing the final winner(s), even if their favorite candidate doesn't win." + "answer": "Write-In had the fewest votes in Round 1. Since Write-In was eliminated, the voters who supported Write-In had their votes count for their next choices in Round 2. Transferring votes ensures that every voter can be included in choosing the final winners, even if their favorite candidate doesn't win." }, { "question": "Why was Harvey Curley elected this round?", @@ -52,7 +52,7 @@ }, { "question": "Why was Sarah Lucido eliminated?", - "answer": "Sarah Lucido had the fewest votes in Round 3. Since Sarah Lucido was eliminated, the voters who supported Sarah Lucido had their votes count for their next choices in Round 4. Transferring votes ensures that every voter can be included in choosing the final winner(s), even if their favorite candidate doesn't win." + "answer": "Sarah Lucido had the fewest votes in Round 3. Since Sarah Lucido was eliminated, the voters who supported Sarah Lucido had their votes count for their next choices in Round 4. Transferring votes ensures that every voter can be included in choosing the final winners, even if their favorite candidate doesn't win." }, { "question": "Why was Larry Edwards elected this round?", @@ -81,4 +81,4 @@ "answer": "A ballot is \"inactive\" if the voter did not rank any of the candidates remaining in that round. Voters are not required to rank all the candidates, so if all the candidates they ranked are eliminated, their ballot becomes inactive." } ] -] +] \ No newline at end of file diff --git a/testData/tiebreak.json b/testData/tiebreak.json new file mode 100644 index 00000000..13309286 --- /dev/null +++ b/testData/tiebreak.json @@ -0,0 +1,55 @@ +{ + "config" : { + "contest" : "Tiebreak test", + "date" : "2017-12-03", + "generatedBy" : "RCTab 2.0.0", + "jurisdiction" : "Funkytown, USA", + "office" : "Sergeant-at-Arms" + }, + "jsonFormatVersion" : "1", + "results" : [ { + "inactiveBallots" : { + "exhaustedChoices" : "0", + "overvotes" : "0", + "repeatedRankings" : "0", + "skippedRankings" : "0" + }, + "round" : 1, + "tally" : { + "George Gervin" : "3", + "Mookie Blaylock" : "3", + "Yinka Dare" : "3" + }, + "tallyResults" : [ { + "eliminated" : "Yinka Dare", + "transfers" : { + "exhausted" : "3" + } + } ], + "threshold" : "5" + }, { + "inactiveBallots" : { + "exhaustedChoices" : "3", + "overvotes" : "0", + "repeatedRankings" : "0", + "skippedRankings" : "0" + }, + "round" : 2, + "tally" : { + "George Gervin" : "3", + "Mookie Blaylock" : "3" + }, + "tallyResults" : [ { + "elected" : "George Gervin", + "transfers" : { } + } ], + "threshold" : "4" + } ], + "summary" : { + "finalThreshold" : "4", + "numCandidates" : 3, + "numWinners" : 1, + "totalNumBallots" : "9", + "undervotes" : 0 + } +} \ No newline at end of file diff --git a/visualizer/descriptors/faq.py b/visualizer/descriptors/faq.py index e0f0ceb5..08a4310a 100644 --- a/visualizer/descriptors/faq.py +++ b/visualizer/descriptors/faq.py @@ -122,11 +122,18 @@ def get_answer(self, roundNum): eliminatedNames = self.summary.rounds[roundNum].eliminatedNames elims = common.comma_separated_names_with_and(eliminatedNames) wasOrWere = "was" if len(eliminatedNames) == 1 else "were" - return f"{elims} had the fewest votes in Round {roundNum}. Since {elims} "\ + winners = "winner" if len(self.summary.winnerNames) == 1 else "winners" + + if len(self.summary.rounds[roundNum].eliminatedTiedWith) > 0: + start = f"There was a tie and {elims} lost the tiebreak" + else: + start = f"{elims} had the fewest votes in Round {roundNum}" + + return f"{start}. Since {elims} "\ f"{wasOrWere} eliminated, the voters who supported {elims} had their "\ f"votes count for their next choices in Round {roundNum + 1}. "\ "Transferring votes ensures that every voter can be included in choosing "\ - "the final winner(s), even if their favorite candidate doesn't win." + f"the final {winners}, even if their favorite candidate doesn't win." class WhyBatchEliminated(FAQBase): @@ -150,6 +157,50 @@ def get_answer(self, roundNum): "produces the same results, \"batch\" elimination just takes fewer rounds." +class HowWereTiesBroken(FAQBase): + """ Whenever there's a tie in eliminations or elections """ + + def is_active(self, roundNum): + rnd = self.summary.rounds[roundNum] + return len(rnd.eliminatedTiedWith) > 0 or len(rnd.winnerTiedWith) > 0 + + def get_question(self, roundNum): + return "How were ties broken?" + + def get_answer(self, roundNum): + rnd = self.summary.rounds[roundNum] + + result = "The tiebreak method is up to the election administrator. "\ + "RCVis does not know what method was chosen to break this tie, only that " + + parts = [] + + # Handle elimination ties + if rnd.eliminatedTiedWith: + eliminatedNames = rnd.eliminatedNames + elims = common.comma_separated_names_with_and(eliminatedNames) + wasOrWere = "was" if len(eliminatedNames) == 1 else "were" + parts.append(f"{elims} {wasOrWere} eliminated") + + # Handle election/winner ties + if rnd.winnerTiedWith: + winnerNames = rnd.winnerNames + winners = common.comma_separated_names_with_and(winnerNames) + wasOrWere = "was" if len(winnerNames) == 1 else "were" + actionText = textForWinnerUtils.as_event(self.config, len(winnerNames)) + parts.append(f"{winners} {wasOrWere} {actionText}") + + # Combine the parts + if len(parts) == 2: + result += parts[0] + " and " + parts[1] + "." + elif len(parts) == 1: + result += parts[0] + "." + else: + result += "a tie was broken." + + return result + + class WhySingleWinner(FAQBase): """ Whenever someone is elected in IRV """ @@ -169,6 +220,10 @@ def get_answer(self, roundNum): if c.isActive] areOnlyTwoActiveCandidates = len(activeCandidates) == 2 + # Check for tiebreak first + if len(self.summary.rounds[roundNum].winnerTiedWith) > 0: + return f"There was a tie and {winner} won the tiebreak." + if self.config.forceFirstRoundDeterminesPercentages and areOnlyTwoActiveCandidates: # Special case for IRV with forced first-round percentages: # at this point, they didn't necessarily win because they received more than 50%, @@ -348,6 +403,7 @@ class FAQGenerator(): WhyBatchEliminated, WhySingleWinner, WhyMultiWinner, + HowWereTiesBroken, WhyThreshold, WhyPercentageBasedOnFirstRound, WhySurplusTransfer, diff --git a/visualizer/graph/graphSummary.py b/visualizer/graph/graphSummary.py index 4900c235..46dafeff 100644 --- a/visualizer/graph/graphSummary.py +++ b/visualizer/graph/graphSummary.py @@ -51,12 +51,16 @@ def __init__(self, graph): linksByTargetNode[link.target] = [] linksByTargetNode[link.target].append(link) + # Detect ties for each round + for rnd in rounds: + rnd.find_ties(graph) + self.rounds = rounds self.candidates = candidates self.linksByTargetNode = linksByTargetNode self.winnerNames = [i.name for i in alreadyWonInPreviousRound] self.numWinners = len(self.winnerNames) - self.numEliminated = sum(len(r.eliminatedNames) for r in rounds) + self.numEliminated = sum(len(r.eliminatedCandidates) for r in rounds) def percent_denominator(self, roundNum, forceFirstRoundDeterminesPercentages): """ @@ -71,16 +75,27 @@ def percent_denominator(self, roundNum, forceFirstRoundDeterminesPercentages): return self.rounds[roundNum].totalActiveVotes +# pylint: disable=too-many-instance-attributes class RoundInfo: """ Summarizes a single round, with functions to build the round """ def __init__(self, round_i): self.round_i = round_i + + # Lists of Candidates and their names eliminated or elected this round self.eliminatedCandidates = [] self.winnerCandidates = [] + + # Since we use the candidate name list so often, have a separate list for it self.eliminatedNames = [] self.winnerNames = [] - self.totalActiveVotes = 0 # The total number of active ballots this round + + # List of all candidates who tied with eliminated/winning candidates + self.eliminatedTiedWith = [] + self.winnerTiedWith = [] + + # The total number of active ballots this round + self.totalActiveVotes = 0 def key(self): """ Returns the "key" for this round (just the round number) """ @@ -105,6 +120,64 @@ def add_votes(self, candidate, numVotes): self.totalActiveVotes += numVotes + def find_ties(self, graph): + """ + Detects ties in this round for both eliminated and winning candidates. + Populates eliminatedTiedWith and winnerTiedWith lists. + + A tie occurs when a candidate in the action list (eliminated/winner) has the same + vote count as another candidate not in that list. + """ + # Check ties for eliminated candidates + if self.eliminatedCandidates: + self.eliminatedTiedWith = self._find_ties_for_candidates( + self.round_i - 1, graph, self.eliminatedCandidates) + + # Check ties for winning candidates + if self.winnerCandidates: + self.winnerTiedWith = self._find_ties_for_candidates( + self.round_i, graph, self.winnerCandidates) + + def _find_ties_for_candidates(self, lookupRound, graph, candidateList): + """ + Helper to find which candidates tied with the given candidate list. + + Returns a list of candidates that tied but weren't in candidateList + """ + if len(candidateList) == 0: + return [] + + # Look at the previous round to get vote counts at time of elimination/election + if lookupRound < 0: + return [] + + nodesThisRound = graph.nodesPerRound[lookupRound] + + # Get vote counts for all active candidates + candidateVotes = {candidate: nodesThisRound[candidate].count + for candidate in nodesThisRound.keys() + if candidate.isActive} + + # Get vote counts of the target candidates + targetVotes = [candidateVotes.get(c, 0) for c in candidateList] + if not targetVotes: + return [] + + # If there are multiple candidates in the candidate list and they don't have the + # same vote count, we can safely ignore ties + minVotes = min(targetVotes) + maxVotes = max(targetVotes) + if minVotes != maxVotes: + return [] + + # Find all candidates with the same vote count who aren't in candidateList + tiedCandidates = [] + for candidate, votes in candidateVotes.items(): + if candidate not in candidateList and votes == minVotes: + tiedCandidates.append(candidate) + + return tiedCandidates + class CandidateInfo: """ Summarizes a single candidate over each round """ diff --git a/visualizer/tests/filenames.py b/visualizer/tests/filenames.py index 51e1a38e..4ab39864 100644 --- a/visualizer/tests/filenames.py +++ b/visualizer/tests/filenames.py @@ -22,6 +22,7 @@ INACTIVE_BALLOT_RENAME_DATA = 'testData/inactive-ballot-rename.json' INACTIVE_BALLOT_RENAME_SIDECAR = 'testData/inactive-ballot-rename-sidecar.json' ZERO_VOTE_MULTIWINNER = 'testData/zero-vote-multiwinner.json' +TIEBREAK = 'testData/tiebreak.json' # Regression tests for commit c28d6a7 (fix-inactive-after-double-elim) RESIDUAL_SURPLUS_MAIN = 'testData/with-residual-surplus.json' diff --git a/visualizer/tests/testFaq.py b/visualizer/tests/testFaq.py index 5e2918ac..509f334d 100644 --- a/visualizer/tests/testFaq.py +++ b/visualizer/tests/testFaq.py @@ -286,3 +286,18 @@ def test_describer_consolidates_events(self): for electionRound in allRounds: verbCounter = Counter([r['verb'] for r in electionRound]) self.assertTrue([verb <= 1 for verb in verbCounter.values()]) + + def test_tiebreak(self): + """Ensure tiebreax text appears only in Round 2""" + with open(filenames.TIEBREAK, 'r', encoding='utf-8') as f: + graph = make_graph_with_file(f, False) + args = (graph, self.config) + self.assertFalse(faq.HowWereTiesBroken(*args).is_active(0)) + self.assertTrue(faq.HowWereTiesBroken(*args).is_active(1)) + self.assertEqual( + faq.HowWereTiesBroken(*args).get_answer(1), + "The tiebreak method is up to the election administrator. " + "RCVis does not know what method was chosen to break this tie, " + "only that Yinka Dare was eliminated and George Gervin was was elected.") + self.assertTrue(faq.WhyEliminated(*args).get_answer(1).startswith("There was a tie")) + self.assertTrue(faq.WhySingleWinner(*args).get_answer(1).startswith("There was a tie"))