diff --git a/pabutools/analysis/projectloss.py b/pabutools/analysis/projectloss.py index 8c762f17..15d0c468 100644 --- a/pabutools/analysis/projectloss.py +++ b/pabutools/analysis/projectloss.py @@ -80,12 +80,13 @@ def calculate_project_loss( List of :py:class:`~pabutools.analysis.projectloss.ProjectLoss` objects. """ - if not hasattr(allocation_details, "iterations") or not hasattr( - allocation_details, "initial_budget_per_voter" + if not all( + hasattr(allocation_details, attr) + for attr in ["iterations", "initial_budget_per_voter", "voter_multiplicity"] ): raise ValueError( "Provided budget allocation details do not support calculating project loss. The allocation_details " - "should have an 'iterations' and an 'initial_budget_per_voter' attributes." + "should have an 'iterations', 'initial_budget_per_voter' and 'voter_multiplicity' attributes." ) if len(allocation_details.iterations) == 0: if verbose: @@ -94,6 +95,7 @@ def calculate_project_loss( project_losses = [] voter_count = len(allocation_details.iterations[0].voters_budget) + voter_multiplicity = allocation_details.voter_multiplicity voter_spendings: dict[int, list[Tuple[Project, Numeric]]] = {} for idx in range(voter_count): voter_spendings[idx] = [] @@ -108,15 +110,21 @@ def calculate_project_loss( f"Considering: {iteration.project.name}, status: {iteration.was_picked}" ) budget_lost = {} - for spending in [voter_spendings[i] for i in iteration.supporter_indices]: + for idx in iteration.supporter_indices: + spending = voter_spendings[idx] for project, spent in spending: if project not in budget_lost.keys(): budget_lost[project] = 0 - budget_lost[project] = budget_lost[project] + spent + budget_lost[project] = ( + budget_lost[project] + spent * voter_multiplicity[idx] + ) project_losses.append( ProjectLoss( iteration.project, - sum(current_voters_budget[i] for i in iteration.supporter_indices), + sum( + current_voters_budget[i] * voter_multiplicity[i] + for i in iteration.supporter_indices + ), budget_lost, ) ) diff --git a/pabutools/rules/mes/mes_details.py b/pabutools/rules/mes/mes_details.py index 2557cf0e..87792a63 100644 --- a/pabutools/rules/mes/mes_details.py +++ b/pabutools/rules/mes/mes_details.py @@ -23,9 +23,10 @@ class MESAllocationDetails(AllocationDetails): A list of all iterations of a MES rule run. It is progressively populated during a MES rule run. """ - def __init__(self, initial_budget_per_voter: Numeric): + def __init__(self, initial_budget_per_voter: Numeric, voter_multiplicity: list[int]): super().__init__() self.initial_budget_per_voter: Numeric = initial_budget_per_voter + self.voter_multiplicity: list[int] = voter_multiplicity self.iterations: list[MESIteration] = [] def __str__(self): @@ -76,10 +77,8 @@ def __init__( project: Project, supporter_indices: list[int], was_picked: bool, - voters_budget=None, + voters_budget: list[int] = [], ): - if voters_budget is None: - voters_budget = [] self.project: Project = project self.supporter_indices: list[int] = supporter_indices self.was_picked: bool = was_picked diff --git a/pabutools/rules/mes/mes_rule.py b/pabutools/rules/mes/mes_rule.py index 1e8021d3..377f2eaf 100644 --- a/pabutools/rules/mes/mes_rule.py +++ b/pabutools/rules/mes/mes_rule.py @@ -564,7 +564,7 @@ def method_of_equal_shares_scheme( budget_allocation = BudgetAllocation( initial_budget_allocation, - MESAllocationDetails(initial_budget_per_voter) if analytics else None, + MESAllocationDetails(initial_budget_per_voter, [voter.multiplicity for voter in voters]) if analytics else None, ) previous_outcome: BudgetAllocation | list[BudgetAllocation] = budget_allocation diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 69844a68..1098756b 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -261,7 +261,7 @@ def test_profile_properties(self): assert median_total_score(instance, card_multi_profile) == 3 def test_project_loss(self): - projects = [Project(chr(ord("a") + idx), 2) for idx in range(6)] + projects = [Project(chr(ord("a") + idx), 4) for idx in range(6)] supporters = [[0, 1, 2, 4], [2, 3, 4], [0, 2], [0, 1], [4], [5]] was_picked = [True, True, False, False, False, False] voters_budget = [ @@ -280,24 +280,20 @@ def test_project_loss(self): ) for idx in range(len(projects)) ] - allocation_details = MESAllocationDetails(initial_budget_per_voter) + allocation_details = MESAllocationDetails( + initial_budget_per_voter, + [2 for _ in range(len(voters_budget[0]))], + ) allocation_details.iterations = iterations project_losses = calculate_project_loss(allocation_details) - expected_budgets = [ - frac(4, 1), - frac(2, 1), - frac(1, 2), - frac(1, 1), - frac(0, 1), - frac(1, 1), - ] + expected_budgets = [8, 4, 1, 2, 0, 2] expected_losses = [ {}, - {projects[0]: frac(1, 1)}, - {projects[0]: frac(1, 1), projects[1]: frac(1, 2)}, - {projects[0]: frac(1, 1)}, - {projects[0]: frac(1, 2), projects[1]: frac(1, 2)}, + {projects[0]: 2}, + {projects[0]: 2, projects[1]: 1}, + {projects[0]: 2}, + {projects[0]: 1, projects[1]: 1}, {}, ] @@ -305,3 +301,7 @@ def test_project_loss(self): assert project_loss.name == projects[idx].name assert project_loss.supporters_budget == expected_budgets[idx] assert project_loss.budget_lost == expected_losses[idx] + + # No iterations + project_losses = calculate_project_loss(MESAllocationDetails(1, [1])) + assert project_losses == [] diff --git a/tests/test_rule.py b/tests/test_rule.py index e97bbb00..6c42f289 100644 --- a/tests/test_rule.py +++ b/tests/test_rule.py @@ -725,6 +725,16 @@ def mes_phragmen(instance, profile, resoluteness=True): [frac(1, 2), frac(1, 2), frac(1, 2), frac(1, 2), frac(1, 2)], [frac(1, 2), frac(1, 2), frac(1, 2), frac(1, 2), frac(1, 2)], ), + ( + [5, 1, 2, 1, 2], + [0, 1, 2], + [0, 1, 2], + [1, 3], + [frac(1, 2), frac(1, 4), frac(1, 4), frac(1, 4), frac(1, 4)], + [frac(1, 2), frac(1, 4), frac(1, 4), frac(1, 4), frac(1, 4)], + True, + [2, 1, 2, 1, 2, 2], + ), ] ) def test_mes_analytics( @@ -735,6 +745,8 @@ def test_mes_analytics( picked_projects_idxs, expected_third_voter_budget, expected_fourth_voter_budget, + multiprofile=False, + expected_multiplicity=[1 for _ in range(10)], ): projects = [Project(chr(ord("a") + idx), costs[idx]) for idx in range(0, 5)] instance = Instance(projects, budget_limit=5) @@ -752,21 +764,29 @@ def test_mes_analytics( ApprovalBallot({projects[4]}), ] ) + if multiprofile: + profile = profile.as_multiprofile() result = method_of_equal_shares(instance, profile, Cost_Sat, analytics=True) assert sorted(list(result), key=lambda proj: proj.name) == [ projects[idx] for idx in picked_projects_idxs ] assert result.details.initial_budget_per_voter == frac(1, 2) + assert result.details.voter_multiplicity == expected_multiplicity + check_voters = [2, 2, 5] if multiprofile else [3, 4, 8] for idx, anl in enumerate( sorted(result.details.iterations, key=lambda iter: iter.project.name) ): assert anl.project.name == projects[idx].name assert anl.was_picked == (idx in picked_projects_idxs) - assert anl.voters_budget[3] == expected_third_voter_budget[idx] - assert anl.voters_budget[4] == expected_fourth_voter_budget[idx] - assert anl.voters_budget[8] == frac(1, 2) + assert ( + anl.voters_budget[check_voters[0]] == expected_third_voter_budget[idx] + ) + assert ( + anl.voters_budget[check_voters[1]] == expected_fourth_voter_budget[idx] + ) + assert anl.voters_budget[check_voters[2]] == frac(1, 2) def test_mes_analytics_irresolute(self): projects = [Project(chr(ord("a") + idx), 3) for idx in range(0, 3)]