From ea27029d9fb5c33deff02a22fb1ef4734300a192 Mon Sep 17 00:00:00 2001 From: Armin Samii Date: Thu, 6 Nov 2025 12:44:14 -0500 Subject: [PATCH 1/4] Add tiebreak FAQs --- visualizer/descriptors/faq.py | 45 ++++++++++++++++++++ visualizer/graph/graphSummary.py | 73 ++++++++++++++++++++++++++++++++ visualizer/tests/filenames.py | 1 + visualizer/tests/testFaq.py | 13 ++++++ 4 files changed, 132 insertions(+) diff --git a/visualizer/descriptors/faq.py b/visualizer/descriptors/faq.py index e0f0ceb5..bf4841d2 100644 --- a/visualizer/descriptors/faq.py +++ b/visualizer/descriptors/faq.py @@ -150,6 +150,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 """ @@ -346,6 +390,7 @@ class FAQGenerator(): WhyNoVotes, WhyEliminated, WhyBatchEliminated, + HowWereTiesBroken, WhySingleWinner, WhyMultiWinner, WhyThreshold, diff --git a/visualizer/graph/graphSummary.py b/visualizer/graph/graphSummary.py index 4900c235..4d0853b2 100644 --- a/visualizer/graph/graphSummary.py +++ b/visualizer/graph/graphSummary.py @@ -51,6 +51,10 @@ 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 @@ -81,6 +85,8 @@ def __init__(self, round_i): self.eliminatedNames = [] self.winnerNames = [] self.totalActiveVotes = 0 # The total number of active ballots this round + self.eliminatedTiedWith = [] # List of Candidates tied with eliminated candidates + self.winnerTiedWith = [] # List of Candidates tied with winning candidates def key(self): """ Returns the "key" for this round (just the round number) """ @@ -105,6 +111,73 @@ 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. + + Args: + graph: The graph object to access vote counts + """ + # Check ties for eliminated candidates + if self.eliminatedCandidates: + self.eliminatedTiedWith = self._find_ties_for_candidates( + graph, self.eliminatedCandidates) + + # Check ties for winning candidates + if self.winnerCandidates: + self.winnerTiedWith = self._find_ties_for_candidates( + graph, self.winnerCandidates) + + def _find_ties_for_candidates(self, graph, candidateList): + """ + Helper to find which candidates tied with the given candidate list. + + Args: + graph: The graph object + candidateList: List of candidates to check for ties + + Returns: + List of candidates that tied (had same vote count) but weren't in candidateList + """ + if len(candidateList) == 0: + return [] + + # Look at the previous round to get vote counts at time of elimination/election + # (In sankey, eliminations/elections are shown on the previous round) + lookupRound = self.round_i - 1 + if lookupRound < 0: + return [] + + if lookupRound >= len(graph.nodesPerRound): + 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 [] + + # Find the threshold vote count (minimum for eliminated, could be adjusted for winners) + thresholdVoteCount = min(targetVotes) + + # 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 == thresholdVoteCount: + 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..8266c5bb 100644 --- a/visualizer/tests/testFaq.py +++ b/visualizer/tests/testFaq.py @@ -286,3 +286,16 @@ 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.") From 41e307aa9eaea3a4ec72aa13940279029f6c5d2e Mon Sep 17 00:00:00 2001 From: Armin Samii Date: Thu, 6 Nov 2025 12:56:23 -0500 Subject: [PATCH 2/4] update other FAQs based on ties --- visualizer/descriptors/faq.py | 17 ++++++++++++++--- visualizer/graph/graphSummary.py | 17 +++++++++++++---- visualizer/tests/testFaq.py | 2 ++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/visualizer/descriptors/faq.py b/visualizer/descriptors/faq.py index bf4841d2..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): @@ -213,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%, @@ -390,9 +401,9 @@ class FAQGenerator(): WhyNoVotes, WhyEliminated, WhyBatchEliminated, - HowWereTiesBroken, WhySingleWinner, WhyMultiWinner, + HowWereTiesBroken, WhyThreshold, WhyPercentageBasedOnFirstRound, WhySurplusTransfer, diff --git a/visualizer/graph/graphSummary.py b/visualizer/graph/graphSummary.py index 4d0853b2..723b01bb 100644 --- a/visualizer/graph/graphSummary.py +++ b/visualizer/graph/graphSummary.py @@ -60,7 +60,7 @@ def __init__(self, graph): 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): """ @@ -75,18 +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 - self.eliminatedTiedWith = [] # List of Candidates tied with eliminated candidates - self.winnerTiedWith = [] # List of Candidates tied with winning candidates + + # 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) """ diff --git a/visualizer/tests/testFaq.py b/visualizer/tests/testFaq.py index 8266c5bb..509f334d 100644 --- a/visualizer/tests/testFaq.py +++ b/visualizer/tests/testFaq.py @@ -299,3 +299,5 @@ def test_tiebreak(self): "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")) From 9e3c847f2478b86657a5c1ec602115a78ea379e6 Mon Sep 17 00:00:00 2001 From: Armin Samii Date: Thu, 6 Nov 2025 13:25:05 -0500 Subject: [PATCH 3/4] add test file --- testData/tiebreak.json | 55 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 testData/tiebreak.json 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 From a6bb228ece39c05c746ae899d78415e5afd72305 Mon Sep 17 00:00:00 2001 From: Armin Samii Date: Thu, 6 Nov 2025 13:37:57 -0500 Subject: [PATCH 4/4] fix tests + bugfix --- testData/expected-multiwinner-faqs.json | 6 ++--- visualizer/graph/graphSummary.py | 31 +++++++++---------------- 2 files changed, 14 insertions(+), 23 deletions(-) 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/visualizer/graph/graphSummary.py b/visualizer/graph/graphSummary.py index 723b01bb..46dafeff 100644 --- a/visualizer/graph/graphSummary.py +++ b/visualizer/graph/graphSummary.py @@ -127,43 +127,30 @@ def find_ties(self, graph): A tie occurs when a candidate in the action list (eliminated/winner) has the same vote count as another candidate not in that list. - - Args: - graph: The graph object to access vote counts """ # Check ties for eliminated candidates if self.eliminatedCandidates: self.eliminatedTiedWith = self._find_ties_for_candidates( - graph, self.eliminatedCandidates) + self.round_i - 1, graph, self.eliminatedCandidates) # Check ties for winning candidates if self.winnerCandidates: self.winnerTiedWith = self._find_ties_for_candidates( - graph, self.winnerCandidates) + self.round_i, graph, self.winnerCandidates) - def _find_ties_for_candidates(self, graph, candidateList): + def _find_ties_for_candidates(self, lookupRound, graph, candidateList): """ Helper to find which candidates tied with the given candidate list. - Args: - graph: The graph object - candidateList: List of candidates to check for ties - - Returns: - List of candidates that tied (had same vote count) but weren't in candidateList + 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 - # (In sankey, eliminations/elections are shown on the previous round) - lookupRound = self.round_i - 1 if lookupRound < 0: return [] - if lookupRound >= len(graph.nodesPerRound): - return [] - nodesThisRound = graph.nodesPerRound[lookupRound] # Get vote counts for all active candidates @@ -176,13 +163,17 @@ def _find_ties_for_candidates(self, graph, candidateList): if not targetVotes: return [] - # Find the threshold vote count (minimum for eliminated, could be adjusted for winners) - thresholdVoteCount = min(targetVotes) + # 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 == thresholdVoteCount: + if candidate not in candidateList and votes == minVotes: tiedCandidates.append(candidate) return tiedCandidates