Pokémon TCG AI Battle — MLX Training Pipeline (reconciled current)
Reconciled training reference: historical FP16 delivery contract, current strict-FP32 trainer behavior, TBPTT memory, auxiliary-loss reduction and handoff boundary.
Pokémon TCG AI Battle — MLX Training Pipeline (current)
August 14 audit pointer
The runtime contract below is preserved as the August 7 training baseline. For the current source-versus-blueprint boundary, use pokemon_tcg_aug14_architecture_and_handoff_audit. For current Parquet, SQLite and ETL observations, use pokemon_tcg_aug14_data_etl_database_audit. For the later FP32 curriculum and tournament cohorts, use pokemon_tcg_aug14_ablations_tournaments. The future RoPE-ND and MoE phases remain documented and are not silently removed from the project record.
Current source correction at 20d7d0d
The body below preserves the FP16 delivery snapshot for historical reproducibility. The live trainer now calls model.set_dtype(mx.float32) and rejects any non-FP32 model leaf. The live PyTorch converter likewise validates strict FP32 tensors. Use pokemon_tcg_current_state_reconciliation for the current source contract and this page's dated sections for the historical transition.
The current trainer also carries scratch memory through TBPTT lanes and agent/main.py carries memory between autoregressive action substeps. Statements below that describe discarded scratch output, one-pass topk(count) selection or FP16 as the current runtime are historical and have been superseded by the source-backed pages.
Boundary
This is the canonical reference for the training pipeline running on develop in ~/workdir/pokemon-tcg as of 2026-08-07. It supersedes the phased narrative in pokemon_tcg_mlx_migration, which is now preserved as a migration record. Trainer entry point: scripts/bc/bc_train_mlx.py (invoked as uv run tcg-train).
Runtime split is fixed:
training -> MLX on Apple Silicon (M3 Pro, 24 GiB unified memory), current model FP32
inference -> PyTorch FP32, arena-side, self-contained submission
recurrence -> TBPTT (always on)
No other paths are supported for this phase.
Runtime contract
| aspect | commitment |
|---|---|
| device | mlx.core.gpu (Metal) |
| dtypes | Current source: FP32 parameters, activations, embeddings and QKV; FP32 loss, reductions, accumulation and optimizer moments. Historical FP16 delivery is retained below. |
| positional info | zone-typed token embeddings + explicit opt_src_pos/opt_tgt_pos reference gather; no RoPE / sinusoidal / RoPE-ND (RoPE-ND is pokemon_tcg_ladder_and_research backlog, not implemented) |
| padding | padding_idx=0 semantics reproduced explicitly (ids != 0 mask; zero vector, not a learned "absence" embedding) |
| attention mask | additive (0 valid / large negative padded), FP16-safe. Not boolean. |
| MHA bias | on in the attention projections (bias=True, matching the PyTorch reference) |
| static features | EN_Card_Data.csv loaded once as a numpy buffer; a learned Linear projects it into d_model space; the table itself is not a trainable parameter |
End-to-end flow
sqlite: days ⋈ datasets
│
▼
day list resolved
│ (filters: --top-elo, --max-rows-per-day, --max-rows)
▼
pyarrow.dataset over N parquet files
│
▼
_ParquetRowGroupCache (train) _ParquetRowGroupCache (val)
│ │
▼ ▼
TBPTT lane packer (episode_id, side) val temporal batcher (same)
│ │
▼ ▼
forward ←──── model (current FP32 params/acts, split heads, aux heads)
│ │
▼ ▼
FP32 loss (CE + weighted aux) FP32 loss + metrics
│ │
▼ │
backward → FP32 grad accum │
│ │
▼ │
in_opt_step(): │
clip → optimizer.update → mx.eval │
│ │
▼ ▼
tensorboard scalar per opt_step tensorboard scalar per epoch
Data resolution
The trainer never scans directories directly. It reads day-partitioned Parquet paths from the SQLite catalog (datasets ⋈ days; see pokemon_tcg_sqlite_schema_current) and refuses to start if the resolved list is empty or a resolved path is missing on disk.
Day selection is one of three mutually exclusive flags:
--days 2026-07-30,2026-08-01(explicit list),--last-n-days 5(N most recently registered),--all-days(every day in the catalog).
Row-level filters, applied after day resolution:
--top-elo N— episode-level filter againstagent_elo_daily WHERE source='remote'; bothplayer_nameandopponent_namemust be in the day's top-N agents. See pokemon_tcg_top_elo_curriculum_filter.--max-rows-per-day N— per-day cap rounded down to the nearest episode boundary.--max-rows N— global cap across all resolved days.
Parquet layout, column schema, and streaming reads are documented in pokemon_tcg_parquet_dataset.
KV cache
Two _ParquetRowGroupCache instances (train and val) hold row groups resident between microbatches. Three tiers (hot pinned / transient LRU / SSD spill), pressure-driven eviction at 85% host memory, promotion after 2 hits, opt-step protection wrapped around optimizer.update. Full mechanics in pokemon_tcg_kv_cache_hierarchical.
Model
Instantiated via build_token_net_mlx(ct, net_cfg). Architecture and token schema in pokemon_tcg_agent_architecture. Baseline hyperparameters (config default): d_model=128, nhead=4, nlayers=3, ff_dim=512, scratch_registers=16, static=true, split_heads=true, structured=false. Current session config runs nlayers=4, scratch_registers=32.
Parameter counts around this size: ~1.30M total. The historical August 7 baseline set the model to FP16. The current trainer sets it to FP32 via model.set_dtype(mx.float32) and refuses to start unless every model leaf is mlx.core.float32.
Auxiliary heads
Four Linear heads on top of the CLS output (or VALUE_TOK when split_heads=true):
| head | activation | target column | loss | flag |
|---|---|---|---|---|
ko_head_aux | scalar | aux_ko | BCE | --aux-ko-weight |
prize_head_aux | scalar | aux_prize_delta | MSE | --aux-prize-weight |
terminal_head_aux | scalar | aux_terminal | BCE | --aux-terminal-weight |
return_head_aux | scalar | aux_return | MSE | --aux-return-weight |
All four are masked by aux_valid per row. Weights are fixed at whatever the CLI/config provides — there is no automatic uncertainty weighting yet (Kendall & Gal is pokemon_tcg_ladder_and_research backlog).
The current source has an important reduction detail: _aux_loss returns a weighted sum over valid rows despite a stale docstring describing a mean, while validation metrics use masked means per head. The optimizer later divides accumulated gradients by total examples. This scale distinction is part of the Stage 3/4 incident in pokemon_tcg_stage3_training_failure_postmortem and must not be described as a fully normalized multi-task objective.
The dataset builder is required to emit aux_valid, aux_ko, aux_prize_delta, aux_terminal, aux_return. A day without these will not train.
Optimizer: Muon + AdamW routing
--optimizer muon_adamw is the only supported topology. Parameters split by shape:
- Muon — hidden 2D matrix weights (attention QKV+O, FFN linears, projections). Currently ~845k params.
- AdamW — everything else: embeddings, output heads,
type_query/type_bias, scalar/vector parameters, layer norm gains/biases. ~456k params.
Total: ~1.30M trainable. Routing is decided by walking nn.utils.tree_flatten(model.trainable_parameters()) and applying _use_muon_parameter(path, parameter) per leaf. The rule is stable across resumes and stored in the checkpoint's optimizer_contract.
Weight decay: muon_weight_decay for Muon leaves, adamw_weight_decay for AdamW leaves, structured_weight_decay overriding the AdamW value on type_query / type_bias when --structured true.
The optimizer routing is on tensorboard at run start:
optimizer routing: Muon=845,568 hidden-matrix params;
AdamW=456,583 embedding/head/vector params
Gradient accumulation
For accum_steps=K:
for _ in K microbatches:
forward → FP32 CE + aux loss → backward → FP32 grad add
normalize by real example count (including a partial last microbatch)
clip once (max_grad_norm)
optimizer.update
scheduler.step (counts optimizer updates, not forward passes)
mx.eval(model.parameters(), optimizer.state) # materialize
Current baseline uses accum_steps=1 (one microbatch per opt step). Accumulation is verified to normalize by the real example count even when the last microbatch is short.
Scheduler
The LR scheduler counts optimizer updates, not forward passes:
total_optimizer_steps = epochs × ceil(microbatches / accum_steps)
warmup_steps is clamped to max(total_optimizer_steps / 5, 1) so short runs don't spend the whole schedule warming up. --lr-min-ratio sets the LR floor after decay completes (Orbit-style hold at floor).
On --scheduler-state resume, the scheduler restores scheduler_phase_step from the checkpoint and refuses to start if the resumed horizon does not have enough remaining steps for the current run.
TBPTT
Sequential training over (episode_id, side) lanes in ordered chunks (default 16 decisions). At chunk boundaries: stop_gradient on scratch memory carry, no backprop through the boundary. Reset flag from the row's new_episode column resets scratch memory at match transitions. Full recurrent accounting in pokemon_tcg_tbptt_training_contract.
The trainer's temporal batcher packs lanes into a _TBPTTChunk list until the --batch row budget is hit. Val uses the same lane packer with --val-batch-size as its budget.
Instrumentation
Tensorboard event files at runs/<tag>_<epoch_timestamp>/. Every optimizer step writes:
train/loss— primary CE per opt steptrain/aux_loss— combined weighted aux loss per opt steptrain/grad_norm— post-cliptrain/lr— scheduler-emitted LRtrain/scheduler_phase_steptrain/step_time_mstrain/examples_per_stepsys/mlx_peak_memory_gib— frommx.metal.get_peak_memory()sys/host_memory_percent— frompsutil.virtual_memory().percentcache_train/*andcache_val/*— hit_rate_pct, resident_hot, resident_transient, promotions, evictions, ssd_hits, ssd_spills, ssd_resident (see pokemon_tcg_kv_cache_hierarchical)
Every epoch writes:
train/running_loss/train/running_aux_loss/train/gstepval/loss/val/acc/val/equiv/val/top3/val/atk/val/koval/epoch_time_saux/aux_ko_bce/aux/aux_prize_mse/aux/aux_terminal_bce/aux/aux_return_mse(on val)
At run end: summary/best_val_acc, summary/final_gstep.
41 scalar tags total. Sample of one full run (5d_10ep_OFF, 10 epochs, 1410 optimizer steps) is inspectable via:
uv run tensorboard --logdir runs
Val loop
Val split is deterministic given --seed, --val-frac and the resolved day list — it falls on episode boundaries, not row boundaries, so train and val are guaranteed episode-disjoint. Val batches stream through their own KV cache (see pokemon_tcg_kv_cache_hierarchical) — nothing is materialized to RAM. The old ~22 GiB val materialization regime is gone.
Val metrics computed:
- CE loss — proper cross-entropy:
-(logit[label] - logsumexp(logits)). Not the historicallog(raw_logit). - acc — top-1 accuracy on legal-masked logits.
- equiv — top-1 among options in the same equivalence class as the label (uses
opt_group). - top3 — top-3 accuracy on legal-masked logits.
- atk — accuracy conditional on
is_attack. - ko —
aux_ko_headaccuracy vsaux_kotarget.
Val runs at the end of every epoch. Best-val checkpoint is saved to --out; the latest is always rolled to <--out>_latest.pkl.
Checkpoints
The checkpoint payload is pickled (not mx.savez, which suffers from flatten/unflatten name mismatches) and carries the full state to resume:
model (mlx params, current source FP32; historical checkpoints may be FP16)
optimizer (mlx optimizer.state)
optimizer_contract (routing identity)
optimizer_phase_step
arch_config (model.get_config())
static_card_features (buffer)
static_feature_contract (sha256 of the loaded csv)
run_config (resolved cfg.to_dict + data_days + data_paths + zero_wouldko)
inference_config (seed, bc_would_ko, wk_nvar, provenance)
dataset_manifest (per-day sha256, size, aux-target flag)
dataset_build_fingerprint
phase_id (from --phase-id)
epoch (0-indexed loop var)
gstep (global optimizer-step counter)
val_acc / best_val_acc
seed
accum_steps
microbatches_per_epoch / optimizer_steps_per_epoch
scheduler_phase_step / scheduler_total_steps / scheduler_contract / scheduler_state
On --resume PATH --optimizer-state resume --scheduler-state resume: model params load, optimizer.state loads, scheduler_phase_step restores, start_epoch = state.epoch + 1. The resume path validates arch_config matches the current build; a mismatch raises rather than silently loading incompatible weights.
Reproducibility
Seed applies to numpy AND mx.random. A previous bug seeded only numpy, causing untracked variance across runs even at identical CLI. Fixed.
np.random.seed(seed) + mx.random.seed(seed) at trainer entry. --seed 13 is the current session default.
Historical context — what came before
Reading this page in isolation makes today's pipeline look like a single monolithic design. It is not. It is the fifth-generation shape after several substantial pivots the wiki still documents in full:
- The sidecar era (2026-07-29) — a separate second model (the "prospective V2" planner with RoPE-ND positional encoding) was designed and shipped to score counterfactual continuations at inference. It ran as an offline
.npysidecar joined into the training set. Full design in pokemon_tcg_prospective_v2; the reasoning for its rejection is captured in the private project memoryproject-pokemon-tcg-sidecar. Take-away that survived: any prospective-planning signal that returns will be an integrated aux head or an offline GRPO objective, not a second separate model. Take-away that did not survive: the sidecar's dependency footprint, its.npyon-disk format, and its RoPE-ND positional encoding — none is present in the current runtime. - The NPY dataset era (up to 2026-08-03) — the dataset builder wrote
.npyshards per episode with anepisode_meta.npysidecar for(episode_id, side, step_id)metadata. Trainer read via memory-mapped slabs (slab_rowswas a live knob). Full contract in pokemon_tcg_data_pipeline. Replaced 2026-08-03 by day-partitioned Parquet with metadata as first-class row columns (pokemon_tcg_parquet_dataset). Take-away that survived: the aux-target contract, the episode-boundary val split, would-KO. Take-away that did not survive: the shard layout, theepisode_meta.npysidecar, and the mmapped-slab abstraction (replaced by the Parquet row-group + KV cache combination). - The flat KV cache (2026-08-06 morning) — commit
e772fe0introduced a single-tier row-group cache with FIFO eviction. Rewritten a few hours later (fa38caa) into the current three-tier hierarchical design after we observed under-load thrash. Full mechanics in pokemon_tcg_kv_cache_hierarchical.
The commit-level version of this story is in pokemon_tcg_repository_timeline.
Failure modes not observed in the historical suite
Evidence-based, from the pokemon_tcg_bc_curriculum_ablation runs (10 checkpoints × 10 epochs × ~150–1400 optimizer steps each):
| failure mode | evidence it did not happen |
|---|---|
| encoding bug | training loss decreased monotonically on every run (5d_10ep_OFF: 3.49 → 1.12) |
| MLX → PyTorch conversion drift | tournaments emitted zero opponent errors in ~5,400 games; agent always chose legal actions |
| loss misalignment | val_acc improved with data and epochs (up to 0.61) |
| FP16 overflow/underflow | no NaN observed in any run; train/grad_norm bounded |
| KV cache corruption | val_acc improved monotonically; cache hit rate 99.9% across all runs |
| optimizer contract broken | aux heads (ko_bce, prize_mse, terminal_bce, return_mse) decreased on every run |
| collapsed model | intra-suite peer round-robin sits at 44–53% across all 10 models — normal Elo dispersion |
Within the August 6-7 suite, these observations supported a data- or regime-limited interpretation rather than a broad architecture failure. They do not rule out later objective or validation defects: the Stage 3/4 incident exposed exactly such a training-contract problem. Discussion and cohort scope are recorded in pokemon_tcg_bc_curriculum_ablation and pokemon_tcg_stage3_training_failure_postmortem.
Provenance / anchor commits
9e92d1d(2026-07-25) — feat(C): FP16-native trainer with gradient accumulation and complete checkpoints — landed the FP16-end-to-end contract and the resumable checkpoint payload.75ae036(2026-07-26) — feat(F): memory API with persistent registers + TBPTT support — landed TBPTT and the scratch-register memory API. Follow-up fixesd4da903,f5a4bc8,6f7067b,b216f4f.2e8fd34(2026-07-26) — feat: centralized config module + Kaggle data manager with entrypoints — landedTrainConfig,tcg-train,tcg-data.a942373(2026-08-03) — sidecar removed, aux heads + meta features + parquet pipeline — the pivot commit: switched the dataset to Parquet, added the four aux heads, integrated meta buckets, removed the prospective sidecar.504118d(2026-08-03) — smoke pipeline validated: strict semantics, competition_day, streaming TBPTT — end-to-end validation of the post-pivot pipeline on real smoke data.e772fe0(2026-08-06) — train loader: KV-style parquet cache, async prefetch, top-elo/per-day filters, tensorboard — added the row-group cache (flat), single-worker prefetch,--top-eloand--max-rows-per-day, and full per-optimizer-step tensorboard.fa38caa(2026-08-06) — train loader: hierarchical KV cache + opt-step protection + val streaming — replaced the flat cache with the three-tier hierarchical design and moved val to stream through its own cache.290d6f9(2026-08-07) — CLAUDE.md: current phase log — snapshot of the current state that this page mirrors.
Full timeline in pokemon_tcg_repository_timeline.
Cross-references
- pokemon_tcg_glossary — vocabulary
- pokemon_tcg_agent_architecture — model architecture
- pokemon_tcg_parquet_dataset — data format
- pokemon_tcg_kv_cache_hierarchical — the row-group cache
- pokemon_tcg_train_config_reference — flag-level reference
- pokemon_tcg_top_elo_curriculum_filter — --top-elo semantics
- pokemon_tcg_tbptt_training_contract — TBPTT exact accounting
- pokemon_tcg_sqlite_schema_current — schema the trainer reads from
- pokemon_tcg_torch_inference — arena-side runtime
- pokemon_tcg_bc_curriculum_ablation — 10-config ablation that validated this pipeline
- pokemon_tcg_mlx_migration — the migration record (historical)
- pokemon_tcg_repository_timeline — full verified timeline, with the older 147-count snapshot preserved