diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29d60f7..ae47c07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index ef02093..240b748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CITATION.cff b/CITATION.cff index f4e962b..010a2ad 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -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" diff --git a/README.md b/README.md index 8a564fb..d430cb7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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] @@ -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 | @@ -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 diff --git a/docs/maximin-flow-assignment.md b/docs/maximin-flow-assignment.md new file mode 100644 index 0000000..61efed3 --- /dev/null +++ b/docs/maximin-flow-assignment.md @@ -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`. diff --git a/docs/releasing.md b/docs/releasing.md index 74f2134..4663c28 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.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), @@ -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 ``` diff --git a/examples/maximin-flow-config.json b/examples/maximin-flow-config.json new file mode 100644 index 0000000..9e5e96b --- /dev/null +++ b/examples/maximin-flow-config.json @@ -0,0 +1,5 @@ +{ + "reviewers_per_document": 1, + "minimum_score": 0.0, + "strategy": "maximin-flow" +} diff --git a/pyproject.toml b/pyproject.toml index 199142f..924d223 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/scripts/benchmark_maximin_flow.py b/scripts/benchmark_maximin_flow.py new file mode 100644 index 0000000..057ebf4 --- /dev/null +++ b/scripts/benchmark_maximin_flow.py @@ -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() diff --git a/src/peermatchlab/__init__.py b/src/peermatchlab/__init__.py index aab1f9a..117b6e1 100644 --- a/src/peermatchlab/__init__.py +++ b/src/peermatchlab/__init__.py @@ -192,4 +192,4 @@ "write_publish_artifact", ] -__version__ = "0.16.0" +__version__ = "0.17.0" diff --git a/src/peermatchlab/assignment.py b/src/peermatchlab/assignment.py index e0ac51a..54ddcf0 100644 --- a/src/peermatchlab/assignment.py +++ b/src/peermatchlab/assignment.py @@ -47,6 +47,7 @@ class AssignmentStrategy(StrEnum): GREEDY = "greedy" MINMAX = "minmax" MAXIMIN = "maximin" + MAXIMIN_FLOW = "maximin-flow" FAIR_LOCAL = "fair-local" @@ -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 ( @@ -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, @@ -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, ...], diff --git a/src/peermatchlab/config.py b/src/peermatchlab/config.py index dfa523a..b5b8aa7 100644 --- a/src/peermatchlab/config.py +++ b/src/peermatchlab/config.py @@ -45,9 +45,17 @@ 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", "fair-local"}: + if self.strategy not in { + "optimal", + "greedy", + "minmax", + "maximin", + "maximin-flow", + "fair-local", + }: raise DataValidationError( - "strategy must be 'optimal', 'greedy', 'minmax', 'maximin', or 'fair-local'" + "strategy must be 'optimal', 'greedy', 'minmax', 'maximin', " + "'maximin-flow', or 'fair-local'" ) if isinstance(self.current_year, bool) or not isinstance(self.current_year, int): raise DataValidationError("current_year must be an integer") @@ -67,6 +75,8 @@ 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 == "maximin-flow" and self.load_balance_penalty: + raise DataValidationError("maximin-flow 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 ( diff --git a/tests/test_maximin_flow.py b/tests/test_maximin_flow.py new file mode 100644 index 0000000..2d619d1 --- /dev/null +++ b/tests/test_maximin_flow.py @@ -0,0 +1,409 @@ +"""Independent small-graph oracle for the bottleneck edge-flow objective.""" + +from __future__ import annotations + +import itertools +import json +import random +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from peermatchlab.affinity import Affinity +from peermatchlab.assignment import AssignmentEngine +from peermatchlab.cli import main +from peermatchlab.config import MatchConfig +from peermatchlab.models import Conflict, DataValidationError, Document, Expert, MatchScore +from peermatchlab.pipeline import run_affinity_matching + + +@dataclass +class _Scores: + 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 _objective(selected: tuple[MatchScore, ...]) -> tuple[int, float, int]: + return ( + len(selected), + min((item.total for item in selected), default=0.0), + sum(round(item.total * 1_000_000) for item in selected), + ) + + +def _oracle( + scorer: _Scores, + *, + default_demand: int, + minimum_score: float, + diverse: bool, + senior_floor: int, +) -> tuple[int, float, int]: + """Enumerate *all* edge subsets, without invoking solver flow or threshold code.""" + + edges = tuple(item for item in scorer.scores if item.eligible and item.total >= minimum_score) + best = (0, 0.0, 0) + for mask in itertools.product((False, True), repeat=len(edges)): + selected = tuple(item for item, enabled in zip(edges, mask, strict=True) if enabled) + by_document = {key: [] for key in scorer.documents} + by_expert = dict.fromkeys(scorer.experts, 0) + for item in selected: + by_document[item.document_id].append(item.expert_id) + by_expert[item.expert_id] += 1 + if any(by_expert[key] > item.capacity for key, item in scorer.experts.items()): + continue + feasible = True + for key, item in scorer.documents.items(): + assigned = by_document[key] + demand = item.required_experts if item.required_experts is not None else default_demand + if len(assigned) > demand or len(assigned) != len(set(assigned)): + feasible = False + break + if sum(scorer.experts[expert].seniority < 0.75 for expert in assigned) > demand - min( + senior_floor, demand + ): + feasible = False + break + affiliations = [ + scorer.experts[expert].institution + for expert in assigned + if scorer.experts[expert].institution is not None + ] + if diverse and len(affiliations) != len(set(affiliations)): + feasible = False + break + if feasible: + best = max(best, _objective(selected)) + return best + + +def _selected(scorer: _Scores, plan_pairs: set[tuple[str, str]]) -> tuple[MatchScore, ...]: + return tuple(item for item in scorer.scores if (item.document_id, item.expert_id) in plan_pairs) + + +def test_maximin_flow_improves_bottleneck_without_losing_coverage() -> 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), + ] + optimal = run_affinity_matching( + documents, experts, affinities, config=MatchConfig(reviewers_per_document=1) + ) + fair = run_affinity_matching( + documents, + experts, + affinities, + config=MatchConfig(strategy="maximin-flow", reviewers_per_document=1), + ) + assert sorted(item.score for item in optimal.plan.assignments) == [0.70, 0.95] + assert sorted(item.score for item in fair.plan.assignments) == [0.75, 0.75] + assert fair.plan.strategy == "maximin-flow" + assert fair.plan.diagnostics is not None and fair.plan.diagnostics.certified + assert fair.audit.safe + + +def test_bottleneck_differs_from_document_sum_maximin_for_multiple_reviewers() -> None: + scorer = _Scores( + {key: Document(key, "D", required_experts=2) for key in ("d1", "d2")}, + {key: Expert(key, key, capacity=1) for key in ("a", "b", "c", "d")}, + tuple( + _score(document, expert, value) + for document, values in ( + ("d1", (0.2, 0.2, 0.7, 0.0)), + ("d2", (0.0, 0.1, 0.7, 0.8)), + ) + for expert, value in zip(("a", "b", "c", "d"), values, strict=True) + ), + ) + engine = AssignmentEngine(scorer) + paper_sum = engine.assign(strategy="maximin") + edge_floor = engine.assign(strategy="maximin-flow") + assert {(item.document_id, item.expert_id) for item in paper_sum.assignments} == { + ("d1", "a"), + ("d1", "c"), + ("d2", "b"), + ("d2", "d"), + } + assert {(item.document_id, item.expert_id) for item in edge_floor.assignments} == { + ("d1", "a"), + ("d1", "b"), + ("d2", "c"), + ("d2", "d"), + } + assert min(item.score for item in paper_sum.assignments) == 0.1 + assert min(item.score for item in edge_floor.assignments) == 0.2 + assert min( + sum(item.score for item in paper_sum.assignments if item.document_id == document) + for document in scorer.documents + ) == pytest.approx(0.9) + assert min( + sum(item.score for item in edge_floor.assignments if item.document_id == document) + for document in scorer.documents + ) == pytest.approx(0.4) + + +@pytest.mark.parametrize("seed", range(30)) +def test_maximin_flow_matches_independent_global_edge_subset_oracle(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, True, False))) + for document in documents + for expert in experts + if rng.choice((True, False)) + ) + scorer = _Scores(documents, experts, scores) + diverse = bool(seed % 2) + senior_floor = 1 if not diverse and seed % 3 == 0 else 0 + minimum_score = 0.2 if seed % 5 == 0 else 0.0 + engine = AssignmentEngine(scorer) + plan = engine.assign( + strategy="maximin-flow", + reviewers_per_document=1, + minimum_score=minimum_score, + require_distinct_institutions=diverse, + minimum_senior_reviewers=senior_floor, + ) + pairs = {(item.document_id, item.expert_id) for item in plan.assignments} + assert _objective(_selected(scorer, pairs)) == _oracle( + scorer, + default_demand=1, + minimum_score=minimum_score, + diverse=diverse, + senior_floor=senior_floor, + ) + assert len(plan.assignments) == len(pairs) + assert plan.diagnostics is not None and plan.diagnostics.certified + assert plan == engine.assign( + strategy="maximin-flow", + reviewers_per_document=1, + minimum_score=minimum_score, + require_distinct_institutions=diverse, + minimum_senior_reviewers=senior_floor, + ) + + +def test_maximin_flow_preserves_eligible_conflicts_and_unmet() -> None: + run = run_affinity_matching( + [Document("d1", "D"), Document("d2", "D")], + [Expert("e", "E", capacity=1)], + [Affinity("d1", "e", 0.8), Affinity("d2", "e", 0.9)], + conflicts=[Conflict("d2", "e")], + config=MatchConfig(strategy="maximin-flow", reviewers_per_document=1), + ) + assert [(item.document_id, item.expert_id) for item in run.plan.assignments] == [("d1", "e")] + assert run.plan.unmet == {"d2": 1} + assert run.audit.safe + + +def test_partial_coverage_keeps_cardinality_but_may_change_unmet_document() -> None: + scorer = _Scores( + {key: Document(key, "D") for key in ("d1", "d2")}, + {"e": Expert("e", "E", capacity=1)}, + (_score("d1", "e", 0.1), _score("d2", "e", 0.9)), + ) + plan = AssignmentEngine(scorer).assign(strategy="maximin-flow", reviewers_per_document=1) + assert [(item.document_id, item.expert_id) for item in plan.assignments] == [("d2", "e")] + assert plan.unmet == {"d1": 1} + assert plan.diagnostics is not None and plan.diagnostics.certified + + +def test_equal_score_ties_are_reproducible_under_score_input_permutation() -> None: + documents = {key: Document(key, "D") for key in ("d1", "d2")} + experts = {key: Expert(key, "E", capacity=1) for key in ("a", "b")} + scores = tuple(_score(document, expert, 0.5) for document in documents for expert in experts) + forward = AssignmentEngine(_Scores(documents, experts, scores)).assign( + strategy="maximin-flow", reviewers_per_document=1 + ) + reversed_input = AssignmentEngine(_Scores(documents, experts, scores[::-1])).assign( + strategy="maximin-flow", reviewers_per_document=1 + ) + assert forward == reversed_input + + +def test_raw_bottleneck_threshold_distinguishes_sub_micro_scores() -> None: + scorer = _Scores( + {key: Document(key, "D") for key in ("d1", "d2")}, + {key: Expert(key, "E", capacity=1) for key in ("a", "b")}, + ( + _score("d1", "a", 0.5000001), + _score("d1", "b", 0.5000002), + _score("d2", "a", 0.5000002), + _score("d2", "b", 0.5000001), + ), + ) + plan = AssignmentEngine(scorer).assign(strategy="maximin-flow", reviewers_per_document=1) + assert {(item.document_id, item.expert_id) for item in plan.assignments} == { + ("d1", "b"), + ("d2", "a"), + } + + +def test_no_eligible_pair_has_zero_floor_and_certified_shortfall() -> None: + scorer = _Scores( + {"d": Document("d", "D")}, + {"e": Expert("e", "E", capacity=1)}, + (_score("d", "e", 0.9, eligible=False),), + ) + plan = AssignmentEngine(scorer).assign(strategy="maximin-flow") + assert plan.assignments == () + assert plan.unmet == {"d": 2} + assert plan.diagnostics is not None and plan.diagnostics.certified + + +def test_maximin_flow_rejects_competing_load_objective_and_duplicates() -> None: + with pytest.raises(DataValidationError, match="does not support"): + MatchConfig(strategy="maximin-flow", load_balance_penalty=0.1) + scorer = _Scores( + {"d": Document("d", "D")}, + {"e": Expert("e", "E")}, + (_score("d", "e", 0.5), _score("d", "e", 0.8)), + ) + with pytest.raises(ValueError, match="one eligible score"): + AssignmentEngine(scorer).assign(strategy="maximin-flow") + with pytest.raises(ValueError, match="does not support"): + AssignmentEngine(scorer).assign(strategy="maximin-flow", load_balance_penalty=0.1) + + +@pytest.mark.parametrize("dimension", ("documents", "experts", "slots", "capacity")) +def test_dimension_limits_precede_scoring(dimension: str) -> None: + class ExplodingScorer: + def __init__(self) -> None: + self.documents = {"d": Document("d", "D")} + self.experts = {"e": Expert("e", "E", capacity=1)} + if dimension == "documents": + self.documents = {f"d{i}": Document(f"d{i}", "D") for i in range(25)} + elif dimension == "experts": + self.experts = {f"e{i}": Expert(f"e{i}", "E") for i in range(49)} + elif dimension == "slots": + self.documents = {"d": Document("d", "D", required_experts=65)} + else: + self.experts = {"e": Expert("e", "E", capacity=257)} + + def matrix(self) -> tuple[MatchScore, ...]: + raise AssertionError("must not score oversized graph") + + with pytest.raises(ValueError, match="maximin-flow supports at most"): + AssignmentEngine(ExplodingScorer()).assign(strategy="maximin-flow") + + +def test_pair_limit_and_larger_than_exhaustive_solver() -> None: + scorer = _Scores( + {f"d{i}": Document(f"d{i}", "D") for i in range(7)}, + {f"e{i}": Expert(f"e{i}", "E", capacity=1) for i in range(9)}, + tuple( + _score(f"d{i}", f"e{j}", ((i + j) % 10 + 1) / 10) for i in range(7) for j in range(9) + ), + ) + plan = AssignmentEngine(scorer).assign(strategy="maximin-flow", reviewers_per_document=1) + assert len(plan.assignments) == 7 + with pytest.raises(ValueError, match="maximin supports at most"): + AssignmentEngine(scorer).assign(strategy="maximin", reviewers_per_document=1) + scorer.scores = scorer.scores * 5 + with pytest.raises(ValueError, match="256 eligible pairs"): + AssignmentEngine(scorer).assign(strategy="maximin-flow", reviewers_per_document=1) + + +def test_distinct_thresholds_require_at_most_ten_flow_solves( + monkeypatch: pytest.MonkeyPatch, +) -> None: + scorer = _Scores( + {f"d{i}": Document(f"d{i}", "D") for i in range(16)}, + {f"e{i}": Expert(f"e{i}", "E", capacity=1) for i in range(16)}, + tuple( + _score(f"d{i}", f"e{j}", (i * 16 + j + 1) / 256) for i in range(16) for j in range(16) + ), + ) + engine = AssignmentEngine(scorer) + original = engine._optimal + calls = 0 + + def counted(*args: object, **kwargs: object): + nonlocal calls + calls += 1 + assert calls <= 10 + return original(*args, **kwargs) + + monkeypatch.setattr(engine, "_optimal", counted) + plan = engine.assign(strategy="maximin-flow", reviewers_per_document=1) + assert len(plan.assignments) == 16 + assert 1 < calls <= 10 + + +def test_cli_affinity_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": "maximin-flow", "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"] == "maximin-flow" + assert sorted(item["score"] for item in exported["assignments"]) == [0.75, 0.75] + + +def test_synthetic_benchmark_is_audited_and_shows_objective_tradeoff() -> None: + script = Path(__file__).parents[1] / "scripts" / "benchmark_maximin_flow.py" + completed = subprocess.run( + [sys.executable, str(script)], capture_output=True, text=True, check=True + ) + report = json.loads(completed.stdout) + assert report["fixture"] == "deterministic-synthetic-v1" + assert (report["documents"], report["experts"], report["eligible_pairs"]) == (12, 12, 144) + assert report["optimal"]["assigned"] == report["maximin-flow"]["assigned"] == 12 + assert report["optimal"]["minimum_selected_edge_score"] == 0.7 + assert report["maximin-flow"]["minimum_selected_edge_score"] == 0.75 + assert report["optimal"]["total_score"] > report["maximin-flow"]["total_score"] + assert report["optimal"]["audit_safe"] and report["maximin-flow"]["audit_safe"] diff --git a/uv.lock b/uv.lock index 94ed79f..40ba07e 100644 --- a/uv.lock +++ b/uv.lock @@ -478,7 +478,7 @@ wheels = [ [[package]] name = "peermatchlab" -version = "0.16.0" +version = "0.17.0" source = { editable = "." } [package.optional-dependencies]