Skip to content
Open
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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ repos:
args: [--maxkb=2048]

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.14
rev: v0.15.16
hooks:
# Quote --select=...: in YAML flow style, commas inside [...] split list items (E902 on Windows).
- id: ruff
Expand Down
31 changes: 19 additions & 12 deletions elsim/methods/runoff.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import numpy as np

from elsim.methods._common import (_all_indices, _get_tiebreak, _no_tiebreak,
_order_tiebreak_keep, _random_tiebreak)
from elsim.methods._common import (
_all_indices,
_get_tiebreak,
_inc_rank_idx,
_no_tiebreak,
_order_tiebreak_keep,
_random_tiebreak,
_tally_at_rank_idx,
)

_tiebreak_map = {'order': _order_tiebreak_keep,
'random': _random_tiebreak,
Expand Down Expand Up @@ -93,19 +100,19 @@ def runoff(election, tiebreaker=None):
if None in finalists:
return None

# TODO: Can this be vectorized or numbafied?
finalist_0 = finalists[0]
finalist_1 = finalists[1]

finalist_0_tally = 0
finalist_1_tally = 0

for ballot in election:
ballot_list = ballot.tolist()
if ballot_list.index(finalist_0) < ballot_list.index(finalist_1):
finalist_0_tally += 1
else:
finalist_1_tally += 1
n_cands = election.shape[1]
cand_tallies = np.empty(n_cands, dtype=np.uint)
voter_top_rank_idx = np.zeros(n_voters, dtype=np.uint8)
eliminated_mask = np.ones(n_cands, dtype=bool)
eliminated_mask[finalist_0] = False
eliminated_mask[finalist_1] = False
_inc_rank_idx(election, voter_top_rank_idx, eliminated_mask)
_tally_at_rank_idx(cand_tallies, election, voter_top_rank_idx)
finalist_0_tally = cand_tallies[finalist_0]
finalist_1_tally = cand_tallies[finalist_1]

assert finalist_0_tally + finalist_1_tally == n_voters

Expand Down
36 changes: 36 additions & 0 deletions tests/test_runoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from hypothesis.strategies import integers, lists, permutations

from elsim.methods import runoff
from elsim.methods._common import _inc_rank_idx, _tally_at_rank_idx


def collect_random_results(method, election):
Expand All @@ -22,6 +23,41 @@ def collect_random_results(method, election):
return winners


def test_docstring_example():
"""Contingent vote example from runoff() docstring."""
A, B, C = 0, 1, 2
election = np.array([[A, C, B],
[A, C, B],
[B, C, A],
[B, C, A],
[C, A, B]])
assert runoff(election) == A


def test_head_to_head_tally_via_irv_helpers():
"""
Second-round tally skips eliminated candidates then counts top preference
among the two finalists (same path as IRV).
"""
A, B, C = 0, 1, 2
election = np.array([[A, C, B],
[A, C, B],
[B, C, A],
[B, C, A],
[C, A, B]])
n_voters, n_cands = election.shape
voter_top_rank_idx = np.zeros(n_voters, dtype=np.uint8)
cand_tallies = np.empty(n_cands, dtype=np.uint)
eliminated_mask = np.ones(n_cands, dtype=bool)
eliminated_mask[A] = False
eliminated_mask[B] = False
_inc_rank_idx(election, voter_top_rank_idx, eliminated_mask)
_tally_at_rank_idx(cand_tallies, election, voter_top_rank_idx)
assert cand_tallies[A] == 3
assert cand_tallies[B] == 2
assert cand_tallies[C] == 0


def test_one_round():
# 60% majority, tie between others
election = np.array([[2, 0, 1],
Expand Down