diff --git a/tests/test_approval.py b/tests/test_approval.py index 16b30e92..8686441a 100644 --- a/tests/test_approval.py +++ b/tests/test_approval.py @@ -145,6 +145,12 @@ def test_cav_basic(tiebreaker): assert combined_approval(election, tiebreaker) == 1 # Nashville +def test_combined_approval_balanced_opposing_ballots_tie(): + election = np.array([[1, -1], [-1, 1]], dtype=np.int8) + assert combined_approval(election) is None + assert combined_approval(election, tiebreaker='order') == 0 + + def combined_approval_ballots(min_cands=1, max_cands=25, min_voters=1, max_voters=100): """ diff --git a/tests/test_black.py b/tests/test_black.py index 32881282..99358694 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -3,7 +3,7 @@ from hypothesis import given from hypothesis.strategies import integers, lists, permutations -from elsim.methods import black +from elsim.methods import black, borda, condorcet @pytest.mark.parametrize("tiebreaker", [None, 'random', 'order']) @@ -150,6 +150,17 @@ def test_legit_winner_none(election): assert winner in set(range(n_cands)) | {None} +@given(election=complete_ranked_ballots(min_cands=2, max_cands=20, + min_voters=1, max_voters=80)) +def test_black_equals_condorcet_or_borda(election): + election = np.asarray(election) + cw = condorcet(election) + if cw is not None: + assert black(election) == cw + else: + assert black(election) == borda(election) + + if __name__ == "__main__": # Run unit tests, in separate process to avoid warnings about cached # modules, printing output line by line in realtime diff --git a/tests/test_condorcet.py b/tests/test_condorcet.py index 3e75d387..844c6731 100644 --- a/tests/test_condorcet.py +++ b/tests/test_condorcet.py @@ -302,6 +302,32 @@ def test_ranked_election_to_matrix(election): assert_array_equal(np.diagonal(matrix), 0) +@given(election=complete_ranked_ballots(min_cands=2, max_cands=25, + min_voters=1, max_voters=100)) +def test_pairwise_counts_partition_voters(election): + election = np.asarray(election) + matrix = ranked_election_to_matrix(election) + n_cands = matrix.shape[0] + n_voters = election.shape[0] + for i in range(n_cands): + for j in range(i + 1, n_cands): + assert matrix[i, j] + matrix[j, i] == n_voters + + +@given(election=complete_ranked_ballots(min_cands=2, max_cands=25, + min_voters=1, max_voters=100)) +def test_condorcet_winner_beats_everyone_pairwise(election): + election = np.asarray(election) + winner = condorcet(election) + if winner is None: + return + matrix = ranked_election_to_matrix(election) + for opponent in range(matrix.shape[0]): + if opponent == winner: + continue + assert matrix[winner, opponent] > matrix[opponent, winner] + + if __name__ == "__main__": # Run unit tests, in separate process to avoid warnings about cached # modules, printing output line by line in realtime diff --git a/tests/test_elections.py b/tests/test_elections.py index b831fa7c..65b753cf 100644 --- a/tests/test_elections.py +++ b/tests/test_elections.py @@ -1,5 +1,8 @@ import numpy as np import pytest +from hypothesis import assume, given +from hypothesis.extra.numpy import arrays +from hypothesis.strategies import floats, integers, tuples from numpy.testing import (assert_allclose, assert_array_equal, assert_array_less) @@ -10,17 +13,19 @@ def test_random_utilities(): rng = np.random.default_rng(1788) # Deterministic tests - for n_voters in (1, 5, 100): - for n_cands in (1, 2, 3, 7, 100): + for n_voters in (0, 1, 5, 100): + for n_cands in (0, 1, 2, 3, 7, 100): election = random_utilities(n_voters, n_cands, random_state=rng) # Make sure rows are voters and columns are candidates assert election.shape == (n_voters, n_cands) - # Make sure each row is within [0, 1] + # Make sure each row is within [0, 1) (``Generator.random``) + if n_cands == 0: + continue for row in election: assert row.min() >= 0 - assert row.max() <= 1 + assert row.max() < 1 # Check that utilities are equally distributed from 0 to 1 n_voters = 10000 @@ -43,8 +48,9 @@ def test_impartial_culture(): assert election.shape == (n_voters, n_cands) # Make sure each row is a permutation with no ties + if n_cands == 0: + continue for row in election: - # (Empty arrays test equal, which is fine) assert_array_equal(np.bincount(row), 1) # Check that rankings are equally distributed @@ -83,6 +89,18 @@ def test_normal_electorate(): assert_allclose(np.std(voters[:, 1])*disp, np.std(cands[:, 1]), rtol=0.1) + # Smaller draw: alternate correlation sign, 3D, n_voters or n_cands zero + voters_s, cands_s = normal_electorate(80, 40, 3, -0.2, 1.5, + random_state=rng) + assert voters_s.shape == (80, 3) + assert cands_s.shape == (40, 3) + assert np.isfinite(voters_s).all() and np.isfinite(cands_s).all() + + voters_z, cands_z = normal_electorate(0, 5, 2, 0.0, 1.0, + random_state=rng) + assert voters_z.shape == (0, 2) + assert cands_z.shape == (5, 2) + def test_normed_dist_utilities(): voters = [[1, 1], @@ -101,6 +119,12 @@ def test_normed_dist_utilities(): [0.762689671355725, 0.000000000000000, 1.000000000000000]] ) + # Two candidates at the same point: per-voter min == max after shifting, + # so normalization divides 0 by 0 and the row is all NaN. + voters_coinc = np.array([[0.0, 0.0]]) + cands_coinc = np.array([[1.0, 0.0], [1.0, 0.0]]) + assert np.isnan(normed_dist_utilities(voters_coinc, cands_coinc)).all() + @pytest.mark.parametrize("func", [random_utilities, impartial_culture, normal_electorate]) @@ -132,6 +156,81 @@ def test_invalid_random_state(func): func(5, 5, random_state='bananas') +@given(n_voters=integers(0, 60), n_cands=integers(0, 60)) +def test_random_utilities_hypothesis_shape_and_range(n_voters, n_cands): + rng = np.random.default_rng(2026) + election = random_utilities(n_voters, n_cands, random_state=rng) + assert election.shape == (n_voters, n_cands) + if n_voters and n_cands: + assert (election >= 0).all() + assert (election < 1).all() + + +@given(n_voters=integers(0, 60), n_cands=integers(0, 60)) +def test_impartial_culture_hypothesis_valid_rankings(n_voters, n_cands): + rng = np.random.default_rng(2027) + election = impartial_culture(n_voters, n_cands, random_state=rng) + assert election.shape == (n_voters, n_cands) + if n_cands == 0: + return + for row in election: + assert_array_equal( + np.bincount(row, minlength=n_cands), np.ones(n_cands, dtype=int) + ) + + +@given( + n_voters=integers(0, 40), + n_cands=integers(0, 40), + dims=integers(1, 6), + corr=floats(-0.99, 0.99, allow_nan=False, allow_infinity=False), + disp=floats(0.01, 4.0, allow_nan=False, allow_infinity=False), +) +def test_normal_electorate_hypothesis_shapes_finite( + n_voters, n_cands, dims, corr, disp +): + assume(corr < 1 - 1e-9) + if dims > 1: + assume(corr > -1.0 / (dims - 1) + 1e-12) + rng = np.random.default_rng(2028) + voters, cands = normal_electorate( + n_voters, n_cands, dims, corr, disp, random_state=rng + ) + assert voters.shape == (n_voters, dims) + assert cands.shape == (n_cands, dims) + assert np.isfinite(voters).all() + assert np.isfinite(cands).all() + + +@given( + voters=arrays( + np.float64, + tuples(integers(1, 12), integers(1, 4)), + elements=floats(-50, 50, allow_nan=False, allow_infinity=False), + ), + cands=arrays( + np.float64, + tuples(integers(2, 12), integers(1, 4)), + elements=floats(-50, 50, allow_nan=False, allow_infinity=False), + ), +) +def test_normed_dist_utilities_hypothesis_bounds_when_spread( + voters, cands, +): + assume(voters.shape[1] == cands.shape[1]) + dists = np.linalg.norm( + voters[:, np.newaxis, :] - cands[np.newaxis, :, :], axis=-1 + ) + assume(np.all(np.ptp(dists, axis=1) > 1e-9)) + utilities = normed_dist_utilities(voters, cands) + assert utilities.shape == (voters.shape[0], cands.shape[0]) + assert np.isfinite(utilities).all() + assert (utilities >= 0).all() + assert (utilities <= 1).all() + assert_array_equal(utilities.min(axis=1), np.zeros(voters.shape[0])) + assert_array_equal(utilities.max(axis=1), np.ones(voters.shape[0])) + + if __name__ == "__main__": # Run unit tests, in separate process to avoid warnings about cached # modules, printing output line by line in realtime diff --git a/tests/test_methods.py b/tests/test_methods.py index d568e855..589c2b0e 100644 --- a/tests/test_methods.py +++ b/tests/test_methods.py @@ -1,4 +1,7 @@ +import numpy as np import pytest +from hypothesis import given +from hypothesis.strategies import integers, lists, permutations from elsim.methods import (approval, black, borda, combined_approval, coombs, fptp, irv, runoff, score, utility_winner) @@ -35,6 +38,40 @@ def test_ranked_method_unanimity(method): assert method(election, 'order') == 3 +def complete_ranked_ballots(min_cands=2, max_cands=25, min_voters=1, + max_voters=100): + 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( + "method", + [black, borda, fptp, runoff, irv, coombs], +) +@given(election=complete_ranked_ballots(min_cands=2, max_cands=15, + min_voters=1, max_voters=60)) +def test_ranked_methods_order_tiebreak_returns_candidate_id(method, election): + election = np.asarray(election) + winner = method(election, tiebreaker='order') + n_cands = election.shape[1] + assert winner in set(range(n_cands)) + + +@pytest.mark.parametrize( + "method", + [black, borda, fptp, runoff, irv, coombs], +) +@given(election=complete_ranked_ballots(min_cands=2, max_cands=15, + min_voters=1, max_voters=60)) +def test_ranked_methods_no_tiebreak_returns_none_or_id(method, election): + election = np.asarray(election) + winner = method(election) + n_cands = election.shape[1] + assert winner in {None} | set(range(n_cands)) + + if __name__ == "__main__": # Run unit tests, in separate process to avoid warnings about cached # modules, printing output line by line in realtime diff --git a/tests/test_sntv.py b/tests/test_sntv.py index 7f603bf5..fa465757 100644 --- a/tests/test_sntv.py +++ b/tests/test_sntv.py @@ -5,7 +5,7 @@ from hypothesis import given from hypothesis.strategies import integers, lists, permutations -from elsim.methods import sntv +from elsim.methods import fptp, sntv def collect_random_results(method, election): @@ -291,6 +291,18 @@ def test_defaults(election): assert sntv(election) == sntv(election, 1) == sntv(election, 1, None) +@given(election=complete_ranked_ballots(min_cands=2, max_cands=20, + min_voters=1, max_voters=80)) +def test_sntv_one_seat_matches_fptp_order(election): + election = np.asarray(election) + f_w = fptp(election, tiebreaker='order') + s_w = sntv(election, 1, tiebreaker='order') + if f_w is None: + assert s_w is None + else: + assert s_w == {f_w} + + if __name__ == "__main__": # Run unit tests, in separate process to avoid warnings about cached # modules, printing output line by line in realtime diff --git a/tests/test_strategies.py b/tests/test_strategies.py index 730c7d09..46e5243c 100644 --- a/tests/test_strategies.py +++ b/tests/test_strategies.py @@ -1,11 +1,12 @@ import numpy as np import pytest -from hypothesis import given +from hypothesis import assume, given from hypothesis.extra.numpy import arrays from hypothesis.strategies import floats, integers, tuples from numpy.testing import assert_array_equal -from elsim.strategies import approval_optimal, honest_normed_scores, vote_for_k +from elsim.strategies import (approval_optimal, honest_normed_scores, + honest_rankings, vote_for_k) def test_approval_optimal(): @@ -22,6 +23,11 @@ def test_approval_optimal(): [0, 0, 1], ]) + uniform = np.full((3, 4), 0.25) + assert_array_equal( + approval_optimal(uniform), np.zeros((3, 4), dtype=np.uint8) + ) + def test_honest_normed_scores(): utilities = np.array([[0.0, 0.4, 1.0], @@ -37,6 +43,12 @@ def test_honest_normed_scores(): [0, 2, 7], ]) + indifferent = np.ones((4, 5)) + assert_array_equal( + honest_normed_scores(indifferent, max_score=9), + np.zeros((4, 5), dtype=np.uint8), + ) + def test_vote_for_k(): utilities = np.array([[0.0, 0.4, 1.0], @@ -63,6 +75,14 @@ def test_vote_for_k(): assert_array_equal(vote_for_k(utilities, 2), b) assert_array_equal(vote_for_k(utilities, -1), b) + # Strict total order per row: each voter approves exactly k candidates. + strict = ( + np.linspace(0.0, 1.0, 5, dtype=np.float64) + + np.arange(4, dtype=np.float64)[:, np.newaxis] * 1e-4 + ) + assert_array_equal(vote_for_k(strict, 1).sum(axis=1), np.ones(4)) + assert_array_equal(vote_for_k(strict, 4).sum(axis=1), np.full(4, 4)) + @pytest.mark.parametrize("k", [0, 3, -3, -4, 4]) def test_invalid_k(k): @@ -72,6 +92,11 @@ def test_invalid_k(k): vote_for_k(election, k) +def test_honest_rankings_complete_indifference_ordering(): + utilities = np.array([[0.0, 0.0, 0.0]]) + assert_array_equal(honest_rankings(utilities), [[2, 1, 0]]) + + def utilities(min_cands=2, max_cands=25, min_voters=1, max_voters=100): """ Strategy to generate utilities arrays @@ -86,6 +111,8 @@ def test_approval_optimal_properties(utilities): election = approval_optimal(utilities) assert election.shape == utilities.shape assert set(election.flat) <= {0, 1} + means = utilities.mean(axis=1, keepdims=True) + assert np.all((election == 0) | (utilities > means)) @given(utilities=utilities()) @@ -98,19 +125,48 @@ def test_vote_for_k_properties(utilities): @given(utilities=utilities(), max_score=integers(1, 100)) def test_honest_normed_scores_properties(utilities, max_score): + utilities = np.asarray(utilities, dtype=np.float64) + utilities = utilities + np.linspace( + 0.0, 1e-7, utilities.shape[1], dtype=np.float64 + ) election = honest_normed_scores(utilities, max_score) assert election.shape == utilities.shape - # Normalized should contain both 0 and max_score for all voters. However, - # Hypothesis will find degenerate cases with indifferent voters. (TODO) assert_array_equal(election.min(axis=1), 0) - assert election.min() == 0 + assert_array_equal(election.max(axis=1), max_score) assert election.min() <= max_score # Output should be integers assert_array_equal(election % 1, 0) +@given(utilities=utilities()) +def test_honest_rankings_rows_are_permutations(utilities): + assume(utilities.shape[1] <= 255) + election = honest_rankings(utilities) + assert election.shape == utilities.shape + n_cands = utilities.shape[1] + for row in election: + assert_array_equal( + np.bincount(row, minlength=n_cands), np.ones(n_cands, dtype=int) + ) + + +@given( + n_cands=integers(2, 20), + n_voters=integers(1, 40), +) +def test_vote_for_k_exactly_k_approvals_when_strict_order(n_cands, n_voters): + utilities = ( + np.linspace(0.0, 1.0, n_cands, dtype=np.float64) + + np.arange(n_voters, dtype=np.float64)[:, np.newaxis] * 1e-4 + ) + for k in (1, n_cands - 1): + election = vote_for_k(utilities, k) + assert_array_equal(election.sum(axis=1), np.full(n_voters, k)) + assert set(election.flat) <= {0, 1} + + if __name__ == "__main__": # Run unit tests, in separate process to avoid warnings about cached # modules, printing output line by line in realtime diff --git a/tests/test_utility_winner.py b/tests/test_utility_winner.py index 440d3404..4ac0a1ca 100644 --- a/tests/test_utility_winner.py +++ b/tests/test_utility_winner.py @@ -100,6 +100,25 @@ def test_legit_winner_no_tiebreaker(election): assert winner in set(range(n_cands)) | {None} +@given( + utilities=arrays( + np.float64, + tuples(integers(1, 25), integers(2, 12)), + elements=floats(0, 1, allow_nan=False, allow_infinity=False), + ), +) +def test_utility_winner_matches_argmax_of_column_sums(utilities): + winner = utility_winner(utilities, tiebreaker='order') + totals = utilities.sum(axis=0) + assert winner == int(np.argmax(totals)) + + +def test_utility_winner_two_way_tie_no_tiebreaker(): + utilities = [[1.0, 0.0], [0.0, 1.0]] + assert utility_winner(utilities) is None + assert utility_winner(utilities, tiebreaker='order') == 0 + + if __name__ == "__main__": # Run unit tests, in separate process to avoid warnings about cached # modules, printing output line by line in realtime