Skip to main content

Chess tournament pairing engines for Python — FIDE Dutch System (A.7-conformant, with Baku Acceleration), FIDE Berger round-robin, and a simple casual Swiss engine.

Project description

caissify-pairings

Chess tournament pairing engines for Python. Ships three engines:

  • dutch — the FIDE Dutch System (C.04.3), cross-validated against the FIDE-endorsed reference bbpPairings to full A.7 conformance. Use this for rated Swiss tournaments. Optional Baku Acceleration (FIDE C.04.5.1) via accelerated=True for large opens.
  • round_robinFIDE Berger Tables (FIDE Handbook §C.05). Every player meets every other player; supports single and double round-robin. Verified to match the published FIDE tables exactly.
  • casual — a small, deterministic Swiss engine for club nights and non-rated events. Simpler, more readable, no FIDE guarantees.
Engine Use case FIDE compliance Complexity
dutch Rated Swiss / official tournaments A.7 — 0 discrepancies on 70k rounds; Baku C.04.5.1 acceleration available High (full C.04.3)
round_robin Round-robin / Scheveningen / club leagues Berger Tables verified vs FIDE Handbook §C.05 Low
casual Club nights, ladders, non-rated Swiss (not the goal) Low

What you get

  • A programmable pairing engine — use it as a library, call it from a web service, or wrap it in a tournament-director UI.
  • TRF16 parser and writer.
  • A Free Pairings Checker (FPC) — validate a TRF file against the engine.
  • A Random Tournament Generator (RTG) — generate simulated tournaments for testing.
  • A simple JSON-over-stdin CLI.

FIDE A.7 conformance

The FIDE Swiss Pairings Programs Commission evaluates endorsed pairing software using the A.7 test: a program is expected to produce pairings that match an already-endorsed program on at least 4990 of 5000 random tournaments (≤ 10 discrepancies).

Measured against bbpPairings (FIDE-endorsed, C++) using our Random Tournament Generator + Free Pairings Checker pipeline:

Benchmark Rounds checked Discrepancies FIDE target Result
5000 × 20-player / 9-round 45,000 0 ≤ 10 ✅ PASS
5000 × 10-player / 5-round 25,000 0 ≤ 10 ✅ PASS

See doc/FIDE_CONFORMANCE.md for per-engine status against the FIDE Handbook, and doc/DIVERGENCE_TESTING.md for the methodology.

Note. A.7 conformance is a technical criterion. caissify-pairings is not yet FIDE-endorsed — endorsement is a separate administrative process with the FIDE SPP Commission.

Installation

pip install caissify-pairings

Requires Python 3.10+. The only runtime dependency is networkx (for maximum-weight matching).

Quick start — library

from caissify_pairings import generate_pairings

players = [
    {"id": 1, "name": "Carlsen",  "score": 2.0, "rating": 2830,
     "starting_number": 1, "color_hist": ["white", "black"],
     "float_history": ["none", "none"], "bye_count": 0},
    {"id": 2, "name": "Firouzja", "score": 2.0, "rating": 2785,
     "starting_number": 2, "color_hist": ["black", "white"],
     "float_history": ["none", "none"], "bye_count": 0},
    {"id": 3, "name": "Ding",     "score": 1.5, "rating": 2780,
     "starting_number": 3, "color_hist": ["white", "black"],
     "float_history": ["none", "down"], "bye_count": 0},
    {"id": 4, "name": "Nepo",     "score": 1.5, "rating": 2775,
     "starting_number": 4, "color_hist": ["black", "white"],
     "float_history": ["none", "up"], "bye_count": 0},
]

pairings = generate_pairings(
    system="dutch",
    players=players,
    previous_pairings={(1, 3), (2, 4)},
    round_number=3,
    total_rounds=9,
)

for p in pairings:
    print(f"Table {p['table']}: {p['white_id']} vs {p['black_id']}")

Caller responsibilities (Dutch, R2 onward)

caissify-pairings is a stateless pairing engine, not a tournament manager. Every call to generate_pairings(system="dutch", ...) is a self-contained snapshot: the engine paint nothing it isn't told. From round 2 onward the caller (your tournament manager / app / API) owns the per-player history below and MUST recompute it from played results before each call. Passing empty lists from R2+ is a bug; the engine will produce internally consistent pairings that nevertheless violate FIDE C.04.A.

Field Type What it must contain (after R{n-1})
score float Cumulative score after all played rounds (1.0 win, 0.5 draw, 0.0 loss, plus byes).
color_hist list[str] One entry per played round, value "white" / "black" (or "none" for unplayed rounds, e.g. byes/forfeits).
float_history list[str] One entry per played round, value "up" / "down" / "none". See "How to derive floats" below.
bye_count int Number of byes the player has received (any colour code) so far.
forfeit_win_count int Optional. Number of forfeit wins. Defaults to 0.
previous_pairings set[(int,int)] Set of unordered (player_id_a, player_id_b) tuples for every game played so far. Used to forbid rematches.

How to derive float_history

For each played round R, for each player P paired against opponent O:

  • If score(P) before R > score(O) before R: P had a "down" float in R, O had "up".
  • If score(P) before R < score(O) before R: P had "up", O had "down".
  • Otherwise (same pre-round score, the normal in-bracket pairing): both had "none".

Append the resulting value to each player's float_history list. By round R, every player should have exactly R-1 entries. This is the same derivation FIDE-endorsed engines (bbpPairings, JaVaFo) perform on TRF input, and it's what caissify_pairings.fpc.check_trf does internally.

Why the engine doesn't compute this for you. The engine receives only previous_pairings as a set[(a, b)] — no round numbers, no per-round scores, no results. That data shape is intentionally minimal so that callers stay free to use any storage layout, but it means the engine cannot reconstruct float history from its inputs alone. The owner of the result/results history is the only one who can.

Smoking-gun warning

If you call generate_pairings(system="dutch", round_number=N, ...) with N >= 2, previous_pairings non-empty, and every player's float_history empty, the engine emits a MissingFloatHistoryWarning (a UserWarning subclass). It does not change the pairings, only flags that you are almost certainly hitting the contract violation above. Promote it to an error in your test suite if you want hard enforcement:

import warnings
from caissify_pairings import MissingFloatHistoryWarning

warnings.simplefilter("error", MissingFloatHistoryWarning)

Command-line usage

# Pair a round from a JSON state on stdin, write pairings to stdout
caissify-pairings < tournament_state.json

# Validate a FIDE TRF file against the Dutch engine
caissify-pairings-check tournament.trf

# Generate random tournaments for testing
caissify-pairings-rtg --players 20 --rounds 9 -n 100 -o ./output/

Input JSON schema

{
  "system": "dutch",
  "players": [
    {
      "id": 1,
      "name": "Player Name",
      "score": 0.0,
      "rating": 2400,
      "starting_number": 1,
      "color_hist": [],
      "float_history": [],
      "bye_count": 0
    }
  ],
  "previous_pairings": [[1, 3], [2, 4]],
  "round_number": 1,
  "total_rounds": 9,
  "bye_value": 1.0,
  "max_byes_per_player": 1
}

Any JSON key other than the five core fields (system, players, previous_pairings, round_number, total_rounds) is forwarded verbatim to the selected engine as a keyword argument. That is how you pass engine-specific options from the CLI:

Engine Extra keys you can include
dutch bye_value, max_byes_per_player, initial_color, accelerated (Baku, C.04.5.1)
round_robin cycles (1 = single, 2 = double), bye_type
casual bye_value, max_byes_per_player, bye_type

Output JSON schema

A regular game has both white_id and black_id set. A bye row has black_id: null and bye: true:

[
  {"white_id": 1, "black_id": 4, "table": 1},
  {"white_id": 3, "black_id": 2, "table": 2},
  {"white_id": 5, "black_id": null, "table": 3, "bye": true, "bye_type": "U"}
]
Field Type Notes
white_id int Player id assigned white (or the sole player if bye=true).
black_id int | null Player id assigned black. Nullablenull on bye rows.
table int (≥1) 1-based table number within the round.
bye bool (optional) true on bye rows. Absent or false otherwise.
bye_type string (optional) FIDE TRF16 bye code: "U" (engine-issued PAB), "F" (admin full-point), "H" (half-point), "Z" (zero-point).
float_type string (optional) Cross-score-group float: "down", "up", "none". Currently emitted by the casual engine only.

A machine-readable JSON Schema (draft 2020-12) ships inside the wheel at caissify_pairings/schemas/engine_output.schema.json. Downstream consumers (Rust, TypeScript, Swift, …) are strongly encouraged to code-generate their types from it rather than re-deriving the shape.

from caissify_pairings.schemas import engine_output_schema
schema = engine_output_schema()  # the same dict downstream tools consume

Or from the wheel directly, e.g. for quicktype/datamodel-code-generator:

python -c "import json; from caissify_pairings.schemas import engine_output_schema as s; print(json.dumps(s()))" > engine_output.schema.json
quicktype -s schema engine_output.schema.json -o engine_output.rs --lang rust

What works today

  • FIDE Dutch System (C.04.3, Feb 2026 spec) — full A.7 conformance against bbpPairings on 20p/9r and 10p/5r benchmarks (see above).
  • FIDE Berger round-robin — opt-in via system="round_robin", matches the published FIDE Berger tables exactly.
  • Casual Swiss engine — opt-in via system="casual" for club-level events where FIDE conformance is not required.
  • Full TRF16 round-trip parsing/writing.
  • Free Pairings Checker (FPC) for validating existing TRF files.
  • Random Tournament Generator (RTG) for test corpora.
  • Pure-Python, single runtime dependency (networkx).

Baku Acceleration — quick start

Baku Acceleration (FIDE Handbook §C.04.5.1) is an opt-in modifier on the Dutch engine. For rounds 1 and 2 the top half of the field (by initial pairing number / rating) gets a +1 virtual point added to its score for pairing purposes only. The result: top-half plays top-half and bottom-half plays bottom-half early, spreading the field — exactly what large opens need.

from caissify_pairings import generate_pairings

pairings = generate_pairings(
    system="dutch",
    players=players,            # any list of player dicts
    previous_pairings=set(),
    round_number=1,
    total_rounds=9,
    accelerated=True,           # ← opt in to Baku
)

What you can rely on:

  • The virtual point is applied only for rounds 1 and 2; round 3 onwards is a normal Dutch pairing on real scores.
  • Real score, color_hist, etc. are never modified — only the internal pairing-time score is inflated, on a private copy.
  • For odd player counts the extra slot goes to the top half (FIDE convention — ceiling division).
  • Output shape is unchanged (white_id, black_id, table, …).

To generate accelerated TRF fixtures end-to-end the RTG exposes the same flag. Cross-validation against bbpPairings itself is done via the TRF XXA tag — bbp has no --accelerated command-line flag; acceleration is configured per-round in the TRF file — so this is currently an internal-consistency tool, not yet a full A.7 run for the Baku configuration.

caissify-pairings-rtg --players 100 --rounds 9 -n 50 --accelerated -o ./baku_fixtures/

Round-robin — quick start

from caissify_pairings import generate_pairings

# Single round-robin: pair round 1 of an 8-player event.
pairings = generate_pairings(
    system="round_robin",
    players=players,            # any 8 players (list of dicts)
    previous_pairings=set(),    # ignored — RR is deterministic
    round_number=1,
    total_rounds=7,             # n - 1 for n=8
)

# Double round-robin: 14 rounds, each pair meets twice with reversed colours.
pairings = generate_pairings(
    system="round_robin",
    players=players,
    previous_pairings=set(),
    round_number=8,             # first round of cycle 2
    total_rounds=14,
    cycles=2,
)

Need the full schedule up front (e.g. to print all rounds at once)?

from caissify_pairings.engines.round_robin import berger_schedule

# Returns 7 rounds × (n/2) pairs as (white_pairing_no, black_pairing_no).
all_rounds = berger_schedule(8)

Pairing numbers are taken from each player's starting_number (ascending). Odd player counts get one bye per round (each player byes exactly once over a single cycle).

Casual engine — quick start

from caissify_pairings import generate_pairings

pairings = generate_pairings(
    system="casual",
    players=players,
    previous_pairings=set(),
    round_number=1,
    total_rounds=5,
    bye_type="F",            # "F" full-point, "H" half-point, "U" PAB, …
    max_byes_per_player=1,   # each player gets at most one bye
)

The casual engine follows a very small rulebook:

  1. Players sorted by (-score, -rating, id).
  2. Round 1 uses the Dutch half-split (top half vs bottom half).
  3. Later rounds pair greedily within score groups; unmatched players float down one bracket.
  4. Odd fields award one bye to the lowest-scored eligible player.
  5. Colours: perfect alternation > avoid a 3-in-a-row streak > minimise colour imbalance.

It never mutates your input dicts. Use dutch if you need FIDE A.7 behaviour — the two engines share the exact same input/output contract, so switching is a one-line change.

What does not work yet

Being honest up front — these are known limitations you will hit if your use-case is beyond them:

  • Swiss systems other than Dutch are not implemented yet. No Dubov, Burstein, or Monrad generators — those are on the roadmap.
  • Large-tournament fixture match rates are lower against pre-recorded bbpPairings outputs for 40+ players (e.g. 40p/9r reports ~11% exact pair agreement on a handful of fixtures). The 5000-tournament A.7 conformance benchmark tops out at 20p/9r, where the engine is at 0 discrepancies; at larger scales tie-breaking divergences are expected.
  • Baku Acceleration is not yet A.7 cross-validated. The Dutch engine passes A.7 on unaccelerated tournaments; the accelerated path is unit-tested and self-consistent but has not been run through a full 5000-tournament XXA-enabled cross-check against bbpPairings.
  • No tournament-director UI. This is an engine / library, not a standalone product.
  • Not FIDE-endorsed. A.7 conformance is met technically; endorsement is a separate administrative process with the FIDE TEC (formerly SPP) Commission.
  • API is not stabilised. Version 0.x may introduce breaking changes. API will be frozen at 1.0.0.

If any of these block you, please open an issue — priorities are driven by real user needs.

Using an engine directly

from caissify_pairings.engines.dutch import dutch_pairings

pairings = dutch_pairings(
    players=players,
    previous_pairings=set(),
    round_number=1,
    total_rounds=9,
)

Extending

New pairing systems plug in via a small registry.

from caissify_pairings.base import BasePairingEngine

class MyEngine(BasePairingEngine):
    name = "my_system"

    def generate_pairings(self) -> list[dict]:
        ...

Register it in caissify_pairings/engines/__init__.py to expose it through the top-level generate_pairings(system="my_system", ...) call.

Testing

# Fast suite (skip @slow 5000-tournament benchmarks)
pytest -m "not slow"

# Full suite including FIDE A.7 benchmarks (takes ~25 min)
pytest

Release process (maintainers)

Secrets live in a git-ignored .env at the repo root.

cp .env.example .env
# edit .env and set PYPI_API_TOKEN=pypi-...

Then, after bumping the version in pyproject.toml, updating CHANGELOG.md, committing, and tagging vX.Y.Z:

scripts/release.sh --dry-run   # build + twine check only
scripts/release.sh --test      # upload to TestPyPI
scripts/release.sh             # upload to PyPI

The script builds an sdist + wheel in an isolated venv, runs twine check, uploads to the chosen index, and then verifies by installing the published version in a throwaway venv.

Never commit .env. Rotate your PyPI token if it has ever been shared or exposed.

Acknowledgements

Cross-validated against the FIDE-endorsed reference implementations:

Thanks to their authors for making these tools freely available.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

caissify_pairings-0.4.5.tar.gz (425.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

caissify_pairings-0.4.5-py3-none-any.whl (70.7 kB view details)

Uploaded Python 3

File details

Details for the file caissify_pairings-0.4.5.tar.gz.

File metadata

  • Download URL: caissify_pairings-0.4.5.tar.gz
  • Upload date:
  • Size: 425.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.3

File hashes

Hashes for caissify_pairings-0.4.5.tar.gz
Algorithm Hash digest
SHA256 b79af4710482d6b2905f53f916768121977474e2a23c0eca8991947e30cb1ce9
MD5 07e8c147aa4c67bf758b47e16cf77542
BLAKE2b-256 b9db0b3585a91039cb37eca7dc5381515b9bef09403477c846f36802d0a35077

See more details on using hashes here.

File details

Details for the file caissify_pairings-0.4.5-py3-none-any.whl.

File metadata

File hashes

Hashes for caissify_pairings-0.4.5-py3-none-any.whl
Algorithm Hash digest
SHA256 50f46ce95798aebd3452f12a56c27e6cfa9dfb609aff232036022379d66fda38
MD5 42853e880be6eed5de3beaea566c8d47
BLAKE2b-256 743ea8aa3c08559bc359351e14683f9dcb40e3809fde8694127fce5b8819780d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page