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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ jobs:
--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 peermatch what-if --documents examples/documents.json
--experts examples/experts.json --conflicts examples/conflicts.json
--config examples/config.json --scenarios examples/what-if-scenarios.json
--output what-if-smoke.json
- run: uv run --locked python -m build --no-isolation
- name: Verify the wheel in a clean environment
shell: bash
Expand All @@ -76,6 +81,7 @@ jobs:
.ci-wheel-check/bin/python -m pip install --disable-pip-version-check --no-deps dist/*.whl
.ci-wheel-check/bin/python -c "import peermatchlab; print(peermatchlab.__version__)"
.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.what_if import WhatIfPlan, compare_what_if; assert WhatIfPlan and compare_what_if"
.ci-wheel-check/bin/peermatch --help

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

_No changes yet._

## 0.15.0 - 2026-09-20

- Added bounded, deterministic local capacity/conflict what-if comparisons with
non-cumulative scenarios, independent audits, signed assignment/workload
deltas, strict plan/reference validation, a CLI example, and no input mutation.
This is not a causal constraint diagnosis or an interactive web UI.

## 0.14.0 - 2026-09-20

- Added opt-in `fair-local` assignment: a maximum-cardinality flow baseline
Expand Down
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ authors:
- family-names: "Yan"
given-names: "Weiping"
alias: "appleweiping"
version: 0.14.0
date-released: 2026-09-19
version: 0.15.0
date-released: 2026-09-20
license: MIT
repository-code: "https://github.com/appleweiping/PeerMatchLab"
url: "https://github.com/appleweiping/PeerMatchLab"
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Most matching prototypes stop after computing pairwise similarity. Real allocati
- Per-document demand overrides and minimum acceptable score thresholds.
- Machine-readable unmet-demand diagnostics for conflicts, zero capacity, score filtering, sparse
affinities, senior reservations, institution gates, and global capacity coupling.
- Bounded [capacity/conflict what-if comparisons](docs/what-if.md) that rerun and audit
independent scenarios without changing baseline inputs.
- Independent checks for conflict, capacity, demand, references, duplicate assignments, coverage,
workload inequality, and institution duplication.
- Strict JSON and JSONL adapters with unknown-field and duplicate-ID rejection.
Expand All @@ -42,7 +44,7 @@ Most matching prototypes stop after computing pairwise similarity. Real allocati
`Retry-After` handling, proactive request pacing, injectable transport, and strict response schemas.
- Opt-in OpenReview reviewer profile/publication acquisition with exact author-ID joins,
invitation/date/content filters, privacy-minimized snapshots, and replayable file hashes.
- `validate`, `score`, `match`, `match-affinity`, `audit`, `import-openreview`, `fetch-openreview`,
- `validate`, `score`, `match`, `match-affinity`, `what-if`, `audit`, `import-openreview`, `fetch-openreview`,
and `expertise` CLI commands.
- Stable JSON output suitable for review, version control, and downstream systems.

Expand Down Expand Up @@ -134,6 +136,7 @@ The public modules have intentionally narrow responsibilities:
| `openreview` | Loss-aware conversion of local OpenReview exports |
| `openreview_api` | Bounded OpenReview API v2 synchronization and evidence manifests |
| `pipeline` | One-call score → assign → audit orchestration |
| `what_if` | Bounded independent capacity/conflict scenario comparison |
| `cli` | Reproducible command-line workflows |
| `report` | Portable, script-free HTML review dashboard |

Expand Down Expand Up @@ -484,7 +487,8 @@ checksum, and build-provenance guarantees.
- Optional calibrated embedding adapters without making a hosted service mandatory.
- Group constraints beyond institution diversity and senior coverage, where they can be
expressed exactly rather than approximated.
- Interactive what-if reports for capacity and conflict changes.
- An interactive what-if UI and broader constraint scenarios; the bounded local
[capacity/conflict comparison](docs/what-if.md) is available from the CLI.
- Import/export adapters for common review-management schemas.

## License
Expand Down
42 changes: 42 additions & 0 deletions docs/what-if.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Bounded capacity and conflict what-if comparisons

`peermatch what-if` compares a baseline match with 1–8 independent local
scenarios. A scenario can change expert capacities or explicitly add/remove
declared document–expert conflicts. Every scenario starts from the same
unchanged baseline, runs the existing matcher, and undergoes the independent
assignment audit. The JSON report includes assigned and unmet demand, status,
scores, assignments, per-expert workload, audit and unmet reason codes, plus
signed deltas and added/removed assignment pairs.

```sh
peermatch what-if \
--documents examples/documents.json \
--experts examples/experts.json \
--conflicts examples/conflicts.json \
--config examples/config.json \
--scenarios examples/what-if-scenarios.json \
--output what-if-report.json
```

The schema-1 plan has a `scenarios` array. Each scenario requires a unique,
short lowercase `id` and all three change fields: `capacity_overrides` maps
existing expert IDs to integer capacities from 0 to 128; `add_conflicts` and
`remove_conflicts` are arrays of `[document_id, expert_id]` pairs. Adding an
existing conflict or removing a nonexistent one is an error. A scenario must
have at least one change. Scenario IDs are evaluated and reported in sorted
order, regardless of plan order. Conflict changes affect *only* the named
scenario. The source files and Python domain objects are never modified.

The local comparison is limited to 32 documents, 64 experts, 128 requested
slots, 2,048 conflicts, 8 scenarios, 1 MiB per document/expert/conflict CLI
input, and 64 KiB per plan/config CLI input. The output path cannot alias an
input path, including through symlinks and hard links. It is an ordinary
replaceable local JSON output, not a signed or create-only registry record.

This is a deterministic sensitivity *observation*, not a causal attribution
or proof that a particular constraint is the unique cause of unmet demand.
Changing capacity or conflict declarations can alter assignments globally.
The tool neither infers real-world conflicts nor recommends removing them;
human operators must verify the declarations and approve any actual change.
It does not implement an interactive web UI, OpenReview write-back, or the
frozen Matcher solver families still listed as gaps in the capability map.
23 changes: 23 additions & 0 deletions examples/what-if-scenarios.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"schema_version": 1,
"scenarios": [
{
"id": "capacity-drop",
"capacity_overrides": {"expert-a": 0},
"add_conflicts": [],
"remove_conflicts": []
},
{
"id": "new-conflict",
"capacity_overrides": {},
"add_conflicts": [["paper-search", "expert-a"]],
"remove_conflicts": []
},
{
"id": "remove-declared-conflict",
"capacity_overrides": {},
"add_conflicts": [],
"remove_conflicts": [["paper-search", "expert-d"]]
}
]
}
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.14.0"
version = "0.15.0"
description = "Transparent, constraint-aware expert matching for offline workflows"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
6 changes: 5 additions & 1 deletion src/peermatchlab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
write_publish_artifact,
)
from peermatchlab.scoring import MatchScorer, ScoreWeights
from peermatchlab.what_if import WhatIfPlan, WhatIfScenario, compare_what_if

__all__ = [
"Affinity",
Expand Down Expand Up @@ -138,7 +139,10 @@
"ScoreWeights",
"TermContribution",
"UnmetReason",
"WhatIfPlan",
"WhatIfScenario",
"build_expertise_corpus",
"compare_what_if",
"embedding_requests",
"extract_keyphrases",
"fetch_openreview_snapshot",
Expand Down Expand Up @@ -166,4 +170,4 @@
"write_publish_artifact",
]

__version__ = "0.14.0"
__version__ = "0.15.0"
66 changes: 66 additions & 0 deletions src/peermatchlab/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@
from peermatchlab.gold_evaluation import GoldEvaluationConfig, evaluate_gold_files
from peermatchlab.io import (
load_conflicts,
load_conflicts_text,
load_documents,
load_documents_text,
load_experts,
load_experts_text,
load_json_text,
plan_from_dict,
plan_to_dict,
Expand All @@ -51,6 +54,7 @@
)
from peermatchlab.pipeline import MatchRun, run_affinity_matching, run_matching
from peermatchlab.report import write_html
from peermatchlab.what_if import WhatIfPlan, compare_what_if


def _inputs(parser: argparse.ArgumentParser) -> None:
Expand Down Expand Up @@ -84,6 +88,14 @@ def build_parser() -> argparse.ArgumentParser:
match.add_argument("--scores", help="optional full score-matrix JSON path")
match.add_argument("--html", help="optional self-contained HTML report path")

what_if = commands.add_parser(
"what-if", help="compare bounded non-cumulative capacity/conflict scenarios"
)
_inputs(what_if)
what_if.add_argument("--config", help="optional JSON run configuration")
what_if.add_argument("--scenarios", required=True, help="schema-1 scenario plan JSON")
what_if.add_argument("--output", required=True, help="comparison JSON path")

affinity = commands.add_parser("match-affinity", help="assign a sparse external affinity CSV")
_inputs(affinity)
affinity.add_argument(
Expand Down Expand Up @@ -249,6 +261,58 @@ def _config(path: str | None) -> MatchConfig:
return MatchConfig.from_json(path) if path else MatchConfig()


def _bounded_text(path: str, maximum: int, label: str) -> str:
with Path(path).open("rb") as stream:
source = stream.read(maximum + 1)
if len(source) > maximum:
raise DataValidationError(f"{label} exceeds {maximum} bytes")
try:
return source.decode("utf-8")
except UnicodeDecodeError as error:
raise DataValidationError(f"{label} must be UTF-8") from error


def _what_if(args: argparse.Namespace) -> int:
inputs = [args.documents, args.experts, args.scenarios]
if args.conflicts:
inputs.append(args.conflicts)
if args.config:
inputs.append(args.config)
if any(_same_file(args.output, source) for source in inputs):
raise DataValidationError("output path must not refer to an input")
documents = load_documents_text(
_bounded_text(args.documents, 1024 * 1024, "documents"),
source=args.documents,
max_records=32,
)
experts = load_experts_text(
_bounded_text(args.experts, 1024 * 1024, "experts"),
source=args.experts,
max_records=64,
)
conflicts = (
load_conflicts_text(
_bounded_text(args.conflicts, 1024 * 1024, "conflicts"),
source=args.conflicts,
max_records=2048,
)
if args.conflicts
else ()
)
config = MatchConfig()
if args.config:
raw_config = load_json_text(_bounded_text(args.config, 64 * 1024, "config"))
if not isinstance(raw_config, dict):
raise DataValidationError("what-if config must be a JSON object")
config = MatchConfig.from_mapping(raw_config)
with Path(args.scenarios).open("rb") as stream:
plan = WhatIfPlan.from_bytes(stream.read(64 * 1024 + 1))
result = compare_what_if(documents, experts, plan, conflicts=conflicts, config=config)
write_json(args.output, result)
print(f"compared {len(plan.scenarios)} scenarios to {args.output}")
return 0


def _import_openreview(args: argparse.Namespace) -> int:
imported_documents = load_openreview_submissions(
args.submissions,
Expand Down Expand Up @@ -521,6 +585,8 @@ def main(argv: Sequence[str] | None = None) -> int:

args = build_parser().parse_args(argv)
try:
if args.command == "what-if":
return _what_if(args)
if args.command == "import-openreview":
return _import_openreview(args)
if args.command == "fetch-openreview":
Expand Down
14 changes: 13 additions & 1 deletion src/peermatchlab/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,8 +381,20 @@ def load_conflicts(path: str | Path | None) -> tuple[Conflict, ...]:

if path is None:
return ()
return _conflicts_from_records(_records(path))


def load_conflicts_text(
text: str, *, source: str = "conflict input", max_records: int | None = None
) -> tuple[Conflict, ...]:
"""Validate conflicts from an already bounded, decoded byte snapshot."""

return _conflicts_from_records(_records_from_text(text, source, max_records=max_records))


def _conflicts_from_records(rows: Iterable[Mapping[str, Any]]) -> tuple[Conflict, ...]:
result: list[Conflict] = []
for row in _records(path):
for row in rows:
unknown = set(row) - {"document_id", "expert_id", "reason"}
if unknown:
raise DataValidationError(f"unknown conflict fields: {sorted(unknown)}")
Expand Down
Loading
Loading