From 57e7cc06d4ee31bf5c4022f4d119bd41df379349 Mon Sep 17 00:00:00 2001 From: Grace Gibson Date: Fri, 29 May 2026 15:28:32 -0500 Subject: [PATCH 1/6] summing profile fxn --- src/votekit/pref_profile/utils.py | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/votekit/pref_profile/utils.py b/src/votekit/pref_profile/utils.py index 597be5e4..2d49259f 100644 --- a/src/votekit/pref_profile/utils.py +++ b/src/votekit/pref_profile/utils.py @@ -427,3 +427,35 @@ def convert_rank_profile_to_score_profile_via_score_vector( df=new_df, candidates=rank_profile.candidates, ) + + +def sum_profiles(profiles: Sequence[RankProfile | ScoreProfile]) -> RankProfile | ScoreProfile: + """ + Combines multiple PreferenceProfiles by combining their ball lists. + + Args: + profiles (Sequence[PreferenceProfile]): The profiles to sum. + + Returns: + PreferenceProfile: The combined preference profile. + + Raises: + ValueError: Cannot sum an empty list of profiles. + TypeError: All profiles must be of the same type. + """ + + if len(profiles) == 0: + raise ValueError("Cannot sum an empty list of profiles.") + + if len(profiles) == 1: + return profiles[0] + + first_type = type(profiles[0]) + if not all(isinstance(profile, first_type) for profile in profiles): + raise TypeError("All profiles must be of same type.") + + combined_profiles = profiles[0] + for profile in profiles[1:]: + combined_profiles = combined_profiles + profile + + return combined_profiles From 201988ac0b53a2a0863f2f030249513ba1fc0cb5 Mon Sep 17 00:00:00 2001 From: Grace Gibson Date: Mon, 1 Jun 2026 07:14:00 -0500 Subject: [PATCH 2/6] add sum profile tests --- tests/pref_profile/utils/test_sum_profiles.py | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 tests/pref_profile/utils/test_sum_profiles.py diff --git a/tests/pref_profile/utils/test_sum_profiles.py b/tests/pref_profile/utils/test_sum_profiles.py new file mode 100644 index 00000000..81bc89e0 --- /dev/null +++ b/tests/pref_profile/utils/test_sum_profiles.py @@ -0,0 +1,167 @@ +import pytest + +from votekit.ballot import RankBallot, ScoreBallot +from votekit.pref_profile import RankProfile, ScoreProfile +from votekit.pref_profile.utils import sum_profiles + + +def test_sum_profiles_with_mixed_types(): + score_profile = ScoreProfile( + ballots=[ + ScoreBallot(scores={"A": 2, "B": 2}, weight=2), + ScoreBallot(scores={"A": 2, "C": 2}, voter_set={"Chris"}), + ScoreBallot(), + ScoreBallot(weight=0), + ], + candidates=["A", "B", "C", "D"], + ) + rank_profile = RankProfile( + ballots=[ + RankBallot(ranking=({"A"}, {"B"}, {"C"}), weight=2), + RankBallot(ranking=({"A", "B"}, frozenset(), {"D"}), voter_set={"Chris"}), + RankBallot(), + RankBallot(weight=0), + ], + candidates=["A", "B", "C", "D"], + max_ranking_length=3, + ) + with pytest.raises( + TypeError, + match="All profiles must be of same type.", + ): + sum_profiles([score_profile, score_profile, score_profile, rank_profile]) + + +def test_sum_empty_profile(): + with pytest.raises( + ValueError, + match="Cannot sum an empty list of profiles", + ): + sum_profiles([]) + + +def test_sum_one_profile(): + profile = ScoreProfile( + ballots=[ + ScoreBallot(scores={"A": 2, "B": 2}, weight=2), + ScoreBallot(scores={"A": 2, "C": 2}, voter_set={"Chris"}), + ScoreBallot(), + ScoreBallot(weight=0), + ], + candidates=["A", "B", "C", "D"], + ) + summed_profile = sum_profiles([profile]) + assert summed_profile == profile + + +def test_sum_score_profiles(): + profile_1 = ScoreProfile( + ballots=[ + ScoreBallot(scores={"A": 2, "B": 2}, weight=2), + ScoreBallot(scores={"A": 2, "C": 2}, voter_set={"Chris"}), + ScoreBallot(), + ScoreBallot(weight=0), + ], + candidates=["A", "B", "C", "D"], + ) + + profile_2 = ScoreProfile( + ballots=[ + ScoreBallot(scores={"D": 2, "E": 2}, weight=2), + ScoreBallot(scores={"D": 2, "E": 2, "F": 3.1}, weight=2), + ScoreBallot(), + ScoreBallot(weight=0), + ], + candidates=["D", "E", "F"], + ) + + profile_3 = ScoreProfile( + ballots=[ + ScoreBallot(scores={"G": 2, "H": 2}, weight=2), + ScoreBallot(scores={"G": 2, "H": 2, "I": 3.1}, weight=2), + ScoreBallot(), + ScoreBallot(weight=0), + ], + candidates=["G", "H", "I"], + ) + summed_profile = sum_profiles([profile_1, profile_2, profile_3]) + true_summed_profile = ScoreProfile( + ballots=[ + ScoreBallot(scores={"A": 2, "B": 2}, weight=2), + ScoreBallot(scores={"A": 2, "C": 2}, voter_set={"Chris"}), + ScoreBallot(), + ScoreBallot(weight=0), + ScoreBallot(scores={"D": 2, "E": 2}, weight=2), + ScoreBallot(scores={"D": 2, "E": 2, "F": 3.1}, weight=2), + ScoreBallot(), + ScoreBallot(weight=0), + ScoreBallot(scores={"G": 2, "H": 2}, weight=2), + ScoreBallot(scores={"G": 2, "H": 2, "I": 3.1}, weight=2), + ScoreBallot(), + ScoreBallot(weight=0), + ], + candidates=["A", "B", "C", "D", "E", "F", "G", "H", "I"], + ) + + assert set(summed_profile.candidates) == set(["A", "B", "C", "D", "E", "F", "G", "H", "I"]) + assert isinstance(summed_profile, ScoreProfile) + assert true_summed_profile == summed_profile + + +def test_sum_rank_profiles(): + profile_1 = RankProfile( + ballots=[ + RankBallot(ranking=({"A"}, {"B"}, {"C"}), weight=2), + RankBallot(ranking=({"A", "B"}, frozenset(), {"D"}), voter_set={"Chris"}), + RankBallot(), + RankBallot(weight=0), + ], + candidates=["A", "B", "C", "D"], + max_ranking_length=3, + ) + + profile_2 = RankProfile( + ballots=[ + RankBallot(ranking=({"E"}, {"D"}, {"F"}, {"E"}), weight=2), + RankBallot(ranking=({"D"}, {"E"}, {"F"}), weight=2), + RankBallot(), + RankBallot(weight=0), + ], + candidates=["D", "E", "F"], + max_ranking_length=0, + ) + + profile_3 = RankProfile( + ballots=[ + RankBallot(ranking=({"G"}, {"H"}, {"I"}, {"G"}), weight=2), + RankBallot(ranking=({"G"}, {"H"}, {"I"}), weight=2), + RankBallot(), + RankBallot(weight=0), + ], + candidates=["G", "H", "I"], + max_ranking_length=0, + ) + summed_profile = sum_profiles([profile_1, profile_2, profile_3]) + true_summed_profile = RankProfile( + ballots=[ + RankBallot(ranking=({"A"}, {"B"}, {"C"}), weight=2), + RankBallot(ranking=({"A", "B"}, frozenset(), {"D"}), voter_set={"Chris"}), + RankBallot(), + RankBallot(weight=0), + RankBallot(ranking=({"E"}, {"D"}, {"F"}, {"E"}), weight=2), + RankBallot(ranking=({"D"}, {"E"}, {"F"}), weight=2), + RankBallot(), + RankBallot(weight=0), + RankBallot(ranking=({"G"}, {"H"}, {"I"}, {"G"}), weight=2), + RankBallot(ranking=({"G"}, {"H"}, {"I"}), weight=2), + RankBallot(), + RankBallot(weight=0), + ], + candidates=["A", "B", "C", "D", "E", "F", "G", "H", "I"], + max_ranking_length=4, + ) + + assert set(summed_profile.candidates) == set(["A", "B", "C", "D", "E", "F", "G", "H", "I"]) + assert summed_profile.max_ranking_length == 4 + assert isinstance(summed_profile, RankProfile) + assert true_summed_profile == summed_profile From db1f394070df5edb396f2a888a8803a3929bfd64 Mon Sep 17 00:00:00 2001 From: Grace Gibson Date: Wed, 3 Jun 2026 11:06:46 -0500 Subject: [PATCH 3/6] move sum logic to sum_profiles instead of add --- src/votekit/pref_profile/pref_profile.py | 68 ++-------------- src/votekit/pref_profile/utils.py | 80 ++++++++++++++++++- tests/pref_profile/utils/test_sum_profiles.py | 46 ++++++++++- 3 files changed, 126 insertions(+), 68 deletions(-) diff --git a/src/votekit/pref_profile/pref_profile.py b/src/votekit/pref_profile/pref_profile.py index 88e12d9f..da7d787c 100644 --- a/src/votekit/pref_profile/pref_profile.py +++ b/src/votekit/pref_profile/pref_profile.py @@ -27,6 +27,7 @@ from votekit.pref_profile.utils import ( convert_row_to_rank_ballot, convert_row_to_score_ballot, + sum_profiles, ) @@ -672,44 +673,9 @@ def __add__(self, other) -> RankProfile: """ Add two PreferenceProfiles by combining their ballot lists. """ - if not isinstance(other, RankProfile): - raise TypeError("Unsupported operand type. Must be an instance of RankProfile.") - - assert self.max_ranking_length is not None and other.max_ranking_length is not None - max_ranking_length = max([self.max_ranking_length, other.max_ranking_length]) - candidates = list(set(self.candidates).union(other.candidates)) - - df_1 = self.df.copy() - df_2 = other.df.copy() - - if self.max_ranking_length < max_ranking_length: - for i in range(self.max_ranking_length, max_ranking_length): - df_1.insert( - len(df_1.columns), - f"Ranking_{i + 1}", - pd.Series([frozenset("~")] * len(df_1), dtype=object, index=df_1.index), - ) - if other.max_ranking_length < max_ranking_length: - for i in range(other.max_ranking_length, max_ranking_length): - df_2.insert( - len(df_2.columns), - f"Ranking_{i + 1}", - pd.Series([frozenset("~")] * len(df_2), dtype=object, index=df_2.index), - ) - - new_df = pd.concat([df_1, df_2], ignore_index=True) - new_df.index.name = "Ballot Index" - ranking_cols = [c for c in new_df.columns if "Ranking_" in c] - new_df[ranking_cols] = new_df[ranking_cols].astype("object") - new_df = new_df[ - [f"Ranking_{i + 1}" for i in range(max_ranking_length)] + ["Weight", "Voter Set"] - ] - - return RankProfile( - candidates=candidates, - df=new_df, - max_ranking_length=max_ranking_length, - ) + new_profile = sum_profiles([self, other]) + assert isinstance(new_profile, RankProfile) + return new_profile def group_ballots(self) -> RankProfile: """ @@ -1254,29 +1220,9 @@ def __add__(self, other): """ Add two PreferenceProfiles by combining their ballot lists. """ - if not isinstance(other, ScoreProfile): - raise TypeError("Unsupported operand type. Must be an instance of ScoreProfile.") - - df_1 = self.df.copy() - df_2 = other.df.copy() - - cand1 = set(self.candidates) - cand2 = set(other.candidates) - for cand in cand2 - cand1: - df_1[cand] = [np.nan] * len(df_1) - for cand in cand1 - cand2: - df_2[cand] = [np.nan] * len(df_2) - - new_df = pd.concat([df_1, df_2], ignore_index=True) - new_df.index.name = "Ballot Index" - - new_candidates = sorted(set(self.candidates).union(other.candidates)) - new_df = new_df[new_candidates + ["Weight", "Voter Set"]] - - return ScoreProfile( - candidates=new_candidates, - df=new_df, - ) + new_profile = sum_profiles([self, other]) + assert isinstance(new_profile, ScoreProfile) + return new_profile def group_ballots(self) -> ScoreProfile: """ diff --git a/src/votekit/pref_profile/utils.py b/src/votekit/pref_profile/utils.py index 2d49259f..20734101 100644 --- a/src/votekit/pref_profile/utils.py +++ b/src/votekit/pref_profile/utils.py @@ -429,6 +429,76 @@ def convert_rank_profile_to_score_profile_via_score_vector( ) +def _sum_rank_profiles(rank_profiles: Sequence[RankProfile]) -> RankProfile: + """ + Helper function for sum_profiles that sums RankProfiles. + + Args: + rank_profiles (Sequence[RankProfile]): The RankProfiles to sum. + """ + + from votekit.pref_profile.pref_profile import RankProfile + + candidates = list(set().union(*[set(profile.candidates) for profile in rank_profiles])) + max_ranking_length = max([profile.max_ranking_length for profile in rank_profiles]) + + total_dfs = [] + for p in rank_profiles: + curr_df = p.df.copy() + if p.max_ranking_length < max_ranking_length: + for i in range(p.max_ranking_length, max_ranking_length): + curr_df.insert( + len(curr_df.columns), + f"Ranking_{i + 1}", + pd.Series([frozenset("~")] * len(curr_df), dtype=object, index=curr_df.index), + ) + total_dfs.append(curr_df) + + new_df = pd.concat(total_dfs, ignore_index=True) + new_df.index.name = "Ballot Index" + ranking_cols = [c for c in new_df.columns if "Ranking_" in c] + new_df[ranking_cols] = new_df[ranking_cols].astype("object") + new_df = new_df[ + [f"Ranking_{i + 1}" for i in range(max_ranking_length)] + ["Weight", "Voter Set"] + ] + + return RankProfile( + candidates=candidates, + df=new_df, + max_ranking_length=max_ranking_length, + ) + + +def _sum_score_profiles(score_profiles: Sequence[ScoreProfile]) -> ScoreProfile: + """ + Helper function for sum_profiles that sums ScoreProfiles. + + Args: + score_profiles (Sequence[ScoreProfile]): The ScoreProfiles to sum. + """ + + from votekit.pref_profile.pref_profile import ScoreProfile + + total_cand = set().union(*[set(profile.candidates) for profile in score_profiles]) + total_dfs = [] + for p in score_profiles: + curr_df = p.df.copy() + curr_cand = set(p.candidates) + for cand in total_cand - curr_cand: + curr_df[cand] = [np.nan] * len(curr_df) + total_dfs.append(curr_df) + + new_df = pd.concat(total_dfs, ignore_index=True) + new_df.index.name = "Ballot Index" + new_candidates = sorted(total_cand) + new_df = new_df[new_candidates + ["Weight", "Voter Set"]] + + return ScoreProfile( + candidates=new_candidates, + df=new_df, + ) + + def sum_profiles(profiles: Sequence[RankProfile | ScoreProfile]) -> RankProfile | ScoreProfile: """ Combines multiple PreferenceProfiles by combining their ball lists. @@ -444,6 +514,8 @@ def sum_profiles(profiles: Sequence[RankProfile | ScoreProfile]) -> RankProfile TypeError: All profiles must be of the same type. """ + from votekit.pref_profile.pref_profile import RankProfile, ScoreProfile + if len(profiles) == 0: raise ValueError("Cannot sum an empty list of profiles.") @@ -454,8 +526,8 @@ def sum_profiles(profiles: Sequence[RankProfile | ScoreProfile]) -> RankProfile if not all(isinstance(profile, first_type) for profile in profiles): raise TypeError("All profiles must be of same type.") - combined_profiles = profiles[0] - for profile in profiles[1:]: - combined_profiles = combined_profiles + profile + if isinstance(profiles[0], RankProfile): + return _sum_rank_profiles(profiles) # type: ignore[arg-type] - return combined_profiles + if isinstance(profiles[0], ScoreProfile): + return _sum_score_profiles(profiles) # type: ignore[arg-type] diff --git a/tests/pref_profile/utils/test_sum_profiles.py b/tests/pref_profile/utils/test_sum_profiles.py index 81bc89e0..a473e4c7 100644 --- a/tests/pref_profile/utils/test_sum_profiles.py +++ b/tests/pref_profile/utils/test_sum_profiles.py @@ -5,7 +5,7 @@ from votekit.pref_profile.utils import sum_profiles -def test_sum_profiles_with_mixed_types(): +def test_sum_profiles_with_mixed_types_raises_type_error(): score_profile = ScoreProfile( ballots=[ ScoreBallot(scores={"A": 2, "B": 2}, weight=2), @@ -32,7 +32,7 @@ def test_sum_profiles_with_mixed_types(): sum_profiles([score_profile, score_profile, score_profile, rank_profile]) -def test_sum_empty_profile(): +def test_sum_empty_profile_raises_value_error(): with pytest.raises( ValueError, match="Cannot sum an empty list of profiles", @@ -40,7 +40,7 @@ def test_sum_empty_profile(): sum_profiles([]) -def test_sum_one_profile(): +def test_sum_one_profile_returns_same_profile(): profile = ScoreProfile( ballots=[ ScoreBallot(scores={"A": 2, "B": 2}, weight=2), @@ -53,6 +53,46 @@ def test_sum_one_profile(): summed_profile = sum_profiles([profile]) assert summed_profile == profile + profile = RankProfile( + ballots=[ + RankBallot(ranking=({"A"}, {"B"}, {"C"}), weight=2), + RankBallot(ranking=({"A", "B"}, frozenset(), {"D"}), voter_set={"Chris"}), + RankBallot(), + RankBallot(weight=0), + ], + candidates=["A", "B", "C", "D"], + max_ranking_length=3, + ) + summed_profile = sum_profiles([profile]) + assert summed_profile == profile + + +def test_sum_one_profile_no_list_raises_type_error(): + profile = ScoreProfile( + ballots=[ + ScoreBallot(scores={"A": 2, "B": 2}, weight=2), + ScoreBallot(scores={"A": 2, "C": 2}, voter_set={"Chris"}), + ScoreBallot(), + ScoreBallot(weight=0), + ], + candidates=["A", "B", "C", "D"], + ) + with pytest.raises(TypeError, match="has no len()"): + sum_profiles(profile) # type: ignore[arg-type] + + profile = RankProfile( + ballots=[ + RankBallot(ranking=({"A"}, {"B"}, {"C"}), weight=2), + RankBallot(ranking=({"A", "B"}, frozenset(), {"D"}), voter_set={"Chris"}), + RankBallot(), + RankBallot(weight=0), + ], + candidates=["A", "B", "C", "D"], + max_ranking_length=3, + ) + with pytest.raises(TypeError, match="has no len()"): + sum_profiles(profile) # type: ignore[arg-type] + def test_sum_score_profiles(): profile_1 = ScoreProfile( From e4be73f3c1bb4614cb6f05e11fab9cd48a986d54 Mon Sep 17 00:00:00 2001 From: Grace Gibson Date: Fri, 5 Jun 2026 09:46:20 -0500 Subject: [PATCH 4/6] fix typing, add copy method --- src/votekit/pref_profile/pref_profile.py | 39 +++++++++-- src/votekit/pref_profile/utils.py | 70 ++++++++++++++----- tests/pref_profile/utils/test_sum_profiles.py | 8 ++- 3 files changed, 92 insertions(+), 25 deletions(-) diff --git a/src/votekit/pref_profile/pref_profile.py b/src/votekit/pref_profile/pref_profile.py index da7d787c..50c2c3db 100644 --- a/src/votekit/pref_profile/pref_profile.py +++ b/src/votekit/pref_profile/pref_profile.py @@ -25,9 +25,10 @@ _validate_score_csv_format, ) from votekit.pref_profile.utils import ( + _sum_rank_profiles, + _sum_score_profiles, convert_row_to_rank_ballot, convert_row_to_score_ballot, - sum_profiles, ) @@ -277,6 +278,9 @@ def __str__(self) -> str: def group_ballots(self) -> Self: raise NotImplementedError + def copy(self) -> Self: + raise NotImplementedError + @property def ballots(self) -> tuple[Ballot, ...]: raise NotImplementedError @@ -673,9 +677,7 @@ def __add__(self, other) -> RankProfile: """ Add two PreferenceProfiles by combining their ballot lists. """ - new_profile = sum_profiles([self, other]) - assert isinstance(new_profile, RankProfile) - return new_profile + return _sum_rank_profiles([self, other]) def group_ballots(self) -> RankProfile: """ @@ -711,6 +713,19 @@ def group_ballots(self) -> RankProfile: max_ranking_length=self.max_ranking_length, ) + def copy(self) -> RankProfile: + """ + Returns a copy of a RankProfile + + Returns: + RankProfile: New RankProfile object + """ + return RankProfile( + candidates=self.candidates, + df=self.df.copy(), + max_ranking_length=self.max_ranking_length, + ) + def __eq__(self, other): if not isinstance(other, RankProfile): return False @@ -1220,9 +1235,7 @@ def __add__(self, other): """ Add two PreferenceProfiles by combining their ballot lists. """ - new_profile = sum_profiles([self, other]) - assert isinstance(new_profile, ScoreProfile) - return new_profile + return _sum_score_profiles([self, other]) def group_ballots(self) -> ScoreProfile: """ @@ -1258,6 +1271,18 @@ def group_ballots(self) -> ScoreProfile: candidates=self.candidates, ) + def copy(self) -> ScoreProfile: + """ + Returns a copy of a ScoreProfile + + Returns: + ScoreProfile: New ScoreProfile object + """ + return ScoreProfile( + df=self.df.copy(), + candidates=self.candidates, + ) + def __eq__(self, other): if not isinstance(other, ScoreProfile): return False diff --git a/src/votekit/pref_profile/utils.py b/src/votekit/pref_profile/utils.py index 20734101..e9926489 100644 --- a/src/votekit/pref_profile/utils.py +++ b/src/votekit/pref_profile/utils.py @@ -429,22 +429,41 @@ def convert_rank_profile_to_score_profile_via_score_vector( ) -def _sum_rank_profiles(rank_profiles: Sequence[RankProfile]) -> RankProfile: +def _sum_rank_profiles(rank_profiles: Sequence[PreferenceProfile]) -> RankProfile: """ Helper function for sum_profiles that sums RankProfiles. Args: - rank_profiles (Sequence[RankProfile]): The RankProfiles to sum. + rank_profiles (Sequence[PreferenceProfile]): List of profiles to sum. + + Raises: + TypeError: Each profile must be of RankProfile type """ from votekit.pref_profile.pref_profile import RankProfile + if len(rank_profiles) == 1 and isinstance(rank_profiles[0], RankProfile): + return rank_profiles[0].copy() + + if not (all(isinstance(p, RankProfile) for p in rank_profiles)): + invalid_profiles = [ + (i, type(p).__name__) + for i, p in enumerate(rank_profiles) + if not isinstance(p, RankProfile) + ] + invalid_profiles_str = ", ".join(f"index {i} ({t})" for i, t in invalid_profiles) + raise TypeError( + "All profiles must be of the same type, RankProfile. " + f"non-RankProfiles found at: {invalid_profiles_str}" + ) + candidates = list(set().union(*[set(profile.candidates) for profile in rank_profiles])) max_ranking_length = max([profile.max_ranking_length for profile in rank_profiles]) total_dfs = [] for p in rank_profiles: curr_df = p.df.copy() + assert p.max_ranking_length is not None if p.max_ranking_length < max_ranking_length: for i in range(p.max_ranking_length, max_ranking_length): curr_df.insert( @@ -469,16 +488,34 @@ def _sum_rank_profiles(rank_profiles: Sequence[RankProfile]) -> RankProfile: ) -def _sum_score_profiles(score_profiles: Sequence[ScoreProfile]) -> ScoreProfile: +def _sum_score_profiles(score_profiles: Sequence[PreferenceProfile]) -> ScoreProfile: """ Helper function for sum_profiles that sums ScoreProfiles. Args: - score_profiles (Sequence[ScoreProfile]): The ScoreProfiles to sum. + score_profiles (Sequence[PreferenceProfile]): The profiles to sum. + + Raises: + TypeError: Each profile must be of ScoreProfile type """ from votekit.pref_profile.pref_profile import ScoreProfile + if len(score_profiles) == 1 and isinstance(score_profiles[0], ScoreProfile): + return score_profiles[0].copy() + + if not (all(isinstance(p, ScoreProfile) for p in score_profiles)): + invalid_profiles = [ + (i, type(p).__name__) + for i, p in enumerate(score_profiles) + if not isinstance(p, ScoreProfile) + ] + invalid_profiles_str = ", ".join(f"index {i} ({t})" for i, t in invalid_profiles) + raise TypeError( + "All profiles must be of the same type, ScoreProfile. " + f"non-ScoreProfiles found at: {invalid_profiles_str}" + ) + total_cand = set().union(*[set(profile.candidates) for profile in score_profiles]) total_dfs = [] for p in score_profiles: @@ -499,7 +536,7 @@ def _sum_score_profiles(score_profiles: Sequence[ScoreProfile]) -> ScoreProfile: ) -def sum_profiles(profiles: Sequence[RankProfile | ScoreProfile]) -> RankProfile | ScoreProfile: +def sum_profiles(profiles: Sequence[PreferenceProfile]) -> PreferenceProfile: """ Combines multiple PreferenceProfiles by combining their ball lists. @@ -507,11 +544,11 @@ def sum_profiles(profiles: Sequence[RankProfile | ScoreProfile]) -> RankProfile profiles (Sequence[PreferenceProfile]): The profiles to sum. Returns: - PreferenceProfile: The combined preference profile. + PreferenceProfile: A new PreferenceProfile object containing the combined profile. Raises: ValueError: Cannot sum an empty list of profiles. - TypeError: All profiles must be of the same type. + TypeError: Can only sum profiles of type RankProfile or ScoreProfile. """ from votekit.pref_profile.pref_profile import RankProfile, ScoreProfile @@ -519,15 +556,14 @@ def sum_profiles(profiles: Sequence[RankProfile | ScoreProfile]) -> RankProfile if len(profiles) == 0: raise ValueError("Cannot sum an empty list of profiles.") - if len(profiles) == 1: - return profiles[0] - - first_type = type(profiles[0]) - if not all(isinstance(profile, first_type) for profile in profiles): - raise TypeError("All profiles must be of same type.") - if isinstance(profiles[0], RankProfile): - return _sum_rank_profiles(profiles) # type: ignore[arg-type] + return _sum_rank_profiles(profiles) - if isinstance(profiles[0], ScoreProfile): - return _sum_score_profiles(profiles) # type: ignore[arg-type] + elif isinstance(profiles[0], ScoreProfile): + return _sum_score_profiles(profiles) + + else: + raise TypeError( + f"Cannot sum profiles of type {type(profiles[0]).__name__}. " + "List can only contain RankProfiles or ScoreProfiles." + ) diff --git a/tests/pref_profile/utils/test_sum_profiles.py b/tests/pref_profile/utils/test_sum_profiles.py index a473e4c7..5449c3f7 100644 --- a/tests/pref_profile/utils/test_sum_profiles.py +++ b/tests/pref_profile/utils/test_sum_profiles.py @@ -27,10 +27,16 @@ def test_sum_profiles_with_mixed_types_raises_type_error(): ) with pytest.raises( TypeError, - match="All profiles must be of same type.", + match="All profiles must be of the same type.", ): sum_profiles([score_profile, score_profile, score_profile, rank_profile]) + with pytest.raises( + TypeError, + match="All profiles must be of the same type.", + ): + sum_profiles([rank_profile, score_profile, score_profile, rank_profile]) + def test_sum_empty_profile_raises_value_error(): with pytest.raises( From a33babaa932b5033382770e37e6bdf40ca7199da Mon Sep 17 00:00:00 2001 From: graceg571 Date: Wed, 10 Jun 2026 14:35:59 -0500 Subject: [PATCH 5/6] fix return comment of sum_profiles --- src/votekit/pref_profile/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/votekit/pref_profile/utils.py b/src/votekit/pref_profile/utils.py index e9926489..d9bf9eed 100644 --- a/src/votekit/pref_profile/utils.py +++ b/src/votekit/pref_profile/utils.py @@ -544,7 +544,7 @@ def sum_profiles(profiles: Sequence[PreferenceProfile]) -> PreferenceProfile: profiles (Sequence[PreferenceProfile]): The profiles to sum. Returns: - PreferenceProfile: A new PreferenceProfile object containing the combined profile. + PreferenceProfile: A new PreferenceProfile object containing the combined profiles. Raises: ValueError: Cannot sum an empty list of profiles. From bf7e18049c94a6c4b9273830c86dcd5ed886f105 Mon Sep 17 00:00:00 2001 From: Grace Gibson Date: Thu, 11 Jun 2026 11:13:50 -0500 Subject: [PATCH 6/6] avoid unnecessary copy, confirm copy in test --- src/votekit/pref_profile/utils.py | 31 ++++++++++--------- tests/pref_profile/utils/test_sum_profiles.py | 4 ++- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/votekit/pref_profile/utils.py b/src/votekit/pref_profile/utils.py index d9bf9eed..664ae580 100644 --- a/src/votekit/pref_profile/utils.py +++ b/src/votekit/pref_profile/utils.py @@ -405,7 +405,7 @@ def convert_rank_profile_to_score_profile_via_score_vector( raise ValueError("Ballots must not contain ties.") cand_to_score_list = { - c: [np.nan for _ in range(len(rank_profile.df))] for c in rank_profile.candidates + cand: [np.nan for _ in range(len(rank_profile.df))] for cand in rank_profile.candidates } for df_tuple in rank_profile.df[ranking_cols].itertuples(): @@ -461,21 +461,22 @@ def _sum_rank_profiles(rank_profiles: Sequence[PreferenceProfile]) -> RankProfil max_ranking_length = max([profile.max_ranking_length for profile in rank_profiles]) total_dfs = [] - for p in rank_profiles: - curr_df = p.df.copy() - assert p.max_ranking_length is not None - if p.max_ranking_length < max_ranking_length: - for i in range(p.max_ranking_length, max_ranking_length): - curr_df.insert( - len(curr_df.columns), - f"Ranking_{i + 1}", - pd.Series([frozenset("~")] * len(curr_df), dtype=object, index=curr_df.index), - ) + for profile in rank_profiles: + assert profile.max_ranking_length is not None + curr_df = ( + profile.df.copy() if profile.max_ranking_length < max_ranking_length else profile.df + ) + for i in range(profile.max_ranking_length, max_ranking_length): + curr_df.insert( + len(curr_df.columns), + f"Ranking_{i + 1}", + pd.Series([frozenset("~")] * len(curr_df), dtype=object, index=curr_df.index), + ) total_dfs.append(curr_df) new_df = pd.concat(total_dfs, ignore_index=True) new_df.index.name = "Ballot Index" - ranking_cols = [c for c in new_df.columns if "Ranking_" in c] + ranking_cols = [col for col in new_df.columns if "Ranking_" in col] new_df[ranking_cols] = new_df[ranking_cols].astype("object") new_df = new_df[ [f"Ranking_{i + 1}" for i in range(max_ranking_length)] + ["Weight", "Voter Set"] @@ -518,9 +519,9 @@ def _sum_score_profiles(score_profiles: Sequence[PreferenceProfile]) -> ScorePro total_cand = set().union(*[set(profile.candidates) for profile in score_profiles]) total_dfs = [] - for p in score_profiles: - curr_df = p.df.copy() - curr_cand = set(p.candidates) + for profile in score_profiles: + curr_cand = set(profile.candidates) + curr_df = profile.df.copy() if curr_cand < total_cand else profile.df for cand in total_cand - curr_cand: curr_df[cand] = [np.nan] * len(curr_df) total_dfs.append(curr_df) diff --git a/tests/pref_profile/utils/test_sum_profiles.py b/tests/pref_profile/utils/test_sum_profiles.py index 5449c3f7..64bdaed1 100644 --- a/tests/pref_profile/utils/test_sum_profiles.py +++ b/tests/pref_profile/utils/test_sum_profiles.py @@ -46,7 +46,7 @@ def test_sum_empty_profile_raises_value_error(): sum_profiles([]) -def test_sum_one_profile_returns_same_profile(): +def test_sum_one_profile_returns_copy_of_same_profile(): profile = ScoreProfile( ballots=[ ScoreBallot(scores={"A": 2, "B": 2}, weight=2), @@ -58,6 +58,7 @@ def test_sum_one_profile_returns_same_profile(): ) summed_profile = sum_profiles([profile]) assert summed_profile == profile + assert id(summed_profile) != id(profile) profile = RankProfile( ballots=[ @@ -71,6 +72,7 @@ def test_sum_one_profile_returns_same_profile(): ) summed_profile = sum_profiles([profile]) assert summed_profile == profile + assert id(summed_profile) != id(profile) def test_sum_one_profile_no_list_raises_type_error():