diff --git a/elsim/methods/__init__.py b/elsim/methods/__init__.py index d169cb7..3b6c6a1 100644 --- a/elsim/methods/__init__.py +++ b/elsim/methods/__init__.py @@ -4,21 +4,24 @@ These take collections of ballots (elections) as inputs and return the winner according to the rules of that method. """ -from elsim.methods.approval import approval, combined_approval -from elsim.methods.black import black -from elsim.methods.borda import borda -from elsim.methods.condorcet import (condorcet, condorcet_from_matrix, - ranked_election_to_matrix) -from elsim.methods.coombs import coombs -from elsim.methods.fptp import fptp, sntv -from elsim.methods.irv import irv -from elsim.methods.runoff import runoff -from elsim.methods.score import score -from elsim.methods.star import matrix_from_scores, star -from elsim.methods.utility_winner import utility_winner +from .approval import approval, combined_approval +from .black import black +from .blanket_primary import (approval_runoff, irv_primary_top_n_runoff, + top_n_condorcet, top_n_irv, top_n_runoff) +from .borda import borda +from .condorcet import (condorcet, condorcet_from_matrix, + ranked_election_to_matrix) +from .coombs import coombs +from .fptp import fptp, sntv +from .irv import irv +from .runoff import runoff +from .score import score +from .star import matrix_from_scores, star +from .utility_winner import utility_winner __all__ = [ 'approval', + 'approval_runoff', 'black', 'borda', 'combined_approval', @@ -27,11 +30,15 @@ 'coombs', 'fptp', 'irv', + 'irv_primary_top_n_runoff', 'matrix_from_scores', 'ranked_election_to_matrix', 'runoff', 'sntv', 'score', 'star', + 'top_n_condorcet', + 'top_n_irv', + 'top_n_runoff', 'utility_winner', ] diff --git a/elsim/methods/blanket_primary.py b/elsim/methods/blanket_primary.py new file mode 100644 index 0000000..4b51007 --- /dev/null +++ b/elsim/methods/blanket_primary.py @@ -0,0 +1,377 @@ +""" +Two-round voting methods that combine a primary (narrowing the field) with a +general election. + +These implement nonpartisan blanket primaries and related reforms: Unified +Primary (top-two approval primary plus a general election using the contingent +vote), pick-one Top Four or Final Five with an IRV general, pick-one Top Four +or Final Five with a general election using the contingent vote among +finalists, ``irv(..., n_winners=n)`` followed by that same contingent-vote general among finalists, and a Condorcet general. +""" +import numpy as np + +from elsim.methods._common import (_get_tiebreak, _no_tiebreak, + _order_tiebreak_keep, _random_tiebreak) +from elsim.methods.condorcet import condorcet +from elsim.methods.fptp import sntv +from elsim.methods.irv import irv +from elsim.methods.runoff import runoff + +_tiebreak_map_keep = {'order': _order_tiebreak_keep, + 'random': _random_tiebreak, + None: _no_tiebreak} + + +def _top_n_from_plurality_tallies(tallies, n, tiebreaker): + """ + Candidate indices with the top ``n`` first-preference counts, breaking + boundary ties the same way as ``sntv``. + """ + tallies = np.asarray(tallies) + n_cands = len(tallies) + if n < 1: + raise ValueError('n must be at least 1') + if n >= n_cands: + return set(range(n_cands)) + + sorted_indices = np.argsort(tallies) + top_candidates = sorted_indices[-n:] + worst_top = top_candidates[0] + best_not_top = sorted_indices[-n - 1] + + if tallies[worst_top] == tallies[best_not_top]: + untied_winners = sorted_indices[tallies[sorted_indices] > + tallies[worst_top]] + tied_candidates = np.where(tallies == tallies[worst_top])[0] + tiebreak = _get_tiebreak(tiebreaker, _tiebreak_map_keep) + n_needed = n - len(untied_winners) + tie_winners = tiebreak(list(tied_candidates), n_needed) + if tie_winners == [None]: + return None + winners = set(untied_winners) | set(tie_winners) + else: + winners = set(top_candidates) + + return set(int(w) for w in winners) + + +def _primary_top_n_approval(approval_election, n, tiebreaker): + approval_election = np.asarray(approval_election, dtype=np.uint8) + tallies = approval_election.sum(axis=0) + n_cands = approval_election.shape[1] + if n >= n_cands: + return set(range(n_cands)) + return _top_n_from_plurality_tallies(tallies, n, tiebreaker) + + +def _restrict_ballots(election, allowed): + """Drop non-finalists from each ballot, preserving order (local IDs 0..k-1).""" + election = np.asarray(election) + allowed_sorted = sorted(allowed) + old_to_new = {cand: j for j, cand in enumerate(allowed_sorted)} + new_to_old = {j: cand for j, cand in enumerate(allowed_sorted)} + rows = [] + for ballot in election: + row = [old_to_new[c] for c in ballot.tolist() if c in old_to_new] + rows.append(row) + return np.asarray(rows, dtype=election.dtype), new_to_old + + +def _head_to_head_two(finalist_0, finalist_1, election, tiebreaker): + """Pairwise winner between two finalists using full rankings (contingent vote).""" + tiebreak = _get_tiebreak(tiebreaker, _tiebreak_map_keep) + n_voters = election.shape[0] + 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 + assert finalist_0_tally + finalist_1_tally == n_voters + if finalist_0_tally == finalist_1_tally: + return tiebreak([finalist_0, finalist_1])[0] + if finalist_0_tally > finalist_1_tally: + return finalist_0 + return finalist_1 + + +def approval_runoff(approval_election, ranked_election, tiebreaker=None): + """ + Find the winner of an election using a top-two approval primary and a + general election under the contingent vote on ranked ballots. + + Also known as the Unified Primary. [1]_ + + The two candidates with the most approvals advance. The general election + is a pairwise majority vote between those two on the ranked ballots (the + contingent vote, the same ranked-ballot abstraction as ``runoff`` uses + between its finalists). [2]_ Delemazure et al. study this approval-with-runoff + pattern in the computational social choice literature. [3]_ + + Parameters + ---------- + approval_election : array_like + A 2D collection of approval ballots. See `approval` for format. + ranked_election : array_like + A collection of ranked ballots for the same voters as + ``approval_election`` (same number of rows). See `borda` for format. + tiebreaker : {'random', 'order', None}, optional + If there is a tie, and `tiebreaker` is ``'random'``, a random finalist + is returned in the primary or in a tied general. + If 'order', the lowest-ID tied candidate is returned. + By default, ``None`` is returned for ties. + + Returns + ------- + winner : {int, None} + The ID number of the winner, or ``None`` for an unbroken tie. + + References + ---------- + .. [1] `Unified Primary `__ + .. [2] `Contingent vote `__ + .. [3] `Delemazure et al., "Approval with Runoff" (IJCAI 2022) `__ + + Examples + -------- + >>> A, B, C = 0, 1, 2 + >>> approvals = [[1, 1, 0], [1, 1, 0], [0, 1, 1]] + >>> ranked = [[B, A, C], [B, C, A], [C, B, A]] + >>> approval_runoff(approvals, ranked) + 1 + """ + approval_election = np.asarray(approval_election, dtype=np.uint8) + ranked_election = np.asarray(ranked_election) + if approval_election.shape[0] != ranked_election.shape[0]: + raise ValueError('approval_election and ranked_election must have the ' + 'same number of rows (voters)') + if approval_election.max() > 1: + raise ValueError('Approval ballots must contain only 0 and 1') + + finalists = _primary_top_n_approval(approval_election, 2, tiebreaker) + if finalists is None: + return None + if len(finalists) == 1: + (only,) = tuple(finalists) + return only + finalist_0, finalist_1 = sorted(finalists) + out = _head_to_head_two(finalist_0, finalist_1, ranked_election, + tiebreaker) + return int(out) if out is not None else None + + +def top_n_irv(election, n, tiebreaker=None): + """ + Find the winner of an election using a pick-one top-``n`` primary and an + IRV general. + + The ``n`` candidates with the most first-preference votes advance (same + rule as ``sntv``). The winner is then chosen by ``irv`` on the same + rankings restricted to those finalists. ``top_n_irv(..., n=4)`` matches + Alaska's Top Four primary with an IRV general; ``n`` = 5 matches the Final + Five package (top-five primary plus this IRV general). [1]_ [2]_ [3]_ [4]_ + + Parameters + ---------- + election : array_like + A collection of ranked ballots. See `borda` for election format. + Currently, this must include full rankings for each voter. + n : int + Number of candidates who advance from the primary (``n`` = 4 for + Alaska's Top Four; ``n`` = 5 for the primary slate in Final Five with + this IRV general). + tiebreaker : {'random', 'order', None}, optional + If there is a tie, and `tiebreaker` is ``'random'``, a random finalist + is returned or tied candidates are eliminated at random, according to + the underlying ``sntv`` / ``irv`` steps. + If 'order', the lowest-ID tied candidate is preferred in each tie. + By default, ``None`` is returned for ties. + + Returns + ------- + winner : {int, None} + The ID number of the winner, or ``None`` for an unbroken tie. + + References + ---------- + .. [1] `Top-four primary `__ + .. [2] `Alaska Division of Elections, "Ranked Choice Voting" (top-four primary and RCV general) `__ + .. [3] `FairVote, "Top Four" policy guide (PDF, 2013) `__ + .. [4] `Gehl & Porter (2017), "Why Competition in the Politics Industry is Failing America" (PDF) `__ + + Examples + -------- + >>> A, B, C = 0, 1, 2 + >>> election = [*6*[[A, B, C]], *3*[[B, A, C]], *1*[[C, B, A]]] + >>> top_n_irv(election, 2) + 0 + """ + finalists = sntv(election, n, tiebreaker) + if finalists is None: + return None + sub, new_to_old = _restrict_ballots(election, finalists) + w = irv(sub, tiebreaker) + if w is None: + return None + return int(new_to_old[w]) + + +def top_n_runoff(election, n, tiebreaker=None): + """ + Find the winner of an election using a pick-one top-``n`` primary and a + general election using the contingent vote among those finalists. + + The primary is ``sntv``: the ``n`` candidates with the most first-preference + votes in the full field advance. The general is ``runoff`` applied to the + sub-election that keeps only those finalists on each ballot. That is not + the same as calling ``runoff`` on the original ballots, because here the + first stage of ``runoff`` counts first preferences **among the finalists + only** to pick two of them, then the pairwise stage uses the same + restricted rankings (the contingent vote, as in ``runoff``). [1]_ [2]_ + + Parameters + ---------- + election : array_like + A collection of ranked ballots. See `borda` for election format. + Currently, this must include full rankings for each voter. + n : int + Number of candidates who advance from the primary. + tiebreaker : {'random', 'order', None}, optional + If there is a tie, and `tiebreaker` is ``'random'``, a random finalist + is returned. + If 'order', the lowest-ID tied candidate is returned. + By default, ``None`` is returned for ties. + + Returns + ------- + winner : {int, None} + The ID number of the winner, or ``None`` for an unbroken tie. + + References + ---------- + .. [1] `Top-four primary: Variations `__ + .. [2] `Contingent vote `__ + + Examples + -------- + >>> A, B, C, D = 0, 1, 2, 3 + >>> election = [*3*[[A, B, C, D]], *2*[[B, A, C, D]], *1*[[C, D, A, B]]] + >>> isinstance(top_n_runoff(election, 4), int) + True + """ + finalists = sntv(election, n, tiebreaker) + if finalists is None: + return None + sub, new_to_old = _restrict_ballots(election, finalists) + w = runoff(sub, tiebreaker) + if w is None: + return None + return int(new_to_old[w]) + + +def top_n_condorcet(election, n, tiebreaker=None): + """ + Find the winner of an election using a pick-one top-``n`` primary and a + Condorcet general. + + The primary uses the same top-``n`` rule as ``sntv``. The general election + applies ``condorcet`` to the restricted rankings (no tiebreaker in the + general, matching ``condorcet`` itself). [1]_ Condorcet's 1785 essay + defines pairwise majority comparisons among candidates. [2]_ A modern + overview is in [3]_. + + Parameters + ---------- + election : array_like + A collection of ranked ballots. See `borda` for election format. + Currently, this must include full rankings for each voter. + n : int + Number of candidates who advance from the primary. + tiebreaker : {'random', 'order', None}, optional + Used only for the primary. If there is a tie, and `tiebreaker` is + ``'random'``, random tied candidates are returned. + If 'order', the lowest-ID tied candidates are returned. + By default, ``None`` is returned for ties in the primary. + + Returns + ------- + winner : {int, None} + The ID number of the winner, or ``None`` for an unbroken tie in the + primary or no Condorcet winner in the general. + + References + ---------- + .. [1] `Top-four primary: Variations `__ + .. [2] `Condorcet (1785), *Essai sur l'application de l'analyse à la probabilité des décisions rendues à la pluralité des voix* (BnF Gallica) `__ + .. [3] `Condorcet method `__ + + Examples + -------- + >>> A, B, C = 0, 1, 2 + >>> election = [[A, B, C], [A, B, C], [B, A, C]] + >>> top_n_condorcet(election, 2) + 0 + """ + finalists = sntv(election, n, tiebreaker) + if finalists is None: + return None + sub, new_to_old = _restrict_ballots(election, finalists) + w = condorcet(sub) + if w is None: + return None + return int(new_to_old[w]) + + +def irv_primary_top_n_runoff(election, n, tiebreaker=None): + """ + Find the winner of an election using a ranked sequential primary to a + slate of ``n``, then a general election using the contingent vote among + those finalists. + + The primary is ``irv(..., n_winners=n)``: the same last-place elimination + and transfers as ``irv``, but without stopping when someone reaches an + overall majority, until ``n`` candidates remain. The general is ``runoff`` + on ballots restricted to that slate (first preferences among finalists + only to pick two of them, then the pairwise stage of the contingent vote, + as in ``runoff``). [1]_ [2]_ + + Parameters + ---------- + election : array_like + A collection of ranked ballots. See `borda` for election format. + Currently, this must include full rankings for each voter. + n : int + Target number of finalists after the primary. + tiebreaker : {'random', 'order', None}, optional + If there is a tie, and `tiebreaker` is ``'random'``, a random finalist + is returned. + If 'order', the lowest-ID tied candidate is returned. + By default, ``None`` is returned for ties. + + Returns + ------- + winner : {int, None} + The ID number of the winner, or ``None`` for an unbroken tie. + + References + ---------- + .. [1] `Top-four primary: Variations `__ + .. [2] `FairVote, "Top Four" policy guide (PDF, 2013) `__ + + Examples + -------- + >>> A, B, C = 0, 1, 2 + >>> election = [*6*[[A, B, C]], *3*[[B, A, C]], *1*[[C, B, A]]] + >>> irv_primary_top_n_runoff(election, 2, tiebreaker='order') + 0 + """ + finalists = irv(election, tiebreaker, n_winners=n) + if finalists is None: + return None + sub, new_to_old = _restrict_ballots(election, finalists) + w = runoff(sub, tiebreaker) + if w is None: + return None + return int(new_to_old[w]) diff --git a/elsim/methods/irv.py b/elsim/methods/irv.py index 086bcf8..871283b 100644 --- a/elsim/methods/irv.py +++ b/elsim/methods/irv.py @@ -9,7 +9,42 @@ None: _no_tiebreak} -def irv(election, tiebreaker=None): +def _irv_eliminate_until_n_winners_remain(election, tiebreaker, n_winners): + """ + Same elimination rule as `irv`, but do not stop on a majority; keep + eliminating the last-place active candidate until at most ``n_winners`` + remain. + """ + n_voters, n_cands = election.shape + tiebreak = _get_tiebreak(tiebreaker, _tiebreak_map) + + voter_top_rank_idx = np.zeros(n_voters, dtype=np.uint8) + cand_tallies = np.empty(n_cands, dtype=np.uint) + + _tally_at_rank_idx(cand_tallies, election, voter_top_rank_idx) + eliminated_cands = set(_all_indices(cand_tallies, 0)) + if eliminated_cands: + _inc_rank_idx(election, voter_top_rank_idx, eliminated_cands) + + while n_cands - len(eliminated_cands) > n_winners: + _tally_at_rank_idx(cand_tallies, election, voter_top_rank_idx) + cand_tallies_list = cand_tallies.tolist() + active_tallies = [cand_tallies_list[c] for c in range(n_cands) + if c not in eliminated_cands] + last_place_tally = min(active_tallies) + last_place_cands = [c for c in range(n_cands) + if c not in eliminated_cands + and cand_tallies_list[c] == last_place_tally] + cand_to_eliminate = tiebreak(last_place_cands)[0] + if cand_to_eliminate is None: + return None + eliminated_cands.add(cand_to_eliminate) + _inc_rank_idx(election, voter_top_rank_idx, eliminated_cands) + + return {c for c in range(n_cands) if c not in eliminated_cands} + + +def irv(election, tiebreaker=None, *, n_winners=None): """ Find the winner of an election using instant-runoff voting. @@ -37,11 +72,27 @@ def irv(election, tiebreaker=None): are eliminated or selected at random. If 'order', the lowest-ID tied candidate is preferred in each tie. By default, ``None`` is returned if there are any ties. + n_winners : int, optional + If omitted (default), return the usual single IRV winner. + If ``1``, same as omitted (one winner). + If ``k`` with ``1 < k < n_candidates``, return the set of candidate IDs + still standing after repeatedly eliminating the last-place active + candidate (same transfer rule as above) until ``k`` remain, without + stopping early when someone has a majority. This models a ranked + primary that narrows the field to ``k`` for a later round. + If ``k`` is greater than or equal to the number of candidates, every + candidate is returned as a set. Returns ------- - winner : {int, None} - The ID number of the winner, or ``None`` for an unbroken tie. + outcome : {int, set of int, None} + If ``n_winners`` is omitted or ``1``, the winner's ID, or ``None`` for + an unbroken tie. + If ``n_winners`` is strictly between ``1`` and the number of + candidates, the set of surviving candidate IDs, or ``None`` for an + unbroken tie during elimination. + If ``n_winners`` is greater than or equal to the number of candidates, + the set of all candidate IDs. References ---------- @@ -70,9 +121,31 @@ def irv(election, tiebreaker=None): >>> irv(election) 0 + + The same ballots with ``n_winners`` = 2 (no majority short-circuit; + eliminate last place once so two candidates remain): + + >>> election_b = [[A, C, B], + ... [A, C, B], + ... [B, C, A], + ... [B, C, A], + ... [C, A, B], + ... ] + >>> sorted(irv(election_b, n_winners=2, tiebreaker='order')) + [0, 1] """ election = np.asarray(election) n_voters, n_cands = election.shape + + if n_winners is not None: + if n_winners < 1: + raise ValueError('n_winners must be at least 1') + if n_winners >= n_cands: + return set(range(n_cands)) + if n_winners > 1: + return _irv_eliminate_until_n_winners_remain(election, tiebreaker, + n_winners) + tiebreak = _get_tiebreak(tiebreaker, _tiebreak_map) voter_top_rank_idx = np.zeros(n_voters, dtype=np.uint8) cand_tallies = np.empty(n_cands, dtype=np.uint) diff --git a/tests/test_blanket_primary.py b/tests/test_blanket_primary.py new file mode 100644 index 0000000..80e3baa --- /dev/null +++ b/tests/test_blanket_primary.py @@ -0,0 +1,234 @@ +import numpy as np +import pytest +from hypothesis import given +from hypothesis.strategies import integers, lists, permutations + +from elsim.methods import (approval_runoff, irv, irv_primary_top_n_runoff, + runoff, top_n_condorcet, top_n_irv, top_n_runoff) +from elsim.methods.blanket_primary import (_head_to_head_two, + _top_n_from_plurality_tallies) + + +def complete_ranked_ballots(min_cands=3, max_cands=256, min_voters=1, + max_voters=1000): + n_cands = integers(min_value=min_cands, max_value=max_cands) + return n_cands.flatmap(lambda n: lists(permutations(range(n)), + min_size=min_voters, + max_size=max_voters)) + + +@pytest.mark.parametrize('tiebreaker', [None, 'random', 'order']) +def test_top_n_irv_four_and_five_tennessee(tiebreaker): + Memphis, Nashville, Chattanooga, Knoxville = 0, 1, 2, 3 + election = [*42 * [[Memphis, Nashville, Chattanooga, Knoxville]], + *26 * [[Nashville, Chattanooga, Knoxville, Memphis]], + *15 * [[Chattanooga, Knoxville, Nashville, Memphis]], + *17 * [[Knoxville, Chattanooga, Nashville, Memphis]], + ] + assert top_n_irv(election, 4, tiebreaker) == Knoxville + assert top_n_irv(election, 5, tiebreaker) == Knoxville + assert irv(election, tiebreaker) == Knoxville + + +@pytest.mark.parametrize('tiebreaker', [None, 'random', 'order']) +def test_top_n_runoff_four_tennessee(tiebreaker): + Memphis, Nashville, Chattanooga, Knoxville = 0, 1, 2, 3 + election = [*42 * [[Memphis, Nashville, Chattanooga, Knoxville]], + *26 * [[Nashville, Chattanooga, Knoxville, Memphis]], + *15 * [[Chattanooga, Knoxville, Nashville, Memphis]], + *17 * [[Knoxville, Chattanooga, Nashville, Memphis]], + ] + assert top_n_runoff(election, 4, tiebreaker) == runoff( + election, tiebreaker) + + +@pytest.mark.parametrize('tiebreaker', [None, 'random', 'order']) +def test_irv_primary_runoff_tennessee(tiebreaker): + Memphis, Nashville, Chattanooga, Knoxville = 0, 1, 2, 3 + election = [*42 * [[Memphis, Nashville, Chattanooga, Knoxville]], + *26 * [[Nashville, Chattanooga, Knoxville, Memphis]], + *15 * [[Chattanooga, Knoxville, Nashville, Memphis]], + *17 * [[Knoxville, Chattanooga, Nashville, Memphis]], + ] + assert irv_primary_top_n_runoff(election, 4, tiebreaker) == runoff( + election, tiebreaker) + + +def test_irv_n_winners_three_cycle(): + election = np.array([[0, 1, 2], [1, 2, 0], [2, 0, 1]]) + assert irv(election, n_winners=2) is None + assert sorted(irv(election, n_winners=2, tiebreaker='order')) == [0, 1] + + +def test_irv_n_winners_invalid_n(): + with pytest.raises(ValueError, match='n_winners must be at least 1'): + irv([[0, 1]], n_winners=0) + + +def test_irv_n_winners_all_survive_when_n_ge_total(): + assert irv(np.array([[0, 1], [1, 0]]), n_winners=2, + tiebreaker='order') == {0, 1} + + +def test_irv_n_winners_pre_elimination_zero_first_place(): + election = np.array([[0, 1, 2, 3], + [0, 1, 2, 3], + [1, 0, 2, 3], + [1, 0, 2, 3]]) + assert irv(election, n_winners=2, tiebreaker='order') == {0, 1} + + +def test__top_n_from_plurality_tallies(): + with pytest.raises(ValueError, match='n must be at least 1'): + _top_n_from_plurality_tallies(np.array([1, 2]), 0, None) + assert _top_n_from_plurality_tallies(np.array([5, 4]), 2, None) == {0, 1} + assert _top_n_from_plurality_tallies(np.array([9, 4, 2, 1]), 2, None) == {0, 1} + assert _top_n_from_plurality_tallies(np.array([5, 5, 5, 5]), 2, None) is None + + +def test__head_to_head_two(): + e2 = np.array([[0, 1], [0, 1], [1, 0]]) + assert _head_to_head_two(0, 1, e2, tiebreaker='order') == 0 + assert _head_to_head_two(0, 1, np.array([[1, 0], [1, 0], [1, 0]]), + tiebreaker='order') == 1 + assert _head_to_head_two(0, 1, np.array([[0, 1], [1, 0]]), + tiebreaker=None) is None + + +def test_approval_runoff_primary_and_general_edges(): + app = np.ones((4, 4), dtype=np.uint8) + ranked = np.array([[0, 1, 2, 3]] * 4) + assert approval_runoff(app, ranked, tiebreaker=None) is None + + app_one = np.array([[1], [1]], dtype=np.uint8) + ranked_one = np.array([[0], [0]]) + assert approval_runoff(app_one, ranked_one) == 0 + + with pytest.raises(ValueError, match='0 and 1'): + approval_runoff(np.array([[2, 0]], dtype=np.uint8), + np.array([[0, 1]])) + + app_tie = np.array([[1, 1], [1, 1]], dtype=np.uint8) + ranked_tie = np.array([[0, 1], [1, 0]]) + assert approval_runoff(app_tie, ranked_tie, tiebreaker=None) is None + + +def test_top_n_irv_primary_tie_returns_none(): + first = np.array([0, 0, 0, 1, 1, 2, 2, 3]) + rows = [] + for fp in first: + tail = [c for c in range(4) if c != fp] + rows.append([fp, *tail]) + election = np.array(rows) + assert top_n_irv(election, 2, tiebreaker=None) is None + + +def test_top_n_irv_general_returns_none(): + election = np.array([[0, 1, 2], [1, 2, 0], [2, 0, 1]]) + assert top_n_irv(election, 3, tiebreaker=None) is None + + +def test_top_n_runoff_returns_none_paths(): + first = np.array([0, 0, 0, 1, 1, 2, 2, 3]) + rows = [] + for fp in first: + tail = [c for c in range(4) if c != fp] + rows.append([fp, *tail]) + election = np.array(rows) + assert top_n_runoff(election, 2, tiebreaker=None) is None + + election = np.array([[2, 0, 1], + [0, 1, 2], + [1, 0, 2], + [2, 0, 1], + [2, 1, 0], + [0, 1, 2], + [2, 0, 1], + [1, 0, 2], + [2, 1, 0], + [0, 2, 1]]) + assert runoff(election) is None + assert top_n_runoff(election, 3, None) is None + + +def test_top_n_condorcet_primary_tie_returns_none(): + first = np.array([0, 0, 0, 1, 1, 2, 2, 3]) + rows = [[fp, *[c for c in range(4) if c != fp]] for fp in first] + election = np.array(rows) + assert top_n_condorcet(election, 2, tiebreaker=None) is None + + +def test_top_n_condorcet_returns_none_on_cycle(): + election = np.array([[0, 1, 2], [1, 2, 0], [2, 0, 1]]) + assert top_n_condorcet(election, 3, tiebreaker='order') is None + + +def test_top_n_condorcet_five_smoke(): + assert top_n_condorcet([[0, 1], [0, 1], [1, 0]], 5, + tiebreaker='order') == 0 + + +def test_irv_primary_top_n_runoff_returns_none(): + assert irv_primary_top_n_runoff([[0, 1, 2], [1, 2, 0], [2, 0, 1]], 2, + tiebreaker=None) is None + + election = np.array([[2, 0, 1], + [0, 1, 2], + [1, 0, 2], + [2, 0, 1], + [2, 1, 0], + [0, 1, 2], + [2, 0, 1], + [1, 0, 2], + [2, 1, 0], + [0, 2, 1]]) + assert irv_primary_top_n_runoff(election, 3, None) is None + + +def test_approval_runoff_matches_head_to_head(): + A, B, C = 0, 1, 2 + approvals = np.array([[1, 1, 0], [1, 0, 1], [0, 1, 1]], dtype=np.uint8) + ranked = np.array([[A, B, C], [B, A, C], [C, B, A]]) + assert approval_runoff(approvals, ranked, tiebreaker='order') == 1 + + +def test_approval_runoff_row_mismatch(): + with pytest.raises(ValueError, match='same number of rows'): + approval_runoff([[1, 0]], [[0, 1], [1, 0]]) + + +def test_blanket_methods_reject_invalid_tiebreaker(): + election = [[0, 1, 2], [1, 2, 0], [2, 0, 1]] + with pytest.raises(ValueError): + top_n_irv(election, 2, tiebreaker='duel') + with pytest.raises(ValueError): + top_n_runoff(election, 2, tiebreaker='duel') + with pytest.raises(ValueError): + top_n_condorcet(election, 2, tiebreaker='duel') + with pytest.raises(ValueError): + irv(election, tiebreaker='duel', n_winners=2) + + +@pytest.mark.parametrize('tiebreaker', ['random', 'order']) +@given(election=complete_ranked_ballots(min_cands=2, max_cands=25, + min_voters=1, max_voters=100)) +def test_top_n_irv_winner_in_range(election, tiebreaker): + n_cands = np.shape(election)[1] + w = top_n_irv(election, min(4, n_cands), tiebreaker) + assert w in range(n_cands) + + +@given(election=complete_ranked_ballots(min_cands=2, max_cands=25, + min_voters=1, max_voters=100)) +def test_top_n_irv_winner_none_tiebreaker(election): + n_cands = np.shape(election)[1] + w = top_n_irv(election, min(4, n_cands)) + assert w in {None} | set(range(n_cands)) + + +if __name__ == '__main__': + from subprocess import PIPE, Popen + with Popen(['pytest', '--tb=short', str(__file__)], + stdout=PIPE, bufsize=1, universal_newlines=True) as p: + for line in p.stdout: + print(line, end='') diff --git a/tests/test_methods.py b/tests/test_methods.py index d568e85..0ae00b3 100644 --- a/tests/test_methods.py +++ b/tests/test_methods.py @@ -1,7 +1,22 @@ import pytest -from elsim.methods import (approval, black, borda, combined_approval, coombs, - fptp, irv, runoff, score, utility_winner) +from elsim.methods import (approval, approval_runoff, black, borda, + combined_approval, coombs, fptp, irv, + irv_primary_top_n_runoff, runoff, score, + top_n_condorcet, top_n_irv, top_n_runoff, + utility_winner) + +_blanket_ranked = [ + lambda e, tb=None: top_n_irv(e, 4, tb), + lambda e, tb=None: top_n_irv(e, 5, tb), + lambda e, tb=None: top_n_runoff(e, 4, tb), + lambda e, tb=None: top_n_runoff(e, 5, tb), + lambda e, tb=None: irv_primary_top_n_runoff(e, 4, tb), + lambda e, tb=None: top_n_irv(e, 3, tb), + lambda e, tb=None: top_n_runoff(e, 3, tb), + lambda e, tb=None: top_n_condorcet(e, 3, tb), + lambda e, tb=None: top_n_condorcet(e, 4, tb), +] @pytest.mark.parametrize("method", [black, borda, fptp, runoff, irv, coombs, @@ -14,7 +29,13 @@ def test_invalid_tiebreaker(method): method(election, tiebreaker='duel') -@pytest.mark.parametrize("method", [black, borda, fptp, runoff, irv, coombs]) +def test_invalid_tiebreaker_approval_runoff(): + with pytest.raises(ValueError): + approval_runoff([[1, 0]], [[0, 1]], tiebreaker='duel') + + +@pytest.mark.parametrize("method", [black, borda, fptp, runoff, irv, coombs, + *_blanket_ranked]) def test_ranked_method_degenerate_case(method): election = [[0]] assert method(election) == 0 @@ -27,7 +48,8 @@ def test_ranked_method_degenerate_case(method): assert method(election, 'order') == 0 -@pytest.mark.parametrize("method", [black, borda, fptp, runoff, irv, coombs]) +@pytest.mark.parametrize("method", [black, borda, fptp, runoff, irv, coombs, + *_blanket_ranked]) def test_ranked_method_unanimity(method): election = [[3, 0, 1, 2], [3, 0, 2, 1], [3, 2, 1, 0]] assert method(election) == 3