diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa86478..f72f7e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ae87c..5415f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CITATION.cff b/CITATION.cff index cf44315..51795d8 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -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" diff --git a/README.md b/README.md index 7da3687..244cc65 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/fair-local-assignment.md b/docs/fair-local-assignment.md new file mode 100644 index 0000000..18954ac --- /dev/null +++ b/docs/fair-local-assignment.md @@ -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. diff --git a/docs/releasing.md b/docs/releasing.md index 7a4a29d..74f2134 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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), @@ -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 ``` diff --git a/examples/fair-local-config.json b/examples/fair-local-config.json new file mode 100644 index 0000000..1b65136 --- /dev/null +++ b/examples/fair-local-config.json @@ -0,0 +1,6 @@ +{ + "reviewers_per_document": 2, + "strategy": "fair-local", + "fair_local_max_steps": 32, + "fair_local_max_checks": 200000 +} diff --git a/pyproject.toml b/pyproject.toml index 08d5bc5..34b597e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/peermatchlab/__init__.py b/src/peermatchlab/__init__.py index 3c1f2ba..67cbe7e 100644 --- a/src/peermatchlab/__init__.py +++ b/src/peermatchlab/__init__.py @@ -166,4 +166,4 @@ "write_publish_artifact", ] -__version__ = "0.13.0" +__version__ = "0.14.0" diff --git a/src/peermatchlab/assignment.py b/src/peermatchlab/assignment.py index 690a05f..e0ac51a 100644 --- a/src/peermatchlab/assignment.py +++ b/src/peermatchlab/assignment.py @@ -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, @@ -46,6 +47,7 @@ class AssignmentStrategy(StrEnum): GREEDY = "greedy" MINMAX = "minmax" MAXIMIN = "maximin" + FAIR_LOCAL = "fair-local" @dataclass(slots=True) @@ -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. @@ -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 @@ -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 ): @@ -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, @@ -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]], diff --git a/src/peermatchlab/config.py b/src/peermatchlab/config.py index 602296d..dfa523a 100644 --- a/src/peermatchlab/config.py +++ b/src/peermatchlab/config.py @@ -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, @@ -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") @@ -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 ): @@ -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 diff --git a/src/peermatchlab/fair_local.py b/src/peermatchlab/fair_local.py new file mode 100644 index 0000000..dd464b9 --- /dev/null +++ b/src/peermatchlab/fair_local.py @@ -0,0 +1,212 @@ +"""Bounded, deterministic local leximin improvement of a feasible assignment. + +This is deliberately not an implementation of OpenReview's FairFlow algorithm. +The caller supplies a certified maximum-cardinality flow solution; every move +preserves its cardinality and is checked against the same hard constraints. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterator, Mapping +from itertools import combinations, islice + +from peermatchlab.models import Expert, MatchPlan, MatchScore + +Pair = tuple[str, str] +Move = tuple[tuple[Pair, ...], tuple[Pair, ...]] + + +def _neighbors( + selected: set[Pair], + by_document: Mapping[str, tuple[MatchScore, ...]], + by_expert: Mapping[str, tuple[MatchScore, ...]], + admissible: Mapping[Pair, MatchScore], +) -> Iterator[Move]: + """Yield one-edge replacements, transfers, then two-document swaps.""" + + pairs = sorted(selected) + for document_id, old_expert in pairs: + for score in by_document[document_id]: + if score.expert_id != old_expert: + yield (((document_id, old_expert),), ((document_id, score.expert_id),)) + for old_document, expert_id in pairs: + for score in by_expert[expert_id]: + if score.document_id != old_document: + yield (((old_document, expert_id),), ((score.document_id, expert_id),)) + for (first_document, first_expert), (second_document, second_expert) in combinations(pairs, 2): + if first_document == second_document or first_expert == second_expert: + continue + first_new = (first_document, second_expert) + second_new = (second_document, first_expert) + if first_new in admissible and second_new in admissible: + yield ( + ((first_document, first_expert), (second_document, second_expert)), + (first_new, second_new), + ) + + +def _valid_document( + document_id: str, + selected_experts: set[str], + *, + experts: Mapping[str, Expert], + demands: Mapping[str, int], + require_distinct_institutions: bool, + minimum_senior_reviewers: int, + senior_threshold: float, +) -> bool: + demand = demands[document_id] + if len(selected_experts) > demand: + return False + free_slots = demand - min(minimum_senior_reviewers, demand) + if sum(experts[key].seniority < senior_threshold for key in selected_experts) > free_slots: + return False + if require_distinct_institutions: + known = [ + experts[key].institution + for key in selected_experts + if experts[key].institution is not None + ] + if len(known) != len(set(known)): + return False + return True + + +def improve_fair_local( + *, + document_ids: tuple[str, ...], + experts: Mapping[str, Expert], + demands: Mapping[str, int], + admissible: Mapping[Pair, MatchScore], + baseline: MatchPlan, + require_distinct_institutions: bool, + minimum_senior_reviewers: int, + senior_threshold: float, + max_steps: int, + max_checks: int, +) -> dict[str, list[MatchScore]]: + """Improve the sorted document-score vector without reducing cardinality. + + The search is not globally optimal and a budget cutoff can stop before a + local fixed point. Only a strict lexicographic improvement, or a stable + pair-order tie, can be accepted, so neither cycling nor fairness regression + is possible. All score comparisons use the original unrounded affinities. + """ + + by_document: dict[str, tuple[MatchScore, ...]] = { + document_id: tuple( + sorted( + (score for score in admissible.values() if score.document_id == document_id), + key=lambda score: score.expert_id, + ) + ) + for document_id in document_ids + } + by_expert: dict[str, tuple[MatchScore, ...]] = { + expert_id: tuple( + sorted( + (score for score in admissible.values() if score.expert_id == expert_id), + key=lambda score: score.document_id, + ) + ) + for expert_id in sorted(experts) + } + selected: set[Pair] = { + (assignment.document_id, assignment.expert_id) for assignment in baseline.assignments + } + # For equal-sized pair sets, the lexicographically first sorted pair list + # has the highest bit at their earliest difference. A fixed-width bitmask + # compares that tie-break without sorting hundreds of pairs per candidate. + pair_bits = { + pair: 1 << (len(admissible) - index - 1) for index, pair in enumerate(sorted(admissible)) + } + selected_mask = sum(pair_bits[pair] for pair in selected) + chosen: dict[str, set[str]] = {document_id: set() for document_id in document_ids} + loads = dict.fromkeys(experts, 0) + for document_id, expert_id in selected: + chosen[document_id].add(expert_id) + loads[expert_id] += 1 + + def total(document_id: str, expert_ids: set[str]) -> float: + return math.fsum(admissible[(document_id, expert_id)].total for expert_id in expert_ids) + + totals = {document_id: total(document_id, chosen[document_id]) for document_id in document_ids} + checks = 0 + for _ in range(max_steps): + best_vector = tuple(sorted(totals.values())) + best_mask = selected_mask + best_move: Move | None = None + for removed, added in islice( + _neighbors(selected, by_document, by_expert, admissible), max_checks - checks + ): + checks += 1 + if any(pair in selected for pair in added) or any( + pair not in selected for pair in removed + ): + continue + # Copy only the affected documents. This also catches a candidate + # that would duplicate an expert within one document. + affected = {document_id for document_id, _ in (*removed, *added)} + proposal = {document_id: set(chosen[document_id]) for document_id in affected} + for document_id, expert_id in removed: + proposal[document_id].remove(expert_id) + for document_id, expert_id in added: + proposal[document_id].add(expert_id) + if sum(map(len, proposal.values())) != sum(len(chosen[key]) for key in affected): + continue + if any( + not _valid_document( + document_id, + proposal[document_id], + experts=experts, + demands=demands, + require_distinct_institutions=require_distinct_institutions, + minimum_senior_reviewers=minimum_senior_reviewers, + senior_threshold=senior_threshold, + ) + for document_id in affected + ): + continue + proposed_loads = {expert_id: loads[expert_id] for _, expert_id in (*removed, *added)} + for _, expert_id in removed: + proposed_loads[expert_id] -= 1 + for _, expert_id in added: + proposed_loads[expert_id] += 1 + if any( + load > experts[expert_id].capacity for expert_id, load in proposed_loads.items() + ): + continue + proposed_totals = dict(totals) + for document_id in affected: + proposed_totals[document_id] = total(document_id, proposal[document_id]) + vector = tuple(sorted(proposed_totals.values())) + if vector < best_vector: + continue + candidate_mask = selected_mask + for pair in (*removed, *added): + candidate_mask ^= pair_bits[pair] + if vector > best_vector or candidate_mask > best_mask: + best_vector, best_mask, best_move = vector, candidate_mask, (removed, added) + if best_move is None: + break + removed, added = best_move + for document_id, expert_id in removed: + selected.remove((document_id, expert_id)) + chosen[document_id].remove(expert_id) + loads[expert_id] -= 1 + for document_id, expert_id in added: + selected.add((document_id, expert_id)) + chosen[document_id].add(expert_id) + loads[expert_id] += 1 + selected_mask = best_mask + for document_id in {document_id for document_id, _ in (*removed, *added)}: + totals[document_id] = total(document_id, chosen[document_id]) + if checks >= max_checks: + break + return { + document_id: [ + admissible[(document_id, expert_id)] for expert_id in sorted(chosen[document_id]) + ] + for document_id in document_ids + } diff --git a/src/peermatchlab/pipeline.py b/src/peermatchlab/pipeline.py index 4a4fa20..94888c2 100644 --- a/src/peermatchlab/pipeline.py +++ b/src/peermatchlab/pipeline.py @@ -51,6 +51,8 @@ def run_matching( load_balance_penalty=selected.load_balance_penalty, minimum_senior_reviewers=selected.minimum_senior_reviewers, senior_threshold=selected.senior_threshold, + fair_local_max_steps=selected.fair_local_max_steps, + fair_local_max_checks=selected.fair_local_max_checks, ) audit = audit_plan( plan, @@ -91,6 +93,8 @@ def run_affinity_matching( load_balance_penalty=selected.load_balance_penalty, minimum_senior_reviewers=selected.minimum_senior_reviewers, senior_threshold=selected.senior_threshold, + fair_local_max_steps=selected.fair_local_max_steps, + fair_local_max_checks=selected.fair_local_max_checks, ) audit = audit_plan( plan, diff --git a/tests/test_fair_local.py b/tests/test_fair_local.py new file mode 100644 index 0000000..f52d7aa --- /dev/null +++ b/tests/test_fair_local.py @@ -0,0 +1,429 @@ +"""Independent feasibility oracle and limits for bounded local fairness.""" + +from __future__ import annotations + +import itertools +import json +import math +import random +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from peermatchlab import fair_local as fair_local_module +from peermatchlab.affinity import Affinity +from peermatchlab.assignment import AssignmentEngine +from peermatchlab.cli import main +from peermatchlab.config import MatchConfig +from peermatchlab.models import DataValidationError, Document, Expert, MatchPlan, MatchScore +from peermatchlab.pipeline import run_affinity_matching + + +@dataclass +class _Scorer: + documents: dict[str, Document] + experts: dict[str, Expert] + scores: tuple[MatchScore, ...] + + def matrix(self) -> tuple[MatchScore, ...]: + return self.scores + + +def _score(document: str, expert: str, value: float, *, eligible: bool = True) -> MatchScore: + return MatchScore(document, expert, value, value, 0, 0, 0, 0, eligible=eligible) + + +def _vector(plan: MatchPlan, documents: dict[str, Document]) -> tuple[float, ...]: + return tuple( + sorted( + math.fsum(item.score for item in plan.assignments if item.document_id == document) + for document in documents + ) + ) + + +def _assert_valid( + plan: MatchPlan, + scorer: _Scorer, + *, + diverse: bool, + senior_floor: int, +) -> None: + permitted = { + (edge.document_id, edge.expert_id): edge for edge in scorer.scores if edge.eligible + } + pairs = [(item.document_id, item.expert_id) for item in plan.assignments] + assert len(pairs) == len(set(pairs)) + assert all(pair in permitted for pair in pairs) + assert all( + item.score == permitted[(item.document_id, item.expert_id)].total + for item in plan.assignments + ) + for document_id, document in scorer.documents.items(): + selected = [expert for current, expert in pairs if current == document_id] + demand = document.required_experts or 1 + assert len(selected) <= demand + assert sum(scorer.experts[key].seniority < 0.75 for key in selected) <= demand - min( + senior_floor, demand + ) + known = [ + scorer.experts[key].institution + for key in selected + if scorer.experts[key].institution is not None + ] + assert not diverse or len(known) == len(set(known)) + assert plan.unmet.get(document_id, 0) == demand - len(selected) + for expert_id, expert in scorer.experts.items(): + assert sum(key == expert_id for _, key in pairs) <= expert.capacity + + +def _oracle_cardinality( + scorer: _Scorer, + *, + diverse: bool, + senior_floor: int, + senior_threshold: float = 0.75, +) -> int: + """Check every subset of independently declared edges, not solver moves.""" + + edges = tuple(score for score in scorer.scores if score.eligible) + best = 0 + for enabled in itertools.product((False, True), repeat=len(edges)): + picked = [edge for edge, use in zip(edges, enabled, strict=True) if use] + by_document = {key: [] for key in scorer.documents} + by_expert = {key: 0 for key in scorer.experts} + for edge in picked: + by_document[edge.document_id].append(edge.expert_id) + by_expert[edge.expert_id] += 1 + if any(by_expert[key] > expert.capacity for key, expert in scorer.experts.items()): + continue + valid = True + for document_id, document in scorer.documents.items(): + selected = by_document[document_id] + demand = document.required_experts or 1 + if len(selected) > demand: + valid = False + break + free = demand - min(senior_floor, demand) + if sum(scorer.experts[key].seniority < senior_threshold for key in selected) > free: + valid = False + break + affiliations = [ + scorer.experts[key].institution + for key in selected + if scorer.experts[key].institution is not None + ] + if diverse and len(affiliations) != len(set(affiliations)): + valid = False + break + if valid: + best = max(best, len(picked)) + return best + + +def test_fair_local_improves_weakest_document_without_losing_cardinality() -> None: + documents = [Document("d1", "One"), Document("d2", "Two")] + experts = [Expert("e1", "One", capacity=1), Expert("e2", "Two", capacity=1)] + affinities = [ + Affinity("d1", "e1", 0.95), + Affinity("d1", "e2", 0.75), + Affinity("d2", "e1", 0.75), + Affinity("d2", "e2", 0.70), + ] + baseline = run_affinity_matching( + documents, experts, affinities, config=MatchConfig(reviewers_per_document=1) + ) + fair = run_affinity_matching( + documents, + experts, + affinities, + config=MatchConfig(strategy="fair-local", reviewers_per_document=1), + ) + assert _vector(baseline.plan, {item.id: item for item in documents}) == (0.70, 0.95) + assert _vector(fair.plan, {item.id: item for item in documents}) == (0.75, 0.75) + assert len(fair.plan.assignments) == len(baseline.plan.assignments) == 2 + assert fair.plan.strategy == "fair-local" + assert fair.plan.diagnostics is not None and fair.plan.diagnostics.certified + assert fair.audit.safe + + +def test_fair_local_transfer_improves_unfilled_document_and_preserves_certificate() -> None: + documents = { + "d1": Document("d1", "One", required_experts=2), + "d2": Document("d2", "Two", required_experts=1), + } + experts = { + "e1": Expert("e1", "One", capacity=1), + "e2": Expert("e2", "Two", capacity=1), + } + scorer = _Scorer( + documents, + experts, + (_score("d1", "e1", 1), _score("d1", "e2", 0.9), _score("d2", "e2", 0.8)), + ) + engine = AssignmentEngine(scorer) + baseline = engine.assign(reviewers_per_document=1) + fair = engine.assign(strategy="fair-local", reviewers_per_document=1) + assert _vector(baseline, documents) == (0.0, 1.9) + assert _vector(fair, documents) == (0.8, 1.0) + assert len(fair.assignments) == len(baseline.assignments) == 2 + assert fair.unmet == {"d1": 1} + assert fair.diagnostics is not None and fair.diagnostics.certified + _assert_valid(fair, scorer, diverse=False, senior_floor=0) + + +def test_fair_local_is_not_misrepresented_as_global_maximin() -> None: + scorer = _Scorer( + {f"d{i}": Document(f"d{i}", "D") for i in range(3)}, + {f"e{i}": Expert(f"e{i}", "E", capacity=1) for i in range(3)}, + ( + _score("d0", "e0", 1.0), + _score("d0", "e1", 0.7), + _score("d1", "e1", 1.0), + _score("d1", "e2", 0.7), + _score("d2", "e2", 0.4), + _score("d2", "e0", 0.7), + ), + ) + engine = AssignmentEngine(scorer) + local = engine.assign(strategy="fair-local", reviewers_per_document=1) + exact = engine.assign(strategy="maximin", reviewers_per_document=1) + assert _vector(local, scorer.documents) == (0.4, 1.0, 1.0) + assert _vector(exact, scorer.documents) == (0.7, 0.7, 0.7) + + +@pytest.mark.parametrize("seed", range(20)) +def test_fixed_pair_bitmask_order_matches_sorted_pair_lists_for_multi_edge_moves(seed: int) -> None: + """Independent combinatorial check of the exact tie-order identity.""" + + rng = random.Random(seed) + universe = sorted((f"d{i}", f"e{j}") for i in range(4) for j in range(4)) + weights = {pair: 1 << (len(universe) - index - 1) for index, pair in enumerate(universe)} + for _ in range(50): + size = rng.randrange(4, 13) + before = set(rng.sample(universe, size)) + count = rng.randrange(1, min(3, size, len(universe) - size) + 1) + removed = set(rng.sample(sorted(before), count)) + added = set(rng.sample(sorted(set(universe) - before), count)) + after = (before - removed) | added + before_mask = sum(weights[pair] for pair in before) + after_mask = before_mask + for pair in removed | added: + after_mask ^= weights[pair] + assert (after_mask > before_mask) == (tuple(sorted(after)) < tuple(sorted(before))) + assert (after_mask < before_mask) == (tuple(sorted(after)) > tuple(sorted(before))) + + +@pytest.mark.parametrize("seed", range(20)) +def test_fair_local_matches_independent_cardinality_oracle_and_never_regresses(seed: int) -> None: + rng = random.Random(seed) + documents = {f"d{i}": Document(f"d{i}", "D", required_experts=1 + i % 2) for i in range(3)} + experts = { + f"e{i}": Expert( + f"e{i}", + "E", + capacity=rng.randrange(3), + institution=("A" if i < 2 else None), + seniority=(0.9 if i % 2 else 0.1), + ) + for i in range(4) + } + scores = tuple( + _score(document, expert, rng.randrange(11) / 10, eligible=rng.choice((True, False))) + for document in documents + for expert in experts + if rng.choice((True, False)) + ) + scorer = _Scorer(documents, experts, scores) + diverse = bool(seed % 2) + senior_floor = 1 if not diverse and seed % 3 == 0 else 0 + engine = AssignmentEngine(scorer) + baseline = engine.assign( + reviewers_per_document=1, + require_distinct_institutions=diverse, + minimum_senior_reviewers=senior_floor, + ) + fair = engine.assign( + strategy="fair-local", + reviewers_per_document=1, + require_distinct_institutions=diverse, + minimum_senior_reviewers=senior_floor, + ) + assert ( + len(fair.assignments) + == len(baseline.assignments) + == _oracle_cardinality(scorer, diverse=diverse, senior_floor=senior_floor) + ) + assert _vector(fair, documents) >= _vector(baseline, documents) + _assert_valid(fair, scorer, diverse=diverse, senior_floor=senior_floor) + assert fair == engine.assign( + strategy="fair-local", + reviewers_per_document=1, + require_distinct_institutions=diverse, + minimum_senior_reviewers=senior_floor, + ) + + +def test_fair_local_exceeds_exact_search_dimensions_and_respects_budget() -> None: + scorer = _Scorer( + {f"d{i}": Document(f"d{i}", "D") for i in range(12)}, + {f"e{i}": Expert(f"e{i}", "E", capacity=1) for i in range(15)}, + tuple( + _score(f"d{i}", f"e{j}", ((i + j) % 10 + 1) / 10) + for i in range(12) + for j in range(15) + if (i + j) % 3 == 0 + ), + ) + engine = AssignmentEngine(scorer) + baseline = engine.assign(reviewers_per_document=1) + fair = engine.assign(strategy="fair-local", reviewers_per_document=1, fair_local_max_checks=3) + assert len(fair.assignments) == len(baseline.assignments) + assert _vector(fair, scorer.documents) >= _vector(baseline, scorer.documents) + assert fair.diagnostics is not None and fair.diagnostics.certified + + +def test_fair_local_does_not_consume_more_than_the_candidate_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scorer = _Scorer( + {"d1": Document("d1", "D"), "d2": Document("d2", "D")}, + {"e1": Expert("e1", "E", capacity=1), "e2": Expert("e2", "E", capacity=1)}, + tuple( + _score(document, expert, 0.5) for document in ("d1", "d2") for expert in ("e1", "e2") + ), + ) + original = fair_local_module._neighbors + generated = 0 + + def counted(*args: object, **kwargs: object): + nonlocal generated + for move in original(*args, **kwargs): + generated += 1 + if generated > 3: + raise AssertionError("candidate budget exceeded") + yield move + + monkeypatch.setattr(fair_local_module, "_neighbors", counted) + plan = AssignmentEngine(scorer).assign( + strategy="fair-local", reviewers_per_document=1, fair_local_max_checks=3 + ) + assert generated == 3 + assert len(plan.assignments) == 2 + + +def test_fair_local_rejects_excess_requested_slots_before_scoring() -> None: + class ExplodingScorer: + def __init__(self) -> None: + self.documents = {"d": Document("d", "D", required_experts=513)} + self.experts = {"e": Expert("e", "E", capacity=1)} + + def matrix(self) -> tuple[MatchScore, ...]: + raise AssertionError("must not score outside declared limits") + + with pytest.raises(ValueError, match="512 requested slots"): + AssignmentEngine(ExplodingScorer()).assign(strategy="fair-local") + + +def test_fair_local_rejects_excess_eligible_pairs() -> None: + scorer = _Scorer( + {f"d{i}": Document(f"d{i}", "D") for i in range(65)}, + {f"e{i}": Expert(f"e{i}", "E", capacity=1) for i in range(64)}, + tuple(_score(f"d{i}", f"e{j}", 0.5) for i in range(65) for j in range(64)), + ) + with pytest.raises(ValueError, match="4096 eligible pairs"): + AssignmentEngine(scorer).assign(strategy="fair-local") + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("fair_local_max_steps", True), + ("fair_local_max_steps", 0), + ("fair_local_max_steps", 129), + ("fair_local_max_checks", False), + ("fair_local_max_checks", 0), + ("fair_local_max_checks", 2_000_001), + ], +) +def test_fair_local_rejects_invalid_resource_controls(field: str, value: object) -> None: + with pytest.raises(DataValidationError, match=field): + MatchConfig.from_mapping({"strategy": "fair-local", field: value}) + scorer = _Scorer({"d": Document("d", "D")}, {"e": Expert("e", "E")}, ()) + with pytest.raises(ValueError, match=field): + AssignmentEngine(scorer).assign(strategy="fair-local", **{field: value}) + + +def test_fair_local_rejects_competing_load_objective_and_duplicate_pairs() -> None: + with pytest.raises(DataValidationError, match="does not support"): + MatchConfig(strategy="fair-local", load_balance_penalty=0.1) + scorer = _Scorer( + {"d": Document("d", "D")}, + {"e": Expert("e", "E")}, + (_score("d", "e", 0.8), _score("d", "e", 0.9)), + ) + with pytest.raises(ValueError, match="one eligible score"): + AssignmentEngine(scorer).assign(strategy="fair-local") + with pytest.raises(ValueError, match="does not support"): + AssignmentEngine(scorer).assign(strategy="fair-local", load_balance_penalty=0.1) + + +def test_fair_local_controls_are_not_silently_ignored_by_other_strategies() -> None: + with pytest.raises(DataValidationError, match="require strategy"): + MatchConfig(strategy="optimal", fair_local_max_checks=2) + scorer = _Scorer({"d": Document("d", "D")}, {"e": Expert("e", "E")}, ()) + with pytest.raises(ValueError, match="require strategy"): + AssignmentEngine(scorer).assign(strategy="optimal", fair_local_max_steps=2) + + +def test_fair_local_dimension_limits_precede_score_generation() -> None: + class ExplodingScorer: + def __init__(self) -> None: + self.documents = {f"d{i}": Document(f"d{i}", "D") for i in range(129)} + self.experts = {"e": Expert("e", "E")} + + def matrix(self) -> tuple[MatchScore, ...]: + raise AssertionError("must not score outside declared dimensions") + + with pytest.raises(ValueError, match="at most 128 documents"): + AssignmentEngine(ExplodingScorer()).assign(strategy="fair-local") + + +def test_fair_local_cli_smoke(tmp_path: Path) -> None: + documents = tmp_path / "documents.json" + experts = tmp_path / "experts.json" + affinities = tmp_path / "affinities.csv" + config = tmp_path / "config.json" + output = tmp_path / "plan.json" + documents.write_text(json.dumps([{"id": "d1", "title": "One"}, {"id": "d2", "title": "Two"}])) + experts.write_text( + json.dumps( + [{"id": "e1", "name": "One", "capacity": 1}, {"id": "e2", "name": "Two", "capacity": 1}] + ) + ) + affinities.write_text( + "document_id,expert_id,score\nd1,e1,0.95\nd1,e2,0.75\nd2,e1,0.75\nd2,e2,0.70\n" + ) + config.write_text(json.dumps({"strategy": "fair-local", "reviewers_per_document": 1})) + assert ( + main( + [ + "match-affinity", + "--documents", + str(documents), + "--experts", + str(experts), + "--affinities", + str(affinities), + "--config", + str(config), + "--output", + str(output), + ] + ) + == 0 + ) + exported = json.loads(output.read_text(encoding="utf-8")) + assert exported["strategy"] == "fair-local" + assert {item["score"] for item in exported["assignments"]} == {0.75} diff --git a/uv.lock b/uv.lock index 52bf51f..2cd743c 100644 --- a/uv.lock +++ b/uv.lock @@ -478,7 +478,7 @@ wheels = [ [[package]] name = "peermatchlab" -version = "0.13.0" +version = "0.14.0" source = { editable = "." } [package.optional-dependencies]