Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ jobs:
--experts examples/experts.json --conflicts examples/conflicts.json
--affinities examples/affinities.csv --config examples/maximin-config.json
--output maximin-smoke.json
- run: >-
uv run --locked peermatch match-affinity --documents examples/documents.json
--experts examples/experts.json --conflicts examples/conflicts.json
--affinities examples/affinities.csv --config examples/fair-local-config.json
--output fair-local-smoke.json
- run: uv run --locked python -m build --no-isolation
- name: Verify the wheel in a clean environment
shell: bash
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ All notable changes are documented here. The project follows semantic versioning

_No changes yet._

## 0.14.0 - 2026-09-20

- Added opt-in `fair-local` assignment: a maximum-cardinality flow baseline
followed by bounded, deterministic leximin improvements through replacements,
transfers, and swaps. Independent small exhaustive cardinality/constraint
oracles, an explicit three-cycle non-optimality counterexample, a hard
candidate budget, CLI smoke, and cross-platform tests delimit the claim.
This is not OpenReview FairFlow or a globally optimal large-panel solver.

## 0.13.0 - 2026-09-19

- Added an offline-first, bounded OpenReview assignment/aggregate-score edge plan
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ authors:
- family-names: "Yan"
given-names: "Weiping"
alias: "appleweiping"
version: 0.13.0
version: 0.14.0
date-released: 2026-09-19
license: MIT
repository-code: "https://github.com/appleweiping/PeerMatchLab"
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,17 @@ instances fail explicitly rather than silently changing objective. The
[maximin assignment](docs/maximin-assignment.md) for semantics, complexity,
and limitations.

`fair-local` extends document-side fairness beyond the exact small-panel limit.
It starts from a certified maximum-cardinality flow solution and searches
deterministic single-edge replacements, transfers to an unfilled document, and
two-document swaps. Each accepted move improves the sorted vector of document
score totals or resolves an exact tie by stable pair order, without losing a
filled slot or relaxing a hard constraint.
This is a **bounded local heuristic**, not OpenReview FairFlow or a global
maximin guarantee. Its resource limits, a three-cycle counterexample, and
the distinction between feasibility and fairness certificates are documented
in [fair-local assignment](docs/fair-local-assignment.md).

Set `load_balance_penalty` between `0` and `1` to trade a controlled amount of affinity for a more even workload. Each additional assignment to the same expert incurs one more penalty unit (`penalty * current_load`) in the optimization objective. The optimal solver models these convex marginal costs directly in the flow network; the greedy baseline applies the same adjustment at selection time. A value of `0` preserves the unadjusted score objective. Strategy names add `-balanced` when the control is active so exported plans remain self-describing.

### Explaining unmet demand
Expand Down
53 changes: 53 additions & 0 deletions docs/fair-local-assignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Bounded document-side fair-local assignment

Set `"strategy": "fair-local"` in a `peermatch match` or `match-affinity`
JSON config, or use `MatchConfig(strategy="fair-local")` in the Python API.
The normal plan, independent audit, unmet-demand diagnostics, and CLI output
formats are unchanged. [This example](../examples/fair-local-config.json)
uses the default resource budget.

The objective has two stages:

1. Compute an integral maximum-cardinality assignment under the same
eligibility, hard-conflict, score-threshold, expert-capacity, document-demand,
institution-diversity, and reserved-seniority constraints as `optimal`.
2. Accept only moves that lexicographically improve the vector of **all**
document score sums sorted from weakest to strongest. Among exactly equal
vectors, select the lexicographically first sorted pair list. A document
with no selected expert has a score sum of zero.

The local neighborhood comprises a selected edge replaced by another
admissible expert for the same document, an edge transferred to another
document with an open slot, or two selected edges on different documents
whose experts can be swapped. Every candidate is rechecked against the hard
constraints before use. Replacements may change expert loads; swaps and
transfers do not change their total. All moves preserve the number of filled
slots, and strictly ordered accepted states cannot cycle. Raw unrounded scores
are compared; no fairness score is inferred from demographic attributes.

This is **not** the [OpenReview Matcher `FairFlow` algorithm](https://github.com/openreview/openreview-matcher/blob/e6a2dad82880b45560d5b09b5b2236bec13a6cec/matcher/solvers/fairflow.py). That algorithm
searches a paper-score makespan with a lower-bound flow construction and a
different approximation procedure. `fair-local` can stop at a local optimum
that is worse than the global `maximin` result; for example, an improving
three-document cycle may have no improving one- or two-edge move. The tests
include that counterexample. A budget cutoff may stop even before a local
fixed point. The plan's `diagnostics.certified` certifies maximum assignment
cardinality / whole-run demand feasibility, **not** globally optimal fairness.

The default budget is 32 accepted steps and 200,000 considered moves. Set
`fair_local_max_steps` from 1 to 128 and `fair_local_max_checks` from 1 to
2,000,000 if needed; non-default controls with another strategy are rejected.
To bound the initial flow and the local search, the strategy accepts at most
128 documents, 256 experts, 4,096 eligible pairs after minimum-score
filtering, 512 total requested slots, and 4,096 total expert capacity.
Instances outside these limits fail explicitly. This is larger than the
bounded exact `maximin` strategy, but is **not** a full-conference scale claim.
The current scalar `load_balance_penalty` is incompatible with this fairness
objective. As elsewhere in this package, simultaneous seniority floors and
institution diversity are rejected because the flow representation cannot
express both without relaxing one constraint.

`fair-local` does not implement reviewer minimum-load quotas, FairIR's
threshold constraints, group-fairness guarantees, live OpenReview matching,
or an exact global maximin solver at larger scale. Affinity quality and bias
remain the caller's responsibility.
4 changes: 2 additions & 2 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Release process

A push of a semantic-version tag such as `v0.13.0` invokes the release
A push of a semantic-version tag such as `v0.14.0` invokes the release
workflow. The workflow refuses a tag that differs from `project.version`,
installs the committed `uv.lock`, runs the static and coverage-gated suite
(including a separate ≥90% covered-branches/total-branches check),
Expand All @@ -17,7 +17,7 @@ Verify a downloaded file with:

```bash
sha256sum --check SHA256SUMS
gh attestation verify peermatchlab-0.13.0-py3-none-any.whl \
gh attestation verify peermatchlab-0.14.0-py3-none-any.whl \
--repo appleweiping/PeerMatchLab
```

Expand Down
6 changes: 6 additions & 0 deletions examples/fair-local-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"reviewers_per_document": 2,
"strategy": "fair-local",
"fair_local_max_steps": 32,
"fair_local_max_checks": 200000
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "peermatchlab"
version = "0.13.0"
version = "0.14.0"
description = "Transparent, constraint-aware expert matching for offline workflows"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/peermatchlab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,4 @@
"write_publish_artifact",
]

__version__ = "0.13.0"
__version__ = "0.14.0"
104 changes: 104 additions & 0 deletions src/peermatchlab/assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from itertools import combinations
from typing import Protocol

from peermatchlab.fair_local import improve_fair_local
from peermatchlab.models import (
Assignment,
AssignmentDiagnostics,
Expand Down Expand Up @@ -46,6 +47,7 @@ class AssignmentStrategy(StrEnum):
GREEDY = "greedy"
MINMAX = "minmax"
MAXIMIN = "maximin"
FAIR_LOCAL = "fair-local"


@dataclass(slots=True)
Expand Down Expand Up @@ -138,6 +140,8 @@ def assign(
load_balance_penalty: float = 0.0,
minimum_senior_reviewers: int = 0,
senior_threshold: float = 0.75,
fair_local_max_steps: int = 32,
fair_local_max_checks: int = 200_000,
) -> MatchPlan:
"""Create a deterministic assignment plan.

Expand Down Expand Up @@ -171,6 +175,14 @@ def assign(
raise ValueError("minimum_senior_reviewers must not be negative")
if not _finite_number(senior_threshold) or not 0 <= senior_threshold <= 1:
raise ValueError("senior_threshold must be a finite number between 0 and 1")
if isinstance(fair_local_max_steps, bool) or not isinstance(fair_local_max_steps, int):
raise ValueError("fair_local_max_steps must be an integer")
if not 1 <= fair_local_max_steps <= 128:
raise ValueError("fair_local_max_steps must be between 1 and 128")
if isinstance(fair_local_max_checks, bool) or not isinstance(fair_local_max_checks, int):
raise ValueError("fair_local_max_checks must be an integer")
if not 1 <= fair_local_max_checks <= 2_000_000:
raise ValueError("fair_local_max_checks must be between 1 and 2000000")
if minimum_senior_reviewers and require_distinct_institutions:
# The two constraints cannot both be expressed exactly in one
# min-cost flow. Reserving senior slots works by giving those units
Expand All @@ -186,6 +198,10 @@ def assign(
"jointly expressible in this flow network"
)
selected = AssignmentStrategy(strategy)
if selected is not AssignmentStrategy.FAIR_LOCAL and (
fair_local_max_steps != 32 or fair_local_max_checks != 200_000
):
raise ValueError("fair-local resource controls require strategy='fair-local'")
if selected is AssignmentStrategy.MAXIMIN and (
len(self.scorer.documents) > 6 or len(self.scorer.experts) > 8
):
Expand All @@ -194,8 +210,35 @@ def assign(
)
if selected is AssignmentStrategy.MAXIMIN and load_balance_penalty:
raise ValueError("maximin does not support load_balance_penalty")
if selected is AssignmentStrategy.FAIR_LOCAL and load_balance_penalty:
raise ValueError("fair-local does not support load_balance_penalty")
if selected is AssignmentStrategy.FAIR_LOCAL and (
len(self.scorer.documents) > 128 or len(self.scorer.experts) > 256
):
raise ValueError(
"fair-local supports at most 128 documents, 256 experts, and 4096 eligible pairs"
)
if selected is AssignmentStrategy.FAIR_LOCAL and (
sum(self._demand(key, reviewers_per_document) for key in self.scorer.documents) > 512
or sum(expert.capacity for expert in self.scorer.experts.values()) > 4096
):
raise ValueError(
"fair-local supports at most 512 requested slots and 4096 total expert capacity"
)
all_scores = self.scorer.matrix()
scores = tuple(score for score in all_scores if score.eligible)
if selected is AssignmentStrategy.FAIR_LOCAL:
return self._fair_local(
scores,
all_scores,
reviewers_per_document,
minimum_score,
require_distinct_institutions=require_distinct_institutions,
minimum_senior_reviewers=minimum_senior_reviewers,
senior_threshold=float(senior_threshold),
max_steps=fair_local_max_steps,
max_checks=fair_local_max_checks,
)
if selected is AssignmentStrategy.MAXIMIN:
return self._maximin(
scores,
Expand Down Expand Up @@ -765,6 +808,67 @@ def visit(index: int) -> None:
maximum_cardinality_certified=True,
)

def _fair_local(
self,
scores: tuple[MatchScore, ...],
all_scores: tuple[MatchScore, ...],
reviewers_per_document: int,
minimum_score: float,
*,
require_distinct_institutions: bool,
minimum_senior_reviewers: int,
senior_threshold: float,
max_steps: int,
max_checks: int,
) -> MatchPlan:
"""Improve a maximum-cardinality flow plan using bounded local moves."""

admissible_scores = tuple(score for score in scores if score.total >= minimum_score)
if len(admissible_scores) > 4096:
raise ValueError(
"fair-local supports at most 128 documents, 256 experts, and 4096 eligible pairs"
)
admissible = {(score.document_id, score.expert_id): score for score in admissible_scores}
if len(admissible) != len(admissible_scores):
raise ValueError(
"fair-local requires at most one eligible score per document-expert pair"
)
baseline = self._optimal(
scores,
all_scores,
reviewers_per_document,
minimum_score,
require_distinct_institutions=require_distinct_institutions,
load_balance_penalty=0.0,
minimum_senior_reviewers=minimum_senior_reviewers,
senior_threshold=senior_threshold,
strategy_label="fair-local",
)
document_ids = tuple(sorted(self.scorer.documents))
chosen = improve_fair_local(
document_ids=document_ids,
experts=self.scorer.experts,
demands={key: self._demand(key, reviewers_per_document) for key in document_ids},
admissible=admissible,
baseline=baseline,
require_distinct_institutions=require_distinct_institutions,
minimum_senior_reviewers=minimum_senior_reviewers,
senior_threshold=senior_threshold,
max_steps=max_steps,
max_checks=max_checks,
)
return self._to_plan(
chosen,
all_scores,
reviewers_per_document,
minimum_score,
"fair-local",
require_distinct_institutions=require_distinct_institutions,
minimum_senior_reviewers=minimum_senior_reviewers,
senior_threshold=senior_threshold,
maximum_cardinality_certified=True,
)

def _to_plan(
self,
chosen: dict[str, list[MatchScore]],
Expand Down
22 changes: 20 additions & 2 deletions src/peermatchlab/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class MatchConfig:
load_balance_penalty: float = 0.0
minimum_senior_reviewers: int = 0
senior_threshold: float = 0.75
fair_local_max_steps: int = 32
fair_local_max_checks: int = 200_000
weights: Mapping[str, float] = field(
default_factory=lambda: {
"content": 0.50,
Expand All @@ -43,9 +45,9 @@ def __post_init__(self) -> None:
raise DataValidationError("reviewers_per_document must be an integer")
if self.reviewers_per_document < 1:
raise DataValidationError("reviewers_per_document must be positive")
if self.strategy not in {"optimal", "greedy", "minmax", "maximin"}:
if self.strategy not in {"optimal", "greedy", "minmax", "maximin", "fair-local"}:
raise DataValidationError(
"strategy must be 'optimal', 'greedy', 'minmax', or 'maximin'"
"strategy must be 'optimal', 'greedy', 'minmax', 'maximin', or 'fair-local'"
)
if isinstance(self.current_year, bool) or not isinstance(self.current_year, int):
raise DataValidationError("current_year must be an integer")
Expand All @@ -65,6 +67,20 @@ def __post_init__(self) -> None:
raise DataValidationError("load_balance_penalty must be between 0 and 1")
if self.strategy == "maximin" and self.load_balance_penalty:
raise DataValidationError("maximin does not support load_balance_penalty")
if self.strategy == "fair-local" and self.load_balance_penalty:
raise DataValidationError("fair-local does not support load_balance_penalty")
for name, value, maximum in (
("fair_local_max_steps", self.fair_local_max_steps, 128),
("fair_local_max_checks", self.fair_local_max_checks, 2_000_000),
):
if isinstance(value, bool) or not isinstance(value, int):
raise DataValidationError(f"{name} must be an integer")
if not 1 <= value <= maximum:
raise DataValidationError(f"{name} must be between 1 and {maximum}")
if self.strategy != "fair-local" and (
self.fair_local_max_steps != 32 or self.fair_local_max_checks != 200_000
):
raise DataValidationError("fair-local resource controls require strategy='fair-local'")
if isinstance(self.minimum_senior_reviewers, bool) or not isinstance(
self.minimum_senior_reviewers, int
):
Expand Down Expand Up @@ -117,6 +133,8 @@ def from_mapping(cls, value: Mapping[str, Any]) -> MatchConfig:
"load_balance_penalty",
"minimum_senior_reviewers",
"senior_threshold",
"fair_local_max_steps",
"fair_local_max_checks",
"weights",
}
unknown = set(value) - allowed
Expand Down
Loading
Loading