diff --git a/pabutools/rules/cstv.py b/pabutools/rules/cstv.py index 9118109c..4e278d75 100644 --- a/pabutools/rules/cstv.py +++ b/pabutools/rules/cstv.py @@ -1,8 +1,13 @@ """ + + + An implementation of the algorithms in: -"Participatory Budgeting with Cumulative Votes", by Piotr Skowron, Arkadii Slinko, Stanisaw Szufa, +"Participatory Budgeting with Cumulative Votes", +by Piotr Skowron, Arkadii Slinko, Stanisaw Szufa, Nimrod Talmon (2020), https://arxiv.org/pdf/2009.02690 -Programmer: Achiya Ben Natan +Original implementation: Achiya Ben Natan +Changes: Kacper Harasimowicz Date: 2024/05/16. """ @@ -15,7 +20,8 @@ from enum import Enum from pabutools.election.instance import Instance, Project -from pabutools.election.profile.cumulativeprofile import AbstractCumulativeProfile +from pabutools.election.ballot import CumulativeBallot +from pabutools.election.profile.cumulativeprofile import ( CumulativeProfile, AbstractCumulativeProfile ) from pabutools.fractions import frac from pabutools.rules.budgetallocation import BudgetAllocation from pabutools.tiebreaking import TieBreakingRule, lexico_tie_breaking @@ -58,6 +64,17 @@ class CSTV_Combination(Enum): minimal transfer used if no eligible projects; and acceptance of under-supported projects as post-processing method. """ + EWTS = 5 + """ + Project selection via greedy-by-support; eligible projects selected via greedy-by-support; + elimination with transfer used if no eligible projects; and reverse elimination as post-processing method. + """ + + MTS = 6 + """ + Project selection via greedy-by-support; eligible projects selected via greedy-by-support; + minimal transfer used if no eligible projects; and acceptance of under-supported projects as post-processing method. + """ def cstv( instance: Instance, @@ -114,6 +131,7 @@ def cstv( BudgetAllocation The list of selected projects. """ + epsilon = 1e-10 if tie_breaking is None: tie_breaking = lexico_tie_breaking @@ -129,16 +147,26 @@ def cstv( eligible_projects_func = is_eligible_gsc no_eligible_project_func = elimination_with_transfers exhaustiveness_postprocess_func = reverse_eliminations - elif CSTV_Combination.MT: + elif combination == CSTV_Combination.MT: select_project_to_fund_func = select_project_ge eligible_projects_func = is_eligible_ge no_eligible_project_func = minimal_transfer exhaustiveness_postprocess_func = acceptance_of_under_supported_projects - elif CSTV_Combination.MTC: + elif combination == CSTV_Combination.MTC: select_project_to_fund_func = select_project_gsc eligible_projects_func = is_eligible_gsc no_eligible_project_func = minimal_transfer exhaustiveness_postprocess_func = acceptance_of_under_supported_projects + elif combination == CSTV_Combination.EWTS: + select_project_to_fund_func = select_project_gs + eligible_projects_func = is_eligible_gs + no_eligible_project_func = elimination_with_transfers + exhaustiveness_postprocess_func = reverse_eliminations + elif combination == CSTV_Combination.MTS: + select_project_to_fund_func = select_project_gs + eligible_projects_func = is_eligible_gs + no_eligible_project_func = minimal_transfer + exhaustiveness_postprocess_func = acceptance_of_under_supported_projects else: raise ValueError( f"Invalid combination {combination}. Please select an element of the " @@ -171,20 +199,23 @@ def cstv( 'The "resoluteness = False" feature is not yet implemented' ) - # Check if all donors donate the same amount - if not len(set([sum(donor.values()) for donor in profile])) == 1: - raise ValueError( - "Not all donors donate the same amount. Change the donations and try again." - ) - if initial_budget_allocation is None: initial_budget_allocation = BudgetAllocation() else: initial_budget_allocation = BudgetAllocation(initial_budget_allocation) + # Check if all donors donate the same amount + donor_sums = set([sum(donor.values()) for donor in profile]) + if (max(donor_sums) == 0): + return initial_budget_allocation + if frac((max(donor_sums) - min(donor_sums)), max(donor_sums)) > epsilon: + raise ValueError( + "Not all donors donate the same amount. Change the donations and try again." + ) + # Initialize the set of selected projects and eliminated projects selected_projects = initial_budget_allocation - eliminated_projects = set() + eliminated_projects = [] # The donations to avoid to mutate the profile passed as argument donations = [ @@ -193,10 +224,13 @@ def cstv( ] current_projects = set(instance) + if instance.budget_limit > 0: + budget = instance.budget_limit + else: + budget = sum(sum(donor.values()) for donor in donations) # Loop until a halting condition is met while True: # Calculate the total budget - budget = sum(sum(donor.values()) for donor in donations) if verbose: logger.info(f"Budget is: {budget}") @@ -241,6 +275,12 @@ def cstv( ) if not flag: # Perform the inclusive maximality postprocedure + if verbose: + logger.info( + f"Beginning exhaustiveness postprocess\n \ + Remaining projects: {eligible_projects}\n \ + Eliminated projects: {eliminated_projects}", + ) exhaustiveness_postprocess_func( selected_projects, donations, @@ -266,26 +306,24 @@ def cstv( if verbose: logger.info(f"Excess support for {p}: {excess_support}") - # If the project has enough or excess support - if excess_support >= 0: - if excess_support > 0.01: - # Perform the excess redistribution procedure - gama = frac(p.cost, excess_support + p.cost) - excess_redistribution_procedure(donations, p, gama) - else: - # Reset donations for the eliminated project - if verbose: - logger.info(f"Resetting donations for eliminated project: {p}") - for donor in donations: - donor[p] = 0 + # Add the project to the selected set and remove it from further consideration + selected_projects.append(p) + current_projects.remove(p) + if verbose: + logger.info(f"Updated selected projects: {selected_projects}") + budget -= p.cost - # Add the project to the selected set and remove it from further consideration - selected_projects.append(p) - current_projects.remove(p) + if excess_support > 0.01: + # Perform the excess redistribution procedure + gama = frac(p.cost, excess_support + p.cost) + excess_redistribution_procedure(current_projects, donations, p, gama) + else: + # Reset donations for the eliminated project if verbose: - logger.info(f"Updated selected projects: {selected_projects}") - budget -= p.cost - continue + logger.info(f"Resetting donations for eliminated project: {p}") + for donor in donations: + donor[p] = 0 + continue ################################################################### @@ -296,9 +334,10 @@ def cstv( def excess_redistribution_procedure( + current_projects: set[Project], donors: list[dict[Project, Numeric]], selected_project: Project, - gama: Numeric, + gama: Numeric ) -> None: """ Distributes the excess support of a selected project to the remaining projects. @@ -316,25 +355,32 @@ def excess_redistribution_procedure( ------- None """ + project_support = sum(donor.get(selected_project.name, 0) for donor in donors) + cost = selected_project.cost for donor in donors: - donor_copy = donor.copy() - to_distribute = donor_copy[selected_project] * (1 - gama) - donor[selected_project] = to_distribute - donor_copy[selected_project] = 0 - total = sum(donor_copy.values()) - for key, donation in donor_copy.items(): - if donation != selected_project: - if total != 0: + contribution = donor[selected_project] + total = sum(donor.values()) - contribution + if total == 0: + project_support -= contribution + cost -= contribution + if cost > 0: + gama = frac(cost, project_support) + for donor in donors: + contribution = donor[selected_project] + donor.pop(selected_project) + to_distribute = contribution * (1 - gama) + total = sum(donor.values()) + if total != 0: + for key, donation in donor.items(): part = frac(donation, total) donor[key] = donation + to_distribute * part - donor[selected_project] = 0 -def is_eligible_ge( +def is_eligible_greedy( projects: Iterable[Project], donors: list[dict[Project, Numeric]] ) -> list[Project]: """ - Determines the eligible projects based on the General Election (GE) rule. + Determines the eligible projects based on the Greedy rules Parameters ---------- @@ -348,12 +394,57 @@ def is_eligible_ge( list[Project] The list of eligible projects. """ + epsilon = 1e-5 + support = { + project: sum([donor.get(project, 0) for donor in donors]) + for project in projects + } return [ project for project in projects - if (sum(donor.get(project, 0) for donor in donors) - project.cost) >= 0 + if support.get(project, 0) * (1+epsilon) >= project.cost ] +def is_eligible_gs( + projects: Iterable[Project], donors: list[dict[Project, Numeric]] +) -> list[Project]: + """ + Determines the eligible projects based on the Greedy-by-Support (GS) rule. + + Parameters + ---------- + projects : Iterable[Project] + The list of projects. + donors : list[dict[Project, Numeric]] + The list of donor ballots. + + Returns + ------- + list[Project] + The list of eligible projects. + """ + return is_eligible_greedy(projects, donors) + +def is_eligible_ge( + projects: Iterable[Project], donors: list[dict[Project, Numeric]] +) -> list[Project]: + """ + Determines the eligible projects based on the General Election (GE) rule. + + Parameters + ---------- + projects : Iterable[Project] + The list of projects. + donors : list[dict[Project, Numeric]] + The list of donor ballots. + + Returns + ------- + list[Project] + The list of eligible projects. + """ + return is_eligible_greedy(projects, donors) + def is_eligible_gsc( projects: Iterable[Project], donors: list[dict[Project, Numeric]] @@ -373,16 +464,50 @@ def is_eligible_gsc( list[Project] The list of eligible projects. """ - return [ - project + return is_eligible_greedy(projects, donors) + +def select_project_gs( + projects: Iterable[Project], + donors: list[dict[Project, Numeric]], + find_best: bool = True +) -> list[Project]: + """ + Selects the project with the maximum support using the Greedy-by-Support (GS) rule. + + Parameters + ---------- + projects : Iterable[Project] + The list of projects. + donors : list[dict[Project, Numeric]] + The list of donor ballots. + find_best: bool, optional + Set to `True` to select best project, or `False` for worst project + defaults to `True` + + Returns + ------- + list[Project] + The tied selected projects. + """ + support = { + project: sum([donor.get(project, 0) for donor in donors]) for project in projects - if frac(sum(donor.get(project, 0) for donor in donors), project.cost) >= 1 + } + if find_best: + target_support_value = max(support.values()) + else: + target_support_value = min(support.values()) + target_support_projects = [ + project + for project, supp in support.items() + if supp == target_support_value ] - + return target_support_projects def select_project_ge( projects: Iterable[Project], donors: list[dict[Project, Numeric]], + find_best: bool = True ) -> list[Project]: """ Selects the project with the maximum excess support using the General Election (GE) rule. @@ -393,28 +518,35 @@ def select_project_ge( The list of projects. donors : list[dict[Project, Numeric]] The list of donor ballots. - + find_best: bool, optional + Set to `True` to select best project, or `False` for worst project + defaults to `True` + Returns ------- list[Project] The tied selected projects. """ excess_support = { - project: sum(donor.get(project, 0) for donor in donors) - project.cost + project: sum([donor.get(project, 0) for donor in donors]) - project.cost for project in projects } - max_excess_value = max(excess_support.values()) - max_excess_projects = [ + if find_best: + target_excess_value = max(excess_support.values()) + else: + target_excess_value = min(excess_support.values()) + target_excess_projects = [ project for project, excess in excess_support.items() - if excess == max_excess_value + if excess == target_excess_value ] - return max_excess_projects + return target_excess_projects def select_project_gsc( projects: Iterable[Project], donors: list[dict[Project, Numeric]], + find_best: bool = True ) -> list[Project]: """ Selects the project with the maximum excess support using the General Election (GSC) rule. @@ -425,29 +557,35 @@ def select_project_gsc( The list of projects. donors : list[dict[Project, Numeric]] The list of donor ballots. + find_best: bool, optional + Set to `True` to select best project, or `False` for worst project + defaults to `True` Returns ------- list[Project] The tied selected projects. """ - excess_support = { - project: frac(sum(donor.get(project, 0) for donor in donors), project.cost) + support_over_cost = { + project: frac(sum([donor.get(project, 0) for donor in donors]), project.cost) for project in projects } - max_excess_value = max(excess_support.values()) - max_excess_projects = [ + if find_best: + target_SOC_value = max(support_over_cost.values()) + else: + target_SOC_value = min(support_over_cost.values()) + target_SOC_projects = [ project - for project, excess in excess_support.items() - if excess == max_excess_value + for project, SOC in support_over_cost.items() + if SOC == target_SOC_value ] - return max_excess_projects + return target_SOC_projects def elimination_with_transfers( - projects: list[Project], + projects: set[Project], donors: list[dict[Project, Numeric]], - eliminated_projects: set[Project], + eliminated_projects: list[Project], project_to_fund_selection_procedure: Callable, tie_breaking: TieBreakingRule, ) -> bool: @@ -483,32 +621,39 @@ def distribute_project_support( """ for donor in all_donors: to_distribute = donor[eliminated_project] - total = sum(donor.values()) - to_distribute + donor.pop(eliminated_project) + total = sum(donor.values()) if total == 0: continue for key, donation in donor.items(): - if key != eliminated_project: - part = frac(donation, total) - donor[key] = donation + to_distribute * part - donor[eliminated_project] = 0 + part = frac(donation, total) + donor[key] = donation + to_distribute * part if len(projects) < 2: if len(projects) == 1: - eliminated_projects.add(projects.pop()) + eliminated_projects.append(projects.pop()) return False - min_project = min( - projects, key=lambda p: sum(donor.get(p.name, 0) for donor in donors) - p.cost + min_projects = project_to_fund_selection_procedure( + projects, donors, False ) + if len(min_projects) > 1: + min_project = tie_breaking.untie( + projects, + CumulativeProfile([CumulativeBallot(donor) for donor in donors]), + min_projects + ) + else: + min_project = min_projects[0] distribute_project_support(donors, min_project) projects.remove(min_project) - eliminated_projects.add(min_project) + eliminated_projects.append(min_project) return True def minimal_transfer( - projects: Iterable[Project], + projects: set[Project], donors: list[dict[Project, Numeric]], - eliminated_projects: set[Project], + eliminated_projects: list[Project], project_to_fund_selection_procedure: Callable, tie_breaking: TieBreakingRule = lexico_tie_breaking, ) -> bool: @@ -535,10 +680,7 @@ def minimal_transfer( eliminated_projects. """ - if pabutools.fractions.FRACTION != pabutools.fractions.FLOAT_FRAC: - warnings.warn("You are using minimal transfers with exact fractions, this may never end...") - projects_with_chance = [] - for project in projects: + for project in projects.copy(): donors_of_selected_project = [ donor.values() for _, donor in enumerate(donors) @@ -547,65 +689,106 @@ def minimal_transfer( sum_of_don = 0 for d in donors_of_selected_project: sum_of_don += sum(d) - if sum_of_don >= project.cost: - projects_with_chance.append(project) - if not projects_with_chance: + if sum_of_don < project.cost: + eliminated_projects.append(project) + projects.remove(project) + if not projects: return False - chosen_project = project_to_fund_selection_procedure(projects_with_chance, donors)[ - 0 - ] # TODO: there should be a tie-breaking here + tied_projects = project_to_fund_selection_procedure(projects, donors) + if len(tied_projects) > 1: + chosen_project = tie_breaking.untie( + projects, + CumulativeProfile([CumulativeBallot(donor) for donor in donors]), + tied_projects + ) + else: + chosen_project = tied_projects[0] donors_of_selected_project = [ i for i, donor in enumerate(donors) if donor.get(chosen_project.name, 0) > 0 ] - project_cost = chosen_project.cost # Calculate initial support ratio - total_support = sum(donor.get(chosen_project, 0) for donor in donors) + total_support = sum(donors[i].get(chosen_project, 0) for i in donors_of_selected_project) r = frac(total_support, project_cost) - # Loop until the required support is achieved + # Loop until all donors can afford ratio num_loop_run = 0 - while r < 1: - num_loop_run += 1 - # Check if all donors have their entire donation on the chosen project - all_on_chosen_project = all( - sum(donors[i].values()) == donors[i].get(chosen_project, 0) - for i in donors_of_selected_project - ) - if all_on_chosen_project: - for project in projects: - eliminated_projects.add(project) - return False - - for i in donors_of_selected_project: + do_continue = True + while do_continue: + do_continue = False + + for i in list(donors_of_selected_project): donor = donors[i] donation = donor.get(chosen_project, 0) - total = sum(donor.values()) - donation - if total > 0: - to_distribute = min(total, frac(donation, r) - donation) + total = sum(donor.values()) + if frac(donation, r) > total: + do_continue = True for proj_name, proj_donation in donor.items(): - if proj_name != chosen_project and proj_donation > 0: - change = frac(to_distribute * proj_donation, total) - if 1 - change < 1e-14: - change = 1 - donor[proj_name] -= change - donor[chosen_project] += frac(math.ceil(change * 100000000000000), 100000000000000) - - # Recalculate the support ratio - total_support = sum(donor.get(chosen_project, 0) for donor in donors) - r = frac(total_support, project_cost) - - if num_loop_run > 10000: - raise RuntimeError("The while loop of the minimal_transfer function ran for too long. This can be due to" - " issues with floating point arithmetic.") + donor[proj_name] = 0 + donor[chosen_project] = total + donors_of_selected_project.remove(i) + total_support -= total + project_cost -= total + r = frac(total_support, project_cost) + # Loop until the required support is achieved + if donors_of_selected_project: + num_loop_run = 0 + while r < 1: + num_loop_run += 1 + # Check if all donors have their entire donation on the chosen project + all_on_chosen_project = all( + sum(donors[i].values()) == donors[i].get(chosen_project, 0) + for i in donors_of_selected_project + ) + if all_on_chosen_project: + for project in projects: + eliminated_projects.append(project) + return False + + # Redistribute all support when current ratio would need to consume all + for i in donors_of_selected_project: + donor = donors[i] + donation = donor.get(chosen_project, 0) + total = sum(donor.values()) - donation + if total > 0: + to_distribute = min(total, frac(donation, r) - donation) + for proj_name, proj_donation in donor.items(): + if proj_name != chosen_project and proj_donation > 0: + change = frac(to_distribute * proj_donation, total) + if to_distribute - change < 1e-14: + change = to_distribute + donor[proj_name] -= change + donor[chosen_project] += frac(math.ceil(change * 100000000000000), 100000000000000) + + # Recalculate the support ratio + total_support = sum(donors[i].get(chosen_project, 0) for i in donors_of_selected_project) + r = frac(total_support, project_cost) + + if num_loop_run > 10000: + #raise RuntimeError("The while loop of the minimal_transfer function ran for too long. This can be due to" + # " issues with floating point arithmetic.") + break + + # Correction for (potentail) fraction inaccuracy + diff = project_cost - sum(donor.get(chosen_project.name, 0) for donor in donors) + if diff > 0: + mn_supp = project_cost + mn_i = 0 + for idx, donor in enumerate(donors): + supp = donor.get(chosen_project.name, 0) + if supp > 0 and mn_supp > supp: + mn_supp = supp + mn_i = idx + donors[mn_i][chosen_project.name] += diff + return True def reverse_eliminations( selected_projects: BudgetAllocation, donors: list[dict[Project, Numeric]], - eliminated_projects: set[Project], + eliminated_projects: list[Project], project_to_fund_selection_procedure: Callable, budget: Numeric, tie_breaking: TieBreakingRule = lexico_tie_breaking, @@ -632,7 +815,7 @@ def reverse_eliminations( ------- None """ - for project in eliminated_projects: + for project in reversed(eliminated_projects): if project.cost <= budget: selected_projects.append(project) budget -= project.cost @@ -668,15 +851,18 @@ def acceptance_of_under_supported_projects( ------- None """ - while len(eliminated_projects) != 0: - selected_project = project_to_fund_selection_procedure( - eliminated_projects, donors, tie_breaking, True - )[ - 0 - ] # TODO: tie-breaking here + while len(eliminated_projects) > 0: + selected_project = tie_breaking.untie( + eliminated_projects, + CumulativeProfile([CumulativeBallot(donor) for donor in donors]), + project_to_fund_selection_procedure( + eliminated_projects, donors + )) if selected_project.cost <= budget: selected_projects.append(selected_project) - eliminated_projects.remove(selected_project) budget -= selected_project.cost - else: - eliminated_projects.remove(selected_project) + for donor in donors: + if donor.get(selected_project, 0) > 0: + for project in donor.keys(): + donor[project] = 0 + eliminated_projects.remove(selected_project) \ No newline at end of file diff --git a/tests/rules/test_cstv.py b/tests/rules/test_cstv.py index a1a21a29..57913be8 100644 --- a/tests/rules/test_cstv.py +++ b/tests/rules/test_cstv.py @@ -18,6 +18,7 @@ class TestFunctions(unittest.TestCase): def setUp(self): + self.do_verbose = [False] self.p1 = Project("A", 27) self.p2 = Project("B", 30) self.p3 = Project("C", 40) @@ -38,7 +39,7 @@ def test_cstv_budgeting_with_zero_budget(self): for key in donor.keys(): donor[key] = 0 for combination in CSTV_Combination: - for verbose in [True, False]: + for verbose in self.do_verbose: with self.subTest(combination=combination): selected_projects = cstv(self.instance, self.donors, combination, verbose=verbose) self.assertEqual( @@ -52,7 +53,7 @@ def test_cstv_budgeting_with_budget_less_than_min_project_cost(self): donor[self.p2] = 1 donor[self.p3] = 1 for combination in CSTV_Combination: - for verbose in [True, False]: + for verbose in self.do_verbose: with self.subTest(combination=combination): selected_projects = cstv(self.instance, self.donors, combination, verbose=verbose) self.assertEqual( @@ -66,7 +67,7 @@ def test_cstv_budgeting_with_budget_greater_than_max_total_needed_support(self): for key in donor.keys(): donor[key] = 100 for combination in CSTV_Combination: - for verbose in [True, False]: + for verbose in self.do_verbose: with self.subTest(combination=combination): selected_projects = cstv(self.instance, donors, combination, verbose=verbose) self.assertEqual( @@ -75,8 +76,12 @@ def test_cstv_budgeting_with_budget_greater_than_max_total_needed_support(self): def test_cstv_budgeting_with_budget_between_min_and_max(self): # Ensure the number of selected projects is 2 when total budget is between the minimum and maximum costs + for donor in self.donors: + donor[self.p1] = 5 + donor[self.p2] = 5 + donor[self.p3] = 5 for combination in CSTV_Combination: - for verbose in [True, False]: + for verbose in self.do_verbose: with self.subTest(combination=combination): selected_projects = cstv(self.instance, self.donors, combination, verbose=verbose) self.assertEqual( @@ -90,13 +95,50 @@ def test_cstv_budgeting_with_budget_exactly_matching_required_support(self): donor[self.p1] = frac(self.p1.cost, len(self.donors)) donor[self.p2] = frac(self.p2.cost, len(self.donors)) donor[self.p3] = frac(self.p3.cost, len(self.donors)) - for verbose in [True, False]: + for verbose in self.do_verbose: with self.subTest(combination=combination): selected_projects = cstv(self.instance, self.donors, combination, verbose=verbose) self.assertEqual( len(selected_projects), 3 ) + def test_cstv_budgeting_with_single_project_consuming_most_support(self): + # Ensure all projects are selected when the total budget matches the required support exactly + for combination in CSTV_Combination: + self.donors = CumulativeProfile( + [ + CumulativeBallot({self.p1: 20, self.p2: 0, self.p3: 0}), + CumulativeBallot({self.p1: 20, self.p2: 0, self.p3: 0}), + CumulativeBallot({self.p1: 0, self.p2: 20, self.p3: 0}), + ] + ) + for verbose in self.do_verbose: + with self.subTest(combination=combination): + selected_projects = cstv(self.instance, self.donors, combination, verbose=verbose) + self.assertEqual( + len(selected_projects), 2 + ) + + def test_cstv_budgeting_maximality_is_achieved_with_low_support(self): + for combination in CSTV_Combination: + self.p1 = Project("A", 10) + self.p2 = Project("B", 10) + self.p3 = Project("C", 10) + self.p4 = Project("D", 10) + self.instance = Instance([self.p1, self.p2, self.p3, self.p4]) + self.donors = CumulativeProfile( + [ + CumulativeBallot({self.p1: 10, self.p2: 5, self.p3: 0, self.p4: 0}), + CumulativeBallot({self.p1: 0, self.p2: 0, self.p3: 5, self.p4: 10}), + ] + ) + for verbose in self.do_verbose: + with self.subTest(combination=combination): + selected_projects = cstv(self.instance, self.donors, combination, verbose=verbose) + self.assertEqual( + len(selected_projects), 3 + ) + def test_cstv_budgeting_large_input(self): # Ensure the number of selected projects does not exceed the total number of projects for combination in CSTV_Combination: @@ -162,4 +204,504 @@ def generate_donations(total_donation, m): # Ensure the total initial support from donors is at least the total cost of the selected projects self.assertGreaterEqual(support, total_cost) + def test_cstv_party_split(self): + for combination in CSTV_Combination: + projects = [ + Project('A1', 200), + Project('A2', 120), + Project('A3', 80), + Project('B1', 150), + Project('B2', 100), + Project('B3', 70), + Project('C1', 160), + Project('C2', 90), + Project('C3', 80), + ] + instance = Instance(projects) + donors = CumulativeProfile( + [ + CumulativeBallot({projects[i]: 0 for i in range(len(projects))}) + for _ in range(8) + ] + ) + donors[0][projects[0]] = 50 + donors[0][projects[1]] = 30 + donors[0][projects[2]] = 20 + donors[1][projects[0]] = 30 + donors[1][projects[1]] = 40 + donors[1][projects[2]] = 30 + donors[2][projects[0]] = 50 + donors[2][projects[1]] = 45 + donors[2][projects[2]] = 5 + + donors[3][projects[3]] = 50 + donors[3][projects[4]] = 40 + donors[3][projects[5]] = 10 + donors[4][projects[3]] = 30 + donors[4][projects[4]] = 40 + donors[4][projects[5]] = 30 + donors[5][projects[3]] = 50 + donors[5][projects[4]] = 25 + donors[5][projects[5]] = 25 + + donors[6][projects[6]] = 70 + donors[6][projects[7]] = 20 + donors[6][projects[8]] = 10 + donors[7][projects[6]] = 50 + donors[7][projects[7]] = 40 + donors[7][projects[8]] = 10 + + with self.subTest(combination=combination): + selected_projects = cstv(instance, donors, combination) + if combination == CSTV_Combination.EWT or combination == CSTV_Combination.EWTC: + self.assertSetEqual(set(selected_projects), { + projects[4], + projects[1], + projects[2], + projects[7], + projects[5], + projects[3], + projects[6] + }) + elif combination == CSTV_Combination.EWTS: + self.assertSetEqual(set(selected_projects), { + projects[4], + projects[1], + projects[6], + projects[3], + projects[5], + projects[0] + }) + elif combination == CSTV_Combination.MT: + self.assertSetEqual(set(selected_projects), { + projects[4], + projects[5], + projects[1], + projects[2], + projects[7], + projects[8], + projects[3] + }) + elif combination == CSTV_Combination.MTC: + self.assertSetEqual(set(selected_projects), { + projects[4], + projects[5], + projects[1], + projects[2], + projects[6], + projects[3], + projects[7] + }) + elif combination == CSTV_Combination.MTS: + self.assertSetEqual(set(selected_projects), { + projects[4], + projects[0], + projects[3], + projects[6], + projects[2], + projects[5] + }) + + def test_cstv_laminal1(self): + for combination in CSTV_Combination: + projects = [ + Project('L1', 200), + Project('L2', 200), + Project('A1', 150), + Project('A2', 120), + Project('A3', 100), + Project('A4', 90), + Project('A5', 80), + Project('B1', 110), + Project('B2', 100), + Project('B3', 70), + Project('B4', 60), + Project('C1', 130), + Project('C2', 110), + Project('C3', 90), + Project('C4', 70), + ] + instance = Instance(projects) + donors = CumulativeProfile( + [ + CumulativeBallot({projects[i]: 0 for i in range(len(projects))}) + for _ in range(9) + ] + ) + donors[0][projects[0]] = 20 + donors[0][projects[1]] = 10 + donors[0][projects[2]] = 30 + donors[0][projects[3]] = 20 + donors[0][projects[4]] = 10 + donors[0][projects[5]] = 5 + donors[0][projects[6]] = 5 + donors[1][projects[0]] = 40 + donors[1][projects[1]] = 10 + donors[1][projects[2]] = 10 + donors[1][projects[3]] = 10 + donors[1][projects[4]] = 10 + donors[1][projects[5]] = 10 + donors[1][projects[6]] = 10 + donors[2][projects[0]] = 25 + donors[2][projects[1]] = 5 + donors[2][projects[2]] = 15 + donors[2][projects[3]] = 5 + donors[2][projects[4]] = 25 + donors[2][projects[5]] = 15 + donors[2][projects[6]] = 10 + donors[3][projects[0]] = 5 + donors[3][projects[1]] = 25 + donors[3][projects[2]] = 20 + donors[3][projects[3]] = 5 + donors[3][projects[4]] = 5 + donors[3][projects[5]] = 5 + donors[3][projects[6]] = 35 + + donors[4][projects[0]] = 35 + donors[4][projects[1]] = 5 + donors[4][projects[7]] = 25 + donors[4][projects[8]] = 25 + donors[4][projects[9]] = 5 + donors[4][projects[10]] = 5 + donors[5][projects[0]] = 5 + donors[5][projects[1]] = 15 + donors[5][projects[7]] = 35 + donors[5][projects[8]] = 15 + donors[5][projects[9]] = 25 + donors[5][projects[10]] = 5 + donors[6][projects[0]] = 15 + donors[6][projects[1]] = 15 + donors[6][projects[7]] = 25 + donors[6][projects[8]] = 15 + donors[6][projects[9]] = 15 + donors[6][projects[10]] = 15 + + donors[7][projects[0]] = 25 + donors[7][projects[1]] = 5 + donors[7][projects[11]] = 25 + donors[7][projects[12]] = 15 + donors[7][projects[13]] = 25 + donors[7][projects[14]] = 5 + donors[8][projects[0]] = 15 + donors[8][projects[1]] = 15 + donors[8][projects[11]] = 35 + donors[8][projects[12]] = 15 + donors[8][projects[13]] = 5 + donors[8][projects[14]] = 15 + + + with self.subTest(combination=combination): + selected_projects = cstv(instance, donors, combination) + if combination == CSTV_Combination.EWT: + self.assertSetEqual(set(selected_projects), { + projects[0], + projects[6], + projects[7], + projects[9], + projects[10], + projects[11], + projects[4], + projects[2], + }) + elif combination == CSTV_Combination.EWTC: + self.assertSetEqual(set(selected_projects), { + projects[0], + projects[7], + projects[9], + projects[6], + projects[4], + projects[1], + projects[11], + }) + elif combination == CSTV_Combination.EWTS: + self.assertSetEqual(set(selected_projects), { + projects[0], + projects[7], + projects[6], + projects[1], + projects[2], + projects[11], + }) + elif combination == CSTV_Combination.MT: + self.assertSetEqual(set(selected_projects), { + projects[0], + projects[6], + projects[9], + projects[7], + projects[10], + projects[14], + projects[4], + projects[5], + projects[13] + }) + elif combination == CSTV_Combination.MTC: + self.assertSetEqual(set(selected_projects), { + projects[0], + projects[7], + projects[6], + projects[9], + projects[4], + projects[11], + projects[1], + }) + elif combination == CSTV_Combination.MTS: + self.assertSetEqual(set(selected_projects), { + projects[0], + projects[1], + projects[7], + projects[2], + projects[13], + projects[10], + projects[6], + }) + + def test_cstv_laminal2(self): + for combination in CSTV_Combination: + projects = [ + Project('L1', 200), + Project('L2', 200), + Project('ABC', 170), + Project('AB1', 150), + Project('AB2', 130), + Project('AB3', 110), + Project('A1', 80), + Project('A2', 60), + Project('B', 70), + Project('C1', 90), + Project('C2', 80), + Project('C3', 60), + Project('D1', 110), + Project('D2', 90), + Project('D3', 90), + ] + instance = Instance(projects) + donors = CumulativeProfile( + [ + CumulativeBallot({projects[i]: 0 for i in range(len(projects))}) + for _ in range(9) + ] + ) + donors[0][projects[0]] = 20 + donors[0][projects[1]] = 10 + donors[0][projects[2]] = 15 + donors[0][projects[3]] = 15 + donors[0][projects[4]] = 15 + donors[0][projects[5]] = 10 + donors[0][projects[6]] = 5 + donors[0][projects[7]] = 10 + donors[1][projects[0]] = 40 + donors[1][projects[1]] = 10 + donors[1][projects[2]] = 20 + donors[1][projects[3]] = 5 + donors[1][projects[4]] = 5 + donors[1][projects[5]] = 5 + donors[1][projects[6]] = 10 + donors[1][projects[7]] = 5 + donors[2][projects[0]] = 25 + donors[2][projects[1]] = 5 + donors[2][projects[2]] = 15 + donors[2][projects[3]] = 10 + donors[2][projects[4]] = 20 + donors[2][projects[5]] = 5 + donors[2][projects[8]] = 20 + donors[3][projects[0]] = 5 + donors[3][projects[1]] = 25 + donors[3][projects[2]] = 15 + donors[3][projects[3]] = 15 + donors[3][projects[4]] = 5 + donors[3][projects[5]] = 10 + donors[3][projects[8]] = 25 + + donors[4][projects[0]] = 35 + donors[4][projects[1]] = 5 + donors[4][projects[2]] = 15 + donors[4][projects[9]] = 15 + donors[4][projects[10]] = 20 + donors[4][projects[11]] = 10 + donors[5][projects[0]] = 5 + donors[5][projects[1]] = 15 + donors[5][projects[2]] = 25 + donors[5][projects[9]] = 25 + donors[5][projects[10]] = 15 + donors[5][projects[11]] = 15 + + donors[6][projects[0]] = 15 + donors[6][projects[1]] = 15 + donors[6][projects[12]] = 35 + donors[6][projects[13]] = 25 + donors[6][projects[14]] = 10 + donors[7][projects[0]] = 25 + donors[7][projects[1]] = 5 + donors[7][projects[12]] = 25 + donors[7][projects[13]] = 20 + donors[7][projects[14]] = 25 + donors[8][projects[0]] = 15 + donors[8][projects[1]] = 15 + donors[8][projects[12]] = 50 + donors[8][projects[13]] = 15 + donors[8][projects[14]] = 5 + + + with self.subTest(combination=combination): + selected_projects = cstv(instance, donors, combination) + if combination == CSTV_Combination.EWT: + self.assertSetEqual(set(selected_projects), { + projects[12], + projects[0], + projects[8], + projects[2], + projects[7], + projects[13], + projects[11], + projects[10], + }) + elif combination == CSTV_Combination.EWTC: + self.assertSetEqual(set(selected_projects), { + projects[12], + projects[0], + projects[8], + projects[2], + projects[1], + projects[13], + projects[11], + }) + elif combination == CSTV_Combination.EWTS: + self.assertSetEqual(set(selected_projects), { + projects[12], + projects[0], + projects[2], + projects[1], + projects[13], + projects[4], + }) + elif combination == CSTV_Combination.MT: + self.assertSetEqual(set(selected_projects), { + projects[12], + projects[0], + projects[8], + projects[13], + projects[11], + projects[7], + projects[10], + projects[5], + projects[14], + }) + elif combination == CSTV_Combination.MTC: + self.assertSetEqual(set(selected_projects), { + projects[12], + projects[0], + projects[13], + projects[8], + projects[2], + projects[1], + projects[11], + }) + elif combination == CSTV_Combination.MTS: + self.assertSetEqual(set(selected_projects), { + projects[12], + projects[0], + projects[1], + projects[2], + projects[13], + projects[4], + }) + + def test_cstv_EWT_v_MT(self): + for combination in CSTV_Combination: + projects = [ + Project('A', 20), + Project('B', 26), + Project('C', 30), + Project('D', 30), + ] + instance = Instance(projects) + donors = CumulativeProfile( + [ + CumulativeBallot({projects[0]: 15, projects[1]: 7, projects[2]: 0, projects[3]: 0}), + CumulativeBallot({projects[0]: 1, projects[1]: 7, projects[2]: 10, projects[3]: 4}), + ] + ) + with self.subTest(combination=combination): + selected_projects = cstv(instance, donors, combination) + if combination == CSTV_Combination.EWT or combination == CSTV_Combination.EWTC or combination == CSTV_Combination.EWTS: + self.assertSetEqual(set(selected_projects), { + projects[1], + }) + elif combination == CSTV_Combination.MT or combination == CSTV_Combination.MTC or combination == CSTV_Combination.MTS: + self.assertSetEqual(set(selected_projects), { + projects[0], + }) + + def test_cstv_Greedy_difference(self): + for combination in CSTV_Combination: + projects = [ + Project('A', 2200), + Project('B', 1720), + Project('C', 2800), + Project('D', 400), + Project('E', 400), + Project('F', 400), + ] + instance = Instance(projects) + donors = CumulativeProfile( + [ + CumulativeBallot({projects[0]: 2000, projects[1]: 10, projects[2]: 500, projects[3]: 5, projects[4]: 1, projects[5]: 1}), + CumulativeBallot({projects[0]: 10, projects[1]: 2000, projects[2]: 500, projects[3]: 1, projects[4]: 5, projects[5]: 1}), + CumulativeBallot({projects[0]: 500, projects[1]: 10, projects[2]: 2000, projects[3]: 1, projects[4]: 1, projects[5]: 5}), + ] + ) + with self.subTest(combination=combination): + selected_projects = cstv(instance, donors, combination) + if combination == CSTV_Combination.EWT or combination == CSTV_Combination.MT: + self.assertSetEqual(set(selected_projects), { + projects[0], + projects[2], + projects[1], + projects[4], + projects[5], + }) + elif combination == CSTV_Combination.EWTC or combination == CSTV_Combination.MTC: + self.assertSetEqual(set(selected_projects), { + projects[1], + projects[2], + projects[0], + projects[3], + projects[5], + }) + elif combination == CSTV_Combination.EWTS or combination == CSTV_Combination.MTS: + self.assertSetEqual(set(selected_projects), { + projects[2], + projects[0], + projects[1], + projects[4], + projects[3], + }) + + def test_cstv_small(self): + for combination in CSTV_Combination: + projects = [ + Project('A', 20), + Project('B', 35), + Project('C', 40), + Project('D', 40), + Project('E', 20), + ] + instance = Instance(projects) + donors = CumulativeProfile( + [ + CumulativeBallot({projects[0]: 10, projects[1]: 7, projects[2]: 3, projects[3]: 0, projects[4]: 0}), + CumulativeBallot({projects[0]: 10, projects[1]: 8, projects[2]: 1, projects[3]: 1, projects[4]: 0}), + CumulativeBallot({projects[0]: 10, projects[1]: 0, projects[2]: 5, projects[3]: 3, projects[4]: 2}), + CumulativeBallot({projects[0]: 10, projects[1]: 0, projects[2]: 5, projects[3]: 5, projects[4]: 0}), + ] + ) + with self.subTest(combination=combination): + selected_projects = cstv(instance, donors, combination) + self.assertSetEqual(set(selected_projects), { + projects[0], + projects[2], + projects[4], + }) + + pabutools.fractions.FRACTION = "gmpy2"