Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1a0c2c1
first commit
NaamaYahav Apr 22, 2026
3a5aa31
second commit
NaamaYahav Apr 24, 2026
935110d
Probabilities change
NaamaYahav Apr 30, 2026
496955b
fixing
NaamaYahav Apr 30, 2026
937cdf8
finish titles
dotandanino May 4, 2026
9fb66d3
fixing
NaamaYahav May 4, 2026
9a64967
implement functions
NaamaYahav May 11, 2026
f274176
finished algo
dotandanino May 11, 2026
d4818b6
implement
NaamaYahav May 11, 2026
44350a7
fixed some test
dotandanino May 11, 2026
d9962ed
annotation test
NaamaYahav May 11, 2026
2b1d990
DOC
NaamaYahav May 12, 2026
2000279
DOC2
NaamaYahav May 12, 2026
8303fea
logger
dotandanino May 12, 2026
4f28f59
logger
dotandanino May 12, 2026
f1f4580
logger Full
dotandanino May 14, 2026
d26efb2
logger Fix
dotandanino May 14, 2026
cdfe270
initial commit
dotandanino May 17, 2026
fe7690d
initial commit
dotandanino May 19, 2026
7e66658
initial commit
dotandanino May 19, 2026
ff0eb94
GCR
dotandanino Jun 1, 2026
ad72566
tests
dotandanino Jun 1, 2026
9e1a218
tests
dotandanino Jun 1, 2026
e9fc735
finished
dotandanino Jun 2, 2026
a92786a
pull request fixed
dotandanino Jun 2, 2026
64f8ded
Stop tracking Fair_Lotteries_for_Participatory_Budgeting.py
dotandanino Jun 2, 2026
6ff164d
fixed more errors
dotandanino Jun 2, 2026
4b6b286
fixed tests
dotandanino Jun 2, 2026
c42d3c1
fixed bugs
dotandanino Jun 2, 2026
7360d19
bugs fixed
dotandanino Jun 2, 2026
36eccd7
gcr
dotandanino Jun 11, 2026
fdc50c8
fix git.ignore
dotandanino Jun 24, 2026
51ccc6f
Remove code/ and flask_app/ from tracking
dotandanino Jun 24, 2026
4a822a7
add docstring for justifiedrepresentation.py
dotandanino Jun 24, 2026
e17a129
more issues
dotandanino Jun 24, 2026
f15e90e
code cover
dotandanino Jun 24, 2026
265ac37
Merge branch 'main' into main
dotandanino Jun 24, 2026
019906d
Fix broken merge in rules/__init__.py
dotandanino Jun 30, 2026
800ed87
Rewrite JR/strong-UFS checks to use native pabutools types
dotandanino Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Comment thread
dotandanino marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,4 @@ analysis/Pabulib

.vscode/
.python-version
.DS_Store
.DS_Store
87 changes: 67 additions & 20 deletions pabutools/analysis/cohesiveness.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import random
from collections.abc import Collection

from pabutools.utils import Numeric
Expand Down Expand Up @@ -68,29 +69,75 @@ def is_cohesive_cardinal(
return True


def cohesive_groups(instance: Instance, profile: AbstractProfile, projects=None):
def cohesive_groups(
instance: Instance,
profile: AbstractProfile,
projects=None,
sample_size: int | None = None,
):
"""
Find (group, project_set) pairs that are cohesive.

Parameters
----------
instance : Instance
profile : AbstractProfile
projects : Collection[Project], optional
Defaults to all projects in `instance`.
sample_size : int, optional
If `None` (default), exhaustively check every (group, project_set)
pair via `powerset(profile) x powerset(projects)`. If set to an int,
instead randomly sample that many (group, project_set) pairs — use
this to keep runtime bounded on large instances, where the
exhaustive enumeration is exponential in both the number of voters
and the number of projects.
"""
if projects is None:
projects = instance
projects = list(projects)
ballots = list(profile)
res = []
for group in powerset(profile):
if len(group) > 0:
for project_set in powerset(projects):
if len(project_set) > 0:
# This fails for multiprofiles as the multiplicity is not taken into account
if isinstance(profile, AbstractApprovalProfile):
if is_cohesive_approval(instance, profile, project_set, group):
res.append((group, project_set))
elif isinstance(profile, AbstractCardinalProfile):
alpha_min = {p: min(b[p] for b in group) for p in project_set}
if is_cohesive_cardinal(
instance, profile, project_set, group, alpha_min
):
res.append((group, project_set))
else:
raise NotImplementedError(
f"We cannot find cohesive groups in a profile of type {type(profile)}. "
f"Only approval and cardinal profiles are supported."
)

if sample_size is None:
group_project_pairs = (
(group, project_set)
for group in powerset(profile)
for project_set in powerset(projects)
)
else:
def _sampled_pairs():
for _ in range(sample_size):
group = (
random.sample(ballots, random.randint(1, len(ballots)))
if ballots
else []
)
project_set = (
random.sample(projects, random.randint(1, len(projects)))
if projects
else []
)
yield group, project_set

group_project_pairs = _sampled_pairs()

for group, project_set in group_project_pairs:
if len(group) > 0 and len(project_set) > 0:
# This fails for multiprofiles as the multiplicity is not taken into account
if isinstance(profile, AbstractApprovalProfile):
if is_cohesive_approval(instance, profile, project_set, group):
res.append((group, project_set))
elif isinstance(profile, AbstractCardinalProfile):
alpha_min = {p: min(b[p] for b in group) for p in project_set}
if is_cohesive_cardinal(
instance, profile, project_set, group, alpha_min
):
res.append((group, project_set))
else:
raise NotImplementedError(
f"We cannot find cohesive groups in a profile of type {type(profile)}. "
f"Only approval and cardinal profiles are supported."
)
return res


Expand Down
200 changes: 197 additions & 3 deletions pabutools/analysis/justifiedrepresentation.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from __future__ import annotations

import random
from collections.abc import Collection, Callable, Iterable
import gurobipy as gp
from itertools import combinations
try:
import gurobipy as gp
_GUROBI_AVAILABLE = True
except ImportError:
gp = None
_GUROBI_AVAILABLE = False

from pabutools.utils import Numeric

Expand Down Expand Up @@ -252,12 +259,20 @@ def is_EJR_approval(
sat_class: type[SatisfactionMeasure],
budget_allocation: Collection[Project],
up_to_func: Callable[[Iterable[Numeric]], Numeric] | None = None,
sample_size: int | None = None,
) -> bool:
"""
Test if a budget allocation satisfies EJR for the given instance and the given profile of
approval ballots.

Parameters
----------
sample_size : int, optional
If `None` (default), exhaustively check every cohesive group. If set
to an int, randomly sample that many groups instead — use this to
keep runtime bounded on large instances.
"""
for group, project_set in cohesive_groups(instance, profile):
for group, project_set in cohesive_groups(instance, profile, sample_size=sample_size):
one_agent_sat = False
for ballot in group:
sat = sat_class(instance, profile, ballot)
Expand Down Expand Up @@ -320,12 +335,20 @@ def is_PJR_approval(
sat_class: type[SatisfactionMeasure],
budget_allocation: Collection[Project],
up_to_func: Callable[[Iterable[Numeric]], Numeric] | None = None,
sample_size: int | None = None,
) -> bool:
"""
Test if a budget allocation satisfies PJR for the given instance and the given profile of
approval ballots.

Parameters
----------
sample_size : int, optional
If `None` (default), exhaustively check every cohesive group. If set
to an int, randomly sample that many groups instead — use this to
keep runtime bounded on large instances.
"""
for group, project_set in cohesive_groups(instance, profile):
for group, project_set in cohesive_groups(instance, profile, sample_size=sample_size):
sat = sat_class(instance, profile, ApprovalBallot(instance))
threshold = sat.sat(project_set)
group_approved = {p for p in budget_allocation if any(p in b for b in group)}
Expand Down Expand Up @@ -515,3 +538,174 @@ def is_PJR_one_cardinal(
return is_PJR_cardinal(
instance, profile, budget_allocation, up_to_func=lambda x: max(x, default=0)
)


def is_FJR_approval(
instance: Instance,
profile: AbstractApprovalProfile,
sat_class: type[SatisfactionMeasure],
budget_allocation: Collection[Project],
max_subset_size: int | None = None,
sample_size: int | None = None,
) -> bool:
"""
Test whether a budget allocation satisfies Full Justified Representation (FJR),
per Definition 5.3 of Aziz et al. (2024) "Fair Lotteries for Participatory
Budgeting" (the same weakly (beta,T)-cohesive group definition used by
:func:`~pabutools.rules.gcr.gcr_rule.greedy_cohesive_rule`).

For each candidate project set T and threshold beta (1 <= beta <= |T|), the
group S of voters with positive satisfaction for at least beta projects in T
is formed. If S's fair share of the budget can afford T, FJR requires at
least one voter in S to have utility >= beta in the outcome. Returns False
as soon as a violation is found.

Parameters
----------
instance : Instance
The PB instance (projects + budget limit).
profile : AbstractApprovalProfile
The voters' approval ballots.
sat_class : type[SatisfactionMeasure]
Class defining how voter satisfaction is measured.
budget_allocation : Collection[Project]
The allocation being tested.
max_subset_size : int, optional
Caps |T| in the search (None = unlimited, exponential but exhaustive).
sample_size : int, optional
If `None` (default), exhaustively check every (T, beta) pair via
`combinations`. If set to an int, randomly sample that many (T, beta)
pairs instead — use this to keep runtime bounded on large instances.

Returns
-------
bool
True if no FJR violation was found.
"""
projects = list(instance)
n = profile.num_ballots()
B = instance.budget_limit
sat_per_voter = [sat_class(instance, profile, ballot) for ballot in profile]

max_size = max_subset_size if max_subset_size is not None else len(projects)

if sample_size is None:
candidates = (
(T, beta)
for r in range(1, max_size + 1)
for T in combinations(projects, r)
for beta in range(1, r + 1)
)
else:
def _sampled_candidates():
for _ in range(sample_size):
r = random.randint(1, max_size)
T = tuple(random.sample(projects, r))
beta = random.randint(1, r)
yield T, beta

candidates = _sampled_candidates()

for T, beta in candidates:
cost_T = total_cost(T)
if cost_T <= 0 or cost_T > B:
continue

S_idx = [
i
for i, sat in enumerate(sat_per_voter)
if sum(1 for p in T if sat.sat_project(p) > 0) >= beta
]
if not S_idx:
continue
if not is_large_enough(len(S_idx), n, cost_T, B):
continue

exists_satisfied = any(
sat_per_voter[i].sat(budget_allocation) >= beta for i in S_idx
)
if not exists_satisfied:
return False
return True


def is_strong_UFS_approval(
instance: Instance,
profile: AbstractApprovalProfile,
sat_class: type[SatisfactionMeasure],
fractional_allocation: dict[Project, Numeric],
sample_size: int | None = None,
tolerance: Numeric = 1e-7,
) -> bool:
"""
Test whether a fractional outcome satisfies strong Unanimous Fair Share (UFS).

Considers unanimous groups S (all voters in S have identical approval
ballots) and compares the algorithm's fractional utility for a member of S
against the optimal fractional utility achievable with S's fair share of
the budget. Returns False as soon as a violation is found.

Parameters
----------
instance : Instance
The PB instance (projects + budget limit).
profile : AbstractApprovalProfile
The voters' approval ballots.
sat_class : type[SatisfactionMeasure]
Class defining how voter satisfaction is measured.
fractional_allocation : dict[Project, Numeric]
The probability/share assigned to each project by the algorithm.
sample_size : int, optional
If `None` (default), exhaustively check every unanimous group. If set
to an int, randomly sample that many groups instead — use this to
keep runtime bounded on large instances.
tolerance : Numeric, optional
Numerical tolerance for the utility comparison. Defaults to 1e-7.

Returns
-------
bool
True if no strong UFS violation was found.
"""
ballots = list(profile)
n = profile.num_ballots()
B = instance.budget_limit

if sample_size is None:
group_iter = (group for group in powerset(profile) if len(group) > 0)
else:
def _sampled_groups():
for _ in range(sample_size):
yield random.sample(ballots, random.randint(1, len(ballots)))

group_iter = _sampled_groups()

for group in group_iter:
if len({frozenset(b) for b in group}) > 1:
continue # not unanimous

sat = sat_class(instance, profile, group[0])
B_S = (len(group) / n) * B

util_alg = sum(
fractional_allocation.get(p, 0)
for p in instance
if sat.sat_project(p) > 0
)

liked_projects = sorted(
(p for p in instance if sat.sat_project(p) > 0), key=lambda p: p.cost
)
util_opt = 0.0
remaining_B = B_S
for p in liked_projects:
if p.cost <= remaining_B:
util_opt += 1.0
remaining_B -= p.cost
else:
util_opt += remaining_B / p.cost
break

if not util_alg + tolerance >= util_opt:
return False
return True
21 changes: 21 additions & 0 deletions pabutools/election/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,3 +544,24 @@ def get_random_instance(num_projects: int, min_cost: int, max_cost: int) -> Inst
ceil(min(p.cost for p in inst)), ceil(sum(p.cost for p in inst))
)
return inst


def instance_from_project_costs(project_costs: dict, budget_limit: Numeric) -> Instance:
"""
Create an :class:`Instance` from a ``{project_name: cost}`` mapping and a budget limit.

Parameters
----------
project_costs : dict
Maps each project name (str) to its cost (int or float).
budget_limit : Numeric
The total available budget.

Returns
-------
Instance
"""
return Instance(
[Project(name, cost) for name, cost in project_costs.items()],
budget_limit=budget_limit,
)
4 changes: 4 additions & 0 deletions pabutools/election/profile/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@
ApprovalMultiProfile,
get_random_approval_profile,
get_all_approval_profiles,
approval_profile_from_matrix,
)
from pabutools.election.profile.cardinalprofile import (
AbstractCardinalProfile,
CardinalProfile,
CardinalMultiProfile,
cardinal_profile_from_matrix,
)
from pabutools.election.profile.cumulativeprofile import (
AbstractCumulativeProfile,
Expand All @@ -66,9 +68,11 @@
"ApprovalMultiProfile",
"get_random_approval_profile",
"get_all_approval_profiles",
"approval_profile_from_matrix",
"AbstractCardinalProfile",
"CardinalProfile",
"CardinalMultiProfile",
"cardinal_profile_from_matrix",
"AbstractCumulativeProfile",
"CumulativeProfile",
"CumulativeMultiProfile",
Expand Down
Loading
Loading