Skip to content

Commit f46bda2

Browse files
authored
Merge pull request #586 from artoonie/feature/tiebreak-faq
Tiebreak Support
2 parents 476a485 + a6bb228 commit f46bda2

File tree

6 files changed

+207
-7
lines changed

6 files changed

+207
-7
lines changed

testData/expected-multiwinner-faqs.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
},
1717
{
1818
"question": "Why was Write-In eliminated?",
19-
"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."
19+
"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."
2020
},
2121
{
2222
"question": "Why was Harvey Curley elected this round?",
@@ -52,7 +52,7 @@
5252
},
5353
{
5454
"question": "Why was Sarah Lucido eliminated?",
55-
"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."
55+
"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."
5656
},
5757
{
5858
"question": "Why was Larry Edwards elected this round?",
@@ -81,4 +81,4 @@
8181
"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."
8282
}
8383
]
84-
]
84+
]

testData/tiebreak.json

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"config" : {
3+
"contest" : "Tiebreak test",
4+
"date" : "2017-12-03",
5+
"generatedBy" : "RCTab 2.0.0",
6+
"jurisdiction" : "Funkytown, USA",
7+
"office" : "Sergeant-at-Arms"
8+
},
9+
"jsonFormatVersion" : "1",
10+
"results" : [ {
11+
"inactiveBallots" : {
12+
"exhaustedChoices" : "0",
13+
"overvotes" : "0",
14+
"repeatedRankings" : "0",
15+
"skippedRankings" : "0"
16+
},
17+
"round" : 1,
18+
"tally" : {
19+
"George Gervin" : "3",
20+
"Mookie Blaylock" : "3",
21+
"Yinka Dare" : "3"
22+
},
23+
"tallyResults" : [ {
24+
"eliminated" : "Yinka Dare",
25+
"transfers" : {
26+
"exhausted" : "3"
27+
}
28+
} ],
29+
"threshold" : "5"
30+
}, {
31+
"inactiveBallots" : {
32+
"exhaustedChoices" : "3",
33+
"overvotes" : "0",
34+
"repeatedRankings" : "0",
35+
"skippedRankings" : "0"
36+
},
37+
"round" : 2,
38+
"tally" : {
39+
"George Gervin" : "3",
40+
"Mookie Blaylock" : "3"
41+
},
42+
"tallyResults" : [ {
43+
"elected" : "George Gervin",
44+
"transfers" : { }
45+
} ],
46+
"threshold" : "4"
47+
} ],
48+
"summary" : {
49+
"finalThreshold" : "4",
50+
"numCandidates" : 3,
51+
"numWinners" : 1,
52+
"totalNumBallots" : "9",
53+
"undervotes" : 0
54+
}
55+
}

visualizer/descriptors/faq.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,18 @@ def get_answer(self, roundNum):
122122
eliminatedNames = self.summary.rounds[roundNum].eliminatedNames
123123
elims = common.comma_separated_names_with_and(eliminatedNames)
124124
wasOrWere = "was" if len(eliminatedNames) == 1 else "were"
125-
return f"{elims} had the fewest votes in Round {roundNum}. Since {elims} "\
125+
winners = "winner" if len(self.summary.winnerNames) == 1 else "winners"
126+
127+
if len(self.summary.rounds[roundNum].eliminatedTiedWith) > 0:
128+
start = f"There was a tie and {elims} lost the tiebreak"
129+
else:
130+
start = f"{elims} had the fewest votes in Round {roundNum}"
131+
132+
return f"{start}. Since {elims} "\
126133
f"{wasOrWere} eliminated, the voters who supported {elims} had their "\
127134
f"votes count for their next choices in Round {roundNum + 1}. "\
128135
"Transferring votes ensures that every voter can be included in choosing "\
129-
"the final winner(s), even if their favorite candidate doesn't win."
136+
f"the final {winners}, even if their favorite candidate doesn't win."
130137

131138

132139
class WhyBatchEliminated(FAQBase):
@@ -150,6 +157,50 @@ def get_answer(self, roundNum):
150157
"produces the same results, \"batch\" elimination just takes fewer rounds."
151158

152159

160+
class HowWereTiesBroken(FAQBase):
161+
""" Whenever there's a tie in eliminations or elections """
162+
163+
def is_active(self, roundNum):
164+
rnd = self.summary.rounds[roundNum]
165+
return len(rnd.eliminatedTiedWith) > 0 or len(rnd.winnerTiedWith) > 0
166+
167+
def get_question(self, roundNum):
168+
return "How were ties broken?"
169+
170+
def get_answer(self, roundNum):
171+
rnd = self.summary.rounds[roundNum]
172+
173+
result = "The tiebreak method is up to the election administrator. "\
174+
"RCVis does not know what method was chosen to break this tie, only that "
175+
176+
parts = []
177+
178+
# Handle elimination ties
179+
if rnd.eliminatedTiedWith:
180+
eliminatedNames = rnd.eliminatedNames
181+
elims = common.comma_separated_names_with_and(eliminatedNames)
182+
wasOrWere = "was" if len(eliminatedNames) == 1 else "were"
183+
parts.append(f"{elims} {wasOrWere} eliminated")
184+
185+
# Handle election/winner ties
186+
if rnd.winnerTiedWith:
187+
winnerNames = rnd.winnerNames
188+
winners = common.comma_separated_names_with_and(winnerNames)
189+
wasOrWere = "was" if len(winnerNames) == 1 else "were"
190+
actionText = textForWinnerUtils.as_event(self.config, len(winnerNames))
191+
parts.append(f"{winners} {wasOrWere} {actionText}")
192+
193+
# Combine the parts
194+
if len(parts) == 2:
195+
result += parts[0] + " and " + parts[1] + "."
196+
elif len(parts) == 1:
197+
result += parts[0] + "."
198+
else:
199+
result += "a tie was broken."
200+
201+
return result
202+
203+
153204
class WhySingleWinner(FAQBase):
154205
""" Whenever someone is elected in IRV """
155206

@@ -169,6 +220,10 @@ def get_answer(self, roundNum):
169220
if c.isActive]
170221
areOnlyTwoActiveCandidates = len(activeCandidates) == 2
171222

223+
# Check for tiebreak first
224+
if len(self.summary.rounds[roundNum].winnerTiedWith) > 0:
225+
return f"There was a tie and {winner} won the tiebreak."
226+
172227
if self.config.forceFirstRoundDeterminesPercentages and areOnlyTwoActiveCandidates:
173228
# Special case for IRV with forced first-round percentages:
174229
# at this point, they didn't necessarily win because they received more than 50%,
@@ -348,6 +403,7 @@ class FAQGenerator():
348403
WhyBatchEliminated,
349404
WhySingleWinner,
350405
WhyMultiWinner,
406+
HowWereTiesBroken,
351407
WhyThreshold,
352408
WhyPercentageBasedOnFirstRound,
353409
WhySurplusTransfer,

visualizer/graph/graphSummary.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,16 @@ def __init__(self, graph):
5151
linksByTargetNode[link.target] = []
5252
linksByTargetNode[link.target].append(link)
5353

54+
# Detect ties for each round
55+
for rnd in rounds:
56+
rnd.find_ties(graph)
57+
5458
self.rounds = rounds
5559
self.candidates = candidates
5660
self.linksByTargetNode = linksByTargetNode
5761
self.winnerNames = [i.name for i in alreadyWonInPreviousRound]
5862
self.numWinners = len(self.winnerNames)
59-
self.numEliminated = sum(len(r.eliminatedNames) for r in rounds)
63+
self.numEliminated = sum(len(r.eliminatedCandidates) for r in rounds)
6064

6165
def percent_denominator(self, roundNum, forceFirstRoundDeterminesPercentages):
6266
"""
@@ -71,16 +75,27 @@ def percent_denominator(self, roundNum, forceFirstRoundDeterminesPercentages):
7175
return self.rounds[roundNum].totalActiveVotes
7276

7377

78+
# pylint: disable=too-many-instance-attributes
7479
class RoundInfo:
7580
""" Summarizes a single round, with functions to build the round """
7681

7782
def __init__(self, round_i):
7883
self.round_i = round_i
84+
85+
# Lists of Candidates and their names eliminated or elected this round
7986
self.eliminatedCandidates = []
8087
self.winnerCandidates = []
88+
89+
# Since we use the candidate name list so often, have a separate list for it
8190
self.eliminatedNames = []
8291
self.winnerNames = []
83-
self.totalActiveVotes = 0 # The total number of active ballots this round
92+
93+
# List of all candidates who tied with eliminated/winning candidates
94+
self.eliminatedTiedWith = []
95+
self.winnerTiedWith = []
96+
97+
# The total number of active ballots this round
98+
self.totalActiveVotes = 0
8499

85100
def key(self):
86101
""" Returns the "key" for this round (just the round number) """
@@ -105,6 +120,64 @@ def add_votes(self, candidate, numVotes):
105120

106121
self.totalActiveVotes += numVotes
107122

123+
def find_ties(self, graph):
124+
"""
125+
Detects ties in this round for both eliminated and winning candidates.
126+
Populates eliminatedTiedWith and winnerTiedWith lists.
127+
128+
A tie occurs when a candidate in the action list (eliminated/winner) has the same
129+
vote count as another candidate not in that list.
130+
"""
131+
# Check ties for eliminated candidates
132+
if self.eliminatedCandidates:
133+
self.eliminatedTiedWith = self._find_ties_for_candidates(
134+
self.round_i - 1, graph, self.eliminatedCandidates)
135+
136+
# Check ties for winning candidates
137+
if self.winnerCandidates:
138+
self.winnerTiedWith = self._find_ties_for_candidates(
139+
self.round_i, graph, self.winnerCandidates)
140+
141+
def _find_ties_for_candidates(self, lookupRound, graph, candidateList):
142+
"""
143+
Helper to find which candidates tied with the given candidate list.
144+
145+
Returns a list of candidates that tied but weren't in candidateList
146+
"""
147+
if len(candidateList) == 0:
148+
return []
149+
150+
# Look at the previous round to get vote counts at time of elimination/election
151+
if lookupRound < 0:
152+
return []
153+
154+
nodesThisRound = graph.nodesPerRound[lookupRound]
155+
156+
# Get vote counts for all active candidates
157+
candidateVotes = {candidate: nodesThisRound[candidate].count
158+
for candidate in nodesThisRound.keys()
159+
if candidate.isActive}
160+
161+
# Get vote counts of the target candidates
162+
targetVotes = [candidateVotes.get(c, 0) for c in candidateList]
163+
if not targetVotes:
164+
return []
165+
166+
# If there are multiple candidates in the candidate list and they don't have the
167+
# same vote count, we can safely ignore ties
168+
minVotes = min(targetVotes)
169+
maxVotes = max(targetVotes)
170+
if minVotes != maxVotes:
171+
return []
172+
173+
# Find all candidates with the same vote count who aren't in candidateList
174+
tiedCandidates = []
175+
for candidate, votes in candidateVotes.items():
176+
if candidate not in candidateList and votes == minVotes:
177+
tiedCandidates.append(candidate)
178+
179+
return tiedCandidates
180+
108181

109182
class CandidateInfo:
110183
""" Summarizes a single candidate over each round """

visualizer/tests/filenames.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
INACTIVE_BALLOT_RENAME_DATA = 'testData/inactive-ballot-rename.json'
2323
INACTIVE_BALLOT_RENAME_SIDECAR = 'testData/inactive-ballot-rename-sidecar.json'
2424
ZERO_VOTE_MULTIWINNER = 'testData/zero-vote-multiwinner.json'
25+
TIEBREAK = 'testData/tiebreak.json'
2526

2627
# Regression tests for commit c28d6a7 (fix-inactive-after-double-elim)
2728
RESIDUAL_SURPLUS_MAIN = 'testData/with-residual-surplus.json'

visualizer/tests/testFaq.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,18 @@ def test_describer_consolidates_events(self):
286286
for electionRound in allRounds:
287287
verbCounter = Counter([r['verb'] for r in electionRound])
288288
self.assertTrue([verb <= 1 for verb in verbCounter.values()])
289+
290+
def test_tiebreak(self):
291+
"""Ensure tiebreax text appears only in Round 2"""
292+
with open(filenames.TIEBREAK, 'r', encoding='utf-8') as f:
293+
graph = make_graph_with_file(f, False)
294+
args = (graph, self.config)
295+
self.assertFalse(faq.HowWereTiesBroken(*args).is_active(0))
296+
self.assertTrue(faq.HowWereTiesBroken(*args).is_active(1))
297+
self.assertEqual(
298+
faq.HowWereTiesBroken(*args).get_answer(1),
299+
"The tiebreak method is up to the election administrator. "
300+
"RCVis does not know what method was chosen to break this tie, "
301+
"only that Yinka Dare was eliminated and George Gervin was was elected.")
302+
self.assertTrue(faq.WhyEliminated(*args).get_answer(1).startswith("There was a tie"))
303+
self.assertTrue(faq.WhySingleWinner(*args).get_answer(1).startswith("There was a tie"))

0 commit comments

Comments
 (0)