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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ 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/maximin-flow-config.json
--output maximin-flow-smoke.json
- run: uv run --locked python scripts/benchmark_maximin_flow.py
- run: >-
uv run --locked peermatch match-affinity --documents examples/documents.json
--experts examples/experts.json --conflicts examples/conflicts.json
Expand All @@ -99,6 +105,7 @@ jobs:
.ci-wheel-check/bin/python -c "from peermatchlab.keyphrases import KeyphraseConfig, rank_keyphrases; assert rank_keyphrases('alpha beta', config=KeyphraseConfig())"
.ci-wheel-check/bin/python -c "from peermatchlab import CentroidConfig, train_keyphrase_centroid; assert CentroidConfig and train_keyphrase_centroid"
.ci-wheel-check/bin/python -c "from peermatchlab.what_if import WhatIfPlan, compare_what_if; assert WhatIfPlan and compare_what_if"
.ci-wheel-check/bin/python -c "from peermatchlab.assignment import AssignmentStrategy; assert AssignmentStrategy.MAXIMIN_FLOW.value == 'maximin-flow'"
.ci-wheel-check/bin/peermatch --help

tests:
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ All notable changes are documented here. The project follows semantic versioning

_No changes yet._

## 0.17.0 - 2026-09-20

- Added bounded `maximin-flow`: a deterministic integral-flow bottleneck
optimization over the minimum selected document–reviewer pair score at
maximum cardinality, with independent exhaustive oracles, exact resource
limits, CLI/CI/wheel smoke, and an audited synthetic benchmark. This is not
the existing per-document-sum `maximin` or OpenReview FairFlow.

## 0.16.0 - 2026-09-20

- Added an original, bounded local trainable keyphrase-centroid expertise
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.16.0
version: 0.17.0
date-released: 2026-09-20
license: MIT
repository-code: "https://github.com/appleweiping/PeerMatchLab"
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Most matching prototypes stop after computing pairwise similarity. Real allocati
- Content similarity, explicit topic overlap, bid preference, publication recency, and seniority components.
- Exact hard-conflict and zero-capacity exclusion before optimization.
- Integral min-cost-flow assignment that maximizes total score.
- Bounded [edge-bottleneck flow assignment](docs/maximin-flow-assignment.md) that
certifies the best minimum selected pair score at maximum coverage.
- Exact or round-robin assignment with optional per-document institution diversity.
- Per-document demand overrides and minimum acceptable score thresholds.
- Machine-readable unmet-demand diagnostics for conflicts, zero capacity, score filtering, sparse
Expand Down Expand Up @@ -118,8 +120,15 @@ flowchart LR
M --> O{Assignment strategy}
O -->|optimal| F[Min-cost integral flow]
O -->|minmax| MM[Binary-searched load cap + flow]
O -->|maximin-flow| MF[Binary-searched score floor + flow]
O -->|maximin| MX[Bounded exhaustive search]
O -->|fair-local| FL[Flow + local improvements]
O -->|greedy/diverse| G[Round-robin selector]
F --> P[Match plan]
MM --> P
MF --> P
MX --> P
FL --> P
G --> P
P --> U[Independent audit]
U --> J[Stable JSON report]
Expand All @@ -132,7 +141,7 @@ The public modules have intentionally narrow responsibilities:
| `models` | Immutable domain objects and validation invariants |
| `text` | Tokenization, TF-IDF fitting, sparse vectors, similarity |
| `scoring` | Pair eligibility, component scoring, explanations |
| `assignment` | Capacity-constrained optimal, load min-max, document maximin, and greedy selection |
| `assignment` | Capacity-constrained optimal, load min-max, selected-edge bottleneck flow, document maximin, and greedy selection |
| `audit` | Coverage, safety, workload, and diversity diagnostics |
| `io` | Strict JSON/JSONL parsing and stable result serialization |
| `affinity` | Strict sparse affinity CSV adapter |
Expand Down Expand Up @@ -191,6 +200,16 @@ instances fail explicitly rather than silently changing objective. The
[maximin assignment](docs/maximin-assignment.md) for semantics, complexity,
and limitations.

`maximin-flow` is a distinct bounded flow objective: after maximizing filled
slots, it maximizes the **lowest selected pair score** by binary-searching
score thresholds and re-solving the integral flow, then maximizes fixed-point
total score at the best threshold. It supports up to 24 documents, 48 experts,
256 eligible pairs, 64 requested slots, and 256 total expert capacity. This
does not optimize each document's total score and does **not** implement the
frozen OpenReview FairFlow paper-score makespan or minimum reviewer loads. See
[edge-bottleneck flow assignment](docs/maximin-flow-assignment.md) for the
counterexample, partial-coverage semantics, limits, and synthetic benchmark.

`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
Expand Down
54 changes: 54 additions & 0 deletions docs/maximin-flow-assignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Bounded edge-bottleneck flow assignment

Set `"strategy": "maximin-flow"` in the normal `peermatch match` or
`match-affinity` configuration, or call
`AssignmentEngine.assign(strategy="maximin-flow")`. The output remains a
normal independently auditable assignment plan with unmet-demand diagnostics.
The [example configuration](../examples/maximin-flow-config.json) works with
the repository's synthetic affinity CSV.

The optimization order is:

1. Fill as many requested reviewer slots as the declared hard constraints
allow (maximum cardinality).
2. Among plans with that cardinality, maximize the **lowest score on any
selected document–reviewer pair**. With no selected pairs, the floor is
defined as zero.
3. At that floor, maximize the sum of the existing flow solver's six-decimal
fixed-point scores. Its stable, deterministic edge order resolves remaining
ties; no random seed or hidden input is used.

The solver obtains a maximum-cardinality integral flow, sorts the distinct
eligible input scores, then binary-searches the highest threshold whose
pruned network can still carry that many assignments. Monotonicity of this
feasibility predicate proves the selected-edge floor is optimal within the
declared constraints. Each flow also obeys hard conflicts, score threshold,
per-expert capacity, per-document demand, optional distinct institution gates,
and optional reserved senior slots. Senior reservations and institution gates
remain mutually incompatible, as for the existing flow solver. A nonzero
`load_balance_penalty` is rejected instead of silently changing the objective.

This is **not** the existing `maximin` objective, which maximizes the weakest
document's **sum** of selected scores after cardinality. For example, on a
two-paper, two-reviewer-per-paper fixture, `maximin-flow` can improve the
lowest individual edge from 0.1 to 0.2 while reducing the weakest paper sum
from 0.9 to 0.4. It is not a document-fairness guarantee. If full coverage is
infeasible, the cardinality stays optimal, but the identity of an unfilled
document can change during the floor search. Use the independent audit and
inspect all `unmet` entries before any operational decision.

This is also **not** the frozen OpenReview Matcher `FairFlow` solver. That
approximation targets paper-score makespan, considers reviewer minimum loads,
and uses a different reassignment flow. `maximin-flow` has no reviewer minimum
load, no paper-score makespan certificate, and no official OpenReview dataset,
runtime, or assignment parity claim. All input scores and their potential bias
remain the caller's responsibility.

For predictable local resource use, reject more than **24 documents, 48
experts, 256 eligible pairs after minimum-score filtering, 64 requested
slots, or 256 total expert capacity**. The graph and the number of flow solves
are bounded (one baseline plus at most nine threshold trials). This is wider
than the exact exhaustive `maximin` limit of 6/8/16, but is not a conference
scale solver or a latency guarantee. Tests include an independent exhaustive
small-graph feasibility/objective oracle; run the synthetic, audited comparison
locally with `uv run --locked python scripts/benchmark_maximin_flow.py`.
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.14.0` invokes the release
A push of a semantic-version tag such as `v0.17.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.14.0-py3-none-any.whl \
gh attestation verify peermatchlab-0.17.0-py3-none-any.whl \
--repo appleweiping/PeerMatchLab
```

Expand Down
5 changes: 5 additions & 0 deletions examples/maximin-flow-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"reviewers_per_document": 1,
"minimum_score": 0.0,
"strategy": "maximin-flow"
}
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.16.0"
version = "0.17.0"
description = "Transparent, constraint-aware expert matching for offline workflows"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
62 changes: 62 additions & 0 deletions scripts/benchmark_maximin_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Local deterministic synthetic benchmark; no external paper/reviewer data."""

from __future__ import annotations

import json
from time import perf_counter

from peermatchlab.affinity import Affinity
from peermatchlab.config import MatchConfig
from peermatchlab.models import Document, Expert
from peermatchlab.pipeline import run_affinity_matching


def main() -> None:
documents = tuple(
Document(f"paper-{index:02d}", f"Synthetic paper {index}") for index in range(12)
)
experts = tuple(
Expert(f"reviewer-{index:02d}", f"Synthetic reviewer {index}", capacity=1)
for index in range(12)
)
block = ((0.95, 0.75), (0.75, 0.70))
affinities = tuple(
Affinity(
document.id,
expert.id,
block[paper % 2][reviewer % 2] if paper // 2 == reviewer // 2 else 0.05,
)
for paper, document in enumerate(documents)
for reviewer, expert in enumerate(experts)
)
results: dict[str, object] = {
"fixture": "deterministic-synthetic-v1",
"documents": len(documents),
"experts": len(experts),
"eligible_pairs": len(affinities),
"demand_per_document": 1,
}
for strategy in ("optimal", "maximin-flow"):
started = perf_counter()
run = run_affinity_matching(
documents,
experts,
affinities,
config=MatchConfig(strategy=strategy, reviewers_per_document=1),
)
elapsed = perf_counter() - started
assert run.audit.safe
results[strategy] = {
"assigned": len(run.plan.assignments),
"minimum_selected_edge_score": min(
(assignment.score for assignment in run.plan.assignments), default=0.0
),
"total_score": run.plan.total_score,
"seconds_observed": elapsed,
"audit_safe": run.audit.safe,
}
print(json.dumps(results, sort_keys=True, separators=(",", ":")))


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion src/peermatchlab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,4 @@
"write_publish_artifact",
]

__version__ = "0.16.0"
__version__ = "0.17.0"
95 changes: 95 additions & 0 deletions src/peermatchlab/assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class AssignmentStrategy(StrEnum):
GREEDY = "greedy"
MINMAX = "minmax"
MAXIMIN = "maximin"
MAXIMIN_FLOW = "maximin-flow"
FAIR_LOCAL = "fair-local"


Expand Down Expand Up @@ -210,6 +211,21 @@ def assign(
)
if selected is AssignmentStrategy.MAXIMIN and load_balance_penalty:
raise ValueError("maximin does not support load_balance_penalty")
if selected is AssignmentStrategy.MAXIMIN_FLOW and load_balance_penalty:
raise ValueError("maximin-flow does not support load_balance_penalty")
if selected is AssignmentStrategy.MAXIMIN_FLOW and (
len(self.scorer.documents) > 24 or len(self.scorer.experts) > 48
):
raise ValueError(
"maximin-flow supports at most 24 documents, 48 experts, and 256 eligible pairs"
)
if selected is AssignmentStrategy.MAXIMIN_FLOW and (
sum(self._demand(key, reviewers_per_document) for key in self.scorer.documents) > 64
or sum(expert.capacity for expert in self.scorer.experts.values()) > 256
):
raise ValueError(
"maximin-flow supports at most 64 requested slots and 256 total expert capacity"
)
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 (
Expand All @@ -227,6 +243,16 @@ def assign(
)
all_scores = self.scorer.matrix()
scores = tuple(score for score in all_scores if score.eligible)
if selected is AssignmentStrategy.MAXIMIN_FLOW:
return self._maximin_flow(
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),
)
if selected is AssignmentStrategy.FAIR_LOCAL:
return self._fair_local(
scores,
Expand Down Expand Up @@ -808,6 +834,75 @@ def visit(index: int) -> None:
maximum_cardinality_certified=True,
)

def _maximin_flow(
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,
) -> MatchPlan:
"""Maximize the selected-edge floor at certified maximum cardinality.

Feasibility is monotone: if a flow of the target cardinality exists
after dropping all edges below a score floor, every lower floor is
feasible too. A binary search over actual input scores therefore finds
the exact bottleneck floor; each min-cost flow maximizes fixed-point
aggregate affinity within that floor. This is an edge bottleneck, not
the per-document score-sum objective of ``maximin`` or OpenReview's
FairFlow makespan approximation.
"""

admissible = tuple(
sorted(
(score for score in scores if score.total >= minimum_score),
key=lambda score: (score.document_id, score.expert_id),
)
)
if len(admissible) > 256:
raise ValueError(
"maximin-flow supports at most 24 documents, 48 experts, and 256 eligible pairs"
)
pairs = {(score.document_id, score.expert_id) for score in admissible}
if len(pairs) != len(admissible):
raise ValueError(
"maximin-flow requires at most one eligible score per document-expert pair"
)

def solve(eligible: tuple[MatchScore, ...]) -> MatchPlan:
return self._optimal(
eligible,
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="maximin-flow",
)

best = solve(admissible)
target = len(best.assignments)
if target == 0:
return best

thresholds = sorted({score.total for score in admissible})
low, high = 0, len(thresholds) - 1
while low <= high:
mid = (low + high) // 2
threshold = thresholds[mid]
candidate = solve(tuple(score for score in admissible if score.total >= threshold))
if len(candidate.assignments) == target:
best = candidate
low = mid + 1
else:
high = mid - 1
return best

def _fair_local(
self,
scores: tuple[MatchScore, ...],
Expand Down
Loading
Loading