Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions testData/expected-multiwinner-faqs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand Down Expand Up @@ -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?",
Expand Down Expand Up @@ -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."
}
]
]
]
55 changes: 55 additions & 0 deletions testData/tiebreak.json
Original file line number Diff line number Diff line change
@@ -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
}
}
60 changes: 58 additions & 2 deletions visualizer/descriptors/faq.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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 """

Expand All @@ -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%,
Expand Down Expand Up @@ -348,6 +403,7 @@ class FAQGenerator():
WhyBatchEliminated,
WhySingleWinner,
WhyMultiWinner,
HowWereTiesBroken,
WhyThreshold,
WhyPercentageBasedOnFirstRound,
WhySurplusTransfer,
Expand Down
77 changes: 75 additions & 2 deletions visualizer/graph/graphSummary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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) """
Expand All @@ -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 """
Expand Down
1 change: 1 addition & 0 deletions visualizer/tests/filenames.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
15 changes: 15 additions & 0 deletions visualizer/tests/testFaq.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Loading