Pokémon TCG AI Battle — Tournament System (sweep + round-robin)
scripts/tournament.py CLI, sweep semantics, sweep-source contract, JSON reports, per-run tournament design, intra-suite round-robin as proto-self-play, and how the driver script wires everything for the ablation suite.
Pokémon TCG AI Battle — Tournament System
Boundary
Documents scripts/tournament.py (uv run tcg-tournament) as it stands after
the 2026-08-07 CLI enhancements. The tournament driver remains current, but
the FP16 artifact names in the historical search and round-robin examples are
not the current tensor-dtype authority; the live converter now enforces
strict FP32. Complements pokemon_tcg_dashboard_and_tournament
(dashboard-side arena and configuration),
pokemon_tcg_evaluation_results (evaluation provenance) and
pokemon_tcg_current_state_reconciliation. Terminology (sweep,
sweep-source, baselines, round-robin) in pokemon_tcg_glossary.
What the tournament runs
For a single invocation:
-
OUR agent is loaded from
agent/main.py, which reads a checkpoint by priority-ordered search. The first two paths retain the historicalfp16filename, but the current converter validates the loaded floating tensors as strict FP32:model/bc_model/bc_best_torch_fp16.ptmodel/checkpoint/bc_best_torch_fp16.ptmodel/bc_model/bc_best_mlx_final.pklmodel/checkpoint/bc_best_mlx.pklmodel/bc_model/bc_best_final.pklmodel/checkpoint/bc_best.pkl
In practice
bc_best_torch_fp16.ptwins when that legacy-named file exists and was converted bytcg-build; the filename is not the current dtype authority. See pokemon_tcg_torch_inference. -
Opponents are loaded from paths — either a submission
.tar.gzor a directory containingmain.py. Every game alternates sides internally (run_matchuphandles this). -
Games per opponent =
--games N. If sweep is on, this is per deck per opponent.
Results are persisted to model/results.db (matches, match_steps, match_participants) so local Elo builds up over time.
CLI
usage: tcg-tournament [-h] [--games GAMES] [--opponent OPPONENT] [--note NOTE]
[--no-sweep] [--sweep-source {remote,local}]
[--skip-baselines] [--report-json PATH]
[--txt-backup] [--smoke]
| flag | purpose |
|---|---|
--games N | Games per opponent (per deck when sweep is on). Default 20. |
--opponent PATH | Add opponent. May be repeated to select a custom subset. Without any --opponent, iterates everything under public_agents/. |
--no-sweep | Disable the OUR-agent deck sweep. Only agent/deck.csv is used. |
--sweep-source {remote,local} | Elo tier the sweep pulls its top decks from. Default remote. |
--skip-baselines | Drop the built-in random + first opponents. |
--report-json PATH | Emit the structured per-opponent/per-deck rows + overall summary as JSON. |
--note STR | Annotation for this run (stored in SQLite). |
--txt-backup | Also append results to eval_results.txt. |
--smoke | Use only public_agents/submissions/smoke/submission_smoke.tar.gz as OUR agent and force --no-sweep. |
Baselines
Always prepended to the opponents list unless --skip-baselines:
- random — picks a uniformly random legal option every decision. Zero reasoning, absolute noise floor.
- first — picks the first legal option every decision. Deterministic, sometimes surprisingly hard to beat because the first legal option may be "pass turn."
If OUR agent does not beat random and first by a wide margin (>85% typical), something is broken structurally — encoding, checkpoint conversion, or the deck. This is the smoke test at every tournament run.
Deck sweep
When sweep is on (--no-sweep absent) and OUR agent is not the smoke bundle:
- Read up to top-3 decks from
deck_elo_daily WHERE source = <sweep-source>on the most recent day with rows. - Dedup against the current
agent/deck.csvcomposition (90% quantity-aware similarity). - Result is a list of up to 3–4 decks: default + unique top decks.
- For each opponent, iterate through the deck list, rewriting
agent/deck.csvand callingour_module.reload_deck()between rotations. The agent module caches its deck at import, soreload_deckis required for the swap to actually take effect. original_deckis captured up front and always restored in afinallyblock, so a crash mid-sweep does not leaveagent/deck.csvmutated.
Only OUR agent's deck rotates. Opponent agents are loaded once and their decks stay fixed. The sweep measures "how well does our model play each of OUR top decks against a fixed opposition," not archetype matchups.
The sweep is defined for OUR agent only because rewriting an opponent's deck would require rewriting an artifact from public_agents/, which is out of contract (opponents are read-only artifacts).
JSON report
--report-json PATH writes a single JSON with the full structured result:
{
"our_agent": "/Users/alefita/workdir/pokemon-tcg/agent/main.py",
"sweep": true,
"sweep_source": "remote",
"games_per_opponent": 20,
"note": "suite 5d_10ep_OFF per-run",
"rows": [
{"opponent_label": "random", "opponent_path": "random",
"deck_id": 1, "wins": 15, "losses": 5, "draws": 0,
"wr_pct": 75.0, "elapsed_s": 4.32, "error": null},
...
],
"overall": {"wins": 113, "losses": 426, "draws": 1,
"wr_pct": 20.96, "elapsed_s": 275.4}
}
This is the contract callers depend on — no stdout scraping. The suite aggregator (experiments/bc_curriculum_suite.sh phase 3) reads these files to build the aggregated markdown at experiments/bc_curriculum_suite/results.md.
Per-run tournament (in the ablation suite)
For each of the 10 checkpoints, the suite runs a per-run tournament immediately after training:
uv run tcg-tournament \
--games 20 --sweep-source remote \
--opponent public_agents/starters/lb510_mega_abomasnow_ex \
--opponent public_agents/starters/lb526_iono \
--opponent public_agents/starters/lb600_dragapult_ex \
--opponent public_agents/starters/lb600_mega_lucario_ex \
--opponent public_agents/lb826_alakazam_seok \
--opponent public_agents/lb945_multiply_ivan \
--opponent public_agents/lb1009_mega_lucario_ex_islet \
--note "suite <tag> per-run" \
--report-json experiments/bc_curriculum_suite/reports/per_run/<tag>_tourn.json
Opponent shape: 2 baselines (auto) + 4 starters + 3 strong public agents. Sweep on → 3 decks per opponent. Total ≈ 9 opps × 3 decks × 20 = 540 games per run. Full findings in pokemon_tcg_bc_curriculum_ablation.
Round-robin (proto-self-play)
After all 10 checkpoints exist, the suite runs the intra-suite round-robin — 45 unique pairs, --no-sweep --skip-baselines, 30 games per pair. For each pair (i, j):
- Copy
model/checkpoint/suite_<i>/<i>.pkltomodel/checkpoint/bc_best_mlx.pkl. - Run
uv run tcg-build --checkpoint model/checkpoint/bc_best_mlx.pkl --out experiments/bc_curriculum_suite/models/<i>.tar.gzto convert the MLX checkpoint into the legacy-namedmodel/bc_model/bc_best_torch_fp16.ptpath (current converter contents are strict FP32, despite the filename) and refresh the<i>.tar.gztarball. - Run
uv run tcg-tournament --games 30 --no-sweep --skip-baselines --opponent experiments/bc_curriculum_suite/models/<j>.tar.gz --report-json experiments/bc_curriculum_suite/reports/round_robin/<i>_vs_<j>.json.
The result is a 10×10 WR matrix, with the diagonal empty (a model does not play itself in an unordered round-robin). The mirror WR (j vs i from row j in the matrix) is computed as 100 - (i vs j) for cells that were played — draws are folded implicitly.
Why this is a step toward self-play. In classical self-play the current model plays against a snapshot of itself (or a lagged version, or a pool of past checkpoints). The round-robin here matches each of the 10 checkpoints against every other one as an opponent — which is exactly the "played against past selves" pattern, generalized to a hyperparameter fan-out. No RL loop is closed yet (no policy updates from these games), but the mechanics — model-vs-model, WR matrix, no external baselines contaminating the signal — are the same substrate an RL self-play loop would use to sample opponents.
Empirically the peer round-robin distribution sits at 44–53% across all 10 models, a useful within-cohort sanity signal. It is not a general validation of the later objective or validation contract; the Stage 3/4 postmortem records a separate training failure. See pokemon_tcg_bc_curriculum_ablation and pokemon_tcg_stage3_training_failure_postmortem.
Anti-correlation: vs publics vs vs peers
From the same 10-model matrix vs the per-run tournament results:
| model | vs publics | vs peers |
|---|---|---|
| 5d_10ep_OFF | 21.0% (1st) | 47.8% (8th) |
| 5d_10ep_ON | 20.0% (2nd) | 44.1% (10th) |
| 3d_1ep_ON | 20.4% | 53.2% (1st tie) |
| 5d_1ep_ON | 17.8% | 53.2% (1st tie) |
Models that specialize enough to beat public agents (fewer generalization moves, more dataset-specific patterns) become predictable to peers trained on the same distribution. Models that stay generic (1ep) beat peers but do not beat publics.
Consequence for submission choice. vs publics is the ladder-Elo proxy — the Kaggle ladder is not populated by BC clones of this suite. The intra-suite round-robin is a diagnostic (for architecture sanity and specialization), not a submission signal.
Where results live
- SQLite — every game creates rows in
matches,match_steps,match_participants,match_card_usage. This is what enables local Elo to grow over time viacompute_agent_elo(source='local'). - JSON reports —
--report-jsonwrites one per invocation. The suite lays them out underexperiments/bc_curriculum_suite/reports/{per_run,round_robin}/. - stdout — human-readable table (Opponent / W / L / D / WinRate / Time), one row per (opponent, deck) with a per-deck performance summary when sweep is on.
eval_results.txt— text log, optional with--txt-backup.
Cross-references
- pokemon_tcg_glossary — vocabulary
- pokemon_tcg_dashboard_and_tournament — dashboard, arena, configuration, synchronous local play
- pokemon_tcg_evaluation_results — eval provenance boundaries
- pokemon_tcg_torch_inference — how OUR agent's checkpoint is actually loaded
- pokemon_tcg_sqlite_schema_current —
deck_elo_daily,matches,match_steps - pokemon_tcg_bc_curriculum_ablation — the 10-config ablation the suite runs
- pokemon_tcg_submissions_and_elo — how tournament outcomes feed submission decisions
- pokemon_tcg_deck_strategy — immutable submission deck contract that the sweep respects