From 130dcc30bf7634b6f0c121fb9f496cf0ce16829f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 00:53:08 +0000 Subject: [PATCH 1/4] Bump https://github.com/astral-sh/ruff-pre-commit Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.15.14 to 0.15.15. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.14...v0.15.15) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.15.15 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 330c9f9a..7939aad8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: args: [--maxkb=2048] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.14 + rev: v0.15.15 hooks: # Quote --select=...: in YAML flow style, commas inside [...] split list items (E902 on Windows). - id: ruff From 2f07f32369778af172a93dc91385882b44c56f05 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:43:16 +0000 Subject: [PATCH 2/4] Bump https://github.com/astral-sh/ruff-pre-commit Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.15.15 to 0.15.16. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.15...v0.15.16) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.15.16 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7939aad8..f06240f8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: args: [--maxkb=2048] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.15 + rev: v0.15.16 hooks: # Quote --select=...: in YAML flow style, commas inside [...] split list items (E902 on Windows). - id: ruff From 253bfc42760bf8c4528495b1121b061ebfce3808 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 11 May 2026 13:56:51 +0000 Subject: [PATCH 3/4] runoff: reuse IRV Numba tally helpers for head-to-head round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-round contingent vote compares two finalists by whoever appears first on each ballot. That matches advancing each voter's rank index past all non-finalists then tallying at that rank—the same _inc_rank_idx and _tally_at_rank_idx path IRV uses. Co-authored-by: endolith --- elsim/methods/runoff.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/elsim/methods/runoff.py b/elsim/methods/runoff.py index fa3a0200..388c6a27 100644 --- a/elsim/methods/runoff.py +++ b/elsim/methods/runoff.py @@ -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, @@ -93,19 +100,18 @@ 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_cands = set(range(n_cands)) - {finalist_0, finalist_1} + if eliminated_cands: + _inc_rank_idx(election, voter_top_rank_idx, eliminated_cands) + _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 From 4b7478aa788fcc1f4560a1917224bb83bccbe48d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 12 Jun 2026 19:04:23 +0000 Subject: [PATCH 4/4] runoff: use eliminated_mask for IRV tally helpers after rebase Master replaced reflected-set elimination with a boolean mask in _inc_rank_idx. Update the head-to-head round to match IRV/Coombs and add tests for the docstring example and direct tally-helper behavior. Co-authored-by: endolith --- elsim/methods/runoff.py | 7 ++++--- tests/test_runoff.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/elsim/methods/runoff.py b/elsim/methods/runoff.py index 388c6a27..20783de2 100644 --- a/elsim/methods/runoff.py +++ b/elsim/methods/runoff.py @@ -106,9 +106,10 @@ def runoff(election, tiebreaker=None): 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_cands = set(range(n_cands)) - {finalist_0, finalist_1} - if eliminated_cands: - _inc_rank_idx(election, voter_top_rank_idx, eliminated_cands) + 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] diff --git a/tests/test_runoff.py b/tests/test_runoff.py index 09b57de8..7dcc5392 100644 --- a/tests/test_runoff.py +++ b/tests/test_runoff.py @@ -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): @@ -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],