From f4f58e704b5cf03041563c1cea72fe63f3f55e55 Mon Sep 17 00:00:00 2001 From: smpooleChicago Date: Thu, 12 Mar 2026 23:13:53 -0500 Subject: [PATCH 1/3] Create quadratic.py --- .../election_types/scores/quadratic.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/votekit/elections/election_types/scores/quadratic.py 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..122cace5 --- /dev/null +++ b/src/votekit/elections/election_types/scores/quadratic.py @@ -0,0 +1,67 @@ +from .rating import GeneralRating +from votekit.pref_profile import ScoreProfile +import math +from typing import Optional + +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 isCredits. Score values and k (the + credit budget) must be whole numbers. + + 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. + isCredits (boolean): isCredits = True means that scores represent credits and + isCredits = 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 = None, + isCredits = False, + tiebreak: Optional[str] = None, + ): + if not k.is_integer(): + raise TypeError(f"Credit budget k must be a whole number.") + self._check_credits(profile, k, isCredits) + super().__init__(profile, m=m, k=k, tiebreak=tiebreak) + + def _check_credits(self, profile: ScoreProfile, k:float = None, isCredits=False): + """ + Ensures that every ballot is within credit budget (k). + Ensures that if ballots give credits, that credits are perfect squares + converts those credits into votes. + """ + for b in profile.ballots: + #check to make sure that scores are whole numbers + if not all(isinstance(v, float) and v.is_integer() for v in b.scores.values()): + raise TypeError(f"Scores must be whole numbers.") + if isCredits: #isCredits -> scores mean credits + #check to make sure credits are in budget + if sum(b.scores.values()) > k: + raise TypeError(f"Ballot {b} violates credit budget {k}.") + #check to make sure credits are perfect squares + if not all(math.sqrt(v).is_integer() for v in b.scores.values()): + raise TypeError(f"Ballot {b} violates credit's perfect squares requirement.") + #convert scores to votes (takes the square root of score values) + for v in b.scores: + b.scores[v] = math.sqrt(b.scores[v]) + else: #not isCredits -> 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 TypeError(f"Ballot {b} violates credit budget {k}.") + + + From ec76fec55d476864af76f39094439c3157820406 Mon Sep 17 00:00:00 2001 From: smpooleChicago Date: Fri, 13 Mar 2026 13:03:52 -0500 Subject: [PATCH 2/3] Update quadratic.py --- src/votekit/elections/election_types/scores/quadratic.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/votekit/elections/election_types/scores/quadratic.py b/src/votekit/elections/election_types/scores/quadratic.py index 122cace5..7a5d9c11 100644 --- a/src/votekit/elections/election_types/scores/quadratic.py +++ b/src/votekit/elections/election_types/scores/quadratic.py @@ -2,6 +2,7 @@ from votekit.pref_profile import ScoreProfile import math from typing import Optional +import copy class Quadratic(GeneralRating): """ @@ -34,7 +35,7 @@ def __init__( ): if not k.is_integer(): raise TypeError(f"Credit budget k must be a whole number.") - self._check_credits(profile, k, isCredits) + profile = self._check_credits(profile, k, isCredits) super().__init__(profile, m=m, k=k, tiebreak=tiebreak) def _check_credits(self, profile: ScoreProfile, k:float = None, isCredits=False): @@ -43,7 +44,8 @@ def _check_credits(self, profile: ScoreProfile, k:float = None, isCredits=False) Ensures that if ballots give credits, that credits are perfect squares converts those credits into votes. """ - for b in profile.ballots: + profile_copy = copy.deepcopy(profile) + for b in profile_copy.ballots: #check to make sure that scores are whole numbers if not all(isinstance(v, float) and v.is_integer() for v in b.scores.values()): raise TypeError(f"Scores must be whole numbers.") @@ -62,6 +64,7 @@ def _check_credits(self, profile: ScoreProfile, k:float = None, isCredits=False) #check to make sure credits are in budget if sum(v**2 for v in b.scores.values()) > k: raise TypeError(f"Ballot {b} violates credit budget {k}.") + return profile_copy From 77da3f10f5154aa203b06083983b7f0b88e6af4a Mon Sep 17 00:00:00 2001 From: smpooleChicago Date: Fri, 13 Mar 2026 18:48:16 -0500 Subject: [PATCH 3/3] Fixed quadratric.py and added tests I did the necessary changes for quadratic.py (changed variable name to snake case, added appropriate description for helper function, made sure it satisfied typecheck). For test_quadratic.py, it tests that Quadratic can run with credits and votes as scores (with single and multi-winner cases), it can do tiebreaks, and appropriate ValueErrors rise when needed. --- .../election_types/scores/quadratic.py | 108 +++++++++------- .../election_types/score/test_quadratic.py | 118 ++++++++++++++++++ 2 files changed, 182 insertions(+), 44 deletions(-) create mode 100644 tests/elections/election_types/score/test_quadratic.py diff --git a/src/votekit/elections/election_types/scores/quadratic.py b/src/votekit/elections/election_types/scores/quadratic.py index 7a5d9c11..7cc6a9b6 100644 --- a/src/votekit/elections/election_types/scores/quadratic.py +++ b/src/votekit/elections/election_types/scores/quadratic.py @@ -1,26 +1,30 @@ -from .rating import GeneralRating -from votekit.pref_profile import ScoreProfile -import math -from typing import Optional 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 isCredits. Score values and k (the - credit budget) must be whole numbers. + 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. - isCredits (boolean): isCredits = True means that scores represent credits and - isCredits = False means scores represent votes. + 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. """ @@ -29,42 +33,58 @@ def __init__( self, profile: ScoreProfile, m: int = 1, - k: float = None, - isCredits = False, - tiebreak: Optional[str] = None, + k: float = 1, + is_credits=False, + tiebreak: str | None = None, ): - if not k.is_integer(): - raise TypeError(f"Credit budget k must be a whole number.") - profile = self._check_credits(profile, k, isCredits) - super().__init__(profile, m=m, k=k, tiebreak=tiebreak) + 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:float = None, isCredits=False): + def _check_credits(self, profile: ScoreProfile, k=1, is_credits=False): """ Ensures that every ballot is within credit budget (k). - Ensures that if ballots give credits, that credits are perfect squares - converts those credits into votes. + + 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 not all(isinstance(v, float) and v.is_integer() for v in b.scores.values()): - raise TypeError(f"Scores must be whole numbers.") - if isCredits: #isCredits -> scores mean credits - #check to make sure credits are in budget - if sum(b.scores.values()) > k: - raise TypeError(f"Ballot {b} violates credit budget {k}.") - #check to make sure credits are perfect squares - if not all(math.sqrt(v).is_integer() for v in b.scores.values()): - raise TypeError(f"Ballot {b} violates credit's perfect squares requirement.") - #convert scores to votes (takes the square root of score values) - for v in b.scores: - b.scores[v] = math.sqrt(b.scores[v]) - else: #not isCredits -> 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 TypeError(f"Ballot {b} violates credit budget {k}.") + # 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)