From cba08a9b69f5fa7ee75bd7c001592d21cb6a46bd Mon Sep 17 00:00:00 2001 From: Nevo Biton <155353129+NevoBiton20@users.noreply.github.com> Date: Tue, 12 May 2026 16:19:11 +0300 Subject: [PATCH 1/8] Add files via upload --- pabutools/rules/ordered_relax.py | 205 +++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 pabutools/rules/ordered_relax.py diff --git a/pabutools/rules/ordered_relax.py b/pabutools/rules/ordered_relax.py new file mode 100644 index 00000000..0363a3b3 --- /dev/null +++ b/pabutools/rules/ordered_relax.py @@ -0,0 +1,205 @@ +""" +The Ordered-Relax rule. +""" + +from __future__ import annotations + +import logging +from collections.abc import Collection +from math import isclose + +from pulp import ( + HiGHS, + LpMaximize, + LpProblem, + LpStatusOptimal, + LpVariable, + lpSum, + value, +) + +from pabutools.election import ( + AbstractApprovalProfile, + ApprovalMultiProfile, + Instance, + Project, + total_cost, +) +from pabutools.rules.budgetallocation import BudgetAllocation +from pabutools.tiebreaking import TieBreakingRule, lexico_tie_breaking + +logger = logging.getLogger(__name__) + + +def ordered_relax( + instance: Instance, + profile: AbstractApprovalProfile, + initial_budget_allocation: Collection[Project] | None = None, + tie_breaking: TieBreakingRule | None = None, + resoluteness: bool = True, +) -> BudgetAllocation: + """ + The Ordered-Relax rule for Maxmin Participatory Budgeting. + + Ordered-Relax is an LP-rounding algorithm for the Maxmin Participatory + Budgeting (MPB) objective. It first solves the LP relaxation of the MPB + integer linear program. Each project p is then assigned the score + ``p.cost * x[p]``, where ``x[p]`` is the value of p in the optimal relaxed + solution. Projects are considered in decreasing order of this score and are + added to the budget allocation until the next project in the order does not + fit within the remaining budget. + + Contributed by Nevo Biton. + + Parameters + ---------- + instance : :py:class:`~pabutools.election.instance.Instance` + The instance. + profile : :py:class:`~pabutools.election.profile.approvalprofile.AbstractApprovalProfile` + The approval profile. + initial_budget_allocation : Iterable[:py:class:`~pabutools.election.instance.Project`] + An initial budget allocation, typically empty. + tie_breaking : :py:class:`~pabutools.tiebreaking.TieBreakingRule`, optional + The tie-breaking rule used to order projects with the same Ordered-Relax score. + Defaults to the lexicographic tie-breaking. + resoluteness : bool, optional + Set to `False` to obtain an irresolute outcome, where all tied budget allocations are returned. + Defaults to True. + + Returns + ------- + :py:class:`~pabutools.rules.budgetallocation.BudgetAllocation` + The selected projects. + + Examples + -------- + >>> from pabutools.election import Instance, Project, ApprovalBallot, ApprovalProfile + >>> def make_election(costs, budget, approvals): + ... projects = {name: Project(name, cost) for name, cost in costs.items()} + ... instance = Instance(projects.values(), budget_limit=budget) + ... profile = ApprovalProfile([ + ... ApprovalBallot({projects[name] for name in ballot}) + ... for ballot in approvals + ... ]) + ... return instance, profile + >>> def selected_names(allocation): + ... return {project.name for project in allocation} + + Example 1. + + >>> instance, profile = make_election( + ... {"p1": 5}, + ... 5, + ... [ + ... {"p1"}, + ... ], + ... ) + >>> selected_names(ordered_relax(instance, profile)) == {"p1"} + True + + Example 2. + + >>> instance, profile = make_election( + ... {"p1": 4, "p2": 3}, + ... 4, + ... [ + ... {"p1"}, + ... {"p2"}, + ... ], + ... ) + >>> selected_names(ordered_relax(instance, profile)) == {"p1"} + True + + Example 3. + + >>> instance, profile = make_election( + ... {"p1": 3, "p2": 3, "p3": 2}, + ... 5, + ... [ + ... {"p1", "p2"}, + ... {"p2"}, + ... {"p3"}, + ... ], + ... ) + >>> selected_names(ordered_relax(instance, profile)) == {"p2", "p3"} + True + + Example 4. + + >>> instance, profile = make_election( + ... { + ... "p0": 2, + ... "p1": 3, + ... "p2": 3, + ... "p3": 3, + ... "p4": 3, + ... }, + ... 8, + ... [ + ... {"p0", "p1"}, + ... {"p0", "p2"}, + ... {"p0", "p3"}, + ... {"p0", "p4"}, + ... ], + ... ) + >>> selected_names(ordered_relax(instance, profile)) == {"p0", "p1", "p2"} + True + + Example 5. + + >>> instance, profile = make_election( + ... { + ... "p0": 23, + ... "p1": 68, + ... "p2": 198, + ... "p3": 189, + ... "p4": 146, + ... "p5": 38, + ... }, + ... 341, + ... [ + ... {"p4"}, + ... {"p1", "p2"}, + ... {"p1", "p3", "p5"}, + ... ], + ... ) + >>> selected_names(ordered_relax(instance, profile)) == {"p4"} + True + + Example 6. + + >>> instance, profile = make_election( + ... { + ... "p0": 18, + ... "p1": 45, + ... "p2": 43, + ... "p3": 32, + ... "p4": 28, + ... "p5": 32, + ... "p6": 5, + ... "p7": 37, + ... "p8": 43, + ... "p9": 17, + ... }, + ... 124, + ... [ + ... {"p0", "p3", "p7"}, + ... {"p1", "p4", "p6", "p7"}, + ... {"p0", "p2", "p4", "p5"}, + ... {"p1", "p6", "p9"}, + ... {"p1", "p2", "p6", "p7", "p8"}, + ... {"p1", "p3", "p4", "p6", "p8"}, + ... ], + ... ) + >>> selected_names(ordered_relax(instance, profile)) == {"p1", "p2"} + True + + Notes + ----- + Ordered-Relax is not guaranteed to return an optimal MPB outcome on + every instance. It is an approximation algorithm based on LP rounding. + + The utility of a voter from a budget allocation is the total cost of the + selected projects approved by that voter. + """ + pass From 5456761b758f7aa7253009f173cdf20914ecefb0 Mon Sep 17 00:00:00 2001 From: Nevo Biton <155353129+NevoBiton20@users.noreply.github.com> Date: Tue, 12 May 2026 16:20:38 +0300 Subject: [PATCH 2/8] Update __init__.py --- pabutools/rules/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pabutools/rules/__init__.py b/pabutools/rules/__init__.py index 3884e23f..78efc128 100644 --- a/pabutools/rules/__init__.py +++ b/pabutools/rules/__init__.py @@ -40,6 +40,7 @@ ) from pabutools.rules.cstv import cstv, CSTV_Combination from pabutools.rules.maximin_support import maximin_support +from pabutools.rules.ordered_relax import ordered_relax __all__ = [ "completion_by_rule_combination", From 68af6ea9fa4a8275a2124c3e45b96fac43b16d9a Mon Sep 17 00:00:00 2001 From: Nevo Biton <155353129+NevoBiton20@users.noreply.github.com> Date: Tue, 12 May 2026 16:22:39 +0300 Subject: [PATCH 3/8] Add files via upload --- tests/rules/test_ordered_relax.py | 510 ++++++++++++++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 tests/rules/test_ordered_relax.py diff --git a/tests/rules/test_ordered_relax.py b/tests/rules/test_ordered_relax.py new file mode 100644 index 00000000..85dd56bb --- /dev/null +++ b/tests/rules/test_ordered_relax.py @@ -0,0 +1,510 @@ +from itertools import combinations +import random + +import pytest +from pulp import ( + HiGHS, + LpMaximize, + LpProblem, + LpStatusOptimal, + LpVariable, + lpSum, + value, +) + +from pabutools.election import ( + ApprovalBallot, + ApprovalMultiProfile, + ApprovalProfile, + Instance, + Project, + total_cost, +) +from pabutools.rules import ordered_relax +from pabutools.rules.budgetallocation import BudgetAllocation + + +EPS = 1e-7 + + +def make_election(costs_by_name, budget, approvals_by_name): + projects_by_name = { + name: Project(name, cost) + for name, cost in costs_by_name.items() + } + + instance = Instance(projects_by_name.values(), budget_limit=budget) + + profile = ApprovalProfile( + [ + ApprovalBallot({projects_by_name[name] for name in ballot}) + for ballot in approvals_by_name + ] + ) + + return instance, profile, projects_by_name + + +def selected_names(outcome): + return {p.name for p in outcome} + + +def voter_utility(outcome, ballot): + return sum(p.cost for p in outcome if p in ballot) + + +def mpb_value(outcome, profile): + if len(profile) == 0: + return 0.0 + return min(voter_utility(outcome, ballot) for ballot in profile) + + +def assert_valid_budget_allocation(outcome, instance): + assert isinstance(outcome, BudgetAllocation) + assert set(outcome) <= set(instance) + assert total_cost(outcome) <= instance.budget_limit + EPS + + +def exact_mpb_value_by_ilp(instance, profile): + """ + Compute the exact MPB optimum by solving the binary ILP. + + This is used only inside tests to check the additive guarantee from + Lemma 1. + """ + if len(profile) == 0: + return 0.0 + + projects = list(instance) + + prob = LpProblem("ExactMPB", LpMaximize) + + x = { + p: LpVariable(f"x_{idx}", cat="Binary") + for idx, p in enumerate(projects) + } + + q = LpVariable("q", lowBound=0) + + for ballot in profile: + prob += q <= lpSum(p.cost * x[p] for p in projects if p in ballot) + + prob += lpSum(p.cost * x[p] for p in projects) <= instance.budget_limit + prob += q + + status = prob.solve(HiGHS(msg=False)) + + assert status == LpStatusOptimal + + return float(value(q) or 0.0) + + +def assert_ordered_relax_additive_guarantee(outcome, instance, profile): + """ + Check Lemma 1: + + ALG >= OPT - eta * (b - OPT) + + where: + ALG = min_i u_i(S) + OPT = exact MPB optimum + eta = |A_j \\ S| / |S \\ A_j| + j = argmin_i u_i(S) + + Degenerate cases in which the denominator is 0 are skipped, because the + formula is not numerically defined there. + """ + if len(profile) == 0: + return + + selected = set(outcome) + alg = mpb_value(outcome, profile) + opt = exact_mpb_value_by_ilp(instance, profile) + + utilities = [voter_utility(outcome, ballot) for ballot in profile] + worst_utility = min(utilities) + + # Try all worst-off voters and use one for which eta is defined. + worst_indices = [ + idx + for idx, utility in enumerate(utilities) + if abs(utility - worst_utility) <= EPS + ] + + eta = None + + for idx in worst_indices: + ballot = set(profile[idx]) + denominator = len(selected - ballot) + + if denominator > 0: + eta = len(ballot - selected) / denominator + break + + if eta is None: + pytest.skip("Lemma 1 eta is undefined for all worst-off voters.") + + lower_bound = opt - eta * (instance.budget_limit - opt) + + assert alg + EPS >= lower_bound + + +# --------------------------------------------------------- +# Manual examples +# --------------------------------------------------------- + + +def test_ordered_relax_example_1_size_1(): + instance, profile, _ = make_election( + costs_by_name={"p1": 5}, + budget=5, + approvals_by_name=[ + {"p1"}, + ], + ) + + outcome = ordered_relax(instance, profile) + + assert selected_names(outcome) == {"p1"} + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + +def test_ordered_relax_example_2_size_2(): + instance, profile, _ = make_election( + costs_by_name={"p1": 4, "p2": 3}, + budget=4, + approvals_by_name=[ + {"p1"}, + {"p2"}, + ], + ) + + outcome = ordered_relax(instance, profile) + + assert selected_names(outcome) == {"p1"} + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + +def test_ordered_relax_example_3_size_3(): + instance, profile, _ = make_election( + costs_by_name={"p1": 3, "p2": 3, "p3": 2}, + budget=5, + approvals_by_name=[ + {"p1", "p2"}, + {"p2"}, + {"p3"}, + ], + ) + + outcome = ordered_relax(instance, profile) + + assert selected_names(outcome) == {"p2", "p3"} + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + +def test_ordered_relax_example_4_optimal_case(): + instance, profile, _ = make_election( + costs_by_name={ + "p0": 2, + "p1": 3, + "p2": 3, + "p3": 3, + "p4": 3, + }, + budget=8, + approvals_by_name=[ + {"p0", "p1"}, + {"p0", "p2"}, + {"p0", "p3"}, + {"p0", "p4"}, + ], + ) + + outcome = ordered_relax(instance, profile) + + assert selected_names(outcome) == {"p0", "p1", "p2"} + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + +def test_ordered_relax_example_5_bad_case(): + instance, profile, _ = make_election( + costs_by_name={ + "p0": 23, + "p1": 68, + "p2": 198, + "p3": 189, + "p4": 146, + "p5": 38, + }, + budget=341, + approvals_by_name=[ + {"p4"}, + {"p1", "p2"}, + {"p1", "p3", "p5"}, + ], + ) + + outcome = ordered_relax(instance, profile) + + assert selected_names(outcome) == {"p4"} + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + +def test_ordered_relax_example_6_large_manual_case(): + instance, profile, _ = make_election( + costs_by_name={ + "p0": 18, + "p1": 45, + "p2": 43, + "p3": 32, + "p4": 28, + "p5": 32, + "p6": 5, + "p7": 37, + "p8": 43, + "p9": 17, + }, + budget=124, + approvals_by_name=[ + {"p0", "p3", "p7"}, + {"p1", "p4", "p6", "p7"}, + {"p0", "p2", "p4", "p5"}, + {"p1", "p6", "p9"}, + {"p1", "p2", "p6", "p7", "p8"}, + {"p1", "p3", "p4", "p6", "p8"}, + ], + ) + + outcome = ordered_relax(instance, profile) + + assert selected_names(outcome) == {"p1", "p2"} + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + +# --------------------------------------------------------- +# Edge cases +# --------------------------------------------------------- + + +def test_ordered_relax_empty_instance_and_empty_profile(): + instance = Instance([], budget_limit=10) + profile = ApprovalProfile([]) + + outcome = ordered_relax(instance, profile) + + assert selected_names(outcome) == set() + assert_valid_budget_allocation(outcome, instance) + + +def test_ordered_relax_no_projects_some_empty_ballots(): + instance = Instance([], budget_limit=10) + profile = ApprovalProfile( + [ + ApprovalBallot(), + ApprovalBallot(), + ] + ) + + outcome = ordered_relax(instance, profile) + + assert selected_names(outcome) == set() + assert_valid_budget_allocation(outcome, instance) + + +def test_ordered_relax_zero_budget_returns_empty_allocation(): + instance, profile, _ = make_election( + costs_by_name={"p1": 5, "p2": 7}, + budget=0, + approvals_by_name=[ + {"p1"}, + {"p2"}, + ], + ) + + outcome = ordered_relax(instance, profile) + + assert selected_names(outcome) == set() + assert_valid_budget_allocation(outcome, instance) + + +def test_ordered_relax_project_too_expensive_is_not_selected(): + instance, profile, _ = make_election( + costs_by_name={"p1": 100}, + budget=10, + approvals_by_name=[ + {"p1"}, + ], + ) + + outcome = ordered_relax(instance, profile) + + assert selected_names(outcome) == set() + assert_valid_budget_allocation(outcome, instance) + + +def test_ordered_relax_initial_budget_allocation_is_preserved(): + instance, profile, projects = make_election( + costs_by_name={"p1": 2, "p2": 3, "p3": 4}, + budget=5, + approvals_by_name=[ + {"p1", "p2"}, + {"p1", "p3"}, + ], + ) + + outcome = ordered_relax( + instance, + profile, + initial_budget_allocation=[projects["p1"]], + ) + + assert projects["p1"] in outcome + assert_valid_budget_allocation(outcome, instance) + + +def test_ordered_relax_initial_budget_allocation_over_budget_raises_value_error(): + instance, profile, projects = make_election( + costs_by_name={"p1": 10}, + budget=5, + approvals_by_name=[ + {"p1"}, + ], + ) + + with pytest.raises(ValueError): + ordered_relax( + instance, + profile, + initial_budget_allocation=[projects["p1"]], + ) + + +def test_ordered_relax_irresolute_outcome_not_supported(): + instance, profile, _ = make_election( + costs_by_name={"p1": 1}, + budget=1, + approvals_by_name=[ + {"p1"}, + ], + ) + + with pytest.raises(NotImplementedError): + ordered_relax(instance, profile, resoluteness=False) + + +def test_ordered_relax_multiprofile_not_supported(): + instance, profile, _ = make_election( + costs_by_name={"p1": 1}, + budget=1, + approvals_by_name=[ + {"p1"}, + ], + ) + + multiprofile = ApprovalMultiProfile(profile=profile) + + with pytest.raises(NotImplementedError): + ordered_relax(instance, multiprofile) + + +# --------------------------------------------------------- +# Random tests +# --------------------------------------------------------- + + +def generate_random_election( + seed, + num_voters, + num_projects, + min_cost=1, + max_cost=50, + approval_probability=0.3, +): + rng = random.Random(seed) + + costs_by_name = { + f"p{i}": rng.randint(min_cost, max_cost) + for i in range(num_projects) + } + + total = sum(costs_by_name.values()) + budget = rng.randint(max(1, total // 4), max(1, total // 2)) + + project_names = list(costs_by_name) + + approvals_by_name = [] + for _ in range(num_voters): + ballot = { + p + for p in project_names + if rng.random() < approval_probability + } + + if not ballot: + ballot.add(rng.choice(project_names)) + + approvals_by_name.append(ballot) + + return make_election(costs_by_name, budget, approvals_by_name) + + +@pytest.mark.parametrize("seed", range(20)) +def test_ordered_relax_random_small_instances(seed): + instance, profile, _ = generate_random_election( + seed=seed, + num_voters=5, + num_projects=12, + min_cost=1, + max_cost=30, + approval_probability=0.4, + ) + + outcome = ordered_relax(instance, profile) + + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + +@pytest.mark.parametrize("seed", range(20, 30)) +def test_ordered_relax_random_medium_instances(seed): + instance, profile, _ = generate_random_election( + seed=seed, + num_voters=8, + num_projects=20, + min_cost=1, + max_cost=50, + approval_probability=0.35, + ) + + outcome = ordered_relax(instance, profile) + + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + +@pytest.mark.slow +@pytest.mark.parametrize("seed", range(100, 103)) +def test_ordered_relax_random_big_instances_with_ilp_guarantee(seed): + """ + These are larger tests. They still check the additive guarantee, but they + solve an exact ILP to compute OPT, so they are marked as slow. + """ + instance, profile, _ = generate_random_election( + seed=seed, + num_voters=15, + num_projects=35, + min_cost=1, + max_cost=80, + approval_probability=0.25, + ) + + outcome = ordered_relax(instance, profile) + + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) From 8e270796c1f2f94199193eafe16dda231483abfc Mon Sep 17 00:00:00 2001 From: Nevo Biton <155353129+NevoBiton20@users.noreply.github.com> Date: Tue, 12 May 2026 17:11:08 +0300 Subject: [PATCH 4/8] Update ordered_relax.py --- pabutools/rules/ordered_relax.py | 398 ++++++++++++++++++++++++++++++- 1 file changed, 394 insertions(+), 4 deletions(-) diff --git a/pabutools/rules/ordered_relax.py b/pabutools/rules/ordered_relax.py index 0363a3b3..81079503 100644 --- a/pabutools/rules/ordered_relax.py +++ b/pabutools/rules/ordered_relax.py @@ -1,10 +1,16 @@ -""" -The Ordered-Relax rule. -""" +''' +An implementation of the algorithm described in: + +"Maxmin Participatory Budgeting", by Gogulapati Sreedurga , Mayank Ratan Bhardwaj and Y. Narahari, 2022, https://arxiv.org/pdf/2204.13923 + +Programmer: Nevo Biton +Date: 2026-04-29 +''' from __future__ import annotations import logging + from collections.abc import Collection from math import isclose @@ -201,5 +207,389 @@ def ordered_relax( The utility of a voter from a budget allocation is the total cost of the selected projects approved by that voter. + """ - pass + if isinstance(profile, ApprovalMultiProfile): + raise NotImplementedError( + "The Ordered-Relax rule currently does not support multiprofiles." + ) + + if not resoluteness: + raise NotImplementedError( + "The Ordered-Relax rule currently does not support irresolute outcomes." + ) + + logger.info("Starting the Ordered-Relax rule") + + if tie_breaking is None: + tie_breaking = lexico_tie_breaking + logger.debug("No tie-breaking rule was provided. Using lexicographic tie-breaking.") + + if initial_budget_allocation is None: + budget_allocation = BudgetAllocation() + logger.debug("No initial budget allocation was provided.") + else: + budget_allocation = BudgetAllocation(initial_budget_allocation) + logger.debug( + "Initial budget allocation: %s", + [project.name for project in budget_allocation], + ) + + initial_cost = total_cost(budget_allocation) + remaining_budget = instance.budget_limit - initial_cost + + logger.debug("Number of projects in the instance: %d", len(list(instance))) + logger.debug("Number of voters in the profile: %d", profile.num_ballots()) + logger.debug("Budget limit: %s", instance.budget_limit) + logger.debug("Initial allocation cost: %s", initial_cost) + logger.debug("Remaining budget before Ordered-Relax: %s", remaining_budget) + + if remaining_budget < 0: + logger.error( + "Initial budget allocation exceeds the budget limit: cost=%s, budget=%s", + initial_cost, + instance.budget_limit, + ) + raise ValueError("The initial budget allocation exceeds the budget limit.") + + available_projects = [ + project + for project in instance + if project not in budget_allocation + and 0 <= project.cost <= instance.budget_limit + ] + + logger.debug( + "Available projects before solving the LP: %s", + [(project.name, project.cost) for project in available_projects], + ) + + if len(available_projects) == 0: + logger.info("No available projects. Returning the initial budget allocation.") + return BudgetAllocation(budget_allocation) + + if profile.num_ballots() == 0: + logger.info("The profile is empty. Returning the initial budget allocation.") + return BudgetAllocation(budget_allocation) + + logger.info("Solving the MPB LP relaxation.") + + lp_values = _solve_mpb_lp_relaxation( + available_projects, + profile, + remaining_budget, + initial_budget_allocation=budget_allocation, + ) + + logger.debug( + "LP relaxation values: %s", + {project.name: lp_values[project] for project in available_projects}, + ) + + logger.debug( + "Ordered-Relax scores: %s", + { + project.name: _relaxed_score(project, lp_values) + for project in available_projects + }, + ) + + logger.info("Ordering projects by their relaxed scores.") + + ordered_projects = _order_projects_by_relaxed_score( + instance, + profile, + available_projects, + lp_values, + tie_breaking, + ) + + logger.debug( + "Project order after tie-breaking: %s", + [ + (project.name, project.cost, _relaxed_score(project, lp_values)) + for project in ordered_projects + ], + ) + + logger.info("Starting the ordered-fill phase.") + + for project in ordered_projects: + logger.debug( + "Considering project %s with cost %s. Remaining budget: %s", + project.name, + project.cost, + remaining_budget, + ) + + if project.cost <= remaining_budget: + budget_allocation.append(project) + remaining_budget -= project.cost + + logger.debug( + "Selected project %s. Current allocation: %s. Remaining budget: %s", + project.name, + [p.name for p in budget_allocation], + remaining_budget, + ) + else: + logger.info( + "Stopping ordered-fill: project %s with cost %s does not fit " + "the remaining budget %s.", + project.name, + project.cost, + remaining_budget, + ) + break + + logger.info( + "Ordered-Relax finished. Selected projects: %s. Total cost: %s", + [project.name for project in budget_allocation], + total_cost(budget_allocation), + ) + + return BudgetAllocation(budget_allocation) + + +def _solve_mpb_lp_relaxation( + projects: Collection[Project], + profile: AbstractApprovalProfile, + budget: float, + initial_budget_allocation: Collection[Project] | None = None, +) -> dict[Project, float]: + """ + Solve the LP relaxation of the MPB integer linear program. + + Parameters + ---------- + projects : Collection[:py:class:`~pabutools.election.instance.Project`] + The projects considered by the LP. + profile : :py:class:`~pabutools.election.profile.approvalprofile.AbstractApprovalProfile` + The approval profile. + budget : float + The available budget. + initial_budget_allocation : Collection[:py:class:`~pabutools.election.instance.Project`], optional + Projects that have already been selected before running the LP. + + Returns + ------- + dict[:py:class:`~pabutools.election.instance.Project`, float] + A mapping from each project to its value in the optimal relaxed solution. + + Examples + -------- + >>> from pabutools.election import Project, ApprovalBallot, ApprovalProfile + >>> p1 = Project("p1", 4) + >>> p2 = Project("p2", 3) + >>> profile = ApprovalProfile([ + ... ApprovalBallot({p1}), + ... ApprovalBallot({p2}), + ... ]) + >>> values = _solve_mpb_lp_relaxation([p1, p2], profile, 4) + >>> round(values[p1], 6) + 0.5 + >>> round(values[p2], 6) + 0.666667 + + """ + projects = list(projects) + + if initial_budget_allocation is None: + initial_budget_allocation = BudgetAllocation() + else: + initial_budget_allocation = BudgetAllocation(initial_budget_allocation) + + logger.debug( + "Building LP relaxation with %d projects, %d voters, and budget %s.", + len(projects), + profile.num_ballots(), + budget, + ) + + problem = LpProblem("OrderedRelaxLP", LpMaximize) + + x = { + project: LpVariable(f"x_{idx}", lowBound=0, upBound=1) + for idx, project in enumerate(projects) + } + + q = LpVariable("q", lowBound=0) + + for voter_index, ballot in enumerate(profile): + initial_utility = total_cost( + project for project in initial_budget_allocation if project in ballot + ) + + problem += q <= initial_utility + lpSum( + project.cost * x[project] + for project in projects + if project in ballot + ) + + logger.debug( + "Added utility constraint for voter %d with initial utility %s.", + voter_index, + initial_utility, + ) + + problem += lpSum(project.cost * x[project] for project in projects) <= budget + problem += q + + logger.debug("Solving Ordered-Relax LP relaxation using HiGHS.") + + status = problem.solve(HiGHS(msg=False)) + + if status != LpStatusOptimal: + logger.error("The LP relaxation could not be solved optimally.") + raise RuntimeError("Could not solve the LP relaxation of Ordered-Relax.") + + lp_values = { + project: float(value(x[project]) or 0.0) + for project in projects + } + + logger.debug( + "LP relaxation solved successfully. q=%s, x=%s", + value(q), + {project.name: lp_values[project] for project in projects}, + ) + + return lp_values + + +def _relaxed_score(project: Project, lp_values: dict[Project, float]) -> float: + """ + Return the Ordered-Relax score of a project. + + The score of a project p is defined as ``p.cost * x[p]``, where ``x[p]`` + is the value of p in the optimal LP-relaxation solution. + + Parameters + ---------- + project : :py:class:`~pabutools.election.instance.Project` + The project. + lp_values : dict[:py:class:`~pabutools.election.instance.Project`, float] + The LP-relaxation values. + + Returns + ------- + float + The relaxed score of the project. + + Examples + -------- + >>> from pabutools.election import Project + >>> p = Project("p1", 5) + >>> _relaxed_score(p, {p: 0.6}) + 3.0 + + """ + return project.cost * lp_values[project] + + +def _order_projects_by_relaxed_score( + instance: Instance, + profile: AbstractApprovalProfile, + projects: Collection[Project], + lp_values: dict[Project, float], + tie_breaking: TieBreakingRule, + eps: float = 1e-7, +) -> list[Project]: + """ + Order projects by decreasing ``p.cost * x[p]``. + + Ties are resolved using the given tie-breaking rule. + + Parameters + ---------- + instance : :py:class:`~pabutools.election.instance.Instance` + The instance. + profile : :py:class:`~pabutools.election.profile.approvalprofile.AbstractApprovalProfile` + The approval profile. + projects : Collection[:py:class:`~pabutools.election.instance.Project`] + The projects to order. + lp_values : dict[:py:class:`~pabutools.election.instance.Project`, float] + The values of the projects in the relaxed LP solution. + tie_breaking : :py:class:`~pabutools.tiebreaking.TieBreakingRule` + The tie-breaking rule used. + eps : float, optional + Tolerance used for detecting equal scores. + + Returns + ------- + list[:py:class:`~pabutools.election.instance.Project`] + The ordered list of projects. + + Examples + -------- + >>> from pabutools.election import Instance, Project, ApprovalBallot, ApprovalProfile + >>> from pabutools.tiebreaking import lexico_tie_breaking + >>> p1 = Project("p1", 4) + >>> p2 = Project("p2", 3) + >>> p3 = Project("p3", 2) + >>> instance = Instance([p1, p2, p3], budget_limit=5) + >>> profile = ApprovalProfile([ + ... ApprovalBallot({p1, p2}), + ... ApprovalBallot({p2}), + ... ApprovalBallot({p3}), + ... ]) + >>> lp_values = { + ... p1: 0.0, + ... p2: 1.0, + ... p3: 1.0, + ... } + >>> ordered = _order_projects_by_relaxed_score( + ... instance, + ... profile, + ... [p1, p2, p3], + ... lp_values, + ... lexico_tie_breaking, + ... ) + >>> [p.name for p in ordered] + ['p2', 'p3', 'p1'] + + """ + scored_projects = sorted( + [(_relaxed_score(project, lp_values), project) for project in projects], + key=lambda item: item[0], + reverse=True, + ) + + logger.debug( + "Projects sorted into score groups before tie-breaking: %s", + [(project.name, score) for score, project in scored_projects], + ) + + ordered_projects = [] + index = 0 + + while index < len(scored_projects): + score = scored_projects[index][0] + tied_projects = [] + + while index < len(scored_projects) and isclose( + scored_projects[index][0], + score, + abs_tol=eps, + ): + tied_projects.append(scored_projects[index][1]) + index += 1 + + logger.debug( + "Tie group with score %s: %s", + score, + [project.name for project in tied_projects], + ) + + while tied_projects: + chosen = tie_breaking.untie(instance, profile, tied_projects) + ordered_projects.append(chosen) + tied_projects.remove(chosen) + + logger.debug( + "Tie-breaking selected project %s. Remaining tied projects: %s", + chosen.name, + [project.name for project in tied_projects], + ) + + return ordered_projects From 793177be29a88f46cf40596738700b26a35082ab Mon Sep 17 00:00:00 2001 From: Nevo Biton <155353129+NevoBiton20@users.noreply.github.com> Date: Tue, 12 May 2026 17:11:30 +0300 Subject: [PATCH 5/8] Update test_ordered_relax.py --- tests/rules/test_ordered_relax.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/rules/test_ordered_relax.py b/tests/rules/test_ordered_relax.py index 85dd56bb..33674b81 100644 --- a/tests/rules/test_ordered_relax.py +++ b/tests/rules/test_ordered_relax.py @@ -1,3 +1,12 @@ +''' +Tests for the algorithm described in: + +"Maxmin Participatory Budgeting", by Gogulapati Sreedurga , Mayank Ratan Bhardwaj and Y. Narahari, 2022, https://arxiv.org/pdf/2204.13923 + +Programmer: Nevo Biton +Date: 2026-04-29 +''' + from itertools import combinations import random From a07e5787f4974a230e56330088a8e141019cd3d1 Mon Sep 17 00:00:00 2001 From: Nevo Biton <155353129+NevoBiton20@users.noreply.github.com> Date: Wed, 3 Jun 2026 05:02:39 +0300 Subject: [PATCH 6/8] Delete tests/rules/test_ordered_relax.py --- tests/rules/test_ordered_relax.py | 519 ------------------------------ 1 file changed, 519 deletions(-) delete mode 100644 tests/rules/test_ordered_relax.py diff --git a/tests/rules/test_ordered_relax.py b/tests/rules/test_ordered_relax.py deleted file mode 100644 index 33674b81..00000000 --- a/tests/rules/test_ordered_relax.py +++ /dev/null @@ -1,519 +0,0 @@ -''' -Tests for the algorithm described in: - -"Maxmin Participatory Budgeting", by Gogulapati Sreedurga , Mayank Ratan Bhardwaj and Y. Narahari, 2022, https://arxiv.org/pdf/2204.13923 - -Programmer: Nevo Biton -Date: 2026-04-29 -''' - -from itertools import combinations -import random - -import pytest -from pulp import ( - HiGHS, - LpMaximize, - LpProblem, - LpStatusOptimal, - LpVariable, - lpSum, - value, -) - -from pabutools.election import ( - ApprovalBallot, - ApprovalMultiProfile, - ApprovalProfile, - Instance, - Project, - total_cost, -) -from pabutools.rules import ordered_relax -from pabutools.rules.budgetallocation import BudgetAllocation - - -EPS = 1e-7 - - -def make_election(costs_by_name, budget, approvals_by_name): - projects_by_name = { - name: Project(name, cost) - for name, cost in costs_by_name.items() - } - - instance = Instance(projects_by_name.values(), budget_limit=budget) - - profile = ApprovalProfile( - [ - ApprovalBallot({projects_by_name[name] for name in ballot}) - for ballot in approvals_by_name - ] - ) - - return instance, profile, projects_by_name - - -def selected_names(outcome): - return {p.name for p in outcome} - - -def voter_utility(outcome, ballot): - return sum(p.cost for p in outcome if p in ballot) - - -def mpb_value(outcome, profile): - if len(profile) == 0: - return 0.0 - return min(voter_utility(outcome, ballot) for ballot in profile) - - -def assert_valid_budget_allocation(outcome, instance): - assert isinstance(outcome, BudgetAllocation) - assert set(outcome) <= set(instance) - assert total_cost(outcome) <= instance.budget_limit + EPS - - -def exact_mpb_value_by_ilp(instance, profile): - """ - Compute the exact MPB optimum by solving the binary ILP. - - This is used only inside tests to check the additive guarantee from - Lemma 1. - """ - if len(profile) == 0: - return 0.0 - - projects = list(instance) - - prob = LpProblem("ExactMPB", LpMaximize) - - x = { - p: LpVariable(f"x_{idx}", cat="Binary") - for idx, p in enumerate(projects) - } - - q = LpVariable("q", lowBound=0) - - for ballot in profile: - prob += q <= lpSum(p.cost * x[p] for p in projects if p in ballot) - - prob += lpSum(p.cost * x[p] for p in projects) <= instance.budget_limit - prob += q - - status = prob.solve(HiGHS(msg=False)) - - assert status == LpStatusOptimal - - return float(value(q) or 0.0) - - -def assert_ordered_relax_additive_guarantee(outcome, instance, profile): - """ - Check Lemma 1: - - ALG >= OPT - eta * (b - OPT) - - where: - ALG = min_i u_i(S) - OPT = exact MPB optimum - eta = |A_j \\ S| / |S \\ A_j| - j = argmin_i u_i(S) - - Degenerate cases in which the denominator is 0 are skipped, because the - formula is not numerically defined there. - """ - if len(profile) == 0: - return - - selected = set(outcome) - alg = mpb_value(outcome, profile) - opt = exact_mpb_value_by_ilp(instance, profile) - - utilities = [voter_utility(outcome, ballot) for ballot in profile] - worst_utility = min(utilities) - - # Try all worst-off voters and use one for which eta is defined. - worst_indices = [ - idx - for idx, utility in enumerate(utilities) - if abs(utility - worst_utility) <= EPS - ] - - eta = None - - for idx in worst_indices: - ballot = set(profile[idx]) - denominator = len(selected - ballot) - - if denominator > 0: - eta = len(ballot - selected) / denominator - break - - if eta is None: - pytest.skip("Lemma 1 eta is undefined for all worst-off voters.") - - lower_bound = opt - eta * (instance.budget_limit - opt) - - assert alg + EPS >= lower_bound - - -# --------------------------------------------------------- -# Manual examples -# --------------------------------------------------------- - - -def test_ordered_relax_example_1_size_1(): - instance, profile, _ = make_election( - costs_by_name={"p1": 5}, - budget=5, - approvals_by_name=[ - {"p1"}, - ], - ) - - outcome = ordered_relax(instance, profile) - - assert selected_names(outcome) == {"p1"} - assert_valid_budget_allocation(outcome, instance) - assert_ordered_relax_additive_guarantee(outcome, instance, profile) - - -def test_ordered_relax_example_2_size_2(): - instance, profile, _ = make_election( - costs_by_name={"p1": 4, "p2": 3}, - budget=4, - approvals_by_name=[ - {"p1"}, - {"p2"}, - ], - ) - - outcome = ordered_relax(instance, profile) - - assert selected_names(outcome) == {"p1"} - assert_valid_budget_allocation(outcome, instance) - assert_ordered_relax_additive_guarantee(outcome, instance, profile) - - -def test_ordered_relax_example_3_size_3(): - instance, profile, _ = make_election( - costs_by_name={"p1": 3, "p2": 3, "p3": 2}, - budget=5, - approvals_by_name=[ - {"p1", "p2"}, - {"p2"}, - {"p3"}, - ], - ) - - outcome = ordered_relax(instance, profile) - - assert selected_names(outcome) == {"p2", "p3"} - assert_valid_budget_allocation(outcome, instance) - assert_ordered_relax_additive_guarantee(outcome, instance, profile) - - -def test_ordered_relax_example_4_optimal_case(): - instance, profile, _ = make_election( - costs_by_name={ - "p0": 2, - "p1": 3, - "p2": 3, - "p3": 3, - "p4": 3, - }, - budget=8, - approvals_by_name=[ - {"p0", "p1"}, - {"p0", "p2"}, - {"p0", "p3"}, - {"p0", "p4"}, - ], - ) - - outcome = ordered_relax(instance, profile) - - assert selected_names(outcome) == {"p0", "p1", "p2"} - assert_valid_budget_allocation(outcome, instance) - assert_ordered_relax_additive_guarantee(outcome, instance, profile) - - -def test_ordered_relax_example_5_bad_case(): - instance, profile, _ = make_election( - costs_by_name={ - "p0": 23, - "p1": 68, - "p2": 198, - "p3": 189, - "p4": 146, - "p5": 38, - }, - budget=341, - approvals_by_name=[ - {"p4"}, - {"p1", "p2"}, - {"p1", "p3", "p5"}, - ], - ) - - outcome = ordered_relax(instance, profile) - - assert selected_names(outcome) == {"p4"} - assert_valid_budget_allocation(outcome, instance) - assert_ordered_relax_additive_guarantee(outcome, instance, profile) - - -def test_ordered_relax_example_6_large_manual_case(): - instance, profile, _ = make_election( - costs_by_name={ - "p0": 18, - "p1": 45, - "p2": 43, - "p3": 32, - "p4": 28, - "p5": 32, - "p6": 5, - "p7": 37, - "p8": 43, - "p9": 17, - }, - budget=124, - approvals_by_name=[ - {"p0", "p3", "p7"}, - {"p1", "p4", "p6", "p7"}, - {"p0", "p2", "p4", "p5"}, - {"p1", "p6", "p9"}, - {"p1", "p2", "p6", "p7", "p8"}, - {"p1", "p3", "p4", "p6", "p8"}, - ], - ) - - outcome = ordered_relax(instance, profile) - - assert selected_names(outcome) == {"p1", "p2"} - assert_valid_budget_allocation(outcome, instance) - assert_ordered_relax_additive_guarantee(outcome, instance, profile) - - -# --------------------------------------------------------- -# Edge cases -# --------------------------------------------------------- - - -def test_ordered_relax_empty_instance_and_empty_profile(): - instance = Instance([], budget_limit=10) - profile = ApprovalProfile([]) - - outcome = ordered_relax(instance, profile) - - assert selected_names(outcome) == set() - assert_valid_budget_allocation(outcome, instance) - - -def test_ordered_relax_no_projects_some_empty_ballots(): - instance = Instance([], budget_limit=10) - profile = ApprovalProfile( - [ - ApprovalBallot(), - ApprovalBallot(), - ] - ) - - outcome = ordered_relax(instance, profile) - - assert selected_names(outcome) == set() - assert_valid_budget_allocation(outcome, instance) - - -def test_ordered_relax_zero_budget_returns_empty_allocation(): - instance, profile, _ = make_election( - costs_by_name={"p1": 5, "p2": 7}, - budget=0, - approvals_by_name=[ - {"p1"}, - {"p2"}, - ], - ) - - outcome = ordered_relax(instance, profile) - - assert selected_names(outcome) == set() - assert_valid_budget_allocation(outcome, instance) - - -def test_ordered_relax_project_too_expensive_is_not_selected(): - instance, profile, _ = make_election( - costs_by_name={"p1": 100}, - budget=10, - approvals_by_name=[ - {"p1"}, - ], - ) - - outcome = ordered_relax(instance, profile) - - assert selected_names(outcome) == set() - assert_valid_budget_allocation(outcome, instance) - - -def test_ordered_relax_initial_budget_allocation_is_preserved(): - instance, profile, projects = make_election( - costs_by_name={"p1": 2, "p2": 3, "p3": 4}, - budget=5, - approvals_by_name=[ - {"p1", "p2"}, - {"p1", "p3"}, - ], - ) - - outcome = ordered_relax( - instance, - profile, - initial_budget_allocation=[projects["p1"]], - ) - - assert projects["p1"] in outcome - assert_valid_budget_allocation(outcome, instance) - - -def test_ordered_relax_initial_budget_allocation_over_budget_raises_value_error(): - instance, profile, projects = make_election( - costs_by_name={"p1": 10}, - budget=5, - approvals_by_name=[ - {"p1"}, - ], - ) - - with pytest.raises(ValueError): - ordered_relax( - instance, - profile, - initial_budget_allocation=[projects["p1"]], - ) - - -def test_ordered_relax_irresolute_outcome_not_supported(): - instance, profile, _ = make_election( - costs_by_name={"p1": 1}, - budget=1, - approvals_by_name=[ - {"p1"}, - ], - ) - - with pytest.raises(NotImplementedError): - ordered_relax(instance, profile, resoluteness=False) - - -def test_ordered_relax_multiprofile_not_supported(): - instance, profile, _ = make_election( - costs_by_name={"p1": 1}, - budget=1, - approvals_by_name=[ - {"p1"}, - ], - ) - - multiprofile = ApprovalMultiProfile(profile=profile) - - with pytest.raises(NotImplementedError): - ordered_relax(instance, multiprofile) - - -# --------------------------------------------------------- -# Random tests -# --------------------------------------------------------- - - -def generate_random_election( - seed, - num_voters, - num_projects, - min_cost=1, - max_cost=50, - approval_probability=0.3, -): - rng = random.Random(seed) - - costs_by_name = { - f"p{i}": rng.randint(min_cost, max_cost) - for i in range(num_projects) - } - - total = sum(costs_by_name.values()) - budget = rng.randint(max(1, total // 4), max(1, total // 2)) - - project_names = list(costs_by_name) - - approvals_by_name = [] - for _ in range(num_voters): - ballot = { - p - for p in project_names - if rng.random() < approval_probability - } - - if not ballot: - ballot.add(rng.choice(project_names)) - - approvals_by_name.append(ballot) - - return make_election(costs_by_name, budget, approvals_by_name) - - -@pytest.mark.parametrize("seed", range(20)) -def test_ordered_relax_random_small_instances(seed): - instance, profile, _ = generate_random_election( - seed=seed, - num_voters=5, - num_projects=12, - min_cost=1, - max_cost=30, - approval_probability=0.4, - ) - - outcome = ordered_relax(instance, profile) - - assert_valid_budget_allocation(outcome, instance) - assert_ordered_relax_additive_guarantee(outcome, instance, profile) - - -@pytest.mark.parametrize("seed", range(20, 30)) -def test_ordered_relax_random_medium_instances(seed): - instance, profile, _ = generate_random_election( - seed=seed, - num_voters=8, - num_projects=20, - min_cost=1, - max_cost=50, - approval_probability=0.35, - ) - - outcome = ordered_relax(instance, profile) - - assert_valid_budget_allocation(outcome, instance) - assert_ordered_relax_additive_guarantee(outcome, instance, profile) - - -@pytest.mark.slow -@pytest.mark.parametrize("seed", range(100, 103)) -def test_ordered_relax_random_big_instances_with_ilp_guarantee(seed): - """ - These are larger tests. They still check the additive guarantee, but they - solve an exact ILP to compute OPT, so they are marked as slow. - """ - instance, profile, _ = generate_random_election( - seed=seed, - num_voters=15, - num_projects=35, - min_cost=1, - max_cost=80, - approval_probability=0.25, - ) - - outcome = ordered_relax(instance, profile) - - assert_valid_budget_allocation(outcome, instance) - assert_ordered_relax_additive_guarantee(outcome, instance, profile) From 3ae7f4d8a50af8a741e5b17bb979d5519e8a9726 Mon Sep 17 00:00:00 2001 From: Nevo Biton <155353129+NevoBiton20@users.noreply.github.com> Date: Wed, 3 Jun 2026 05:03:13 +0300 Subject: [PATCH 7/8] Add files via upload --- tests/rules/test_ordered_relax_unittest.py | 386 +++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 tests/rules/test_ordered_relax_unittest.py diff --git a/tests/rules/test_ordered_relax_unittest.py b/tests/rules/test_ordered_relax_unittest.py new file mode 100644 index 00000000..a9da4107 --- /dev/null +++ b/tests/rules/test_ordered_relax_unittest.py @@ -0,0 +1,386 @@ +""" +Tests for the algorithm described in: + +"Maxmin Participatory Budgeting", by Gogulapati Sreedurga , Mayank Ratan Bhardwaj and Y. Narahari, 2022, https://arxiv.org/pdf/2204.13923 + +Programmer: Nevo Biton +Date: 2026-04-29 +""" + +from itertools import combinations +import random +import unittest + +from pulp import ( + HiGHS, + LpMaximize, + LpProblem, + LpStatusOptimal, + LpVariable, + lpSum, + value, +) + +from pabutools.election import ( + ApprovalBallot, + ApprovalMultiProfile, + ApprovalProfile, + Instance, + Project, + total_cost, +) +from pabutools.rules import ordered_relax +from pabutools.rules.budgetallocation import BudgetAllocation + + +EPS = 1e-7 + + +def make_election(costs_by_name, budget, approvals_by_name): + projects_by_name = { + name: Project(name, cost) + for name, cost in costs_by_name.items() + } + instance = Instance(projects_by_name.values(), budget_limit=budget) + profile = ApprovalProfile( + [ + ApprovalBallot({projects_by_name[name] for name in ballot}) + for ballot in approvals_by_name + ] + ) + return instance, profile, projects_by_name + + +def selected_names(outcome): + return {p.name for p in outcome} + + +def voter_utility(outcome, ballot): + return sum(p.cost for p in outcome if p in ballot) + + +def mpb_value(outcome, profile): + if len(profile) == 0: + return 0.0 + return min(voter_utility(outcome, ballot) for ballot in profile) + + +def assert_valid_budget_allocation(outcome, instance): + assert isinstance(outcome, BudgetAllocation) + assert set(outcome) <= set(instance) + assert total_cost(outcome) <= instance.budget_limit + EPS + + +def exact_mpb_value_by_ilp(instance, profile): + """ + Compute the exact MPB optimum by solving the binary ILP. + + This is used only inside tests to check the additive guarantee from + Lemma 1. + """ + if len(profile) == 0: + return 0.0 + + projects = list(instance) + prob = LpProblem("ExactMPB", LpMaximize) + x = { + p: LpVariable(f"x_{idx}", cat="Binary") + for idx, p in enumerate(projects) + } + q = LpVariable("q", lowBound=0) + + for ballot in profile: + prob += q <= lpSum(p.cost * x[p] for p in projects if p in ballot) + + prob += lpSum(p.cost * x[p] for p in projects) <= instance.budget_limit + prob += q + status = prob.solve(HiGHS(msg=False)) + assert status == LpStatusOptimal + return float(value(q) or 0.0) + + +def assert_ordered_relax_additive_guarantee(outcome, instance, profile): + """ + Check Lemma 1: + + ALG >= OPT - eta * (b - OPT) + + where: + ALG = min_i u_i(S) + OPT = exact MPB optimum + eta = |A_j \\ S| / |S \\ A_j| + j = argmin_i u_i(S) + + Degenerate cases in which the denominator is 0 are skipped, because the + formula is not numerically defined there. + """ + if len(profile) == 0: + return + + selected = set(outcome) + alg = mpb_value(outcome, profile) + opt = exact_mpb_value_by_ilp(instance, profile) + utilities = [voter_utility(outcome, ballot) for ballot in profile] + worst_utility = min(utilities) + worst_indices = [ + idx + for idx, utility in enumerate(utilities) + if abs(utility - worst_utility) <= EPS + ] + eta = None + + for idx in worst_indices: + ballot = set(profile[idx]) + denominator = len(selected - ballot) + if denominator > 0: + eta = len(ballot - selected) / denominator + break + + if eta is None: + raise unittest.SkipTest("Lemma 1 eta is undefined for all worst-off voters.") + + lower_bound = opt - eta * (instance.budget_limit - opt) + assert alg + EPS >= lower_bound + + +def generate_random_election( + seed, + num_voters, + num_projects, + min_cost=1, + max_cost=50, + approval_probability=0.3, +): + rng = random.Random(seed) + costs_by_name = { + f"p{i}": rng.randint(min_cost, max_cost) + for i in range(num_projects) + } + total = sum(costs_by_name.values()) + budget = rng.randint(max(1, total // 4), max(1, total // 2)) + project_names = list(costs_by_name) + approvals_by_name = [] + + for _ in range(num_voters): + ballot = { + p + for p in project_names + if rng.random() < approval_probability + } + if not ballot: + ballot.add(rng.choice(project_names)) + approvals_by_name.append(ballot) + + return make_election(costs_by_name, budget, approvals_by_name) + + +class TestOrderedRelax(unittest.TestCase): + # --------------------------------------------------------- + # Manual examples + # --------------------------------------------------------- + + def test_ordered_relax_example_1_size_1(self): + instance, profile, _ = make_election( + costs_by_name={"p1": 5}, + budget=5, + approvals_by_name=[{"p1"}], + ) + outcome = ordered_relax(instance, profile) + self.assertEqual(selected_names(outcome), {"p1"}) + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + def test_ordered_relax_example_2_size_2(self): + instance, profile, _ = make_election( + costs_by_name={"p1": 4, "p2": 3}, + budget=4, + approvals_by_name=[{"p1"}, {"p2"}], + ) + outcome = ordered_relax(instance, profile) + self.assertEqual(selected_names(outcome), {"p1"}) + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + def test_ordered_relax_example_3_size_3(self): + instance, profile, _ = make_election( + costs_by_name={"p1": 3, "p2": 3, "p3": 2}, + budget=5, + approvals_by_name=[{"p1", "p2"}, {"p2"}, {"p3"}], + ) + outcome = ordered_relax(instance, profile) + self.assertEqual(selected_names(outcome), {"p2", "p3"}) + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + def test_ordered_relax_example_4_optimal_case(self): + instance, profile, _ = make_election( + costs_by_name={"p0": 2, "p1": 3, "p2": 3, "p3": 3, "p4": 3}, + budget=8, + approvals_by_name=[ + {"p0", "p1"}, + {"p0", "p2"}, + {"p0", "p3"}, + {"p0", "p4"}, + ], + ) + outcome = ordered_relax(instance, profile) + self.assertEqual(selected_names(outcome), {"p0", "p1", "p2"}) + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + def test_ordered_relax_example_5_bad_case(self): + instance, profile, _ = make_election( + costs_by_name={"p0": 23, "p1": 68, "p2": 198, "p3": 189, "p4": 146, "p5": 38}, + budget=341, + approvals_by_name=[{"p4"}, {"p1", "p2"}, {"p1", "p3", "p5"}], + ) + outcome = ordered_relax(instance, profile) + self.assertEqual(selected_names(outcome), {"p4"}) + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + def test_ordered_relax_example_6_large_manual_case(self): + instance, profile, _ = make_election( + costs_by_name={ + "p0": 18, "p1": 45, "p2": 43, "p3": 32, "p4": 28, + "p5": 32, "p6": 5, "p7": 37, "p8": 43, "p9": 17, + }, + budget=124, + approvals_by_name=[ + {"p0", "p3", "p7"}, + {"p1", "p4", "p6", "p7"}, + {"p0", "p2", "p4", "p5"}, + {"p1", "p6", "p9"}, + {"p1", "p2", "p6", "p7", "p8"}, + {"p1", "p3", "p4", "p6", "p8"}, + ], + ) + outcome = ordered_relax(instance, profile) + self.assertEqual(selected_names(outcome), {"p1", "p2"}) + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + # --------------------------------------------------------- + # Edge cases + # --------------------------------------------------------- + + def test_ordered_relax_empty_instance_and_empty_profile(self): + instance = Instance([], budget_limit=10) + profile = ApprovalProfile([]) + outcome = ordered_relax(instance, profile) + self.assertEqual(selected_names(outcome), set()) + assert_valid_budget_allocation(outcome, instance) + + def test_ordered_relax_no_projects_some_empty_ballots(self): + instance = Instance([], budget_limit=10) + profile = ApprovalProfile([ApprovalBallot(), ApprovalBallot()]) + outcome = ordered_relax(instance, profile) + self.assertEqual(selected_names(outcome), set()) + assert_valid_budget_allocation(outcome, instance) + + def test_ordered_relax_zero_budget_returns_empty_allocation(self): + instance, profile, _ = make_election( + costs_by_name={"p1": 5, "p2": 7}, + budget=0, + approvals_by_name=[{"p1"}, {"p2"}], + ) + outcome = ordered_relax(instance, profile) + self.assertEqual(selected_names(outcome), set()) + assert_valid_budget_allocation(outcome, instance) + + def test_ordered_relax_project_too_expensive_is_not_selected(self): + instance, profile, _ = make_election( + costs_by_name={"p1": 100}, budget=10, approvals_by_name=[{"p1"}] + ) + outcome = ordered_relax(instance, profile) + self.assertEqual(selected_names(outcome), set()) + assert_valid_budget_allocation(outcome, instance) + + def test_ordered_relax_initial_budget_allocation_is_preserved(self): + instance, profile, projects = make_election( + costs_by_name={"p1": 2, "p2": 3, "p3": 4}, + budget=5, + approvals_by_name=[{"p1", "p2"}, {"p1", "p3"}], + ) + outcome = ordered_relax(instance, profile, initial_budget_allocation=[projects["p1"]]) + self.assertIn(projects["p1"], outcome) + assert_valid_budget_allocation(outcome, instance) + + def test_ordered_relax_initial_budget_allocation_over_budget_raises_value_error(self): + instance, profile, projects = make_election( + costs_by_name={"p1": 10}, budget=5, approvals_by_name=[{"p1"}] + ) + with self.assertRaises(ValueError): + ordered_relax(instance, profile, initial_budget_allocation=[projects["p1"]]) + + def test_ordered_relax_irresolute_outcome_not_supported(self): + instance, profile, _ = make_election( + costs_by_name={"p1": 1}, budget=1, approvals_by_name=[{"p1"}] + ) + with self.assertRaises(NotImplementedError): + ordered_relax(instance, profile, resoluteness=False) + + def test_ordered_relax_multiprofile_not_supported(self): + instance, profile, _ = make_election( + costs_by_name={"p1": 1}, budget=1, approvals_by_name=[{"p1"}] + ) + multiprofile = ApprovalMultiProfile(profile=profile) + with self.assertRaises(NotImplementedError): + ordered_relax(instance, multiprofile) + + # --------------------------------------------------------- + # Random tests + # --------------------------------------------------------- + + def test_ordered_relax_random_small_instances(self): + for seed in range(20): + with self.subTest(seed=seed): + instance, profile, _ = generate_random_election( + seed=seed, + num_voters=5, + num_projects=12, + min_cost=1, + max_cost=30, + approval_probability=0.4, + ) + outcome = ordered_relax(instance, profile) + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + def test_ordered_relax_random_medium_instances(self): + for seed in range(20, 30): + with self.subTest(seed=seed): + instance, profile, _ = generate_random_election( + seed=seed, + num_voters=8, + num_projects=20, + min_cost=1, + max_cost=50, + approval_probability=0.35, + ) + outcome = ordered_relax(instance, profile) + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + def test_ordered_relax_random_big_instances_with_ilp_guarantee(self): + """ + These are larger tests. They still check the additive guarantee, but they + solve an exact ILP to compute OPT, so they are kept as a separate test. + """ + for seed in range(100, 103): + with self.subTest(seed=seed): + instance, profile, _ = generate_random_election( + seed=seed, + num_voters=15, + num_projects=35, + min_cost=1, + max_cost=80, + approval_probability=0.25, + ) + outcome = ordered_relax(instance, profile) + assert_valid_budget_allocation(outcome, instance) + assert_ordered_relax_additive_guarantee(outcome, instance, profile) + + +if __name__ == "__main__": + unittest.main() From d406bb5f7549e7cb7c663f0d800492aec21564d6 Mon Sep 17 00:00:00 2001 From: Nevo Biton <155353129+NevoBiton20@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:20:57 +0300 Subject: [PATCH 8/8] Update test_ordered_relax_unittest.py --- tests/rules/test_ordered_relax_unittest.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/rules/test_ordered_relax_unittest.py b/tests/rules/test_ordered_relax_unittest.py index a9da4107..e49b62bf 100644 --- a/tests/rules/test_ordered_relax_unittest.py +++ b/tests/rules/test_ordered_relax_unittest.py @@ -242,8 +242,16 @@ def test_ordered_relax_example_5_bad_case(self): def test_ordered_relax_example_6_large_manual_case(self): instance, profile, _ = make_election( costs_by_name={ - "p0": 18, "p1": 45, "p2": 43, "p3": 32, "p4": 28, - "p5": 32, "p6": 5, "p7": 37, "p8": 43, "p9": 17, + "p0": 18, + "p1": 45, + "p2": 43, + "p3": 32, + "p4": 28, + "p5": 32, + "p6": 5, + "p7": 37, + "p8": 43, + "p9": 17, }, budget=124, approvals_by_name=[ @@ -255,8 +263,9 @@ def test_ordered_relax_example_6_large_manual_case(self): {"p1", "p3", "p4", "p6", "p8"}, ], ) + outcome = ordered_relax(instance, profile) - self.assertEqual(selected_names(outcome), {"p1", "p2"}) + assert_valid_budget_allocation(outcome, instance) assert_ordered_relax_additive_guarantee(outcome, instance, profile)