diff --git a/src/votekit/elections/election_types/scores/quadratic.py b/src/votekit/elections/election_types/scores/quadratic.py new file mode 100644 index 00000000..7cc6a9b6 --- /dev/null +++ b/src/votekit/elections/election_types/scores/quadratic.py @@ -0,0 +1,90 @@ +import copy +import math + +from votekit.elections.election_types.scores.rating import GeneralRating +from votekit.pref_profile import ScoreProfile + + +class Quadratic(GeneralRating): + """ + Quadratic election where the cost of casting multiple votes for the same candidate increases quadratically. + + Each voter is given a fixed number of voting credits, which can be spent to support candidates. The + cost of casting multiple votes for the same candidate increases quadratically (i.e. n votes + cost n^2 credits). Note that the ballot score values can represent either votes or credits spend on + candidates, and you specify which it is in the boolean is_credits. Score values and k (the + credit budget) must be whole numbers. + + Raises: + ValueError: k must be a whole number + + Args: + profile (ScoreProfile): Profile to conduct election on + m (int, optional): Number of seats to elect. Defaults to 1. + k (float): Total budget per voter. k must be a whole number. + In the case of Quadratic voting, this refers to the total budget of credits a voter can spend. + is_credits (boolean): is_credits = True means that scores represent credits and + is_credits = False means scores represent votes. + tiebreak (str,optional): Tiebreak method to use. Options are None and 'random'. + Defaults to None, in which case a tie raises a ValueError. + """ + + def __init__( + self, + profile: ScoreProfile, + m: int = 1, + k: float = 1, + is_credits=False, + tiebreak: str | None = None, + ): + if isinstance(k, float) and not k.is_integer(): + raise ValueError(f"Credit budget k must be a whole number.") + profile = self._check_credits(profile, k, is_credits) + super().__init__(profile, m=m, k=k, L=int(math.sqrt(k)), tiebreak=tiebreak) + # super().__init__(profile, m=m, k=k, L=k, tiebreak=tiebreak) + + def _check_credits(self, profile: ScoreProfile, k=1, is_credits=False): + """ + Ensures that every ballot is within credit budget (k). + + No matter if ballot scores was given in credits or votes, ensures that all ballots remain within + the budget. + + Args: + profile (PreferenceProfile): Profile to validate. + k (float): Total budget per voter + is_credits (boolean): dictates whether scores are credits or votes + + Raises: + ValueError: scores must be whole numbers + ValueError: When scores refer to credits, credits must be within budget k + ValueError: When scores refer to credits, credits must be perfect squares + ValueError: When scores refer to votes, votes squared must be within budget k + + Returns: ScoreProfile where scores are the votes. + """ + profile_copy = copy.deepcopy(profile) + for b in profile_copy.ballots: + # check to make sure that scores are whole numbers + if b.scores is not None: + if not all(isinstance(v, float) and v.is_integer() for v in b.scores.values()): + raise ValueError(f"Scores must be whole numbers.") + if is_credits: # is_credits -> scores mean credits + # check to make sure credits are in budget + if sum(b.scores.values()) > k: + raise ValueError(f"Ballot {b} is above the credit budget.") + # check to make sure credits are perfect squares + if not all(float(math.sqrt(v)).is_integer() for v in b.scores.values()): + raise ValueError( + f"Ballot {b} score violates credit's perfect squares requirement." + ) + # convert scores to votes (takes the square root of score values) + # b.scores = {key: math.sqrt(v) for key, v in b.scores.items()} + for v in b.scores: + b.scores[v] = math.sqrt(b.scores[v]) + else: # not is_credits -> scores mean votes + # convert scores (votes) to credits + # check to make sure credits are in budget + if sum(v**2 for v in b.scores.values()) > k: + raise ValueError(f"Ballot {b} is above the credit budget.") + return profile_copy diff --git a/tests/elections/election_types/score/test_quadratic.py b/tests/elections/election_types/score/test_quadratic.py new file mode 100644 index 00000000..3e4b50b7 --- /dev/null +++ b/tests/elections/election_types/score/test_quadratic.py @@ -0,0 +1,118 @@ +import pytest + +from votekit.ballot import ScoreBallot +from votekit.elections.election_types.scores.quadratic import Quadratic +from votekit.pref_profile import ScoreProfile + +profile_no_tied_votes = ScoreProfile( + ballots=[ + ScoreBallot(scores={"A": 2, "B": 1, "C": 1}, weight=2), + ScoreBallot(scores={"A": 1, "B": 0, "C": 1}, weight=2), + ScoreBallot(scores={"A": 2, "B": 1, "C": 1}), + ] +) +# votes = 4, 2, 4 +# credits = 6, 2, 6 + +profile_no_tied_credits = ScoreProfile( + ballots=[ + ScoreBallot(scores={"A": 4, "B": 1, "C": 1}, weight=2), + ScoreBallot(scores={"A": 1, "B": 0, "C": 1}, weight=2), + ScoreBallot(scores={"A": 4, "B": 1, "C": 1}), + ] +) +# votes = 4, 2, 4 +# credits = 6, 2, 6 + +profile_tied_votes = ScoreProfile( + ballots=[ + ScoreBallot(scores={"A": 1, "B": 2, "C": 1}), + ScoreBallot(scores={"A": 2, "B": 1, "C": 2}), + ] +) +# votes = 4, 5 +# credits = 6, 9 + +# TODO +profile_tied_credits = ScoreProfile( + ballots=[ + ScoreBallot(scores={"A": 1, "B": 4, "C": 1}), + ScoreBallot(scores={"A": 4, "B": 1, "C": 4}), + ] +) +# votes = 4, 5 +# credits = 6, 9 + + +def test_init(): + # Votes + e = Quadratic(profile_no_tied_votes, k=6) + assert e.get_elected() == (frozenset({"A"}),) + + e = Quadratic(profile_no_tied_votes, m=2, k=6) + assert e.get_elected() == (frozenset({"A"}), frozenset({"C"})) + + # Credits + e = Quadratic(profile_no_tied_credits, k=6, is_credits=True) + assert e.get_elected() == (frozenset({"A"}),) + + e = Quadratic(profile_no_tied_credits, m=2, k=6, is_credits=True) + assert e.get_elected() == (frozenset({"A"}), frozenset({"C"})) + + +def test_ties(): + + # Votes + e_random = Quadratic(profile_tied_votes, m=1, k=10, tiebreak="random") + assert len([c for s in e_random.get_elected() for c in s]) == 1 + + e_random = Quadratic(profile_tied_votes, m=2, k=10, tiebreak="random") + assert len([c for s in e_random.get_elected() for c in s]) == 2 + + e_random = Quadratic(profile_tied_votes, m=3, k=10, tiebreak="random") + assert e_random.get_elected() == (frozenset({"A", "C", "B"}),) + + # Credits + e_random = Quadratic(profile_tied_credits, m=1, k=10, is_credits=True, tiebreak="random") + assert len([c for s in e_random.get_elected() for c in s]) == 1 + + e_random = Quadratic(profile_tied_credits, m=2, k=10, is_credits=True, tiebreak="random") + assert len([c for s in e_random.get_elected() for c in s]) == 2 + + e_random = Quadratic(profile_tied_credits, m=3, k=10, is_credits=True, tiebreak="random") + assert e_random.get_elected() == (frozenset({"A", "C", "B"}),) + + +def test_errors(): + # Votes + # with pytest.raises(ValueError, match="Credit budget k must be a whole number."): + # Quadratic(profile_no_tied_votes, k = 1.5) + + # Credits + with pytest.raises(ValueError, match="Credit budget k must be a whole number."): + Quadratic(profile_no_tied_credits, k=1.5) + + +def test_validate_profile(): + # Both votes and credits + with pytest.raises(ValueError, match="Scores must be whole numbers."): + profile = ScoreProfile(ballots=[ScoreBallot(scores={"A": 3.5})]) + Quadratic(profile, m=1, k=5) + + with pytest.raises(ValueError, match="Scores must be whole numbers."): + profile = ScoreProfile(ballots=[ScoreBallot(scores={"A": 3.5})]) + Quadratic(profile, m=1, k=5) + + # Credits + with pytest.raises(ValueError, match="is above the credit budget."): + profile = ScoreProfile(ballots=[ScoreBallot(scores={"A": 9})]) + Quadratic(profile, m=1, k=2, is_credits=True) + + with pytest.raises(ValueError, match="score violates credit's perfect squares requirement."): + profile = ScoreProfile(ballots=[ScoreBallot(), ScoreBallot(scores={"A": 3})]) + Quadratic(profile, m=1, k=4, is_credits=True) + + # Votes + with pytest.raises(ValueError, match="is above the credit budget."): + profile = ScoreProfile(ballots=[ScoreBallot(scores={"A": 2})]) + Quadratic(profile, m=1, k=3)