diff --git a/examples/run_ees_addopt.py b/examples/run_ees_addopt.py new file mode 100644 index 0000000..adfced5 --- /dev/null +++ b/examples/run_ees_addopt.py @@ -0,0 +1,168 @@ +""" +Running example for all algorithms in pabutools.rules.ees_addopt. + +Demonstrates: + 1. exact_equal_shares (Algorithm 1 – EES) + 2. get_leftover_budgets (leftover budget helper) + 3. get_leximax_payment (leximax payment helper) + 4. greedy_project_change (Algorithm 2 – GPC) + 5. add_opt (Algorithm 3 – add-opt) + 6. ees_add_opt_completion (EES completion via add-opt) +""" + +import logging + +from pabutools.election import Instance, Project, ApprovalProfile, ApprovalBallot +from pabutools.fractions import frac +from pabutools.rules.budgetallocation import BudgetAllocation +from pabutools.rules.ees_addopt import ( + EESAllocationDetails, + exact_equal_shares, + get_leftover_budgets, + get_leximax_payment, + greedy_project_change, + add_opt, + ees_add_opt_completion, +) + +# Show all log messages from the module. +logging.basicConfig( + level=logging.DEBUG, + format="%(name)s %(levelname)s %(message)s", + stream=__import__("sys").stdout, +) + +SEPARATOR = "\n" + "=" * 70 + "\n" + + +# A small PB instance with 5 voters and 3 projects. +# Projects: p1 (cost 2), p2 (cost 3.2), p3 (cost 6) +# Budget: 10 +# Voter 0 approves {p1} +# Voter 1 approves {p1, p3} +# Voter 2 approves {p2, p3} +# Voter 3 approves {p2, p3} +# Voter 4 approves {p3} + +p1 = Project("p1", 2) +p2 = Project("p2", 3.2) +p3 = Project("p3", 6) +projects = [p1, p2, p3] +budget = 10 + +instance = Instance(projects, budget_limit=budget) +profile = ApprovalProfile( + [ + ApprovalBallot([p1]), + ApprovalBallot([p1, p3]), + ApprovalBallot([p2, p3]), + ApprovalBallot([p2, p3]), + ApprovalBallot([p3]), + ], + instance=instance, +) + +num_voters = len(profile) +print("Instance") +print(f" Budget : {budget}") +print(f" Projects : {[(p.name, p.cost) for p in projects]}") +print(f" Num voters : {num_voters}") +print(f" Approvals :") +for i, ballot in enumerate(profile): + print(f" Voter {i}: {sorted(p.name for p in ballot)}") + + +# 1. exact_equal_shares (Algorithm 1) +print(SEPARATOR) +print("1. exact_equal_shares (Algorithm 1 – EES)") +print("-" * 42) + +ees_result = exact_equal_shares(instance, profile) + +print(f"\n Selected projects : {[p.name for p in ees_result]}") +total_cost = sum(frac(p.cost) for p in ees_result) +print(f" Total cost : {total_cost}") + +payments = ees_result.details.payments +print(" Per-voter payments:") +for voter in range(num_voters): + voter_pay = payments.get(voter, {}) + items = [(p.name, float(v)) for p, v in voter_pay.items()] + print(f" Voter {voter}: {items if items else '(none)'}") + + +# 2. get_leftover_budgets +print(SEPARATOR) +print("2. get_leftover_budgets") +print("-" * 42) + +leftover = get_leftover_budgets(instance, profile, ees_result) + +print(" Leftover budget per voter:") +for voter in range(num_voters): + print(f" Voter {voter}: {float(leftover[voter]):.4f}") + + +# 3. get_leximax_payment +print(SEPARATOR) +print("3. get_leximax_payment") +print("-" * 42) + +leximax = get_leximax_payment(ees_result, num_voters, instance) + +print(" Leximax payment vectors:") +for voter in range(num_voters): + formatted = [(float(amt), name) for amt, name in leximax[voter]] + print(f" Voter {voter}: {formatted}") + + +# 4. greedy_project_change (Algorithm 2 – GPC) +print(SEPARATOR) +print("4. greedy_project_change (Algorithm 2 – GPC)") +print("-" * 42) + +print(" Testing each project as instability certificate:") +for proj in projects: + d = greedy_project_change( + instance, profile, ees_result, proj, leftover, leximax + ) + print(f" Project '{proj.name}' (cost={proj.cost}): d = {d} ({float(d):.4f})") + + +# 5. add_opt (Algorithm 3) +print(SEPARATOR) +print("5. add_opt (Algorithm 3)") +print("-" * 42) + +d_min = add_opt(instance, profile, ees_result) + +print(f"\n Minimum d over all projects: {d_min} ({float(d_min):.4f})") +print(f" Per-voter budget increase : {float(d_min):.4f}") +print(f" Total budget increase (n*d): {float(num_voters * d_min):.4f}") + + +# 6. ees_add_opt_completion (EES completion via add-opt) +print(SEPARATOR) +print("6. ees_add_opt_completion (EES completed via add-opt)") +print("-" * 42) + +completed = ees_add_opt_completion(instance, profile) + +print(f"\n Selected projects : {sorted(p.name for p in completed)}") +completed_cost = sum(frac(p.cost) for p in completed) +print(f" Total cost : {completed_cost} (budget = {budget})") + +if hasattr(completed.details, "payments"): + print(" Per-voter payments:") + for voter in range(num_voters): + voter_pay = completed.details.payments.get(voter, {}) + items = [(p.name, float(v)) for p, v in voter_pay.items()] + print(f" Voter {voter}: {items if items else '(none)'}") + + +# Comparison +print(SEPARATOR) +print("Summary comparison") +print("-" * 42) +print(f" EES alone : {str([p.name for p in ees_result]):30s} cost = {float(total_cost)}") +print(f" EES + add-opt : {str(sorted(p.name for p in completed)):30s} cost = {float(completed_cost)}") diff --git a/experiments/__init__.py b/experiments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/experiment_comparison.py b/experiments/experiment_comparison.py new file mode 100644 index 0000000..3cc29a5 --- /dev/null +++ b/experiments/experiment_comparison.py @@ -0,0 +1,184 @@ +""" +Section A – Performance comparison of EES algorithms vs. other PB rules. + +Compares: + - exact_equal_shares (EES, Algorithm 1) + - ees_add_opt_completion (EES + add-opt, Corollary 4.7) + - method_of_equal_shares (MES, the standard library implementation) + - greedy_utilitarian_welfare (Greedy welfare) + +Metrics: + - runtime (seconds) + - total_cost (budget utilisation) + - num_selected (number of selected projects) + - social_welfare (total approval score of selected projects) + +Uses the experiments-csv library. +""" + +import logging +import sys +import time + +import experiments_csv + +# ── make the repo root importable ────────────────────────────────────── +sys.path.insert(0, ".") + +from pabutools.election import ( + Instance, + Project, + ApprovalProfile, + ApprovalBallot, + Cost_Sat, +) +from pabutools.election.instance import get_random_instance +from pabutools.election.profile.approvalprofile import get_random_approval_profile +from pabutools.rules import greedy_utilitarian_welfare, method_of_equal_shares +from pabutools.rules.ees_addopt import exact_equal_shares, ees_add_opt_completion + +TIME_LIMIT = 60 # seconds per single run + +# ── algorithm registry ───────────────────────────────────────────────── +ALGORITHMS = { + "EES": lambda inst, prof: exact_equal_shares(inst, prof), + "EES_AddOpt": lambda inst, prof: ees_add_opt_completion(inst, prof), + "MES": lambda inst, prof: method_of_equal_shares( + inst, prof, sat_class=Cost_Sat, resoluteness=True + ), + "Greedy": lambda inst, prof: greedy_utilitarian_welfare( + inst, prof, sat_class=Cost_Sat, resoluteness=True + ), +} + + +# ── single experiment function ───────────────────────────────────────── +def run_single(num_projects: int, num_voters: int, algorithm: str, seed: int): + """Run a single experiment: generate a random instance, run the algorithm, + and return measured outputs.""" + import random as _random + + _random.seed(seed) + + # Generate random instance (same style as test_ees_addopt random tests) + approval_prob = 0.4 + budget_factor_low, budget_factor_high = 0.4, 0.8 + + projects = [] + for i in range(num_projects): + projects.append(Project(str(i), _random.randint(0, 100))) + total_project_cost = sum(p.cost for p in projects) + if total_project_cost == 0: + total_project_cost = 1 + budget = int(total_project_cost * _random.uniform(budget_factor_low, budget_factor_high)) + budget = max(budget, 1) + instance = Instance(projects, budget_limit=budget) + + ballots = [] + for v in range(num_voters): + approved = [p for p in projects if _random.random() < approval_prob] + ballots.append(ApprovalBallot(approved)) + profile = ApprovalProfile(ballots, instance=instance) + + algo_fn = ALGORITHMS[algorithm] + + start = time.perf_counter() + result = algo_fn(instance, profile) + elapsed = time.perf_counter() - start + + total_cost = sum(p.cost for p in result) + num_selected = len(result) + + # Social welfare: total number of approvals for the selected projects + social_welfare = 0 + for project in result: + social_welfare += profile.approval_score(project) + + return { + "runtime": round(elapsed, 4), + "total_cost": int(total_cost), + "num_selected": num_selected, + "social_welfare": social_welfare, + "budget_limit": int(instance.budget_limit), + "remaining_budget": int(instance.budget_limit) - int(total_cost), + } + + +# ── main ─────────────────────────────────────────────────────────────── +RESULTS_DIR = "experiments/results" +CSV_FILE = "comparison.csv" +BACKUPS_DIR = "experiments/results/backups" + + +def run_experiments(): + """Run the full comparison experiment.""" + ex = experiments_csv.Experiment(RESULTS_DIR, CSV_FILE, BACKUPS_DIR) + ex.logger.setLevel(logging.INFO) + + input_ranges = { + "num_projects": [10, 30, 60, 100], + "num_voters": [50], + "algorithm": list(ALGORITHMS.keys()), + "seed": [1, 2, 3], + } + + ex.run(run_single, input_ranges) + print(f"\nExperiment data saved to {RESULTS_DIR}/{CSV_FILE}") + + +def plot_results(): + """Generate comparison plots from the CSV results.""" + import pandas as pd + from matplotlib import pyplot as plt + + csv_path = f"{RESULTS_DIR}/{CSV_FILE}" + df = pd.read_csv(csv_path) + + # Compute remaining_budget if not already in CSV (backward compat) + if "remaining_budget" not in df.columns: + df["remaining_budget"] = df["budget_limit"] - df["total_cost"] + + VOTER_COUNTS = [50] + metrics = [ + ("runtime", "Runtime (seconds)"), + ("total_cost", "Total Cost"), + ("remaining_budget", "Remaining Budget (unused)"), + ("social_welfare", "Social Welfare (total approvals)"), + ("num_selected", "Number of Selected Projects"), + ] + + for metric, ylabel in metrics: + for n_voters in VOTER_COUNTS: + plt.figure(figsize=(8, 5)) + subset = df[df["num_voters"] == n_voters] + grouped = subset.groupby(["num_projects", "algorithm"])[metric].mean().reset_index() + + for algo in sorted(grouped["algorithm"].unique()): + algo_data = grouped[grouped["algorithm"] == algo] + plt.plot( + algo_data["num_projects"], + algo_data[metric], + marker="o", + label=algo, + ) + + plt.title(f"{ylabel} vs. Number of Projects (voters={n_voters})") + plt.xlabel("Number of Projects") + plt.ylabel(ylabel) + plt.legend() + plt.grid(True, alpha=0.3) + plt.tight_layout() + save_path = f"{RESULTS_DIR}/{metric}_voters_{n_voters}.png" + plt.savefig(save_path, dpi=150) + plt.close() + print(f"Saved {save_path}") + + print("\nAll plots saved.") + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "plot": + plot_results() + else: + run_experiments() + plot_results() diff --git a/experiments/results/comparison.csv b/experiments/results/comparison.csv new file mode 100644 index 0000000..283b1d9 --- /dev/null +++ b/experiments/results/comparison.csv @@ -0,0 +1,49 @@ +num_projects,num_voters,algorithm,seed,runtime,total_cost,num_selected,social_welfare,budget_limit,remaining_budget +10,50,EES,1,0.0025,135,5,115,342,207 +10,50,EES,2,0.002,120,6,122,204,84 +10,50,EES,3,0.0018,178,5,107,344,166 +10,50,EES_AddOpt,1,0.063,324,8,168,342,18 +10,50,EES_AddOpt,2,0.0735,166,7,141,204,38 +10,50,EES_AddOpt,3,0.2167,332,7,148,344,12 +10,50,MES,1,0.0043,207,6,136,342,135 +10,50,MES,2,0.003,120,6,122,204,84 +10,50,MES,3,0.0036,258,6,129,344,86 +10,50,Greedy,1,0.0011,304,7,157,342,38 +10,50,Greedy,2,0.0016,197,7,146,204,7 +10,50,Greedy,3,0.0014,332,7,148,344,12 +30,50,EES,1,0.0314,973,23,458,1229,256 +30,50,EES,2,0.0368,804,22,461,1109,305 +30,50,EES,3,0.0231,820,20,425,1096,276 +30,50,EES_AddOpt,1,1.4754,1168,25,504,1229,61 +30,50,EES_AddOpt,2,1.3436,1107,26,540,1109,2 +30,50,EES_AddOpt,3,1.6663,1096,24,497,1096,0 +30,50,MES,1,0.0064,1105,22,451,1229,124 +30,50,MES,2,0.0113,964,23,491,1109,145 +30,50,MES,3,0.0121,995,22,474,1096,101 +30,50,Greedy,1,0.0015,1224,24,497,1229,5 +30,50,Greedy,2,0.0015,1106,23,498,1109,3 +30,50,Greedy,3,0.0029,1082,22,476,1096,14 +60,50,EES,1,0.0673,1998,48,1009,2340,342 +60,50,EES,2,0.076,1937,48,940,2316,379 +60,50,EES,3,0.0626,1253,36,742,1550,297 +60,50,EES_AddOpt,1,7.2866,2324,52,1093,2340,16 +60,50,EES_AddOpt,2,7.5881,2313,53,1028,2316,3 +60,50,EES_AddOpt,3,14.6037,1526,40,817,1550,24 +60,50,MES,1,0.019,2205,47,1012,2340,135 +60,50,MES,2,0.0173,2203,50,987,2316,113 +60,50,MES,3,0.0175,1490,36,774,1550,60 +60,50,Greedy,1,0.0064,2338,49,1055,2340,2 +60,50,Greedy,2,0.0043,2305,48,969,2316,11 +60,50,Greedy,3,0.0066,1550,30,674,1550,0 +100,50,EES,1,0.1872,3351,77,1593,3591,240 +100,50,EES,2,0.1853,3026,70,1430,3299,273 +100,50,EES,3,0.1786,2992,71,1493,3217,225 +100,50,EES_AddOpt,1,41.6526,3544,79,1634,3591,47 +100,50,EES_AddOpt,2,52.6927,3288,74,1497,3299,11 +100,50,EES_AddOpt,3,55.2711,3158,73,1535,3217,59 +100,50,MES,1,0.0455,3494,73,1550,3591,97 +100,50,MES,2,0.0621,3192,66,1393,3299,107 +100,50,MES,3,0.0562,3175,64,1415,3217,42 +100,50,Greedy,1,0.0132,3590,68,1474,3591,1 +100,50,Greedy,2,0.0093,3284,64,1372,3299,15 +100,50,Greedy,3,0.0125,3217,61,1361,3217,0 diff --git a/experiments/results/comparison_report.md b/experiments/results/comparison_report.md new file mode 100644 index 0000000..469df9c --- /dev/null +++ b/experiments/results/comparison_report.md @@ -0,0 +1,101 @@ +# Section A – Performance Comparison + +### Algorithms Tested + +| Algorithm | Description | Source | +|-----------|-------------|--------| +| **EES** | Exact Equal Shares (Algorithm 1) – selects projects by bang-per-buck ratio, splitting costs equally among supporters | Paper, `ees_addopt.py` | +| **EES_AddOpt** | EES + Add-Opt Completion (Corollary 4.7) – repeatedly runs EES with increasing virtual budgets until the outcome exhausts the budget | Paper, `ees_addopt.py` | +| **MES** | Method of Equal Shares – the standard library implementation with Cost Satisfaction | `pabutools.rules.mes` | +| **Greedy** | Greedy Utilitarian Welfare – greedy algorithm selecting projects by approval score | `pabutools.rules.greedywelfare` | + +### Metrics Measured + +1. **runtime** – execution time in seconds +2. **remaining_budget** – unused budget (budget_limit − total_cost) +3. **total_cost** – total cost of selected projects +4. **social_welfare** – sum of approval scores for selected projects +5. **num_selected** – number of selected projects + +### Experiment Parameters + +All 4 algorithms run on **exactly the same inputs**: + +- **Number of projects**: 10, 30, 60, 100 +- **Number of voters**: 50 +- **Repetitions**: 3 runs (seeds: 1, 2, 3) – averages shown in plots +- **Input generation**: random costs between 0 and 100, budget = 40%–80% of total cost, approval probability = 0.4 + +--- + +## Results + +### Runtime + +![Runtime](runtime_voters_50.png) + +| Algorithm | 10 projects | 30 projects | 60 projects | 100 projects | +|-----------|------------|------------|------------|-------------| +| **EES** | 0.002s | 0.03s | 0.07s | 0.18s | +| **EES_AddOpt** | 0.12s | 1.50s | 9.83s | **49.87s** | +| **MES** | 0.004s | 0.01s | 0.02s | 0.05s | +| **Greedy** | 0.001s | 0.002s | 0.006s | 0.01s | + +**Findings:** +- **Greedy** is the fastest, followed by **MES** +- **EES** (Algorithm 1 alone) is relatively fast – 0.18s at 100 projects +- **EES_AddOpt** is significantly slower: ~50 seconds at 100 projects, because it runs EES + add_opt multiple times with increasing virtual budgets + +### Remaining Budget + +![Remaining Budget](remaining_budget_voters_50.png) + +| Algorithm | 10 projects | 30 projects | 60 projects | 100 projects | +|-----------|------------|------------|------------|-------------| +| **EES** | 152 | 279 | 339 | 246 | +| **EES_AddOpt** | **23** | **21** | **14** | **39** | +| **MES** | 102 | 123 | 103 | 82 | +| **Greedy** | 19 | 7 | 4 | 5 | + +**Findings:** +- **EES** leaves the most budget unused – it stops as soon as no project can be funded equally among supporters +- **EES_AddOpt** dramatically improves budget utilization: only 14–39 remaining, compared to 152–339 for plain EES +- **MES** is in between: 82–123 remaining +- **Greedy** has the best budget utilization (4–19) but does not guarantee fairness + +### Social Welfare + +![Social Welfare](social_welfare_voters_50.png) + +| Algorithm | 10 projects | 30 projects | 60 projects | 100 projects | +|-----------|------------|------------|------------|-------------| +| **EES** | 115 | 448 | 897 | 1505 | +| **EES_AddOpt** | **152** | **514** | **979** | **1555** | +| **MES** | 129 | 472 | 924 | 1453 | +| **Greedy** | 150 | 490 | 899 | 1402 | + +**Findings:** +- **EES_AddOpt** achieves the highest social welfare at every input size +- At 100 projects: EES_AddOpt (1555) > EES (1505) > MES (1453) > Greedy (1402) +- The gap between EES_AddOpt and MES grows with input size + +### Total Cost and Number of Selected Projects + +![Total Cost](total_cost_voters_50.png) +![Num Selected](num_selected_voters_50.png) + +--- + +## Conclusions + +| | Runtime | Budget Utilization | Social Welfare | Fairness | +|---|---|---|---|---| +| **EES** | Fast | Low | Medium | ✓ | +| **EES_AddOpt** | **Very slow** | **High** | **Highest** | ✓ | +| **MES** | Fast | Medium | Medium-High | ✓ | +| **Greedy** | **Fastest** | **Highest** | Relatively low | ✗ | + +1. **EES_AddOpt** achieves the best outcomes (social welfare + budget utilization) while maintaining fairness – but at a very high runtime cost (~50s at 100 projects) +2. **EES** alone is fast but does not exhaust the budget – suitable as an intermediate step +3. **MES** is a good compromise: fast, fair, and produces reasonable results +4. **Greedy** is the fastest and best at budget utilization, but does not guarantee fair allocation \ No newline at end of file diff --git a/experiments/results/num_selected_voters_50.png b/experiments/results/num_selected_voters_50.png new file mode 100644 index 0000000..22d4455 Binary files /dev/null and b/experiments/results/num_selected_voters_50.png differ diff --git a/experiments/results/remaining_budget_voters_50.png b/experiments/results/remaining_budget_voters_50.png new file mode 100644 index 0000000..89d8110 Binary files /dev/null and b/experiments/results/remaining_budget_voters_50.png differ diff --git a/experiments/results/runtime_voters_50.png b/experiments/results/runtime_voters_50.png new file mode 100644 index 0000000..9766e81 Binary files /dev/null and b/experiments/results/runtime_voters_50.png differ diff --git a/experiments/results/social_welfare_voters_50.png b/experiments/results/social_welfare_voters_50.png new file mode 100644 index 0000000..81d65d1 Binary files /dev/null and b/experiments/results/social_welfare_voters_50.png differ diff --git a/experiments/results/total_cost_voters_50.png b/experiments/results/total_cost_voters_50.png new file mode 100644 index 0000000..525479a Binary files /dev/null and b/experiments/results/total_cost_voters_50.png differ diff --git a/pabutools/rules/__init__.py b/pabutools/rules/__init__.py index 3884e23..54f6636 100644 --- a/pabutools/rules/__init__.py +++ b/pabutools/rules/__init__.py @@ -40,6 +40,12 @@ ) from pabutools.rules.cstv import cstv, CSTV_Combination from pabutools.rules.maximin_support import maximin_support +from pabutools.rules.ees_addopt import ( + exact_equal_shares, + greedy_project_change, + add_opt, + EESAllocationDetails, +) __all__ = [ "completion_by_rule_combination", @@ -57,5 +63,9 @@ "MESIteration", "cstv", "CSTV_Combination", - "maximin_support" + "maximin_support", + "exact_equal_shares", + "greedy_project_change", + "add_opt", + "EESAllocationDetails", ] diff --git a/pabutools/rules/ees_addopt.py b/pabutools/rules/ees_addopt.py new file mode 100644 index 0000000..31837f9 --- /dev/null +++ b/pabutools/rules/ees_addopt.py @@ -0,0 +1,602 @@ +""" +An implementation of the algorithms in "Streamlining Equal Shares", +by Sonja Kraiczy, Isaac Robinson, Edith Elkind (2024), +https://arxiv.org/abs/2502.11797 + +Programmer: Yonatan Gabay +Date: 20-04-2026 +""" + +from __future__ import annotations + +import logging + +from pabutools.election.instance import Instance, Project +from pabutools.election.profile import AbstractApprovalProfile +from pabutools.fractions import frac +from pabutools.rules.budgetallocation import BudgetAllocation, AllocationDetails +from pabutools.tiebreaking import lexico_tie_breaking +from pabutools.utils import Numeric + +logger = logging.getLogger(__name__) + + +class EESAllocationDetails(AllocationDetails): + """ + Details of an EES rule run, storing per-voter payments. + + Attributes + ---------- + payments : dict[int, dict[Project, Numeric]] + payments[voter_index][project] = amount paid by voter for the project. + """ + + def __init__(self, payments: dict[int, dict[Project, Numeric]] | None = None): + super().__init__() + if payments is not None: + self.payments = payments + else: + self.payments = {} + + def __repr__(self): + return f"EESAllocationDetails[payments={self.payments}]" + + +def exact_equal_shares( + instance: Instance, + profile: AbstractApprovalProfile, + utilities: dict[Project, Numeric] | None = None, +) -> BudgetAllocation: + """ + Algorithm 1: EES for uniform utilities. + + Iteratively selects projects that maximise bang-per-buck (|V|·u(p)/cost(p)) + among all feasible (project, supporter-subset) pairs, splitting costs + equally among supporters. + + Parameters + ---------- + instance : :py:class:`~pabutools.election.instance.Instance` + The instance containing the projects and the budget limit. + profile : :py:class:`~pabutools.election.profile.approvalprofile.AbstractApprovalProfile` + The approval profile, one ballot per voter. + utilities : dict[:py:class:`~pabutools.election.instance.Project`, Numeric] or None + utilities[project] = u(p). When None, every project has utility 1 + (pure approval setting). + + Returns + ------- + :py:class:`~pabutools.rules.budgetallocation.BudgetAllocation` + The selected projects (in order of selection). The + :py:attr:`~pabutools.rules.budgetallocation.BudgetAllocation.details` attribute + is an :py:class:`EESAllocationDetails` holding per-voter payments. + + Examples + -------- + >>> from pabutools.election import Instance, Project, ApprovalProfile, ApprovalBallot + >>> p1, p2, p3 = Project("p1", 10), Project("p2", 16), Project("p3", 21) + >>> inst = Instance([p1, p2, p3], budget_limit=40) + >>> prof = ApprovalProfile([ + ... ApprovalBallot([p1]), + ... ApprovalBallot([p1, p3]), + ... ApprovalBallot([p2, p3]), + ... ApprovalBallot([p2, p3]), + ... ], instance=inst) + >>> result = exact_equal_shares(inst, prof) + >>> [p.name for p in result] + ['p1', 'p2'] + """ + logger.info("exact_equal_shares: starting with %d projects, budget_limit=%s, %d voters", + len(instance), instance.budget_limit, len(profile)) + n = len(profile) + if n == 0: + logger.info("exact_equal_shares: no voters, returning empty allocation") + return BudgetAllocation(details=EESAllocationDetails()) + + b = instance.budget_limit + + # Line 1: Start with no selected projects and equal voter budgets. + selected_projects = [] + payments = {} + for i in range(n): + payments[i] = {} + initial_budget_per_voter = frac(b) / n + remaining_budgets = [] + for i in range(n): + remaining_budgets.append(initial_budget_per_voter) + + # Keep voters sorted by remaining budget for fast supporter filtering. + voters_by_remaining_budget = list(range(n)) # all equal initially + + # Line 2: Keep selecting projects until none are feasible. + while True: + best_score = -1 + best_candidates = [] + + # Line 3: Consider each unselected project with supporters who can afford it. + # For each project, find the largest feasible supporter subset V. + selected_set = set(selected_projects) + for project in instance: + if project in selected_set: + continue + + if utilities is not None: + project_utility = utilities[project] + else: + project_utility = 1 + + # N_p: voters who approve project, preserving remaining-budget order. + supporters = [] + for i in voters_by_remaining_budget: + if project in profile[i]: + supporters.append(i) + if not supporters: + continue + + # Greedily remove the poorest voter while they can't afford + # their equal share cost(p)/|V|. + while supporters and remaining_budgets[supporters[0]] < frac(project.cost) / len(supporters): + supporters.pop(0) + + if not supporters: + continue + + # Line 8: Score the project by utility per unit of cost. + if project.cost == 0: + bang_per_buck = float('inf') + else: + bang_per_buck = frac(len(supporters) * project_utility) / project.cost + if bang_per_buck > best_score: + best_score = bang_per_buck + best_candidates = [(project, supporters)] + elif bang_per_buck == best_score: + best_candidates.append((project, supporters)) + + # Line 4-5: Return the current allocation if no feasible project remains. + if not best_candidates: + logger.info("exact_equal_shares: no feasible project remains, returning %d selected projects", + len(selected_projects)) + return BudgetAllocation(selected_projects, details=EESAllocationDetails(payments)) + + # Line 9: Break ties among the best feasible projects. + # Choose the lexicographically first tied project to keep ties stable. + if len(best_candidates) == 1: + chosen_project, chosen_supporters = best_candidates[0] + else: + tied_projects = [] + for candidate_project, _ in best_candidates: + tied_projects.append(candidate_project) + chosen_project = lexico_tie_breaking.untie(instance, profile, tied_projects) + chosen_supporters = None + for candidate_project, candidate_supporters in best_candidates: + if candidate_project == chosen_project: + chosen_supporters = candidate_supporters + break + + # Line 10: Add the chosen project to the allocation. + logger.debug("exact_equal_shares: selected project '%s' (cost=%s, score=%s, supporters=%d)", + chosen_project.name, chosen_project.cost, best_score, len(chosen_supporters)) + selected_projects.append(chosen_project) + + # Line 11: Charge each chosen supporter an equal share of the project cost. + chosen_supporters_set = set(chosen_supporters) + payment = frac(chosen_project.cost) / len(chosen_supporters) + for i in chosen_supporters: + payments[i][chosen_project] = payment + remaining_budgets[i] -= payment + + # Update voters_by_remaining_budget via O(n) merge. All V* voters decreased by + # the same amount so their relative order is preserved. + changed = [] + unchanged = [] + for i in voters_by_remaining_budget: + if i in chosen_supporters_set: + changed.append(i) + else: + unchanged.append(i) + merged = [] + ci = 0 + ui = 0 + while ci < len(changed) and ui < len(unchanged): + if remaining_budgets[changed[ci]] <= remaining_budgets[unchanged[ui]]: + merged.append(changed[ci]) + ci += 1 + else: + merged.append(unchanged[ui]) + ui += 1 + merged.extend(changed[ci:]) + merged.extend(unchanged[ui:]) + voters_by_remaining_budget = merged + + +def get_leftover_budgets( + instance: Instance, + profile: AbstractApprovalProfile, + current_solution: BudgetAllocation, +) -> dict[int, Numeric]: + """ + Compute leftover budget for each voter. + + leftover[i] = b/n - sum of payments by voter i across all selected projects. + """ + logger.debug("get_leftover_budgets: computing for %d voters", len(profile)) + num_voters = len(profile) + budget_limit = instance.budget_limit + allocation_details = current_solution.details + leftover_budgets = {} + + for voter in range(num_voters): + total_paid = 0 + voter_payments = allocation_details.payments.get(voter, {}) + for payment_amount in voter_payments.values(): + total_paid += payment_amount + leftover_budgets[voter] = (frac(budget_limit) / num_voters) - total_paid + + return leftover_budgets + + +def get_leximax_payment( + current_solution: BudgetAllocation, + num_voters: int, + instance: Instance, +) -> dict[int, list[tuple[Numeric, str]]]: + """ + Return the leximax payment vectors for all voters as a dict mapping + voter index to a sorted list of ``(amount, project_name)`` tuples, + ordered descending by amount then ascending by project name for ties. + + When a voter made no payments, their entry is ``[(0, smallest_name)]`` + so that non-paying voters never certify instability. + """ + logger.debug("get_leximax_payment: computing for %d voters", num_voters) + allocation_details = current_solution.details + smallest_project_name = "" + for project in instance: + if smallest_project_name == "" or project.name < smallest_project_name: + smallest_project_name = project.name + leximax_payments = {} + + for voter in range(num_voters): + voter_payments = allocation_details.payments.get(voter, {}) + if not voter_payments: + leximax_payments[voter] = [(0, smallest_project_name)] + else: + payment_vector = [] + for project, payment_amount in voter_payments.items(): + payment_vector.append((payment_amount, project.name)) + payment_vector.sort(key=lambda payment: (-payment[0], payment[1])) + leximax_payments[voter] = payment_vector + + return leximax_payments + + +def greedy_project_change( + instance: Instance, + profile: AbstractApprovalProfile, + current_solution: BudgetAllocation, + project: Project, + leftover_budgets: dict[int, Numeric], + leximax_payments: dict[int, list[tuple[Numeric, str]]], + voters_by_leftover: list[int] | None = None, + voters_by_leximax: list[int] | None = None, +) -> Numeric: + """ + Algorithm 2: GreedyProjectChange (GPC). + + Computes the minimum per-voter budget increase d > 0 such that + *project* certifies instability of the current equal-shares solution + for instance E(b + n·d). + The algorithm walks two sorted arrays simultaneously: + - leftover_budgets (A'): residual budgets of Op(X) voters, sorted ascending. + - leximax_payments (B'): leximax payment vectors of Op(X) voters, + sorted lex-ascending. + + Parameters + ---------- + instance : :py:class:`~pabutools.election.instance.Instance` + The instance containing the projects and the budget limit. + profile : :py:class:`~pabutools.election.profile.approvalprofile.AbstractApprovalProfile` + The approval profile, one ballot per voter. + current_solution : :py:class:`~pabutools.rules.budgetallocation.BudgetAllocation` + The current EES solution. Its + :py:attr:`~pabutools.rules.budgetallocation.BudgetAllocation.details` attribute + must be an :py:class:`EESAllocationDetails` holding per-voter payments. + project : :py:class:`~pabutools.election.instance.Project` + The candidate project p to test instability for. + leftover_budgets : dict[int, Numeric] + Leftover budget per voter, as returned by + :py:func:`get_leftover_budgets`. + leximax_payments : dict[int, list[tuple[Numeric, str]]] + Leximax payment vector per voter, as returned by + :py:func:`get_leximax_payment`. + voters_by_leftover : list[int] or None + All voters pre-sorted by leftover ascending. When provided the + function filters this list to O_p(X) in O(n) instead of sorting + per project. Used by :py:func:`add_opt` for O(mn) total. + voters_by_leximax : list[int] or None + All voters pre-sorted by leximax ascending. + + Returns + ------- + Numeric + Minimum per-voter budget increase d. + + Examples + -------- + >>> from pabutools.election import Instance, Project, ApprovalProfile, ApprovalBallot + >>> p1, p2, p3 = Project("p1", 2), Project("p2", 3.2), Project("p3", 6) + >>> inst = Instance([p1, p2, p3], budget_limit=10) + >>> prof = ApprovalProfile([ + ... ApprovalBallot([p1]), + ... ApprovalBallot([p1, p3]), + ... ApprovalBallot([p2, p3]), + ... ApprovalBallot([p2, p3]), + ... ApprovalBallot([p3]), + ... ], instance=inst) + >>> details = EESAllocationDetails({0: {p1: 1}, 1: {p1: 1}, 2: {p2: 1.6}, 3: {p2: 1.6}, 4: {}}) + >>> solution = BudgetAllocation([p1, p2], details=details) + >>> leftover = get_leftover_budgets(inst, prof, solution) + >>> leximax = get_leximax_payment(solution, 5, inst) + >>> greedy_project_change(inst, prof, solution, p3, leftover, leximax) + mpq(1,2) + """ + logger.debug("greedy_project_change: computing d for project '%s' (cost=%s)", + project.name, project.cost) + n = len(profile) + + # All voters who approve project. + project_supporters = set() + for voter in range(n): + if project in profile[voter]: + project_supporters.add(voter) + + # voters who already pay for project in the current solution. + allocation_details = current_solution.details + current_payers = set() + for voter in project_supporters: + if project in allocation_details.payments.get(voter, {}): + current_payers.add(voter) + + # O_p(X): supporters who approve project but do not currently pay for it. + outside_supporters = project_supporters - current_payers + + # Outside supporters sorted by leftover budget ascending. + if voters_by_leftover is not None: + outside_supporters_by_leftover = [] + for voter in voters_by_leftover: + if voter in outside_supporters: + outside_supporters_by_leftover.append(voter) + else: + outside_supporters_by_leftover = sorted( + outside_supporters, key=lambda voter: leftover_budgets[voter] + ) + num_outside_supporters = len(outside_supporters_by_leftover) + + # Outside supporters sorted by leximax payment ascending. + if voters_by_leximax is not None: + outside_supporters_by_leximax = [] + for voter in voters_by_leximax: + if voter in outside_supporters: + outside_supporters_by_leximax.append(voter) + else: + outside_supporters_by_leximax = sorted( + outside_supporters, key=lambda voter: leximax_payments[voter]) + + # Line 1: Start both scan positions at the beginning. + leftover_index = 0 + leximax_index = 0 + + # Line 2-3 + # SL (solvent list) — voters who can pay by deviating from their leximax project. + solvent_list = set() + # LQ (liquid queue) — voters expected to pay from their leftover budgets. + liquid_queue = set(outside_supporters_by_leftover) + + # Line 4: Initialize the required budget increase as unbounded. + d = float('inf') + + # Line 5: Continue until all queued and skipped voters have been processed. + while liquid_queue or solvent_list: + # Line 6: Compute the current equal payment needed for project. + paying_supporters = current_payers | liquid_queue | solvent_list + per_voter_price = frac(project.cost) / len(paying_supporters) + + per_voter_price_leximax = [(per_voter_price, project.name)] + + # Line 7: Advance past voters whose leximax payment is too small. + if (leximax_index < num_outside_supporters + and leximax_payments[outside_supporters_by_leximax[leximax_index]] + < per_voter_price_leximax): + # Line 8: Remove that voter from the skipped set if present. + solvent_list.discard(outside_supporters_by_leximax[leximax_index]) + # Line 9: Move to the next voter in leximax order. + leximax_index += 1 + + # Line 10: Otherwise, check whether the next leftover voter must be skipped. + elif (leftover_index < num_outside_supporters + and leximax_payments[outside_supporters_by_leftover[leftover_index]] + > per_voter_price_leximax): + # Line 11: Remove the voter from the leftover queue. + liquid_queue.discard(outside_supporters_by_leftover[leftover_index]) + # Line 12: Mark the voter as skipped for later processing. + solvent_list.add(outside_supporters_by_leftover[leftover_index]) + # Line 13: Move to the next voter in leftover order. + leftover_index += 1 + + # Line 14-15: Record the smallest increase that lets this voter pay. + else: + if leftover_index < num_outside_supporters: + next_voter = outside_supporters_by_leftover[leftover_index] + d = min(d, per_voter_price - leftover_budgets[next_voter]) + # Line 16: Remove the processed voter from the leftover queue. + if leftover_index < num_outside_supporters: + liquid_queue.discard(outside_supporters_by_leftover[leftover_index]) + # Line 17: Move to the next voter in leftover order. + leftover_index += 1 + + # Line 20: Return the best budget increase found. + result = max(0, d) + logger.debug("greedy_project_change: project '%s' => d=%s", project.name, result) + return result + + +def add_opt( + instance: Instance, + profile: AbstractApprovalProfile, + current_solution: BudgetAllocation, +) -> Numeric: + """ + Algorithm 3: add-opt. + + Iterates over every project p in the instance, restricts the sorted + leftover-budget and leximax-payment arrays to the voters in Op(X), and + calls GreedyProjectChange to find the minimum per-voter budget increase + that makes p certify instability. Returns the global minimum. + + Parameters + ---------- + instance : :py:class:`~pabutools.election.instance.Instance` + The instance containing the projects and the budget limit. + profile : :py:class:`~pabutools.election.profile.approvalprofile.AbstractApprovalProfile` + The approval profile, one ballot per voter. + current_solution : :py:class:`~pabutools.rules.budgetallocation.BudgetAllocation` + The current EES solution. Its + :py:attr:`~pabutools.rules.budgetallocation.BudgetAllocation.details` attribute + must be an :py:class:`EESAllocationDetails` holding per-voter payments. + + Returns + ------- + Numeric + Minimum per-voter budget increase d > 0 such that the current + solution is unstable for E(b + n·d). Returns ``float('inf')`` + when the solution is stable for every finite budget increase. + + Examples + -------- + >>> from pabutools.election import Instance, Project, ApprovalProfile, ApprovalBallot + >>> p1, p2, p3 = Project("p1", 2), Project("p2", 3.2), Project("p3", 6) + >>> inst = Instance([p1, p2, p3], budget_limit=10) + >>> prof = ApprovalProfile([ + ... ApprovalBallot([p1]), + ... ApprovalBallot([p1, p3]), + ... ApprovalBallot([p2, p3]), + ... ApprovalBallot([p2, p3]), + ... ApprovalBallot([p3]), + ... ], instance=inst) + >>> details = EESAllocationDetails({0: {p1: 1}, 1: {p1: 1}, 2: {p2: 1.6}, 3: {p2: 1.6}, 4: {}}) + >>> solution = BudgetAllocation([p1, p2], details=details) + >>> add_opt(inst, prof, solution) + mpq(1,2) + """ + logger.info("add_opt: computing minimum d over %d projects, %d voters", + len(instance), len(profile)) + n = len(profile) + + # Precompute leftover budgets and leximax payments for all voters + leftover_budgets = get_leftover_budgets(instance, profile, current_solution) + leximax_payments = get_leximax_payment(current_solution, n, instance) + + # Pre-sort all voters once (Algorithm 3, lines A and B) + all_voters = list(range(n)) + voters_by_leftover = sorted(all_voters, key=lambda i: leftover_budgets[i]) + voters_by_leximax = sorted(all_voters, key=lambda i: leximax_payments[i]) + + # Line 1: Initialize the best project change as unbounded. + d = float('inf') + + # Line 2: Test every project as a possible instability certificate. + for project in instance: + # Line 5: Keep the smallest change found for any project. + gpc_result = greedy_project_change(instance, profile, current_solution, project, leftover_budgets, leximax_payments, + voters_by_leftover, voters_by_leximax,) + d = min(d, gpc_result) + + # Line 7: Return the minimum change over all projects. + logger.info("add_opt: minimum d=%s", d) + return d + + +def ees_add_opt_completion( + instance: Instance, + profile: AbstractApprovalProfile, + utilities: dict[Project, Numeric] | None = None, +) -> BudgetAllocation: + """ + Completion of EES via add-opt (§4.2, Corollary 4.7). + + Iteratively runs EES with increasing virtual per-voter budgets until + the outcome is exhaustive (no project can certify instability). + Returns the budget-feasible outcome with the highest total spending. + + Parameters + ---------- + instance : :py:class:`~pabutools.election.instance.Instance` + The instance containing the projects and the budget limit. + profile : :py:class:`~pabutools.election.profile.approvalprofile.AbstractApprovalProfile` + The approval profile, one ballot per voter. + utilities : dict[:py:class:`~pabutools.election.instance.Project`, Numeric] or None + utilities[project] = u(p). When None, every project has utility 1 + (pure approval setting). + + Returns + ------- + :py:class:`~pabutools.rules.budgetallocation.BudgetAllocation` + The completed EES outcome — a feasible outcome whose total cost + is at most ``instance.budget_limit``. + + Examples + -------- + >>> from pabutools.election import Instance, Project, ApprovalProfile, ApprovalBallot + >>> p1, p2, p3 = Project("p1", 2), Project("p2", 3.2), Project("p3", 6) + >>> inst = Instance([p1, p2, p3], budget_limit=10) + >>> prof = ApprovalProfile([ + ... ApprovalBallot([p1]), + ... ApprovalBallot([p1, p3]), + ... ApprovalBallot([p2, p3]), + ... ApprovalBallot([p2, p3]), + ... ApprovalBallot([p3]), + ... ], instance=inst) + >>> result = ees_add_opt_completion(inst, prof) + >>> sorted(p.name for p in result) + ['p1', 'p3'] + """ + logger.info("ees_add_opt_completion: starting with %d projects, budget_limit=%s, %d voters", + len(instance), instance.budget_limit, len(profile)) + n = len(profile) + if n == 0: + logger.info("ees_add_opt_completion: no voters, delegating to exact_equal_shares") + return exact_equal_shares(instance, profile, utilities) + + original_budget = instance.budget_limit + virtual_budget = frac(original_budget) + projects = list(instance) + best_result = None + best_result_cost = frac(-1) + + while True: + virtual_inst = Instance(projects, budget_limit=virtual_budget) + result = exact_equal_shares(virtual_inst, profile, utilities) + + total_cost = 0 + for project in result: + total_cost += frac(project.cost) + logger.debug("ees_add_opt_completion: virtual_budget=%s, EES selected %d projects, total_cost=%s", + virtual_budget, len(result), total_cost) + if total_cost <= original_budget and total_cost > best_result_cost: + best_result = result + best_result_cost = total_cost + + d = add_opt(virtual_inst, profile, result) + if d == float('inf') or d <= 0: + logger.debug("ees_add_opt_completion: stopping (d=%s)", d) + break + + virtual_budget = virtual_budget + n * frac(d) + logger.debug("ees_add_opt_completion: increasing virtual_budget to %s (d=%s)", virtual_budget, d) + + if best_result is None: + best_result = exact_equal_shares(instance, profile, utilities) + + logger.info("ees_add_opt_completion: returning %d projects, total_cost=%s", + len(best_result), best_result_cost) + return best_result diff --git a/tests/rules/test_ees_addopt.py b/tests/rules/test_ees_addopt.py new file mode 100644 index 0000000..b2c59f7 --- /dev/null +++ b/tests/rules/test_ees_addopt.py @@ -0,0 +1,607 @@ +""" +Tests for EES (Algorithm 1), GreedyProjectChange (Algorithm 2) and add-opt (Algorithm 3). + +Programmer: Yonatan Gabay +Since: 2026-04 +""" + +import pytest + +from pabutools.election.instance import Instance, Project +from pabutools.election.profile import ApprovalProfile +from pabutools.election.ballot import ApprovalBallot +from pabutools.rules.budgetallocation import BudgetAllocation +from pabutools.rules.ees_addopt import ( + exact_equal_shares, + greedy_project_change, + add_opt, + ees_add_opt_completion, + EESAllocationDetails, + get_leftover_budgets, + get_leximax_payment, +) + + +# ---- helpers ---- + +def _make_instance_profile(project_costs, budget, approval_sets): + """ + Build an Instance + ApprovalProfile from simple descriptions. + + Parameters + ---------- + project_costs : dict[str, float] + Mapping from project name to cost. + budget : float + Budget limit. + approval_sets : list[set[str]] + One set of approved project names per voter. + + Returns + ------- + tuple[Instance, ApprovalProfile, dict[str, Project]] + The instance, profile, and a name→Project lookup dict. + """ + projects = {} + for name, cost in project_costs.items(): + projects[name] = Project(name, cost) + inst = Instance(projects.values(), budget_limit=budget) + ballots = [] + for approved in approval_sets: + ballot_projects = [] + for pname in approved: + ballot_projects.append(projects[pname]) + ballots.append(ApprovalBallot(ballot_projects)) + prof = ApprovalProfile(ballots, instance=inst) + return inst, prof, projects + + +def _selected_names(result): + """Return project names from a BudgetAllocation.""" + names = [] + for p in result: + names.append(p.name) + return names + + +def _get_payments(result): + """Return the payments dict from a BudgetAllocation's EESAllocationDetails.""" + return result.details.payments + + +def _make_solution(selected_names, payments_by_voter, projects): + """ + Build a BudgetAllocation with EESAllocationDetails from simple descriptions. + + Parameters + ---------- + selected_names : list[str] + Names of selected projects. + payments_by_voter : dict[int, dict[str, float]] + payments_by_voter[voter_index][project_name] = payment. + projects : dict[str, Project] + Name→Project lookup. + """ + selected = [] + for name in selected_names: + selected.append(projects[name]) + pay = {} + for voter_idx, voter_payments in payments_by_voter.items(): + voter_pay = {} + for pname, amount in voter_payments.items(): + voter_pay[projects[pname]] = amount + pay[voter_idx] = voter_pay + return BudgetAllocation(selected, details=EESAllocationDetails(pay)) + + +####### EES tests + + +# empty input should return empty result +def test_ees_nothing_to_select(): + inst = Instance([], budget_limit=0) + prof = ApprovalProfile([], instance=inst) + + result = exact_equal_shares(inst, prof) + assert _selected_names(result) == [] + assert _get_payments(result) == {} + + +# project above budget should not be selected +def test_ees_single_project_too_expensive(): + inst, prof, projects = _make_instance_profile( + {"p1": 200}, 50, [{"p1"}, {"p1"}] + ) + result = exact_equal_shares(inst, prof) + assert projects["p1"] not in result + + +# one approved project should split cost evenly +def test_ees_cost_split_equally(): + inst, prof, projects = _make_instance_profile( + {"p1": 10}, 10, [{"p1"}, {"p1"}] + ) + result = exact_equal_shares(inst, prof) + assert projects["p1"] in result + payments = _get_payments(result) + assert payments[0][projects["p1"]] == pytest.approx(5) + assert payments[1][projects["p1"]] == pytest.approx(5) + + +# paper example +def test_ees_multiple_projects(): + inst, prof, projects = _make_instance_profile( + {"p1": 10, "p2": 16, "p3": 21}, + 40, + [{"p1"}, {"p1", "p3"}, {"p2", "p3"}, {"p2", "p3"}], + ) + result = exact_equal_shares(inst, prof) + assert _selected_names(result) == ["p1", "p2"] + payments = _get_payments(result) + assert payments[0][projects["p1"]] == 5 + assert payments[1][projects["p1"]] == 5 + assert payments[2][projects["p2"]] == 8 + assert payments[3][projects["p2"]] == 8 + + +# no approvals +def test_ees_no_approvals_means_nothing_funded(): + inst, prof, projects = _make_instance_profile( + {"p1": 10, "p2": 15}, + 100, + [set(), set(), set()], + ) + result = exact_equal_shares(inst, prof) + assert _selected_names(result) == [] + + +# payments should stay within budget +def test_ees_budget_feasibility(): + inst, prof, projects = _make_instance_profile( + {"p1": 2, "p2": 3.2, "p3": 6}, + 10, + [{"p1"}, {"p1", "p3"}, {"p2", "p3"}, {"p2", "p3"}, {"p3"}], + ) + result = exact_equal_shares(inst, prof) + payments = _get_payments(result) + + total_paid = 0 + for i in range(len(prof)): + for p in result: + total_paid += payments.get(i, {}).get(p, 0) + assert total_paid <= inst.budget_limit + + # Each funded project is fully covered + for proj in result: + paid_for_proj = 0 + for i in range(len(prof)): + paid_for_proj += payments.get(i, {}).get(proj, 0) + assert paid_for_proj == pytest.approx(proj.cost) + + +####### get_leftover_budgets tests + + +# no payments means full leftover for each voter +def test_leftover_no_payments(): + inst, prof, projects = _make_instance_profile({"p1": 10}, 20, [{"p1"}, {"p1"}]) + solution = _make_solution([], {0: {}, 1: {}}, projects) + leftover = get_leftover_budgets(inst, prof, solution) + # b/n = 20/2 = 10 per voter + assert leftover[0] == pytest.approx(10) + assert leftover[1] == pytest.approx(10) + + +# leftover should equal b/n minus total payments +def test_leftover_after_payments(): + inst, prof, projects = _make_instance_profile({"p1": 2, "p2": 3.2}, 10, [{"p1", "p2"}, {"p1", "p2"}]) + solution = _make_solution(["p1", "p2"],{0: {"p1": 1, "p2": 1.6}, 1: {"p1": 1, "p2": 1.6}},projects,) + leftover = get_leftover_budgets(inst, prof, solution) + # b/n = 10/2 = 5, each voter paid 2.6 + assert leftover[0] == pytest.approx(2.4) + assert leftover[1] == pytest.approx(2.4) + + +# voter who paid nothing has full leftover; voter who paid has less +def test_leftover_uneven_payments(): + inst, prof, projects = _make_instance_profile({"p1": 5}, 10, [{"p1"}, {"p1"}]) + solution = _make_solution(["p1"],{0: {"p1": 5.0}, 1: {}},projects,) + leftover = get_leftover_budgets(inst, prof, solution) + # b/n = 5 + assert leftover[0] == pytest.approx(0) + assert leftover[1] == pytest.approx(5) + + +# paper example +def test_leftover_paper_example(): + inst, prof, projects = _make_instance_profile({"p1": 2, "p2": 3.2, "p3": 6},10,[{"p1"}, {"p1", "p3"}, {"p2", "p3"}, {"p2", "p3"}, {"p3"}],) + solution = _make_solution(["p1", "p2"],{0: {"p1": 1}, 1: {"p1": 1}, 2: {"p2": 1.6}, 3: {"p2": 1.6}, 4: {}},projects,) + leftover = get_leftover_budgets(inst, prof, solution) + # b/n = 10/5 = 2 + assert leftover[0] == pytest.approx(1) + assert leftover[1] == pytest.approx(1) + assert leftover[2] == pytest.approx(0.4) + assert leftover[3] == pytest.approx(0.4) + assert leftover[4] == pytest.approx(2) + + +####### get_leximax_payment tests + + +# non-paying voter gets [(0, smallest_project_name)] +def test_leximax_no_payments(): + inst, prof, projects = _make_instance_profile({"p1": 10, "p2": 5}, 20, [{"p1"}, {"p2"}]) + solution = _make_solution([], {0: {}, 1: {}}, projects) + leximax = get_leximax_payment(solution, len(prof), inst) + # smallest project name is "p1" + assert leximax[0] == [(0, "p1")] + assert leximax[1] == [(0, "p1")] + + +# single payment produces a one-element vector +def test_leximax_single_payment(): + inst, prof, projects = _make_instance_profile({"p1": 10}, 10, [{"p1"}, {"p1"}]) + solution = _make_solution(["p1"],{0: {"p1": 5}, 1: {"p1": 5}},projects,) + leximax = get_leximax_payment(solution, len(prof), inst) + assert leximax[0] == [(5, "p1")] + assert leximax[1] == [(5, "p1")] + + +# multiple payments sorted descending by amount, then ascending by name +def test_leximax_multiple_payments_sorted(): + inst, prof, projects = _make_instance_profile({"p1": 2, "p2": 6}, 10, [{"p1", "p2"}]) + solution = _make_solution(["p1", "p2"],{0: {"p1": 2, "p2": 6}},projects,) + leximax = get_leximax_payment(solution, len(prof), inst) + # descending by amount: 6 before 2 + assert leximax[0] == [(6, "p2"), (2, "p1")] + + +# equal amounts should be sorted ascending by project name +def test_leximax_tiebreak_by_name(): + inst, prof, projects = _make_instance_profile({"a": 4, "b": 4}, 10, [{"a", "b"}]) + solution = _make_solution(["a", "b"], {0: {"a": 4, "b": 4}}, projects,) + leximax = get_leximax_payment(solution, len(prof), inst) + # same amount, ascending by name: "a" before "b" + assert leximax[0] == [(4, "a"), (4, "b")] + + +# paper example +def test_leximax_paper_example(): + inst, prof, projects = _make_instance_profile({"p1": 2, "p2": 3.2, "p3": 6}, 10, [{"p1"}, {"p1", "p3"}, {"p2", "p3"}, {"p2", "p3"}, {"p3"}],) + solution = _make_solution(["p1", "p2"], {0: {"p1": 1}, 1: {"p1": 1}, 2: {"p2": 1.6}, 3: {"p2": 1.6}, 4: {}}, projects,) + leximax = get_leximax_payment(solution, len(prof), inst) + assert leximax[0] == [(1, "p1")] + assert leximax[1] == [(1, "p1")] + assert leximax[2] == [(1.6, "p2")] + assert leximax[3] == [(1.6, "p2")] + assert leximax[4] == [(0, "p1")] # non-payer gets smallest name + + +####### GPC tests + + +# zero-cost project should return zero delta +def test_gpc_free_project_needs_zero_increase(): + inst, prof, projects = _make_instance_profile( + {"p1": 0}, 10, [{"p1"}] + ) + solution = _make_solution([], {}, projects) + leftover = get_leftover_budgets(inst, prof, solution) + leximax = get_leximax_payment(solution, len(prof), inst) + delta = greedy_project_change(inst, prof, solution, projects["p1"], leftover, leximax) + assert delta == pytest.approx(0) + + +# enough leftover should return zero delta +def test_gpc_leftover_covers_cost_exactly(): + inst, prof, projects = _make_instance_profile( + {"p1": 12}, 15, [{"p1"}, {"p1"}, {"p1"}] + ) + solution = _make_solution([], {}, projects) + leftover = get_leftover_budgets(inst, prof, solution) + leximax = get_leximax_payment(solution, len(prof), inst) + delta = greedy_project_change(inst, prof, solution, projects["p1"], leftover, leximax) + assert delta == pytest.approx(0) + + +# not enough leftover should return positive delta +def test_gpc_not_enough_leftover(): + inst, prof, projects = _make_instance_profile( + {"p1": 30}, 6, [{"p1"}, {"p1"}, {"p1"}] + ) + solution = _make_solution([], {}, projects) + leftover = get_leftover_budgets(inst, prof, solution) + leximax = get_leximax_payment(solution, len(prof), inst) + delta = greedy_project_change(inst, prof, solution, projects["p1"], leftover, leximax) + assert delta > 0 + + +# paper example +def test_gpc_paper_example(): + inst, prof, projects = _make_instance_profile( + {"p1": 2, "p2": 3.2, "p3": 6}, + 10, + [{"p1"}, {"p1", "p3"}, {"p2", "p3"}, {"p2", "p3"}, {"p3"}], + ) + solution = _make_solution( + ["p1", "p2"], + {0: {"p1": 1}, 1: {"p1": 1}, 2: {"p2": 1.6}, 3: {"p2": 1.6}, 4: {}}, + projects, + ) + leftover = get_leftover_budgets(inst, prof, solution) + leximax = get_leximax_payment(solution, len(prof), inst) + delta = greedy_project_change(inst, prof, solution, projects["p3"], leftover, leximax) + assert delta == pytest.approx(0.5) + + +# some projects are already funded +def test_gpc_with_existing_selection(): + inst, prof, projects = _make_instance_profile( + {"p1": 5, "p2": 8}, + 10, + [{"p1", "p2"}, {"p1", "p2"}], + ) + solution = _make_solution( + ["p1"], + {0: {"p1": 5.0}, 1: {}}, + projects, + ) + leftover = get_leftover_budgets(inst, prof, solution) + leximax = get_leximax_payment(solution, len(prof), inst) + delta = greedy_project_change(inst, prof, solution, projects["p2"], leftover, leximax) + assert delta >= 0 + + +####### add-opt tests + + +# empty input should return non-negative or infinite +def test_addopt_nothing_to_add(): + inst = Instance([], budget_limit=0) + prof = ApprovalProfile([], instance=inst) + solution = BudgetAllocation([], details=EESAllocationDetails({})) + + delta = add_opt(inst, prof, solution) + assert delta >= 0 or delta == float("inf") + + +# if all projects funded should not improve +def test_addopt_all_projects_already_funded(): + inst, prof, projects = _make_instance_profile( + {"p1": 5}, 10, [{"p1"}] + ) + solution = _make_solution(["p1"], {0: {"p1": 5.0}}, projects) + delta = add_opt(inst, prof, solution) + assert delta >= 0 or delta == float("inf") + + +# paper example +def test_addopt_finds_improvement(): + inst, prof, projects = _make_instance_profile( + {"p1": 2, "p2": 3.2, "p3": 6}, + 10, + [{"p1"}, {"p1", "p3"}, {"p2", "p3"}, {"p2", "p3"}, {"p3"}], + ) + solution = _make_solution( + ["p1", "p2"], + {0: {"p1": 1}, 1: {"p1": 1}, 2: {"p2": 1.6}, 3: {"p2": 1.6}, 4: {}}, + projects, + ) + delta = add_opt(inst, prof, solution) + assert delta == pytest.approx(0.5) + + +####### random tests + + +@pytest.mark.parametrize("num_voters, num_projects", [ + (3, 20), + (20, 3), + (20, 20), +]) +# random instances should stay valid after EES, GPC, and rerun EES +def test_random_ees_gpc_rerun(num_voters, num_projects, test_times=5, approval_prob=0.4, budget_factor=(0.4, 0.8)): + """ + Full random test: + 1. Generate random instance + 2. Run EES and validate result + 3. Choose unselected project and run GPC + 4. Run EES with bigger budget + 5. Validate new EES result + 6. Check GPC project now selected + """ + import random + rng = random.Random() + + for seed in range(test_times): + rng.seed(seed) + + project_names = [] + for i in range(num_projects): + project_names.append(f"p{i}") + costs = {} + for name in project_names: + costs[name] = rng.randint(0, 100) + projects_map = {} + for name, c in costs.items(): + projects_map[name] = Project(name, c) + all_projects = list(projects_map.values()) + + total_cost = sum(costs.values()) + budget = int(total_cost * rng.uniform(*budget_factor)) + + inst = Instance(all_projects, budget_limit=budget) + + # build approval ballots + ballots = [] + for _ in range(num_voters): + approved = [] + for name in project_names: + if rng.random() < approval_prob: + approved.append(projects_map[name]) + ballots.append(ApprovalBallot(approved)) + prof = ApprovalProfile(ballots, instance=inst) + + # run EES + result = exact_equal_shares(inst, prof) + payments = _get_payments(result) + + # validate EES result + total = 0 + for i in range(num_voters): + for p in result: + total += payments.get(i, {}).get(p, 0) + assert total <= budget, f"seed={seed}: EES exceeded budget" + for p in result: + paid = 0 + for i in range(num_voters): + paid += payments.get(i, {}).get(p, 0) + assert paid == pytest.approx(p.cost), f"seed={seed}: project {p.name} not covered" + + # find unselected project that has support + selected_set = set(result) + unselected = [] + for proj in all_projects: + if proj in selected_set: + continue + has_supporter = any(proj in ballot for ballot in prof) + if has_supporter: + unselected.append(proj) + if not unselected: + continue + target_project = rng.choice(unselected) + + # run GPC + leftover = get_leftover_budgets(inst, prof, result) + leximax = get_leximax_payment(result, num_voters, inst) + delta = greedy_project_change(inst, prof, result, target_project, leftover, leximax) + assert delta >= 0, f"seed={seed}: GPC returned negative delta" + + # run EES with new budget + new_budget = budget + num_voters * delta + new_inst = Instance(all_projects, budget_limit=new_budget) + new_result = exact_equal_shares(new_inst, prof) + new_payments = _get_payments(new_result) + + # validate new EES result + new_total = 0 + for i in range(num_voters): + for p in new_result: + new_total += new_payments.get(i, {}).get(p, 0) + assert new_total <= new_budget, f"seed={seed}: new EES exceeded budget" + for p in new_result: + paid = 0 + for i in range(num_voters): + paid += new_payments.get(i, {}).get(p, 0) + assert paid == pytest.approx(p.cost), f"seed={seed}: project {p.name} not covered after increase" + + # Per Remark 1 in the paper, GPC for a single project + # guarantees instability but not that the project is selected. + # Verify the EES outcome changed instead. + assert set(new_result) != set(result) or _get_payments(new_result) != _get_payments(result), ( + f"seed={seed}: GPC said delta={delta} for '{target_project.name}' " + f"but EES returned the same result with budget={new_budget}" + ) + + +####### ees_add_opt_completion tests + + +# empty profile returns empty result +def test_completion_empty_profile(): + inst = Instance([], budget_limit=10) + prof = ApprovalProfile([], instance=inst) + result = ees_add_opt_completion(inst, prof) + assert len(result) == 0 + + +# paper example from §4.1 Example 4.3 +# 5 voters, 3 projects: p1(2), p2(3.2), p3(6), budget=10 +# EES alone picks {p1,p2} (cost 5.2), completion should pick {p1,p3} (cost 8) +def test_completion_paper_example(): + inst, prof, projects = _make_instance_profile({"p1": 2, "p2": 3.2, "p3": 6}, + 10, + [{"p1"}, {"p1", "p3"}, {"p2", "p3"}, {"p2", "p3"}, {"p3"}],) + result = ees_add_opt_completion(inst, prof) + names = sorted(_selected_names(result)) + assert names == ["p1", "p3"] + + +# result should always be budget-feasible +def test_completion_budget_feasible(): + inst, prof, projects = _make_instance_profile({"p1": 2, "p2": 3.2, "p3": 6}, + 10, + [{"p1"}, {"p1", "p3"}, {"p2", "p3"}, {"p2", "p3"}, {"p3"}],) + result = ees_add_opt_completion(inst, prof) + total = 0 + for p in result: + total += p.cost + assert total <= inst.budget_limit + + +# single voter, single affordable project +def test_completion_single_voter_single_project(): + inst, prof, projects = _make_instance_profile({"p1": 5}, + 10, + [{"p1"}],) + result = ees_add_opt_completion(inst, prof) + assert _selected_names(result) == ["p1"] + + +# all projects are affordable from the start +def test_completion_all_projects_selected(): + inst, prof, projects = _make_instance_profile({"p1": 2, "p2": 3}, + 10, + [{"p1", "p2"}, {"p1", "p2"}],) + result = ees_add_opt_completion(inst, prof) + names = sorted(_selected_names(result)) + assert names == ["p1", "p2"] + + +# no project is affordable (cost exceeds budget) +def test_completion_no_affordable_project(): + inst, prof, projects = _make_instance_profile({"p1": 100}, + 10, + [{"p1"}],) + result = ees_add_opt_completion(inst, prof) + assert len(result) == 0 + + +# Remark 1 example: p1(2),p2(98),p3(100),p4(51), budget=150, 3 voters +# A1={p1,p2}, A2={p2,p3}, A3={p3,p4}. EES selects {p1,p3}. +# Completion should produce a feasible result with total cost <= 150. +def test_completion_remark1_example(): + inst, prof, projects = _make_instance_profile({"p1": 2, "p2": 98, "p3": 100, "p4": 51}, + 150, + [{"p1", "p2"}, {"p2", "p3"}, {"p3", "p4"}],) + result = ees_add_opt_completion(inst, prof) + total = 0 + for p in result: + total += p.cost + assert total <= 150 + # should at least select the base EES result {p1, p3} + names = sorted(_selected_names(result)) + assert "p1" in names + assert "p3" in names + + +# completion should spend at least as much as base EES +def test_completion_spends_at_least_as_much_as_ees(): + inst, prof, projects = _make_instance_profile({"p1": 2, "p2": 3.2, "p3": 6}, + 10, + [{"p1"}, {"p1", "p3"}, {"p2", "p3"}, {"p2", "p3"}, {"p3"}],) + ees_result = exact_equal_shares(inst, prof) + ees_cost = 0 + for p in ees_result: + ees_cost += p.cost + + completion_result = ees_add_opt_completion(inst, prof) + completion_cost = 0 + for p in completion_result: + completion_cost += p.cost + + assert completion_cost >= ees_cost + + +if __name__ == "__main__": + pytest.main(["-v", __file__])