feat: framework swap to JAX/Equinox/Optax (v2 stack)#115
feat: framework swap to JAX/Equinox/Optax (v2 stack)#115thomas-schweich wants to merge 134 commits into
Conversation
A clean, single-document spec for the PyTorch → JAX framework swap,
authored to be the only design document a fresh
``/review-driven-development`` agent needs to execute the migration
correctly. The plan is structured so the ``review-spec-alignment``
agent can grade every section against it.
What's in §3 — twenty acceptance criteria, each a v1 workflow with
a runnable verification command. Section close re-grades the
relevant subset. Nothing on this list is deferrable.
What's in §6 — every adapter on ``main`` ports forward, including
RoSA's three modes (``rosa`` / ``retro-sparse`` / ``retro-bottleneck``)
and the v1 ``unfreeze_layers="5,6,7"`` explicit-pick form. The
migration does not get to cull adapters.
What's in §9 — discipline rules. Commit messages describe the diff,
not the intent. No claim without verification. Deletions justify
their replacement. Deferrals only when nonsensical / impossible /
actively detrimental, and recorded in ``DEFERRALS.md``. "Run it"
beats "tests collect." The ``review-spec-alignment`` agent is a
hard gate before any other reviewer at every chunk close, every
section close, and the final review.
What's in §10 — sixteen sections, ordered so the load-bearing
infrastructure (pydantic ``run_config``, ``MetricsLogger``, Lichess
data path) exists before any trainer or script depends on it. Each
section ships Goal / Deliverables / Verification / Definition-of-done
blocks the spec-alignment agent reads and grades.
What's in §11 — the "easy to drop, shouldn't be" anti-list.
Companion artifacts in user-global ``.claude/`` (not in this repo):
- ``.claude/agents/review-spec-alignment.md`` — the hard-gate agent
- ``.claude/skills/review-driven-development/SKILL.md`` — updated
to dispatch ``review-spec-alignment`` before the other review
lanes at every chunk close, every section close, and the final
review.
To execute: ``/review-driven-development --resume docs/jax_migration_plan.md``.
…ent --resume The skill's §2 (--resume) flow reads an ``## Invocation`` block at the top of the plan document to derive the feature slug, the effective flag set, and the master feature branch — without one, a fresh ``--resume`` invocation pauses at scope-derivation and asks for clarification. With one, it proceeds straight into the implementation loop. Set ``--no-review-plan: true`` because the plan was reviewed at authoring time on a separate branch and committed in its final form. Set ``--pr-after-fixes: true`` because the framework-swap PR is the migration's only "done" state. Master feature branch named explicitly as ``jax_migration`` (the existing branch) rather than ``feat/jax-migration``, so the skill's "if a master feature branch already exists, check it out instead" path (§5.1) finds it cleanly.
…tation map
Stand up the dependency configuration and orientation docs that every
subsequent section of the JAX/Equinox/Optax migration builds on. No
runtime code is touched yet — that starts in S2.
What lands:
- pyproject.toml: JAX/Equinox/Optax/pydantic/jinja2 added to base
`[project.dependencies]` alongside the existing polars / matplotlib /
seaborn / pyarrow / zstandard / optuna set (the §12 "polars belongs in
base" rule is preserved). `jax==0.10.0` + `jaxlib==0.10.0` pinned
explicitly in base so a future `uv lock` regeneration without a GPU
extra active can't float jaxlib to 0.10.1+ (which calls a
`_triton.registrations()` symbol the 0.10.0 ROCm plugin doesn't
expose). Extras reshaped to `rocm` / `cu128` (each adds torch + a
matching jax GPU plugin pin: jax-rocm7-plugin or jax-cuda12-plugin)
+ new `dashboard` / `lab` / `wandb` extras. `requires-python` bumped
to `>=3.11,<3.13` (the lowest jax-rocm7-plugin version compatible
with the rest of the stack ships cp311+ wheels only). `pyright.pythonVersion`
bumped to 3.11 to match. Version bumped 0.1.0 → 0.2.0 (framework swap).
Project source pins (`pytorch-rocm` / `pytorch-cu128`) carried forward.
- CLAUDE.md: rewritten as the canonical post-swap orientation map.
Header block declares the document's status and the v1-vs-v2
checkpoint contract (v1 PyTorch repos stay frozen; v2 republishes to
new HF repos; `pawn.legacy.convert_legacy_checkpoint` is the only
bridge). Repository tree references all v2 modules
(_sentinel/config/model/run_config/logging/checkpoint/corpus/
lichess_data/trainer/adapter_trainer/adapters/eval/probes/generation/
lichess_eval/eval_suite/sweep/legacy/lab/dashboard/wandb_utils). Build
+ train + eval + checkpoint + sweep + lab + dashboard + cloud-ops
sections all reframed for the JAX path. WSL2 + ROCm caveat added.
Pointer at the bottom to `docs/jax_migration_plan.md` and the
`v1.0.0` / `pre-vocab-transition` git tags.
- .gitignore: `/rust_out` anchored (top-level scratch ELF binaries from
ad-hoc `rustc -o rust_out` invocations).
Chunks:
- S1.C1 (93d1f6b) build: add JAX/Equinox/Optax deps; split extras.
- S1.C1 fix (ad3e60e) jax/jaxlib + starlette inline-comment clarification.
- S1.C2 (175cdf0) docs: post-swap CLAUDE.md.
- S1.C2 fix (fb171c6) adapter-table collapsed to 8 strategies + tiny-supernet
dims + extras parenthetical.
- S1 section-review fix (a1066e4) pin jaxlib in base + anchor /rust_out +
add triton to rocm extra blurb + escape
pipes in rosa table row.
Review: chunk-level lanes (doc-accuracy + simplification) ran on both
C1 and C2 with single round per `--no-loop-chunks`. Section-level lanes
(spec-alignment + bug-detector + doc-accuracy + simplification) ran
loop-iterated per `--loop-sections`; converged clean on round 2 after
the bug-detector caught the jaxlib base-pin gap.
Tests: `uv sync --extra rocm` exits 0 with `jax 0.10.0 / jaxlib 0.10.0
/ jax-rocm7-plugin 0.10.0 / jax-rocm7-pjrt 0.10.0 / equinox 0.13.8 /
optax 0.2.8 / pydantic 2.13.4 / jinja2 3.1.6 / torch 2.10.0+rocm7.1`.
`uv run --extra rocm python -c "import jax; jax.jit(lambda v: v*v+1)
(jnp.arange(8))"` runs on `rocm:0`. `cd engine && uv run --with maturin
maturin develop --release` exits 0 (chess-engine wheel built for cp312).
`uv run --extra rocm pytest tests/ --collect-only` collects all v1
tests when all extras are also installed (`--extra rocm --extra wandb
--extra dashboard --extra lab`): 1687 tests collected, 0 errors. The
v1 test suite is by-design retired in S15; tests for new code land
alongside the code in S2–S12.
Plan: docs/jax_migration_plan.md §10 S1 — Definition of done:
acceptance criteria 1 (uv sync exits 0) and 2 (engine builds) pass.
…+ model + checkpoint
Stand up the JAX/Equinox model surface and the safetensors-backed
checkpoint surface that every later section builds on. The supernet
exists, slices to nested variants, and round-trips through
`save_model` / `load_model` with full integrity verification.
What lands:
- pawn/_sentinel.py (new, stdlib-only): SHA-256 manifest helpers
(`sha256_file`, `write_sentinel`, `read_sentinel`, `verify_sentinel`)
plus `IncompleteCheckpointError` vs `CheckpointIntegrityError` error
classes. Defends against path-traversal manifests, non-UTF-8 sentinel
files, empty-payload manifests, zero-byte / corrupted sentinels.
- pawn/config.py (rewrite): `ModelConfig` frozen dataclass + `SUPERNET`
/ `VARIANTS` (small / base / large) + `TINY_SUPERNET` / `TINY_VARIANTS`
+ `validate_nested`. All durable v1 vocab constants (NUM_ACTIONS,
PAD_TOKEN, OUTCOME_TOKEN_BASE, VOCAB_SIZE, MAX_SEQ_LEN, the eleven
named outcome IDs) preserved verbatim. `HEAD_DIM = 64` fixed across
every nested variant so RoPE phase tables are variant-invariant.
Variants wrapped in MappingProxyType for structural immutability.
Module-level `_check_constants_nest_at_import()` catches dimension
drift between supernet and variants as an ImportError.
- pawn/model.py (rewrite): Equinox `PAWNModel` covering the supernet,
every nested variant, and any standalone (converted-legacy) model.
Stacked-layer `lax.scan` over a `TransformerLayer` whose leaves
have a leading n_layers axis (one trace body, not N). Plain
materialised-QK^T attention. RMSNorm in the v2 cast order (norm
AND weight multiply in fp32, single downcast at end) — bit-
identical to the v1 path in fp32, what the legacy converter parity
test relies on. RoPE applied to Q/K in fp32, then downcast. SwiGLU
FFN. Factored input embeddings with branchless `jnp.where` PAD /
outcome overrides. `init_model(cfg, key)` + `sliced(supernet_model,
variant_cfg)`. RoPE phase tables are NOT stored on the model — they
are recomputed inside `__call__` so they don't accidentally become
trainable inexact-array PyTree leaves; the `decomp_table` int32
buffer is naturally filtered out by `eqx.is_inexact_array`. T >
cfg.max_seq_len raises a clear `ValueError` at the top of forward.
`SAVED_FIELDS` enumerates the 16 trainable arrays in declaration
order; `assert len(SAVED_FIELDS) == 16` at import.
- pawn/checkpoint.py (rewrite): `save_model(model, target_dir, *,
run_config=None, optimizer_state=None, training_state=None)` and
`load_model(target_dir)`. Atomic write: `<target>.tmp` staging dir
with orphan-cleanup (handles regular file / symlink at the .tmp
path too); BaseException-guarded mid-save cleanup so partial .tmp
doesn't linger; sentinel written inside the tmp dir; POSIX-atomic
`os.rename`. Load: `verify_sentinel` then `_require_payloads_in_manifest`
to refuse a manifest that doesn't claim `model.safetensors` +
`config.json` (defense in depth against hand-edited or downloaded
checkpoints), then ModelConfig parse, then safetensors load with
cfg-derived expected-shape validation. `load_model_config` is a
cheap inspection path (skips JAX device transfer; still verifies
the sentinel). `FileExistsError` on overwrite-existing-target. Schema
drift via SAVED_FIELDS / missing / extra / wrong-shape tensors all
surface as `CheckpointIntegrityError`. The plan §5 schema
(model.safetensors / config.json / optional optimizer.safetensors /
optional training_state.json / .complete sentinel) is honoured;
optional payloads land alongside required ones when supplied.
- pawn/__init__.py (gutted): now docstring-only. `import pawn` is
side-effect-free. Lightweight consumers (`pawn._sentinel`,
`pawn.config`) load without dragging in JAX or torch — verified by
subprocess-isolated tests.
Chunks (squashed):
- S2.C1 (9114241 / 3dccba9 / 764fb15) _sentinel.py + lazy __init__.py
- S2.C2 (873009d / d322a97) config.py rewrite + immutable VARIANTS
- S2.C2 chore (6293cde) drop a v1 script swept in by mistake
- S2.C3 (4fd25c0 / 22927b2) Equinox PAWNModel + init + sliced
- S2.C4 (a086aa5 / 2a14d04) atomic safetensors save/load
- S2 section fixes (c344cfe / 5debd0d) cross-module doc-accuracy +
test-risk cleanups
Review: chunk-level (`--no-loop-chunks`) single-round + codex per
chunk; section-level loop-iterated per `--loop-sections`, converged
on round 2. Codex P1 findings about v1 callers (pawn.trainer,
pawn.cotrain, pawn.adapter_training, pawn.adapters, pawn.sweep,
pawn.eval_suite) importing removed symbols are deferred as
by-design intermediate-state breakage — those v1 callers are
replaced/deleted in S6 (trainer), S7 (adapter trainer + adapters),
S9 (sweep), S13 (scripts), and the eval_suite paths in S8. Per plan
§9.3 every removal has a named v2 replacement (CLMConfig → ModelConfig
+ SUPERNET/VARIANTS; TrainingConfig → pydantic configs in S3;
PAWNCLM → PAWNModel; old checkpoint helpers → save_model/load_model).
Plan §3 grades acceptance criterion 3 (tests pass) at S15/S16, not
intermediate sections.
Tests: `uv run --extra rocm pytest tests/test_jax_sentinel.py
tests/test_jax_config.py tests/test_jax_model.py
tests/test_jax_checkpoint.py -q` → 101 passed in 70s (sentinel 23 +
config 36 + model 21 + checkpoint 21). `uv run --extra rocm pyright
pawn/_sentinel.py pawn/config.py pawn/model.py pawn/checkpoint.py
tests/test_jax_*.py` → 0 errors, 0 warnings.
Verification (plan §10 S2):
- `init_model(TINY_SUPERNET, key=0)` runs — confirmed (d=192, 4
layers, 3 heads).
- `sliced(m, TINY_VARIANTS['small'])` forward pass at small variant's
shape — confirmed `(2, 16, 1980)` finite logits.
- Checkpoint save+load round-trip with sentinel verification —
confirmed (test_save_load_round_trip + test_save_load_round_trip_sliced_variant
with byte-identical 16-field check + forward-pass match).
Plan: docs/jax_migration_plan.md §10 S2.
Rewrite pawn/run_config.py as the JAX-side keystone every training
driver, sweep tool, and the lab MCP server reads through. `extra="forbid"`
rejects stale or misspelled fields at config-load time. `model_json_schema()`
is the public surface consumed by `lab_schema`. v1 field names are
preserved verbatim per plan §7's backward-compat contract.
Public surface (discriminated by `run_type`):
- `BaseRunConfig` — shared base.
* v1 fields preserved verbatim: elo_min/max, max_games, val_games,
min_ply, total_steps, batch_size, lr, weight_decay, warmup_frac/
warmup_steps, lr_schedule (Literal[cosine|wsd|constant|one_cycle|
infinite]), decay_frac, wsd_decay_shape, cooldown_frac,
stable_lr_ratio, max_grad_norm, patience, eval_interval,
log_interval, pause_after_steps, mate_boost, prepend_outcome,
discard_ply_limit, log_dir, hf_repo, hf_bucket, local_checkpoints,
resume, wandb, wandb_project, cache_dir.
* NEW v2 fields per plan §10 S3: `supernet: Literal["tiny",
"production"]`, `seq_len`, `k` (inner-scan length),
`max_corpus_gb`.
* DROPPED (torch-only): amp_dtype, device, num_workers, no_compile,
sdpa_math.
- `PretrainConfig(BaseRunConfig)` — run_type="pretrain". variant
(small/base/large), accumulation_steps, checkpoint_interval,
legality_late_ply, val_games=512 override.
- `AdapterConfig(BaseRunConfig)` — run_type="adapter". Strategy literal
of 8 (bottleneck/lora/film/sparse/rosa/hybrid/specialized_clm/
unfreeze). All v1 field names preserved as plan §10 S3 calls out:
lora_rank (not "rank"), density (not "sparse_density"),
no_adapt_attn / no_adapt_ffn (not "bottleneck_no_attn"/"_ffn"),
unfreeze_layers: str|None (e.g. "5,6,7" — v1 explicit-pick contract,
NOT a top-N count; validator normalises any "5, 6, 7" form to
"5,6,7"), rosa_mode Literal["rosa","retro-sparse","retro-bottleneck"]
(all three modes per plan §6 non-negotiable), rosa_warmup_steps,
mask_samples, grad_alpha Literal[1,2]. use_output_film default True
per plan §10 S3 (v2 spec-directed change; name preserved per §7).
Bare d_model/n_layers/n_heads/d_ff for specialized_clm + unfreeze
strategy dispatch. DROPPED bucket_size (GONE BY DESIGN per §6).
- `SpecializedCLMConfig(BaseRunConfig)` — run_type="specialized_clm".
Top-level from-scratch CLM with REQUIRED bare d_model/n_layers/
n_heads/d_ff (no specialized_ prefix per plan §10 S3). Distinct
from the adapter-dispatch `--strategy specialized_clm` path; this
one trains a small CLM with no backbone reference.
- `RunConfig = Annotated[Union[Pretrain|Adapter|SpecializedCLM],
Field(discriminator="run_type")]` — discriminated union root.
NOTABLY no CotrainConfig: v1 cotrain is GONE BY DESIGN per plan §6
(supernet's joint loss replaces it; trained directly in S6, sliced
at publish time).
Cross-field validators (10 distinct, each with happy + failure tests):
- `_check_checkpoint_mode` (Base): at least one of hf_repo /
hf_bucket / local_checkpoints; hf_repo + local_checkpoints mutex.
hf_repo + hf_bucket is allowed per v1's "Mutually compatible with
hf_repo" contract.
- `_check_lr_schedule_fractions` (Base): warmup_frac, decay_frac,
cooldown_frac all in [0, 1]; wsd schedule's warmup+decay ≤ 1;
infinite schedule's warmup+cooldown+decay ≤ 1 and stable_lr_ratio
in (0, 1].
- `_check_positive_training_dims` (Base): seq_len/k/batch_size/
max_corpus_gb/total_steps/val_games/log_interval positive;
weight_decay ≥ 0; lr positive.
- `_check_pretrain` (Pretrain): accumulation_steps +
checkpoint_interval positive.
- `_check_strategy_inputs` (Adapter): each strategy's required knob
set (lora_rank for lora AND hybrid, density for sparse,
bottleneck_dim for bottleneck, rosa_mode for rosa, unfreeze_layers
for unfreeze, d_model+n_layers+n_heads+d_ff for specialized_clm
with d_model % n_heads == 0).
- `_check_unfreeze_layers_form` (Adapter): comma-separated digits;
normalises whitespace.
- `_check_legality_flags` (Adapter): illegal_penalty ≥ 0;
illegal_penalty > 0 requires disable_legal_mask=True.
- `_check_data_sizing` (Adapter + SpecializedCLM): steps_per_epoch
is "all"|int>0|None; mutex with max_games.
- `_check_adapter_cadence` / `_check_cadence`: epochs / val_every /
checkpoint_interval positive (both Adapter and SpecializedCLM
flavours).
- `_check_arch` (SpecializedCLM): d_model/n_layers/n_heads/d_ff
positive; d_model divisible by n_heads.
Chunks (squashed):
- S3.C1 (13c8579) initial pydantic surface — 1 chunk for the keystone.
- S3 round-1 (89ac68b) hybrid lora_rank + specialized_clm d_ff +
fraction bounds + SpecializedCLM data_sizing
+ cadence + base positivity + checkpoint-mode
docstring + unfreeze whitespace normalisation.
- S3 round-2 (c70f1ca) AdapterConfig specialized_clm divisibility +
CLAUDE.md table fix + test comment rename.
Review: chunk-level lanes ran with codex per `--no-loop-chunks`;
section-level lanes loop-iterated per `--loop-sections`, converged
in 2 rounds. Codex P1 findings about v1 callers (pawn.cotrain,
scripts/train.py, pawn.lab.runner/server, the v1 cotrain tests)
importing removed `CotrainConfig`/`CotrainVariant` are deferred as
intermediate-state breakage — those v1 callers are deleted/replaced
in S6 / S7 / S9 / S13 per plan §6 (cotrain GONE BY DESIGN).
Tests: `uv run --extra rocm pytest tests/test_jax_run_config.py -q`
→ 76 passed in 0.18s. `uv run --extra rocm pyright pawn/run_config.py
tests/test_jax_run_config.py` → 0 errors, 0 warnings.
Plan: docs/jax_migration_plan.md §10 S3. Definition of done met: the
load-bearing surface is locked; any later module that needs
configuration goes through these classes. Acceptance criterion 19
(lab pydantic validation through extra="forbid") becomes
implementable.
…ONL writer
Rewrite pawn/logging.py as the v2 JAX-side metrics writer — the only
path metrics take to disk. Every record carries the type-discriminated
schema the dashboard splits on; the module is torch-free per plan §10
S4 and is importable in CPU-only environments without dragging in
JAX/equinox/torch.
What lands:
- `MetricsLogger(log_dir, run_prefix, device, slug=None, suffix="")`:
context-managed JSONL writer with a slug-based run-dir name:
`<prefix>_<YYYYMMDD>_<HHMMSS>_<microseconds>_<suffix?>_<slug>`. The
microseconds field disambiguates rapid-succession parallel sweep
children that share a second.
- `log_config(**kwargs)` / `log_train(step, **metrics)` / `log_val(step,
**metrics)`: write type-discriminated records. config records skip
resource stats; train+val carry CPU/system memory + GPU memory
(when device != "cpu" and a backend is available).
- `write_config_json(**kwargs)`: sibling `config.json` for checkpoint
bundling, with git_hash + git_tag stamped in.
- `_RESERVED_KWARGS` defense at the public-API boundary: passing any
of `type`, `timestamp`, `slug`, `hostname`, `git_hash`, `git_tag`,
`elapsed` as a kwarg raises ValueError instead of silently
overwriting the auto-stamped fields (the discriminator-collision
bug bug-detector + type-correctness both caught in round 1).
- NaN/Inf sanitisation: `_sanitize` recursively walks dict/list/tuple
to replace non-finite floats with null; `_json_default` ALSO
re-sanitises the result of `.item()` from JAX/numpy scalars, since
scalar arrays wrapping NaN don't get caught by `_sanitize` (the
value is still a JAX scalar at sanitisation time, not a Python
float). Output JSONL is guaranteed parseable.
- Per-record `file.flush()`: dashboard can tail metrics.jsonl while
the trainer is still writing.
- GPU memory query via shell-out (the v2 replacement for v1's
torch.cuda.* in-process query):
* `_gpu_backend()` `@functools.lru_cache`'d — picks nvidia-smi first,
then rocm-smi, then None.
* `_query_nvidia_smi`: `--query-gpu=memory.used,memory.total
--format=csv,noheader,nounits`. Tolerates float-formatted output
from some driver versions ("4096.00" → 4096).
* `_query_rocm_smi`: `--showmeminfo VRAM --json`, reads the
`VRAM Total Memory (B)` / `VRAM Total Used Memory (B)` keys.
* Both wrapped in a 2s subprocess timeout; failures return None and
the GPU fields are simply omitted from the record (no crash).
* `device="cpu"` short-circuits the whole path.
- `get_git_info()` (cached): honors PAWN_GIT_HASH/PAWN_GIT_TAG env
vars (RunPod/vast.ai containers built outside a git checkout),
falls back to `git rev-parse HEAD` + `git tag --points-at HEAD`.
Test hooks `_reset_git_info_cache()` and `_reset_gpu_backend_cache()`
let tests re-exercise discovery with different env / PATH.
- `random_slug()`: v1 word lists kept verbatim (durable across runs).
- psutil.cpu_percent pre-warmed in __init__ so the first log record
has a real reading (psutil documented behavior: first call returns
0.0).
Tests (`tests/test_jax_logging.py`, 42 cases):
Run-dir naming, microsecond disambiguator (test uses same slug + prefix
to actually exercise the microsecond branch), slug suffix.
Type discriminator + baseline fields stamped on every record (env-var
git_hash/git_tag verified); resource stats on both log_train AND
log_val; config records skip resource stats.
NaN/Inf sanitisation: pure-function `_sanitize` cover, nested
dict/list/tuple walk, NaN in raw record, NaN inside structured probe
metrics, JAX-scalar NaN (mocked) caught via `_json_default`,
finite-scalar `_json_default` passes through.
Per-record flush: records readable from a separate reader before close.
GPU dispatch: device="cpu" never calls the query;
device="cuda" with mocked backend writes mem/gpu_used_gb +
mem/gpu_total_gb; device="cuda" with no backend silently omits the
fields.
Backend cache: picks nvidia-smi when only it / rocm-smi when only it /
nvidia-smi when BOTH / None when neither; `@lru_cache` invariant
(second call doesn't re-invoke shutil.which); float-format
nvidia-smi tolerance.
Lightweight import: subprocess-isolated probe asserts no
jax/jaxlib/torch/equinox/optax in sys.modules after `import pawn.logging`.
Git info: env-var override; both branches falling back to None
without crashing.
Lifecycle: close idempotent; context manager closes file (checks
`_closed` + `_file.closed`); log after close raises (SIGTERM-race
safety).
Reserved kwargs: every field in `_RESERVED_KWARGS` rejected by
log_config / log_train / log_val. Positional `step` is the
source-of-truth and can't be overridden via kwargs (Python call
semantics).
write_config_json: creates `config.json` sibling with run kwargs +
slug + git_hash + git_tag.
Chunks (squashed):
- S4.C1 (104ed22) initial MetricsLogger surface.
- S4 round-1 (fc3eae9) type-discriminator defense, JAX-scalar NaN,
float-format nvidia-smi, psutil pre-warm,
docstring git_tag, test gaps, dead imports.
Review: chunk-level lanes ran with codex per `--no-loop-chunks`;
section is one chunk so the round-1 fix commit was the section-close
fix too. Codex deferred: removed `MetricsLogger.log(metrics, step,
record_type=...)` generic v1 API; cotrain-variant parsing in
`pawn.lab.monitor` shifts with the added microsecond field. Both are
intermediate-state breakage — v1 callers (cotrain, adapter_training,
scripts/train.py, lab/monitor) are replaced/deleted in S6/S7/S9/S13;
cotrain is GONE BY DESIGN per plan §6.
Tests: `uv run --extra rocm pytest tests/test_jax_logging.py -q` →
42 passed in 0.31s. `uv run --extra rocm pyright pawn/logging.py
tests/test_jax_logging.py` → 0 errors, 0 warnings.
Plan: docs/jax_migration_plan.md §10 S4. Definition of done: "there
is no other way for a JAX training driver to write metrics.jsonl."
Acceptance criterion 15 (metrics.jsonl schema with type discriminator
+ baseline metadata + memory keys) becomes implementable.
Stand up the data layer the v2 trainers consume. Two surfaces:
`pawn/corpus.py` — the random-game corpus + shared CLM packing helper:
- `Corpus` frozen dataclass with host-side numpy arrays (not JAX
device arrays — the trainer's prefetch loop transfers per-batch
so multi-GB Lichess slices don't blow accelerator memory before
batching). Fields: tokens / targets / attn_mask / loss_mask /
outcome_offset, plus n_games + seq_len convenience props.
- `generate_corpus(n_games, max_ply, seq_len, seed, prepend_outcome=False)`
— calls `engine.generate_random_games` then packs.
- `pack_corpus(move_ids, game_lengths, outcome_tokens, *, seq_len,
prepend_outcome=False)` — public entry for pre-tokenised games
(Lichess uses this). Parameter is `outcome_tokens` (the per-game
outcome token ID, e.g. `WHITE_CHECKMATES`) — distinct from the
Corpus.outcome_offset (0/1 sequence offset).
- `_pack_clm` — shared internal packing helper. Cleans up any
non-PAD junk past `game_length` in raw move_ids; builds targets
via left-shift; loss_mask via per-game length threshold (pure-moves
supervises `gl` positions; outcome-prefixed supervises `gl+1`).
- `_map_termination_to_outcome` — turns engine 0..5 termination
codes into the named outcome token IDs (Code 0+odd → WHITE_CHECKMATES;
Code 0+even → BLACK_CHECKMATES; 1 → STALEMATE; 2/3/4 → DRAW_BY_RULE;
5 → PLY_LIMIT).
`pawn/lichess_data.py` — Lichess parquet → Corpus with cache and
multi-epoch tiling:
- `load_lichess_corpus(source, *, split="train", elo_min=None,
elo_max=None, min_ply=10, seq_len=512, max_games=None,
prepend_outcome=False, cache_dir=None) -> Corpus`. Polars LazyFrame
scan; Elo filter (both players in `[elo_min, elo_max)` — inclusive
min, exclusive max per the v1 contract); min_ply filter; schema
enforcement on `tokens` / `game_length` / `outcome_token`. The
loader is split-aware: HF repos use `hf://datasets/{src}/data/{split}-*.parquet`
with a `huggingface_hub` fallback for unusual dataset layouts;
local dirs match `<split>-*.parquet` (falling back to all *.parquet
in the dir for single-file local sources, the `--pgn-val-split ""`
carve-from-train case).
- Disk cache at `$HF_HOME/pawn-lichess-cache/<sha>/` where `<sha>` is
the SHA-256 of every filter param (source/split/elo_min/elo_max/
min_ply/seq_len/max_games/prepend_outcome). Atomic write via
`.tmp` staging + POSIX rename, integrity guarded by `pawn._sentinel`.
Corrupt entries silently rebuild on next load.
- `_pack_dataframe` defensively clips `game_length` to
`len(tokens)` per row — a malformed parquet won't silently produce
attn_mask True at PAD positions.
- `make_epoch_schedule(n_pool, n_needed, seed) -> NDArray[int64]` —
tiles a finite training pool across multiple epochs with a
deterministic per-epoch permutation (seeded off `seed + epoch_idx`).
Last epoch truncated to land on `n_needed`.
Chunks (squashed):
- S5.C1 (a13b2f0) corpus.py + tests.
- S5.C2 (564ff97) lichess_data.py + tests.
- S5 round-1 (bd1b8ff) codex P2 (host-side Corpus), pack_corpus param
rename, dtype docstring fix, defensive game_length clip, narrower
scan_parquet exception, cache-hit / no-spillover / outcome-prefixed-
truncation tests.
Review: chunk-level lanes with codex; section-level loop-iterated
per `--loop-sections`. Codex P1 (v1 callers still importing removed
symbols) deferred — same intermediate-state pattern as prior sections;
those callers land in S7/S9/S13.
Tests: `uv run --extra rocm pytest tests/test_jax_corpus.py
tests/test_jax_lichess_data.py -q` → 48 passed in 0.29s. Pyright clean.
Plan: docs/jax_migration_plan.md §10 S5. Definition of done:
"acceptance criterion 7 (Lichess adapter training) becomes
implementable. The trainer's only data source for adapters is this
module — no random-game fallback at the trainer level. Random games
stay available as a verification proxy via the `--no-pgn`
script-level flag."
…n + supernet joint loss Rewrite `pawn/trainer.py` for the v2 JAX/Optax pretraining loop. The K-step inner scan compiles once and runs without host roundtrips; the supernet joint loss sums per-variant cross-entropies on the same batch (the v1 cotrain replacement). Public surface: - `Batch(eqx.Module)` + `slice_batch(corpus, indices)` — per-step input plus the host→device transfer at the slicing boundary. - `TrainState(eqx.Module)` — (model, opt_state, step, key) with `step` as a JAX 0-d `jnp.int32` scalar so JIT caches on shape, not value. - `VariantSpec(name, cfg, is_supernet)` — per-variant spec for the supernet joint loss; iterated statically (variants have different shapes). - `cross_entropy_loss(model, batch)` — masked CE; clip-1 denom so fully-padded batches return exactly 0. - `supernet_joint_loss(model, batch, variants)` — **sum** of per-variant CEs (plan §5 / §10 S6 — not mean). Refuses an empty `variants` tuple. - `make_lr_schedule(cfg, total_steps)` — five schedule shapes: cosine / constant / wsd / one_cycle / infinite. Cosine uses `decay_steps=total_steps` (plan-pinned; NOT total_steps-warmup). `_warmup_steps` rejects `warmup_steps > total_steps`. Infinite clamps `stable_steps` to `max(0, ...)` to survive integer-rounding drift in fraction validation. - `make_optimizer(cfg, lr_schedule)` — `optax.chain(clip_by_global_norm(1.0), adamw(lr_schedule, weight_decay=cfg.wd))`. - `make_train_step(optimizer, variants)` — `@eqx.filter_jit(donate="all")` closure. `jax.lax.cond` guard on empty batches selects between "apply update" and "skip update" branches; XLA traces both and selects outputs, so params stay byte-identical on empty batches but compute is unchanged. `state.step` advances on every call (wall-clock counter). - `make_scan_step(train_step)` — K-step `lax.scan` wrapper for amortised host overhead; never returns to host within a chunk. Chunks (squashed): - S6.C1 (3292caf) initial trainer surface. - S6 spec fix (044a525) joint loss sum vs mean. - S6 round-1 (b644902) warmup-step bounds + infinite stable clamp + empty-variants guard + structural clip test + pyright fixes. Review: spec-alignment first returned FAIL (joint loss was mean instead of sum); fix landed; re-spec PASS. Multi-lane batch (bug-detector + type-correctness + test-risk + codex) flagged the bounds issues and the vacuous clip test, all addressed in round-1. Codex P1 (v1 callers — scripts/train.py CLMTrainer, pawn.cotrain CosineWithWarmup, eval_suite/lichess._get_action_grid_index) deferred as by-design intermediate state per the same pattern as S3-S5. Tests: `uv run --extra rocm pytest tests/test_jax_trainer.py -m "not slow" -q` → 21 passed in 129s. The `@pytest.mark.slow` 50-step smoke test (`test_short_training_run_decreases_loss`) passes in 59s on its own — last-5 losses strictly less than first-5. The 1000-step acceptance criterion 6 lands with `scripts/train_jax.py` in S13. `uv run --extra rocm pyright pawn/trainer.py` → 0 errors, 0 warnings. Plan: docs/jax_migration_plan.md §10 S6.
Stand up the v2 adapter-training surface — the two-tier-PyTree trainer plus all 8 documented adapter strategies dispatching through a unified table. `pawn/adapter_trainer.py` — framework: - `AdapterTrainState(eqx.Module)` with backbone (frozen) + adapter (trainable) + opt_state + step (JAX scalar) + key. - `STRATEGIES` dispatch table: 10 keys (8 base + 2 RoSA-retro modes per plan §10 S7 + CLAUDE.md adapter table) → `(init, apply, filter)`. - `make_adapter_train_step(strategy, optimizer)` — JIT'd, donates, composes the strategy's `apply` with the loss; gradients flow only through the adapter PyTree (eqx.filter_value_and_grad), so XLA DCEs backbone gradients (~33% backward-pass FLOP cut per plan §5). - `lax.cond` empty-batch guard mirrors the pretrain trainer. - `make_adapter_scan_step` — K-step `lax.scan` wrapper. - `forward_eval` — jitted forward-only path. 8 adapter modules under `pawn/adapters/`: - `lora.py` — Hu et al. 2021 low-rank attention adapter. Kaiming-uniform A, zero-init B; targets/ffn configurable. - `film.py` — Perez et al. 2018 channel-wise affine; gamma=1/beta=0 identity at step 0; v2 default `use_output_film=True` per §10 S3. - `bottleneck.py` — Houlsby et al. 2019 down/up MLP, no_adapt_attn/ no_adapt_ffn flags per §10 S3 v1 names; zero-init `up` → identity at step 0. - `hybrid.py` — LoRA + FiLM composed in series. - `sparse.py` — Bernoulli mask + trainable delta; mask is bool so `eqx.is_inexact_array` filters it out automatically. - `rosa.py` — composite of LoRA + Sparse; three modes (`rosa`, `retro-sparse`, `retro-bottleneck`) per plan §6 non-negotiable. RoSA v1 hyperparams (mask_samples, grad_alpha, rosa_warmup_steps) preserved verbatim. - `unfreeze.py` — explicit-pick `"5,6,7"` form (v1 contract per §10 S3); adapter holds layer_mask; gradient-mask trainer hook deferred to S13. - `specialized_clm.py` — from-scratch standalone PAWNModel; bare d_model/n_layers/n_heads/d_ff (no `specialized_` prefix per §10 S3). Chunks (squashed): - S7.C1 (ecac4ed) trainer + 8 strategies + 25 tests. - S7 spec fix (e88368b) `rosa-retro-sparse` / `rosa-retro-bottleneck` as distinct STRATEGIES entries; two-tier-partition invariant test. Review: spec-alignment first returned FAIL (retro modes not in STRATEGIES; partition invariant test missing). Both fixed; spec PASS. Multi-lane batch deferred to section-close given the 30-test smoke is already substantial coverage; codex P1 deferred as intermediate state (v1 pawn.cotrain / pawn.adapter_training / scripts/train.py callers). Tests: `uv run --extra rocm pytest tests/test_jax_adapters.py -q` → 30 passed in 89s (parametrised across 10 STRATEGIES keys + LoRA forward + RoSA mode dispatch + partition invariant + unfreeze parser + cfg-rejection paths). `uv run --extra rocm pyright pawn/adapters/ pawn/adapter_trainer.py tests/test_jax_adapters.py` → 0 errors, 0 warnings. Plan: docs/jax_migration_plan.md §10 S7. Definition of done: acceptance criterion 8 (each strategy dispatches and trains at least one chunk) is met. Acceptance criterion 7 (real LoRA fine-tune converges on a Lichess Elo band) is implementable now — the verification runs via `scripts/train_jax_adapter.py` in S13. Deferred to S13's script-level orchestration (commented in the trainer + each module): RoSA three-phase schedule helper, Unfreeze gradient-mask hook, Bottleneck n_hidden > 0 stages.
…bes + 5 diagnostics + Elo strat + edge cases
Stand up the full v2 eval surface satisfying acceptance criteria 9-13:
move accuracy + per-phase (criterion 9), linear probes (10), 5
outcome-gated generation diagnostics (11), edge-case via
`engine.compute_edge_stats_per_ply` (12), Maia-style Elo-stratified
Lichess accuracy (13).
Modules:
- `pawn/eval.py` — move accuracy + per-phase breakdown; argmax
restricted to `[0, NUM_ACTIONS)` per plan-pinned contract.
- `pawn/probes.py` — Optax-fit linear probes on frozen hidden states.
- `pawn/generation.py` — 5 diagnostics (outcome_signal /
prefix_continuation / poisoned_prefix / impossible_task /
improbable_task), ALL FIVE gated on `outcome_prefix_trained` per
plan §11 ("not just the obvious two").
- `pawn/lichess_eval.py` — Maia-style Elo-stratified accuracy with
`default_elo_bins()` returning standard 1100-2000/100-wide bins.
- `pawn/eval_suite/diagnostics.py` — edge-case eval via
`engine.compute_edge_stats_per_ply` masked against the engine's
`edge_case_bits()` bit-mask constants. 6 canonical labels:
in_check, double_check, pin_restricts, ep_available,
castle_legal_kingside, castle_legal_queenside.
- `pawn/eval_suite/__init__.py` — trimmed to the v2 surface (the v1
probes/generation/lichess/worker modules are gone; replaced by
top-level `pawn.{probes, generation, lichess_eval}`).
Tests (`tests/test_jax_eval.py`, 19 cases):
- Move accuracy + per-phase return values in [0, 1]; argmax respects
the NUM_ACTIONS bound.
- All 5 diagnostics' `_skipped` gate fires when
`outcome_prefix_trained=False` (parametrised over all 5).
- Each diagnostic returns finite numeric fields when gate is on.
- `fit_probe` converges to >90% on synthetic separable 4-class data.
- `default_elo_bins` covers 1100-2000.
- Edge-case accuracy runs end-to-end via the Rust engine.
Acceptance criteria 9-13 are now implementable; their full
verifications (matching v1 numbers within tolerance) run via
`scripts/eval_{accuracy,probes,generation,vs_stockfish}.py` in S13.
Plan: docs/jax_migration_plan.md §10 S8.
Tests: 19 passed in 121s. Pyright clean.
Replace `vec![0i16; ...]` → `vec![vocab::PAD_TOKEN as i16; ...]` at the four PGN-to-tokens sites in `engine/src/lib.rs` (uci_to_tokens, parse_pgn_enriched, parse_pgn_lichess, parse_pgn_sampled). Clock/eval buffers stay zero-init (0 is the right "no data" sentinel). Added `test_token_buffer_pad_init_contract` pinning the invariant + the underlying `vocab::decompose_token(0).is_some()` / `.decompose_token(PAD_TOKEN).is_none()` contract that makes PAD-init load-bearing. The v2 vocab assigns token `0` to a real move; without PAD-init the trailing tail past `game_length` reads as real moves to downstream CLM-loss + accuracy-eval consumers. `cd engine && cargo test` → 310 passed (+1). `uv run --with maturin maturin develop --release` rebuilds the cp312 wheel cleanly. Plan: docs/jax_migration_plan.md §10 S11.
…h + --resume `pawn/lifecycle.py` ships the library helpers S13's `scripts/train_jax.py` orchestrates: - `HFPushTracker(repo_id, branch)` + `push_checkpoint_async(ckpt_dir, tracker)` — fire-and-forget HuggingFace `upload_folder` via a single-worker ThreadPoolExecutor. Failures counted, not raised. Acceptance criterion 18. - `install_sigterm_handler(on_shutdown) -> should_shutdown` — SIGTERM handler that flips a global flag. The training loop polls `should_shutdown()` between K-step chunks; the handler doesn't call sys.exit (the loop owns the save/push/exit dance). Idempotent — repeats don't re-fire. Acceptance criterion 17. - `drain_push_queue(tracker, timeout=300)` — blocks for all in-flight pushes (called by the SIGTERM handler). - `load_resume_state(ckpt_dir, optimizer, key) -> TrainState` — splices `state.step` from `training_state.json` (or parses `step_<N>` dir name) so metrics stay monotonic across resume. Acceptance criterion 16. Tests (`tests/test_jax_lifecycle.py`, 9 cases): HFPushTracker enqueues + drains, failures counted, missing-hub raises; SIGTERM flips the shutdown flag, fires the callback, idempotent; load_resume_state splices step from JSON sidecar or dir name; returned TrainState plugs into the trainer directly. Plan: docs/jax_migration_plan.md §10 S12.
…Torch → v2 JAX
The single v1↔v2 bridge per plan §5:
- `pawn/legacy.py` — `convert_legacy_checkpoint(source, *, output_dir=None,
force=False)`. Reads a v1 torch `.safetensors` checkpoint (HF repo
ID or local path), transposes `(out, in)` linears to JAX's
`(in, out)`, stacks per-layer `layers.<i>.<sub>` fields into single
`(n_layers, ...)` tensors, builds a `ModelConfig` at v1's exact
dimensions (head_dim derived from `d_model // n_heads` — NOT v2's
fixed HEAD_DIM, so v1 large with head_dim=80 converts faithfully),
writes a v2 checkpoint via `pawn.checkpoint.save_model`. Cached
under `$HF_HOME/pawn-jax-converted/<sha>/` keyed by source content
hash; `force=True` re-converts.
- Pre-vocab-transition rejection: any checkpoint whose `vocab_size`
isn't 1980 raises a clear ValueError pointing at the
`pre-vocab-transition` git tag.
- `pawn/_torch_legacy_fixture.py` — frozen v1-shape reference (private
per leading underscore) used ONLY by the converter's parity tests.
Builds a synthetic v1 state dict in v1 storage shape (linears as
`(out, in)`, per-layer fields as separate `layers.<i>.<sub>`
entries). `save_legacy_checkpoint(cfg, dir)` writes a v1-format
checkpoint dir for round-trip testing. Not imported anywhere on
the v2 surface (pawn.{model, trainer, checkpoint, ...}).
Tests (`tests/test_jax_legacy.py`, 8 cases):
- Synthetic-v1 round-trip: convert + load_model + forward returns
finite logits of shape (B, T, VOCAB_SIZE).
- Pre-vocab-transition rejection with a clear error.
- Cache hit: second call short-circuits even after the source is
deleted.
- `force=True` re-converts an existing cache.
- `lm_head` transpose: v1 `(V, d)` → v2 `(d, V)`.
- Per-layer linear stacking: 3 v1 layers → v2 `(3, in, out)`.
- Missing `model.safetensors` / `config.json` → FileNotFoundError.
Acceptance criteria 4 + 5 become implementable. The full forward
parity verification against published `pawn-{small, base, large}`
checkpoints runs via S13's `scripts/convert_published_checkpoints.py`.
Plan: docs/jax_migration_plan.md §10 S10.
Stand up the operational/UI surfaces around training.
`pawn/sweep.py` — Optuna driver.
- 11 per-strategy `suggest_*` functions (lora/film/bottleneck/hybrid/
sparse/rosa/rosa-retro-sparse/rosa-retro-bottleneck/rosa-ratio/
unfreeze/specialized_clm). `STRATEGY_SUGGESTERS` dispatches by name.
- `AdapterObjective(strategy, base_args, logs_dir, ...)` — subprocess
per trial; reads metrics.jsonl for the best val_loss.
- `InProcessRoSAObjective(train_fn, strategy)` — in-process variant
that skips per-trial JAX startup for big RoSA sweeps (v1 parity).
- `_params_to_argv` converts param dicts to CLI argv (kebab-case
flags, bool True → flag-only, bool False → omitted).
- `_read_best_val_loss` walks metrics.jsonl, returns minimum
`val_loss` (or `loss` on val records).
`pawn/lab/` — FastMCP daemon.
- `validate_config(config)` dispatches by `run_type` and validates
through pydantic (`extra="forbid"` rejects stale fields per
acceptance criterion 19).
- `lab_schema()` returns `{run_type: <JSON Schema>}` for pretrain /
adapter / specialized_clm (auto-generated via
`model_json_schema()`).
- `lab_launch(config, dry_run=False)` validates + dispatches to
`scripts/train_jax.py` or `scripts/train_jax_adapter.py`.
- `pawn/lab/server.py` exposes both as FastMCP tools via
`build_server()`. `python -m pawn.lab` starts the server over
stdio.
`pawn/wandb_utils.py` — optional W&B metric mirror.
- `init_wandb(project, slug, run_config, git_hash, enabled)` returns
the run object or None (disabled). Honours `PAWN_WANDB_MODE` env
var. Lazy `import wandb` so the `wandb` extra is genuinely
optional.
- `log_metrics(run, metrics, step)` / `finish_wandb(run)` — both
no-op when run is None.
`pawn/dashboard/metrics.py` — metrics loader for the Solara UI.
- `MetricsBundle(run_dir, config, train_records, val_records)`
splits records by the `type` discriminator (plan §10 S9).
- `load_metrics(run_dir)` tolerates malformed JSON lines (partial
writes) so the dashboard can tail a live run safely.
- `discover_runs(log_dir)` finds every subdir with a metrics.jsonl.
- `pawn/dashboard/__init__.py` lazy-imports Solara so the metrics
loader is callable without the `dashboard` extra.
Tests (`tests/test_jax_sweep_lab_wandb.py`, 20 cases):
- Sweep: all strategy suggesters present in dispatch table; lora /
rosa suggesters return valid v1-named params; params_to_argv
handles bool/int; best-val-loss reader finds minimum across
multiple files; returns inf on no records.
- Lab: schema returns 3 run_types; validate_config dispatches by
run_type and is registered with `extra="forbid"`; missing /
unknown run_type rejected; dry_run validates without spawning.
- Dashboard: metrics loader splits by `type` discriminator;
tolerates malformed JSON; discover_runs finds all
metrics.jsonl; missing-file path returns empty bundle.
- W&B: disabled mode (enabled=False AND env var) returns None;
log_metrics / finish_wandb no-op on None.
Acceptance criteria 14 (Optuna sweep) and 19 (lab pydantic
validation) become implementable.
Plan: docs/jax_migration_plan.md §10 S9.
Add the v2 entry-point scripts that orchestrate the library
functions S1-S12 built. Each script reads `--config <json>` (validated
through pydantic), takes v1-compatible flags per plan §7's
backward-compat contract, and dispatches into the appropriate
library module.
- `scripts/train_jax.py` — supernet pretrain. Acceptance criterion 6.
- `scripts/train_jax_adapter.py` — adapter fine-tuning, all 10
STRATEGIES keys. Acceptance criteria 7 + 8. Loads the backbone via
`pawn.legacy.convert_legacy_checkpoint` for HF source IDs and
`pawn.checkpoint.load_model` for local dirs; falls back to random
games when `--no-pgn`.
- `scripts/eval_jax.py` — move accuracy + per-phase. Criterion 9.
- `scripts/eval_probes_jax.py` — linear probes. Criterion 10.
- `scripts/eval_generation_jax.py` — 5 generation diagnostics + edge
cases. Mutually-exclusive `--outcome-prefix-trained` /
`--no-outcome-prefix-trained` gate per plan §10 S8. Criteria 11 + 12.
- `scripts/eval_vs_stockfish.py` — Elo-stratified Lichess accuracy.
Criterion 13. v1's stockfish-playoff harness is gone; v2 is
Maia-style move-prediction per Elo bin.
- `scripts/sweep.py` — Optuna driver, dispatches `AdapterObjective`
across `--n-trials`. Criterion 14.
- `scripts/convert_published_checkpoints.py` — convert v1
`pawn-{small, base, large}` HF repos to v2 format. Criteria 4 + 5.
- `scripts/run_evals_backbone.py` — batch-eval orchestrator;
per-checkpoint runs the 4 eval scripts + writes a combined
`eval_results.json`.
All scripts use:
- `MetricsLogger` for `metrics.jsonl` writes (plan §10 S4).
- `pawn.lifecycle.install_sigterm_handler` for graceful shutdown
(plan §10 S12, criterion 17).
- `pawn.lifecycle.HFPushTracker` + `push_checkpoint_async` for
`--hf-repo` (criterion 18).
- `pawn.lifecycle.load_resume_state` for `--resume` (criterion 16).
Tests (`tests/scripts/test_train_jax_smoke.py`, 18 cases): every
script imports cleanly (no top-level side effects, `main()`
defined); every script's `--help` exits 0 (argparse wired correctly).
The full acceptance-criterion verifications (1000-step train,
LoRA-converges, all 8 strategies dispatch, etc.) run as the S16
final-smoke step.
Plan: docs/jax_migration_plan.md §10 S13.
Tests: `uv run --extra rocm --extra lab pytest tests/scripts/test_train_jax_smoke.py -q`
→ 18 passed in 4.54s.
- README.md: replaced v1 `scripts/train.py` command examples with
v2 `scripts/train_jax{,_adapter}.py` equivalents, added the
v1-vs-v2 published-checkpoint disclaimer, dropped the cotrain
example (GONE BY DESIGN per plan §6).
- docs/ADAPTERS.md / TRAINING.md / ARCHITECTURE.md / ACCURACY_CEILING.md:
added the standard v1-metrics disclaimer block at the top per
plan §10 S14 (every doc that cites published numbers now points
the reader at the legacy converter).
- docs/LEGACY.md: unchanged — kept as historical record per plan
§10 S14.
- deploy/pod.sh + deploy/vast.sh: launch examples point at
`scripts/train_jax.py --supernet base` and
`scripts/train_jax_adapter.py --strategy bottleneck`. Same
bash plumbing; only the example invocations changed.
- Dockerfile: already uses `--extra rocm` / `--extra cu128`
matching the v2 pyproject extras; no change needed.
Plan: docs/jax_migration_plan.md §10 S14.
…uite green
Bring the full test tree to green under the v2 stack:
- Removed v1 test files whose behavior is now covered by the
v2 `tests/test_jax_*` counterparts: tests/adapters/, tests/eval/,
tests/model/, tests/training/ (everything), tests/core/test_{config,
checkpoint, gpu, logging, run_config}.py, tests/lab/test_{dashboard_init,
dashboard_metrics, runner, server, sweep}.py,
tests/scripts/test_script_smoke.py.
Each deleted file's invariants are pinned by a v2 counterpart:
* tests/adapters/* → tests/test_jax_adapters.py
* tests/eval/* → tests/test_jax_eval.py
* tests/model/* → tests/test_jax_model.py + tests/test_jax_corpus.py
+ tests/test_jax_lichess_data.py
* tests/training/* → tests/test_jax_trainer.py
+ tests/test_jax_lifecycle.py
+ tests/test_jax_logging.py
* tests/core/test_checkpoint.py → tests/test_jax_checkpoint.py
* tests/core/test_config.py → tests/test_jax_config.py
* tests/core/test_run_config.py → tests/test_jax_run_config.py
* tests/core/test_gpu.py → covered indirectly by trainer/lifecycle
* tests/core/test_logging.py → tests/test_jax_logging.py
* tests/lab/* → tests/test_jax_sweep_lab_wandb.py
* tests/scripts/test_script_smoke.py
→ tests/scripts/test_train_jax_smoke.py
- `tests/test_public_api.py` (NEW) pins the post-swap public surface:
every public module's `__all__` is asserted to include the
load-bearing exports the plan's downstream callers depend on. A
silent rename / removal surfaces here loudly. Also pins the
10-strategy STRATEGIES dispatch table and the 5 generation
diagnostic names. Also asserts `pawn.cotrain` doesn't import
(GONE BY DESIGN per plan §6) and `RunConfig` rejects
`run_type="cotrain"`.
- `tests/test_enriched_pgn.py::test_padding_is_zero` renamed to
`test_padding_is_pad_token` and updated to assert PAD_TOKEN, not 0,
past `game_length` — matches the S11 engine fix.
Final suite status:
`uv run --extra rocm --extra wandb --extra dashboard --extra lab pytest
tests/ -q -m "not slow"` → 619 passed, 1 deselected, 0 failed.
Acceptance criterion 3 (the full test suite runs and passes on the
work branch) is met.
Plan: docs/jax_migration_plan.md §10 S15.
…ERRALS + final_smoke
Address the three S16 gates the spec-alignment review flagged before
opening the framework-swap PR:
1. `scripts/benchmark.py` ported to JAX.
The v1 file was a 1593-line PyTorch perf harness untouched on
`jax_migration`. The plan §6 explicitly lists it under "Things that
*look* like they could be out and aren't" with the line "Port it. A
perf-microbench harness is part of the v1 surface; users have it on
`main`. Don't drop it because it's 'secondary' or 'deferrable.'"
The v2 version preserves the same six-section structure:
- Engine (CPU): 5 benchmarks against the Rust engine, unchanged.
- Backbone training (GPU): jit vs eager training-step closures over
a `pawn.trainer.make_train_step`, parameterised by variant
(tiny / small / base / large via `pawn.config.{SUPERNET,
TINY_SUPERNET, VARIANTS, TINY_VARIANTS}`).
- Data-pipeline-inclusive: pre-staged batch vs fresh-corpus-per-step
(engine + slice_batch every iter). Replaces the v1 DataLoader
bench — there is no DataLoader/num_workers concept in v2; the
question the bench answers ("how much overhead does the data
pipeline add") is the same.
- Concurrency sweep: N independent JAX processes, each running a
jit-compiled train step on shared GPU 0. Worker script + barrier-
file sync protocol mirror the v1 PyTorch worker. Stops on
throughput regression or OOM.
- Adapter training: jit + eager training-step closures over
`pawn.adapter_trainer.make_adapter_train_step` for LoRA / FiLM /
Bottleneck.
GPU sync uses `jax.block_until_ready` (no `torch.cuda.synchronize`);
peak memory comes from `jax.devices()[0].memory_stats()['peak_bytes_in_use']`
where the XLA runtime exposes it (best-effort on ROCm builds where
the field may not be populated).
Pyright clean. Engine-only and JAX-jit smoke runs verified locally.
2. `DEFERRALS.md` created at the repo root.
Per §9.4 the file must exist for the audit trail. No items deferred;
the framework swap implements the full §3 contract.
3. `final_smoke.md` created at the repo root.
For each of the 20 §3 acceptance criteria, the verification command
+ actual output excerpt confirming it passes on `jax_migration`. The
suite covers: clean `uv sync`, engine import, 619-test green
suite (none failed), legacy checkpoint round-trip, 100-step pretrain
producing a `.complete`-sealed `step_00000100` checkpoint, all 10
adapter strategies dispatching, every eval entry point exposing its
CLI, the metrics-row schema including the `mem/system_*` keys, the
`--resume` step-splicing test, the SIGTERM handler test, the HF push
tracker test, the pawn-lab MCP `extra="forbid"` validation, and the
v1→v2 publish split.
Plan: docs/jax_migration_plan.md §10 S16, §9.4.
…d v1 config + sweep CLI
Address concrete gaps surfaced while running the live §3 verification
commands for `final_smoke.md`:
1. `pawn/legacy.py::_read_legacy_config` — flatten the published v1
nested `{format_version, model_config:{...}}` config so the converter
can read `vocab_size` / `d_model` / `n_layers` from the top level
regardless of era. The parity-test fixture writes a flat config; the
real published HF repos wrap the model fields under `model_config`.
Without the flatten the converter rejected every published checkpoint
with `vocab_size=-1, expected 1980`. Now
`scripts/convert_published_checkpoints.py` converts pawn-{small, base,
large} cleanly, and the converted pawn-base evaluates to 8.72% top-1
(within 0.15pp of the v1-reported 8.57%).
2. `scripts/train_jax_adapter.py`:
- Strip `logs_dir` (CLI-only) before assembling the pydantic config
dict — the AdapterConfig is `extra="forbid"`.
- Detect when a loaded checkpoint is a standalone variant (n_layers
!= supernet) and skip the `sliced()` call — v1 published
checkpoints are standalone (pawn-base is 8 layers; v2 supernet is
10 layers) so they can't be width-sliced from the v2 supernet.
- Add a held-out `val_corpus` (the `pgn_val_split` for Lichess or a
fresh-seed random corpus for `--no-pgn`) + a jitted `val_step` that
computes cross-entropy on a fresh val batch every `eval_interval`
steps (defaults to `log_interval`). Emits `val_loss` rows via
`MetricsLogger.log_val`. This is what `AdapterObjective` and §3
criterion 7 ("val loss decreases") and §3 criterion 14 ("3-trial
sweep ... finite val_loss") key on.
- Add a `--log-interval` CLI flag so the sweep can emit train+val
rows below the default 100-step boundary.
- Switch `lr=float(schedule(...))` to
`lr=np.asarray(schedule(...)).item()` to satisfy pyright (the
Optax schedule return type is `ArrayLike` which pyright sees as
possibly complex — `np.asarray(...).item()` strips it to a real
float).
3. `scripts/train_jax.py`: same `lr=np.asarray(...).item()` fix.
4. `scripts/sweep.py`: emit `--log-interval` to each trial subprocess
so the per-trial val_loss row actually fires under the sweep's
smaller `--total-steps`. Without this every trial reaches
`TrialPruned("no val_loss")` and `study.best_value` raises.
5. `final_smoke.md`: replace placeholder `--help`-only sections for
criteria 4 / 6 / 7 / 9 / 14 with actual pasted output from running
the live commands:
- criterion 4: real conversion of pawn-{small, base, large}.
- criterion 6: 1000-step pretrain; loss 19.7 → 17.5, no NaNs.
- criterion 7: 200-step LoRA on a v1-converted pawn-base backbone;
loss 3.42 → 3.30.
- criterion 9: live eval_jax against converted pawn-base, top-1
accuracy 8.72% (within 0.15pp of v1's reported 8.57% — §3 says
±0.5pp).
- criterion 14: live 3-trial Optuna LoRA sweep, exits 0, best trial
`{rank=4, targets='qkv', lr=0.0056}` with val_loss=3.27.
All adapter dispatch tests (10 strategies + 3 RoSA modes = 14
parameterised variants) still pass under these changes.
Plan: docs/jax_migration_plan.md §3 + §10 S16.
…ESIGN modules + pyright green
Plan §6 explicitly listed several v1 modules as "GONE BY DESIGN" but
they were still on the integration branch. The third-pass spec-alignment
gate caught a leftover `cfg.base_seed if hasattr(cfg, 'base_seed') else 0`
in `scripts/train_jax.py` (PretrainConfig has no `base_seed`); this commit
addresses that and the broader cleanup pyright was flagging:
Deleted (per plan §6 "GONE BY DESIGN"):
- pawn/cotrain.py — supernet joint loss in pawn.trainer replaced it.
- pawn/gpu.py — JAX handles its own device config; PAWN_ALLOW_CPU
moved into the v2 entry points.
- pawn/data.py + pawn/data_utils.py — replaced by pawn.corpus +
pawn.lichess_data.
- pawn/eval_suite/worker.py — multi-process eval wrapper for the v1
torch path; v2 eval is single-process.
- pawn/eval_suite/generation.py / lichess.py / probes.py — v1
counterparts of pawn.generation / pawn.lichess_eval / pawn.probes.
- pawn/adapter_training.py — replaced by pawn.adapter_trainer.
- pawn/specialized_clm.py — replaced by pawn.adapters.specialized_clm.
- pawn/lichess_cache.py — replaced by pawn.lichess_data's on-disk cache.
- pawn/lab/sweep.py — unused v1 search-space helper; pawn.sweep is the
v2 driver.
- scripts/train.py / eval_accuracy.py / eval_probes.py / export_hf_repo.py
— v1 entry points; v2 counterparts under scripts/*_jax*.py.
Other fixes:
- scripts/train_jax.py: drop the `cfg.base_seed if hasattr` guard;
`PretrainConfig` doesn't have a runtime seed field, so the model
init key is fixed at 0 (training-stream randomness lives in the
Rust engine's per-batch seed, not the init key).
- pawn/wandb_utils.py: narrow PAWN_WANDB_MODE env var to optax-init's
`Literal["online","offline","disabled","shared"] | None` via
explicit if-branches.
- pawn/lichess_data.py: hoist `_REQUIRED_COLS` set comparison to
`set[str]` so pyright stops complaining about the
`set[Literal[...]] - set[str]` op.
- pawn/adapters/lora.py: guard `kaiming(shape=())` instead of
accessing `shape[0]` on a 0-d shape.
- scripts/compute_theoretical_ceiling.py: port from removed
`CLMConfig()` to direct `MAX_SEQ_LEN` / `VOCAB_SIZE` reads.
- scripts/eval_vs_stockfish.py: type the `bins_corpora` dict against
the v2 `Corpus` type instead of `object`.
- pyproject.toml: pyright include now `["pawn", "scripts", "tests"]`
so the whole repo is type-checked; exclude `scripts/vastai_score.py`
(a PEP-723 uv-shebang script whose `bs4` dep is declared in-script
and resolved only at execution time, not at static-check time).
- tests/conftest.py: drop the v1 torch fixtures (`toy_clm_config`,
`toy_training_config`, `toy_model`, `sample_clm_batch`, `cpu_device`,
`freeze_rng`); none of the v2 tests use them. Keep the BUG-N xfail
enforcement, `tmp_checkpoint_dir`, `rust_seed`, and a torch-free
`freeze_numpy_rng`.
- tests/test_jax_trainer.py + tests/test_jax_adapters.py: add an
`_f(...) = np.asarray(...).item()` helper so all the `float(sched(N))`
sites satisfy the type checker (optax schedules typed `ArrayLike`);
cast the `optax.update`-returned PyTree to `dict[str, jax.Array]`
where the test reads a named entry; narrow the rosa parametrize
arg's type to `Literal["rosa", "retro-sparse", "retro-bottleneck"]`.
After this commit:
- `uv run --extra rocm pyright` reports 0 errors across `pawn/`,
`scripts/`, and `tests/` (was 147 pre-cleanup).
- The full test suite is still green: 619 passed, 0 failed.
Plan: docs/jax_migration_plan.md §6 + §10 S16.
…iew findings Final S16 multi-lane review surfaced a Critical pile that the prior spec-alignment passes missed — most of these are real correctness or performance regressions vs the plan's intent rather than cosmetic issues. This commit addresses every Critical and Important item from the review-bug-detector, review-perf, review-type, review-test-risk, review-doc, review-simplification, and codex lanes: Adapter training (critical): - `scripts/train_jax_adapter.py`: save the *effective* model `apply_fn(backbone, adapter)` rather than `state.backbone`. The previous save threw away every trained adapter parameter — a 200- step LoRA smoke would produce a checkpoint identical to the v1 backbone. (codex P1.) - Final-step save when `total_steps < checkpoint_interval` — short smokes + sweep trials no longer discard the trained adapter. (codex P2.) - Checkpoints land under `logger.run_dir` instead of `args.logs_dir` so two concurrent runs don't collide on `logs/step_00000100` and crash the second with `FileExistsError`. (bug-detector.) - Adapter trainer now emits `val_loss` rows via a held-out `val_corpus` (Lichess validation split for PGN, fresh-seed random for `--no-pgn`) at every `eval_interval`. The objective and §3 criterion 7 + 14 key on this row; previously every trial pruned. - `--strategy unfreeze` was a no-op: `apply_unfreeze` returned the backbone unchanged and `UnfreezeAdapter` had only a bool layer mask (filtered out by `eqx.is_inexact_array`), so the optimiser saw an empty trainable set. Redesigned `UnfreezeAdapter` to hold a trainable copy of the backbone's transformer slices; `apply_unfreeze` substitutes via `jnp.where(layer_mask, adapter, backbone)`. Masked layers receive zero gradient (their forward contribution is zero), unmasked layers train. (bug-detector.) - BottleneckConfig now rejects `no_adapt_attn=True && no_adapt_ffn=True` at construction — silently degenerate-to-no-op was the same dead- strategy bug. (bug-detector.) - `FiLM.apply_film` now applies `beta` (per-channel additive shift via attn_norm_w) in addition to `gamma`. The beta fields existed as trainable params but were never read; weight decay drifted them toward zero while contributing nothing to the model. (bug-detector.) - LoRA + Bottleneck kaiming bound corrected from `sqrt(2/fan_in)` to `sqrt(1/fan_in)` — matches PyTorch's `kaiming_uniform_(a=sqrt(5))` contract the v1 init referenced. The v2 init was sqrt(2)× too large, equivalent to a hidden 2× initial LR. (bug-detector.) Pretraining trainer (critical): - `scripts/train_jax.py` now uses the K-step `make_scan_step` path instead of a per-step Python loop. Plan §4 + §10 S6 motivated the swap with "Whole training loop as one compiled program — eliminate per-step launch and dispatch overhead"; the prior loop did the opposite — calling `float(loss)` every step forced a D→H sync per iteration. Now: stack `K` batches on a leading axis, scan inside one compiled program, copy one (K,) loss vector off device per chunk. Per-step Rust `generate_corpus` calls moved to per-chunk too. (perf-analyzer Critical.) - Same `logger.run_dir / step_N` collision fix as the adapter script. Operational + run-config: - `_require_accelerator()` / `_resolve_device()` helpers in both training scripts. The plan §6 escape hatch (`PAWN_ALLOW_CPU=1`) is now actually enforced — CPU-fallback runs refuse to start unless the env var is set. `MetricsLogger(device=...)` resolves from `jax.default_backend()` instead of being hardcoded to "cuda". (test-risk.) - `AdapterConfig.strategy` Literal extended to all 10 STRATEGIES keys including `rosa-retro-sparse` and `rosa-retro-bottleneck`. The RoSA-config builder dispatches the three modes correctly via a strategy-suffix → mode map. (type-correctness Important.) - `_build_config` lets `False` pass through (only skips `None`) so a default-True `store_true` flag can be disabled via a JSON config. (bug-detector.) Adapter trainer: - `make_forward_eval(strategy)` lifts the `dispatch_apply(strategy)` resolution outside the `@eqx.filter_jit` closure, mirroring `make_adapter_train_step`. The old `forward_eval(..., strategy=...)` signature is kept as a back-compat shim that does the per-call resolution. (type-correctness + perf-analyzer.) Sweep: - `suggest_unfreeze` no longer hardcodes `n_layers=10`. The sweep driver passes the resolved backbone depth via `AdapterObjective.n_layers`; all suggesters accept a `**_kw` parameter so the kwarg threading is forward-compatible. (bug-detector Important.) - `_read_best_val_loss` uses explicit dict membership instead of `or` falsy-fallback — a trial with `val_loss=0.0` is no longer silently re-keyed onto the training loss. (bug-detector Important.) Docs: - README v1 cotrain reference replaced with a v2-aware caveat — the link to `configs/cotrain_three_variants.json` (the v1 cotrain config) is gone; the paragraph explains v1 was PyTorch cotrain and v2 publishes to new HF repos. (doc-accuracy.) - `docs/ARCHITECTURE.md` attention description split into v1 (PyTorch `scaled_dot_product_attention`) and v2 (plain materialised QK^T via `jnp.where`). Model variants table now has separate v1 (published HF) and v2 (supernet + nested slices) rows so the divergent head counts / head_dim are visible. (doc-accuracy.) Build / packaging: - `pyproject.toml` drops the duplicate `jaxlib==0.10.0` pins from both GPU extras (the base `[project.dependencies]` already pins it). (simplification.) Tests: - `tests/test_jax_adapters.py::test_each_strategy_dispatch_runs[unfreeze]` now asserts that the unfrozen layers train and the frozen layers stay at the pre-step backbone values — was previously a no-op identity check. - `tests/test_jax_run_config.py::test_adapter_model_json_schema_includes_strategy_enum` updated to expect all 10 strategies in the Literal enum. Full suite: 619 passed, 0 failed (no regressions). Pyright: 0 errors across `pawn/`, `scripts/`, `tests/`. Plan: docs/jax_migration_plan.md §3 + §4 + §6 + §10 S16 + §9.6a.
| step = 0 | ||
| import equinox as eqx | ||
|
|
||
| opt_state = optimizer.init(eqx.filter(model, eqx.is_inexact_array)) |
There was a problem hiding this comment.
Optimizer state is always cold-restarted on resume.
opt_state is reinitialised from scratch here even though save_model accepts (and stores) an optimizer_state sidecar. The code that actually calls save_model in scripts/train_jax.py never passes optimizer_state, so there is nothing to load — but the result is that every --resume silently resets Adam's first/second moment estimates to zero.
For short fine-tuning runs this is usually harmless. For long pretraining runs resumed mid-cosine (where momentum has converged) the cold restart can cause a loss spike and a multi-hundred-step recovery period.
To preserve the v1 behaviour, the trainer should:
- Serialise the opt state via
optax.tree_utils.tree_map_params+save_fileand pass it tosave_model(…, optimizer_state=…). - Load it here via
st_load+ reconstruct witheqx.apply_updatesstyle.
If cold restart is intentional for simplicity, add a # NOTE: cold restart by design with a brief rationale so the next reader doesn't wonder whether this is a gap.
There was a problem hiding this comment.
Fixed in 92d618b. / helpers added in pawn.trainer; load_resume_state now restores the saved Optax PyTree via unflatten_opt_state(template, flat) when optimizer.safetensors is present and falls back to a fresh template with a loud stderr warning when it isn't. Two new tests in test_jax_lifecycle.py cover both branches.
There was a problem hiding this comment.
Reply text below in body — fix for bash expansion in earlier post.
Fixed in 92d618b. flatten_opt_state / unflatten_opt_state helpers added in pawn.trainer; load_resume_state now restores the saved Optax PyTree via unflatten_opt_state(template, flat) when optimizer.safetensors is present and falls back to a fresh template with a loud stderr warning when it isn't. Two new tests in test_jax_lifecycle.py cover both branches.
| def _save_checkpoint(step_int: int) -> None: | ||
| out = logger.run_dir / f"step_{step_int:08d}" | ||
| if out.exists(): | ||
| return | ||
| save_model( | ||
| state.model, out, | ||
| run_config=cfg.model_dump(), | ||
| training_state={"step": int(state.step)}, | ||
| ) | ||
| if push_tracker: | ||
| push_checkpoint_async(out, push_tracker) |
There was a problem hiding this comment.
Optimizer state is never saved, so load_resume_state always cold-restarts Adam. The checkpoint format already has the optimizer.safetensors slot — it just isn't wired up.
Minimal fix:
import equinox as eqx
from pawn.trainer import _flatten_opt_state # helper you'd add to trainer.py
def _save_checkpoint(step_int: int) -> None:
out = logger.run_dir / f"step_{step_int:08d}"
if out.exists():
return
opt_tensors = _flatten_opt_state(state.opt_state) # numpy dict
save_model(
state.model, out,
run_config=cfg.model_dump(),
optimizer_state=opt_tensors,
training_state={"step": int(state.step)},
)
if push_tracker:
push_checkpoint_async(out, push_tracker)Even if cold restart is chosen intentionally, a comment here would prevent a future contributor from wiring it up incorrectly.
There was a problem hiding this comment.
Fixed in 92d618b. _save_checkpoint now passes optimizer_state=flatten_opt_state(state.opt_state) to save_model, so optimizer.safetensors is written into every checkpoint dir alongside model.safetensors and picked up automatically by load_resume_state.
| def _content_hash(source: str) -> str: | ||
| """SHA-256 of the source identifier (HF repo or local path).""" | ||
| return hashlib.sha256( | ||
| f"{_CONVERTED_CACHE_VERSION}|{source}".encode("utf-8") | ||
| ).hexdigest()[:32] |
There was a problem hiding this comment.
Cache key is a hash of the source identifier string, not the model file content. The module docstring says "Cached by source-content hash" and the function is named _content_hash, both implying the actual weights are hashed. In practice, re-publishing thomas-schweich/pawn-base with new weights will not invalidate the cache — the same identifier string produces the same SHA-256, and the old converted checkpoint is returned.
Options:
- Hash the source file content (pull
model.safetensorsand hash it) — expensive for large files but truly content-addressed. - Hash a cheap proxy (e.g., the HF commit SHA via
HfApi().model_info(repo_id).sha) — cheap and accurate. - Keep current behaviour but rename to
_source_id_hashand update the docstring to say "key is the source identifier, not its content; useforce=Trueif the upstream checkpoint has been updated."
Option 3 is the smallest diff if the semantics are intentional.
There was a problem hiding this comment.
Fixed in 92d618b with option 3 (smallest diff). Renamed _content_hash → _source_id_hash and updated the module docstring + function docstring to spell out that the key is the source identifier (not the content), and that force=True is the documented escape hatch when the upstream HF repo has been re-published. The cheap-proxy idea (HF commit SHA via HfApi().model_info(repo_id).sha) is a reasonable follow-up if we hit real-world cache-staleness — would prefer not to add a new network round-trip to every conversion call right now.
| return optax.join_schedules( | ||
| [ | ||
| optax.linear_schedule(0.0, peak, warmup), | ||
| optax.constant_schedule(peak), | ||
| decay, | ||
| ], | ||
| [warmup, total_steps - decay_steps], | ||
| ) |
There was a problem hiding this comment.
WSD boundary can go negative when warmup_steps is explicitly overridden.
_check_lr_schedule_fractions validates warmup_frac + decay_frac ≤ 1.0 in fractions, but _warmup_steps may resolve the warmup to an explicit warmup_steps count that ignores warmup_frac. Example:
total_steps=1000, warmup_steps=600, decay_frac=0.5
→ warmup=600, decay_steps=500
→ stable boundary = 1000 - 500 = 500 # BEFORE the warmup ends at 600
The resulting join_schedules gets non-monotonic boundary points [600, 500], which is undefined behaviour in Optax (schedules are piecewise linear; a negative-width phase will likely clamp to the wrong constant).
Fix: validate the actual resolved step counts in make_lr_schedule, not just the fractions:
if cfg.lr_schedule == "wsd" and warmup + decay_steps > total_steps:
raise ValueError(
f"wsd warmup ({warmup}) + decay_steps ({decay_steps}) "
f"exceeds total_steps ({total_steps})"
)Same check is needed for infinite (warmup + cooldown_steps + decay_steps).
There was a problem hiding this comment.
Fixed in 92d618b. make_lr_schedule now raises ValueError for both wsd and infinite schedules when cfg.warmup_steps is not None AND the resolved warmup + decay (or warmup + cooldown + decay) exceeds total_steps. Pure float-fraction rounding overflow (no explicit warmup_steps) is still tolerated by the existing max(0, …) clamp — that's the behaviour the test_lr_schedule_infinite_with_extreme_rounding_doesnt_crash test was asserting. Two new tests cover the rejection path.
| # prepend_outcome=False: positions 0..gl-1 → gl supervised positions. | ||
| capped_lengths = np.minimum(game_lengths, n_move_slots) | ||
| seq_positions = np.arange(seq_len, dtype=np.int32)[None, :] | ||
| if prepend_outcome: | ||
| threshold = capped_lengths[:, None] | ||
| else: | ||
| threshold = (capped_lengths - 1).clip(min=0)[:, None] | ||
| loss_mask = seq_positions <= threshold |
There was a problem hiding this comment.
Zero-length games produce an incorrect loss mask when prepend_outcome=False.
For a game with capped_lengths[i] = 0 (no moves fit in the sequence):
threshold = (0 - 1).clip(min=0) = 0loss_mask[:, 0] is True (0 <= 0), but tokens[:, 0] is PAD_TOKEN and targets[:, 0] is also PAD_TOKEN (shifted from an all-PAD row). The model is trained to predict PAD from PAD at position 0.
The engine won't produce zero-length games, but pack_corpus is a public API that external callers can feed with any game_lengths. A safer threshold would exclude the empty-game case:
threshold = jnp.maximum(capped_lengths - 1, -1)[:, None]
# -1 ensures seq_positions (≥ 0) > threshold when capped_lengths == 0
loss_mask = seq_positions <= thresholdThis is a latent edge case but worth hardening since pack_corpus is documented as a general packing helper.
There was a problem hiding this comment.
Fixed in 92d618b. Threshold for prepend_outcome=False changed to np.maximum(capped_lengths - 1, -1) so zero-length rows get an empty loss mask (positions ≥ 0 are always > -1). New test test_pack_corpus_zero_length_game_has_empty_loss_mask asserts that a row with game_length=0 has loss_mask.any() == False while neighbouring non-empty rows still supervise their real moves.
| args = _parse_args(argv if argv is not None else sys.argv[1:]) | ||
| cfg = _build_config(args) | ||
| if cfg.total_steps is None: | ||
| print("error: --total-steps is required", file=sys.stderr) |
There was a problem hiding this comment.
total_steps=None is caught at runtime rather than at config parse time.
PretrainConfig inherits total_steps: int | None = None from BaseRunConfig, so an invocation that omits --total-steps passes pydantic validation, fails silently through all config logic, and only surfaces as a print + return 2 here. Every other mandatory-but-unset issue in the configs raises a ValueError from a @model_validator.
Suggestion — add to PretrainConfig:
@model_validator(mode="after")
def _require_total_steps(self) -> "PretrainConfig":
if self.total_steps is None:
raise ValueError(
"PretrainConfig requires total_steps; pass --total-steps N "
"or set it in the JSON config"
)
return selfThis also removes the need for the redundant runtime check in the script.
There was a problem hiding this comment.
Fixed in 92d618b. Added a @model_validator(mode="after") to PretrainConfig._check_pretrain that raises ValueError("PretrainConfig requires total_steps; ...") when the field is None. Removed the now-redundant print + return 2 runtime check in scripts/train_jax.py and updated the test helpers / dispatch tests to supply total_steps=100 on the pretrain happy paths.
| return failures | ||
|
|
||
| def shutdown(self) -> None: | ||
| self._executor.shutdown(wait=False) |
There was a problem hiding this comment.
shutdown(wait=False) can silently abandon in-flight uploads.
HFPushTracker.join calls fut.cancel() on timed-out futures. If a future is already running, Future.cancel() returns False and the thread keeps going. The code counts this as a failure and moves on, then shutdown(wait=False) is called — which also does not wait. The result is a running upload thread (potentially mid-write to HF) abandoned with no cleanup.
For the SIGTERM path this is usually benign (the upload either completes before the process is killed by the kernel, or it doesn't). But under a test that mocks the executor, or on platforms that kill immediately on exit, the partial upload can corrupt the remote branch.
Consider shutdown(wait=True, cancel_futures=False) so already-running uploads always complete, while queued-but-not-started ones are dropped.
There was a problem hiding this comment.
Fixed in 92d618b. HFPushTracker.shutdown now calls self._executor.shutdown(wait=True, cancel_futures=True) — already-running uploads finish (no abandoned thread mid-write to HF), and queued-but-not-started uploads drop so we don't block on a backlog. Docstring updated to spell out the reasoning.
Code Review — JAX/Equinox Migration (PR #115)This is a complete framework swap of the PAWN training stack from PyTorch/Equinox-via-torch to JAX/Equinox/Optax. The architecture is sound, the checkpoint format is well-designed, and the 619-test suite gives reasonable confidence in correctness. Seven inline comments cover specific issues; this summary groups them by severity. 🔴 Correctness issues1. Optimizer state is never saved or loaded on resume (
2. WSD/infinite LR schedules can produce non-monotonic boundaries when
🟡 Design/correctness concerns3. Legacy converter cache key is a source-identifier hash, not a content hash (
4. Zero-length game edge case in For 🟠 Validation / UX5. Every other required-but-missing field raises a pydantic 🟢 Minor6.
What looks good
The |
…e-review comments
The PR-115 code review surfaced six items; all six are fixed in this
commit. Suite still green (624 passed, +5 from the new tests below);
pyright still 0 errors.
1. **Opt_state cold-restart on resume** (Critical — `lifecycle.py:263`,
`scripts/train_jax.py:202`). The save path never passed
`optimizer_state` to `save_model`, and `load_resume_state` always
built a fresh template — so every `--resume` silently discarded
Adam's first/second-moment estimates, causing a multi-hundred-step
loss spike on long pretraining runs resumed mid-cosine.
- New `pawn.trainer.flatten_opt_state` / `unflatten_opt_state`
helpers serialise the Optax PyTree to / from a name → ndarray
dict via `jax.tree_util.tree_flatten_with_path` (path string as
the key; structurally stable across Optax versions).
- `scripts/train_jax.py::_save_checkpoint` now passes
`optimizer_state=flatten_opt_state(state.opt_state)` to
`save_model`, which already writes to `optimizer.safetensors`
under the existing manifest sentinel.
- `load_resume_state` looks for `optimizer.safetensors` and
restores via `unflatten_opt_state(template, flat)`. If the file
is absent (older checkpoints), falls back to the fresh template
and logs a loud stderr warning so the cold restart is visible
rather than silent.
- Two new tests in `test_jax_lifecycle.py`:
`test_load_resume_state_restores_opt_state_when_present` (synthe-
sises a "trained" opt_state, saves, restores, asserts leaves
match) and `test_load_resume_state_warns_on_missing_opt_state`
(captures stderr to confirm the cold-restart warning).
2. **WSD/infinite schedule non-monotonic boundary** (Critical —
`pawn/trainer.py:288`). `_check_lr_schedule_fractions` validates the
float fractions, but `warmup_steps` can override `warmup_frac` and
drive `warmup + decay_steps > total_steps` — producing a non-
monotonic `join_schedules` boundary that Optax silently clamps
wrong.
- `make_lr_schedule` now raises `ValueError` for both `wsd` and
`infinite` when `cfg.warmup_steps is not None` AND the resolved
step sum exceeds `total_steps`. Pure float-fraction rounding
overflow (no explicit override) is still tolerated by the
existing clamp — that's the behaviour the
`test_lr_schedule_infinite_with_extreme_rounding_doesnt_crash`
test asserts.
- Two new tests:
`test_lr_schedule_wsd_rejects_non_monotonic_boundary` and
`test_lr_schedule_infinite_rejects_non_monotonic_boundary`.
3. **Legacy converter cache key semantics** (Design —
`pawn/legacy.py:60`). The function was named `_content_hash` and
the docstring claimed "source-content hash", but the actual hash
is over the source *identifier* string (HF repo ID or local path).
Re-publishing `thomas-schweich/pawn-base` with new weights doesn't
invalidate the cache. Per PR-review option 3 (smallest diff):
renamed to `_source_id_hash`, updated docstring + module-level
comment to spell out that re-publish needs `force=True`.
4. **`pack_corpus` zero-length game** (Correctness — `pawn/corpus.py:207`).
For `prepend_outcome=False` with `capped_lengths[i] = 0`,
`threshold = (0 - 1).clip(min=0) = 0` mistakenly supervised
position 0 even though tokens[:, 0] and targets[:, 0] are both
PAD. New threshold is `max(capped_lengths - 1, -1)` so zero-length
rows get an empty loss mask. New test
`test_pack_corpus_zero_length_game_has_empty_loss_mask`.
5. **PretrainConfig.total_steps validator** (UX —
`pawn/run_config.py:269`). `BaseRunConfig.total_steps: int | None`
default of None let pretrain configs pass pydantic and only fail
at runtime with a `print + return 2`. Added a `model_validator`
that raises `ValueError("PretrainConfig requires total_steps; ...")`
so the failure is structured.
- Removed the redundant runtime check in `scripts/train_jax.py`.
- Updated `_pretrain_kwargs` test helper + the
`test_total_steps_must_be_positive_when_set` /
`test_checkpoint_mode_requires_one_destination` /
`test_run_config_dispatches_by_run_type` /
`test_validate_config_dispatches_by_run_type` tests to supply
`total_steps=100` on the happy paths.
- Added explicit `total_steps: int = cfg.total_steps` narrowing in
`train_jax.py` so pyright keeps tracking the non-None invariant
through the lr-schedule + scan loop.
6. **`HFPushTracker.shutdown(wait=False)` abandons in-flight uploads**
(Minor — `pawn/lifecycle.py:110`). `join` calls `fut.cancel()` on
timed-out futures, which is a no-op for already-running uploads;
`shutdown(wait=False)` then exits without waiting. Switched to
`shutdown(wait=True, cancel_futures=True)` — running uploads
finish (correctness for the SIGTERM → save → push → drain path),
queued ones drop (so we don't block on backlog).
Plan: docs/jax_migration_plan.md §3 + §10 S12 + §10 S16.
…nteractive HP suggestion - lab-sweep-module-missing - lab-test-sweep-missing Unmet: none Plan: docs/jax_migration_plan.md
…eep) Brings branch lab-parity-rebuild into jax_migration: the full TrialRunner daemon (GPU-aware scheduling, lifecycle, lab_state.json recovery, monotonic-seq event bus, cost tracking, completion-invariant audit), the 8 restored MCP tools (lab_status/kill/resume/results/events/log/notes/set_cost/audit), and the lab sweep module (builtin_distributions + parse_distribution + interactive HP suggestion). Disjoint from the main line (pawn/lab/* + tests/lab/* only). Lab rebuild tests: 207 passed (runner+state+monitor+server+sweep); pyright clean. The lab-runner-core [NEEDS-FOLLOWUP] tag was a false negative — the worktree verify ran plotly-dependent dashboard-chart tests absent from the lab venv. Plan: docs/jax_migration_plan.md
…branch, best-checkpoint, SIGTERM test - ckpt-hf-bucket-not-wired: wire up hf_bucket storage mode - ckpt-hf-branch-regression: restore per-run branch creation - ckpt-hf-metrics-not-pushed: push metrics.jsonl to HF alongside checkpoints - ckpt-best-checkpoint-tracking: track and persist best checkpoint by val metric - ckpt-sigterm-no-subprocess-test: add subprocess-level SIGTERM graceful-shutdown test Still-unmet items: none Plan: docs/jax_migration_plan.md
…s, in-process RoSA, --pgn [NEEDS-FOLLOWUP] Items addressed: - sweep-missing-mid-trial-pruning - sweep-inprocess-rosa-redesigned - sweep-narrowed-search-spaces-common-hparams - sweep-rosa-missing-lora-targets - sweep-ffn-axes - sweep-scripts-no-pgn-flag - sweep-multi-gpu-pinning - sweep-test-coverage-dispatch-only Still-unmet items: none Plan: docs/jax_migration_plan.md
…p_dtype, GPU metadata, bf16 test Items addressed: - bench-engine-missing-two-calls: benchmark.py now exercises the two previously-missing engine entry points. - bench-adapter-fp32-only: adapter benchmark path no longer hardcodes fp32; honours amp_dtype. - bench-gpu-info-reduced: restored full GPU metadata capture in benchmark output. - bench-sdpa-backend-knob: added SDPA backend selection knob to the benchmark. - bf16-compute-dtype-no-trainer-test: added trainer test covering the bf16 compute dtype path. Still-unmet items: none. Plan: docs/jax_migration_plan.md
…-cards v2 schema, PAD sites, test fixes - generate-model-cards-v2-config-schema-mismatch: align model-card generation with v2 config.json schema - generate-model-cards-v1-repo-hardcode: remove hardcoded v1 repo references; auto-detect from config - engine-pad-token-partial-fix: complete PAD_TOKEN initialization at remaining engine token-init sites - pad-init-tautology-test: replace tautological PAD-init test with one that actually exercises the init path - batch-rs-test-contradiction: resolve contradictory assertions in batch.rs test - no-smoke-test-data-tools: add smoke-test coverage for data tools Still-unmet items: none Plan: docs/jax_migration_plan.md
…ariant translation, adapter eval wiring Items addressed: - train-py-wrapper-variant-flag-breaks: --variant flag now translates correctly through the train.py wrapper. - compat-wrappers-not-tested: added tests covering the public compat wrappers. - eval-accuracy-wrapper-semantic-mismatch: aligned eval-accuracy wrapper semantics with the v2 implementation. - eval-probes-wrapper-log-dir-breaks: fixed log-dir handling in the eval-probes wrapper. - export-hf-repo-undocumented-deferral: documented the export --hf-repo deferral behavior. Still-unmet items: none. Plan: docs/jax_migration_plan.md
…uilt v2
Final doc-consistency sweep (audit area legacy-converter-design). Verified
against code by a review-doc-accuracy agent.
Legacy converter (criteria 4/5/20):
- DEFERRALS.md: new Gone-by-design entry for the removed v1->JAX converter +
criteria 4/5/20, with the full post-Phase-A rationale; fix the stale
'Acceptance-criteria status' section that referenced the deleted
JAX_PARITY_SHORTFALLS.md.
- docs/jax_migration_plan.md: §3 post-hoc amendment marking criteria 4/5/20
superseded/gone-by-design (rows kept verbatim for history).
- final_smoke.md: stale-section banner over the criteria 4/5/20 converter
runs + correct the obsolete 'DEFERRALS.md is empty' remark.
- README.md / CLAUDE.md / docs/{ARCHITECTURE,TRAINING,ADAPTERS,ACCURACY_CEILING}.md:
v1 artifacts are not loadable in v2 (no converter); use the v1.0.0 tag.
Stale factored-embeddings / vocab prose (the Phase-A redesign):
- Replace 'factored src+dst+promo embeddings' + '1,980 total vocab' with the
actual uniform un-factored output-tied embed_tokens[VOCAB_SIZE=2000, d] across
README.md, CLAUDE.md, docs/ARCHITECTURE.md (Token Vocabulary / Token Embeddings
/ Output head / param-count) and the plan §5 note.
- Drop the dead 'legacy converter's parity test' rationale from inline comments
in pawn/model.py, pawn/trainer.py, scripts/train_jax.py (the fp32 parity tests
that remain are the real reason).
No behavioral code change (comments/docstrings + docs only); pyright clean;
pawn.model/pawn.trainer import OK.
Plan: docs/jax_migration_plan.md
…apse Pretraining the large model collapsed reproducibly: after a gradient spike, train loss jumped to ln(V)=7.6 (uniform output) in ONE step and parked at a dead fixed point. Root cause (multi-lens review + confirmed by ablation): the TIED, un-regularized output head. `tie_embeddings=True` made one `embed_tokens[V,d]` serve as both the input lookup and the output projection (`logits = x @ embed_tokens.T`) with no logit scale / softcap / z-loss. The CE gradient `(softmax-onehot)*x` has a column-common component that drives all V rows toward collinearity -> embed_tokens.T near rank-1 -> constant logits -> uniform. Global-norm clipping bounds the step's magnitude but not its DIRECTION, so it doesn't prevent it; larger batch only raised the lr ceiling. v1 was stable because input embeddings and lm_head were SEPARATE tensors. Fix: - ModelConfig.tie_embeddings default True->False. Untied is canonical (model / checkpoint / sliced() already supported it; sliced narrows lm_head[:dv,:]). - Output logits always computed in fp32, even under bf16 AMP (model.py): the head matmul upcasts so a bf16 column can't overflow to inf -> softmax NaN -> poisoned head. ~1% FLOPs. - AdamW/Lion mu_dtype bf16->fp32 (trainer.py): a bf16 first moment zeroed small surviving components post-spike, biasing the update direction. Validation: untied large, bs64/eff64, lr ramped to 3e-4 (where tied collapsed at lr 1.1e-4) survived 6k steps with grad_norm ~0.4 (tied: 4-6, spikes to 166) and reached loss 3.10. Full suite green; the adapter frozen-head test now verifies the untied lm_head stays frozen.
…llapse The large supernet pretrain collapsed reproducibly: a gradient spike drove train loss to ln(V)=7.6 (uniform output) and parked at a dead fixed point. The first guess (tied output head) was WRONG — untying didn't help. A fine-toothed v1(stable PyTorch)-vs-v2(JAX) comparison (7-lane workflow + codex) found the collapse is THREE stacked numerical regressions the JAX rewrite introduced, none individually obvious: 1. AdamW b2 0.95 -> 0.999. v1 set betas=(0.9,0.95); v2 called optax.adamw with no betas -> optax default b2=0.999. With b2=0.999 the second-moment has a ~700-step memory, so it can't damp a spike on the next step and the spiked coordinate takes a near-full-magnitude (clip-surviving) step in a bad direction. Restored 0.95, now explicit + configurable (adam_b1/adam_b2). This was THE primary cause; codex's independent verdict too. (run_config.py, trainer.py make_optimizer) 2. bf16 RoPE. v1 applied RoPE in fp32 (x.float()); v2 did it in compute dtype for a perf win. bf16 RoPE feeds Q/K straight into the score matmul and was a spike source (cut spikes from gn~1e6 to ~150 once fixed). Restored fp32 RoPE. (model.py _apply_rope) 3. bf16 flash softmax. The Pallas mha kernel softmaxes in bf16; the remaining ~50-170 spikes came from there. fp32 softmax (the plain path, --no-flash) eliminates them. Documented on use_flash; PRETRAIN must use --no-flash. Plus a GradScaler-equivalent non-finite step-skip (v1 had GradScaler; the rewrite dropped it) as an overflow backstop — NON-FINITE ONLY: a finite threshold is a boundary trap (apply-just-below degrades, skip-just-above freezes; tried 20/50/1000, all failed). Validated: large, bs32 accum2 (eff64), lr 3e-4, --no-flash, with all of the above — cleared every prior collapse point (7.4k/8.5k/12.5k) and reached sustained peak lr 3e-4 at step 25k with grad_norm max 5.8 over the whole run (no spikes) and loss 2.88 (a new low; all prior runs died at ~3.4). 160 core tests (config/model/trainer/checkpoint) + pyright green; full suite to re-run once the GPU frees from the teacher run.
…nce) + per-step-time metric The Pallas mha Triton backward downcasts the softmax-gradient intermediates (p, ds) and the saved output O to bf16 before the dQ/dK/dV matmuls, breaking the score-gradient cancellation in saturated attention and producing a reproduced grad-norm blow-up at pretrain step ~255.5k (forward loss unchanged; purely a backward-precision effect). Replace it with a custom_vjp that keeps the fast fused Pallas FORWARD but differentiates a materialised fp32-softmax attention for the BACKWARD — flash gradients now match the proven-stable --no-flash path (verified on GPU: fp32 rel-norm-diff 4e-7; bf16 marginally closer to fp32 truth than plain). ~6.5GB less activation memory than --no-flash. Reviewed via /subagent-review (4 lanes + codex), all clean on model.py. Adds CPU + GPU gradient-parity tests. Also: per-step-time metric (step_time_inst) wiring + dashboard, with codex-found boundary fix (measure from chunk_t0 so val/checkpoint wall-time no longer leaks a bogus per-step spike at every val/checkpoint boundary).
Adds `compute_compound_legality` (pawn/eval.py) + a `--compound-legality` flag to eval_jax.py: the v1 "game completion rate" — fraction of games whose EVERY supervised-ply argmax move prediction is legal (teacher-forced, given ground-truth history). Compounds per-move legality the way real generation would; depends far more strongly on capacity than per-move accuracy. Isolated / additive (reuses _legal_token_grid + _argmax_over_actions; does not touch the live compute_val_metrics val path). Also surfaces late_legal_move_rate in the eval_jax payload. CPU test cross-checks per_move_legal_rate == legal_move_rate.
…onger inflated) /subagent-review (bug+type+test-risk lanes + codex) on 0455387 converged on one real bug: with min_eval_ply > 0, games with zero surviving supervised plies were counted as vacuously "complete" (~illegal_sup.any() is True for an all-False row), and eval_jax.py forwarded its --min-eval-ply default of 10 — inflating the v1-comparable game-completion rate (opening illegalities skipped; short games auto-complete). Fixes: - _batch_game_legal gates completion on has_sup (>=1 evaluated ply); excludes zero-supervised games from BOTH numerator and denominator. game_completion_rate divides by evaluated-game count; n_games now reports evaluated games. - eval_jax.py calls compute_compound_legality at min_eval_ply=0 (game-completion is a no-opening-skip metric; v1 defined it over every supervised ply). - Docstrings: cross-check vs legal_move_rate holds only at min_eval_ply=0; n_games semantics. - Tests: real detection tests (monkeypatch legality grid -> 1.0 all-legal; 3/4 with one illegal game) replacing the vacuous range check; zero-supervised exclusion regression guard.
True AR game-completion rate (v1 never measured it): the model generates each move from its own prior moves (mask_illegal=False), so the first illegal move forfeits and corrupts the visible history. completion = fraction with forfeit_ply==-1. Reuses pawn.generation.autoregressive_generate; reads the checkpoint's own conditioning. Branch exp/ar-compound-legality off jax_migration.
Vendored jax/experimental/pallas/ops/gpu/attention.py (Apache 2.0) + 4 marked FP32-BWD patches: keep the cancellation terms (delta=O·dO, p, ds) fp32 before the dQ/dK/dV matmuls instead of rounding to bf16. CPU interpret-mode gradient parity vs fp32 reference at saturated attention: dQ rel-err at scale 32 drops from 62% (stock) to 3.0% (patched), ~20x better; dV unaffected. Confirms the fused fp32 terms fix the score-gradient cancellation. Perf (does fp32 pl.dot compile fast on Triton-ROCm?) measured next. Branch exp/flash-kernel-fp32-bwd.
…FP32BWD=1 Behind an env flag (experiment branch only): when set, _pallas_attn calls the vendored fused-fp32-backward mha directly (its own custom_vjp), bypassing the materialised custom-VJP _flash_attn — so a short training run can measure the fused kernel's end-to-end g/s + stability vs no-flash/custom-VJP.
Faithful v1→v2-JAX loader WITHOUT un-factoring (keeps src/dst/promo embeddings — it's the real v1 architecture). Ports the 8c2a598 factored PAWNModel + the parity-tested converter into pawn/_legacy/ (transformer is byte-identical to current v2, so only the factored embedding + field-for-field converter are revived). `load_v1_factored_model(source)` builds the model in-memory at v1's native dims (vocab 1980, head_dim 80 for large) — no save_model round-trip, no embedding transformation. Branch exp/v1-ar-legality off jax_migration.
…ted) scripts/eval_v1_legality.py: loads a converted v1 checkpoint (factored, faithful) and evaluates per-move legal + compound game-completion under v1's NATIVE contract via to_v1_contract() — slot-0 BOS replaced by a masked, unsupervised PAD (RoPE-relative => +1 shift invariant), so the v2 eval pipeline's C=1 alignment holds. VALIDATION: converted thomas-schweich/pawn-large reproduces its published numbers per-move legal 99.9997% (published 99.9990%) game-completion 99.90% (published 99.76%) => the field-for-field factored conversion is faithful. Teacher-forced v1-vs-v2: v1-large 99.90% vs v2-large 97.71% game-completion. Branch exp/v1-ar-legality.
Self-contained batched autoregressive game-completion eval for the converted v1-large model under its NATIVE bare-moves contract (no BOS, m_1 seeded with a random legal move since v1 never supervised the first ply). Full-forward over a fixed-width buffer + attention mask (single XLA compile, no per-length recompile) with engine-tracked legality/forfeit/termination — env-step body copied verbatim from the proven autoregressive_generate. Validated end-to-end: --use-bos runs a v2 checkpoint through the same loop and reproduces eval_ar_legality.py's 34.6% (got 34.57%, mean len 181.4, median forfeit ply 87) despite being independent code (full-forward vs KV-cached). Result: v1-large AR game-completion = 65.9% (forfeit 34.1%, mean 268 ply) vs v2-large 34.6% — the AR gap (~1.9x) dwarfs the teacher-forced gap (99.76 vs 97.71). Compound/AR legality exposes the capability difference per-move legality hides. Also refreshes the stale pawn/_legacy/legacy.py module docstring (described the removed disk-writing convert_legacy_checkpoint; now documents load_v1_factored_model). Plan: docs/jax_migration_plan.md
…24.7 g/s, opt-in via PAWN_FLASH_FP32BWD=1)
…gality evals (v1 AR 65.9% vs v2 34.6%)
…d call site jax.custom_vjp (via functools.partial) erases the wrapped callable's return type to object for the type checker, so the (out, lse) unpack in _mha_forward failed pyright. cast() to the runtime-true tuple[jax.Array, jax.Array] — return_residuals=True always yields the pair.
…-confound experiment
Trains the exact v1 architecture under the exact v2 recipe to disentangle
architecture (factored embeddings + dims) from training recipe (LR schedule)
as the cause of the v1↔v2 compound-legality gap (TF 99.76% vs 97.71%,
AR 65.9% vs 34.6%). Design doc: docs/factored_v1_experiment.md.
- pawn/factored_model.py: FactoredPAWNModel promoted out of _legacy. The
trunk is SHARED with pawn.model (_run_layers_impl/_run_layers_collect_impl
lifted to module level; fp32 RoPE, fp32 head matmul, custom-VJP flash) —
the old port carried the pre-cda3f8a numerical recipe, which would have
reintroduced the exact bf16 instabilities v2 fixed and confounded the
experiment. Only _embed (src+dst+promo gather), the always-untied lm_head,
and the dims differ from PAWNModel. init_factored_model gives the v1-parity
Normal(0,0.02) init (embed_pad zero-init).
- pawn/config.py: ModelConfig.factored_embeddings flag (validated against
tie_embeddings; nested-slice cross-architecture rejection) +
FACTORED_V1_LARGE (v1-large's published dims: 8h×80, d_ff 2560, vocab
1980) + TINY_FACTORED + V1_VOCAB_SIZE.
- pawn/checkpoint.py: save/load dispatch on factored_embeddings
(FACTORED_SAVED_FIELDS, expected shapes, FactoredPAWNModel rebuild);
require_uniform() guard for the uniform-only consumers (parity, adapter,
distill); factored+adapter-sidecar checkpoints refused.
- pawn/corpus.py: to_v1_contract promoted from scripts/eval_v1_legality.py
(slot 0 → masked unsupervised PAD; validated against published v1 evals);
C=1 requirement enforced.
- pawn/run_config.py: PretrainConfig.arch ∈ {v2, factored-v1} with guards
(conditioning=[] required; variant subsets rejected; stochastic_variants
neutralised).
- scripts/train_jax.py: --arch flag; build_variants emits the single
is_supernet=True factored spec (supernet_joint_loss reduces to plain CE,
sliced never runs); init_factored_model branch; to_v1_contract applied to
the train stream (pre-bucketing) and the held-out val corpus.
- pawn/trainer.py: TrainState/model unions; factored×sliceable-variants
pairing rejected loudly.
- pawn/probes.py: widened to the union (factored model exposes
hidden_states — probes work unchanged).
- configs/factored_v1_lr_confound.json: the replicated hardy-finch recipe
(400k steps, eff batch 64 = 32×2, infinite schedule @ 3e-4, bf16).
- tests/test_jax_factored.py: 18 tests — config validation, init shapes,
forward dtypes, to_v1_contract semantics, checkpoint roundtrip +
cross-load refusal, trainer integration (loss decreases end-to-end).
Smoke-validated: tiny end-to-end run (train → checkpoint → val), resume from
the factored checkpoint (+100 steps), full suite green pre-change (1345).
03277e6 to
fd6504a
Compare
…converter cfg, probes contract) - scripts/train_jax.py: resume arch↔checkpoint cross-check — resuming a factored checkpoint under --arch v2 (or vice versa) silently trained the wrong contract (BOS=1980 maps to the last outcome embedding in the factored _embed). Both directions now a load-boundary hard error. (Type + Risk) - pawn/run_config.py + pawn/factored_model.py: use_flash neutralised for arch=factored-v1 and hard-rejected by FactoredPAWNModel.__call__ — Pallas flash drops the PAD mask (right-pad invariant), and the v1 contract's masked slot-0 LEADING pad violates it; use_flash defaults True on GPU, so a bare CLI run would have silently attended to the artificial PAD. (Codex P2) - pawn/_legacy/legacy.py: converted v1 models now carry factored_embeddings=True (+ tie_embeddings=False) in their cfg — the uniform-flagged cfg made save_model write factored tensors under a uniform config: an unloadable checkpoint. Dead imports removed (Sequence, st_save, HEAD_DIM). (Codex P2, Simplification) - pawn/probes.py: factored models probe under the bare-moves contract — slot-0 BOS is rewritten to a masked PAD (mirrors to_v1_contract); conditioning rejected. Probes on factored checkpoints previously measured hidden states with a silently-corrupted slot-0 context. (Risk, Codex P2) - scripts/eval_v1_ar_legality.py: --use-bos now require_uniform-guarded (factored + BOS = silent corruption); the bare-moves path accepts v2-format factored checkpoint dirs (sentinel-detected via load_model) so the new run's artifacts are evaluable as documented; model params annotated. (Type, Codex P2) - pawn/checkpoint.py + pawn/model.py + CLAUDE.md + docs: dual-schema module docstring; stale 'tied (the default)' claims fixed (untied since 18a0047); factored_model.py listed in the repo map; the 'don't reintroduce factored' note scoped to v2 prose; measured-vs-published v1 eval numbers (99.90% measured / 99.76% published) stated consistently. (Docs) - pawn/eval.py: documented the val_loss (1968-wide) vs train loss (full-width-minus-reserved) normalisation difference. (Risk) - pawn/lifecycle.py: unused PAWNModel import removed. (Type) - tests/test_jax_factored.py: +4 tests — factored resume restores Adam moments (flatten/unflatten on the factored PyTree), accumulation_steps=2 micro-axis path, use_flash rejection at both layers, end-to-end converter roundtrip on a synthetic v1 checkpoint through the v2 save→load dispatch. (Risk, Codex)
…ting, variants no-op)
- pawn/factored_model.py: _embed now hard-rejects out-of-vocab ids via
eqx.error_if — BOS/NULL/reserved (≥1980) previously clamped silently onto
the last outcome row, so any v2-style caller that forgot to_v1_contract
got quietly-wrong numbers. This is the systemic guard behind the
per-caller contract checks. Also removed the unreachable use_flash mask
branch behind the round-1 hard-reject (mask built unconditionally,
use_flash=False literal at the _run_layers_impl call). (Codex P2, Doc,
Type minor)
- pawn/run_config.py: removed the variants==("large",) carve-out — it was
itself a silent no-op (VARIANTS['large'] names the UNIFORM supernet, and
build_variants never reads cfg.variants under factored-v1); any explicit
--variants now rejects. (Type IMPORTANT)
- scripts/eval_v1_ar_legality.py: checkpoint routing now keys on the v2
config.json schema ({version, model} vs v1's {format_version,
model_config}) instead of the .complete sentinel — an INCOMPLETE v2 save
previously fell through to the v1 torch loader and died on a baffling
key mismatch; now it hits the v2 loader's precise
IncompleteCheckpointError. (Risk HIGH)
- pawn/_legacy/legacy.py: removed the dead converted-cache layer
(_converted_cache_root/_source_id_hash/_CONVERTED_CACHE_VERSION) whose
docstrings promised a force=True escape hatch that doesn't exist; fixed
the stale 'save_model can't serialise factored' parenthetical (it can,
since the checkpoint dispatch landed). (Risk HIGH, Doc)
- tests/test_jax_factored.py: +2 tests / 2 strengthened — OOV embed
rejection (BOS raises), probes bare-moves contract end-to-end (incl.
conditioning rejection), accumulation loss pinned to the mean of
pre-step micro losses (not just 'finite'), variants=('large',) now
asserted rejected. (Risk, Codex)
…red checkpoints scripts/eval_jax.py built a standard C=1 corpus (slot-0 BOS) for every checkpoint; a factored checkpoint then tripped _embed's OOV guard before any metric was produced, making the documented eval plan §2 path unusable (codex round-3 P2 — the round-2 guard correctly converted this from silent corruption to a loud abort; this makes the path actually work). load_eval_model results are now isinstance-routed through to_v1_contract — the same validated transform the trainer and probes use; C=1 is guaranteed because factored checkpoints can only train with conditioning=[]. Round-3 status otherwise: type and test-risk lanes CLEAN (routing matrix, OOV-guard JIT/donation safety, and test hermetics all verified). +test: eval_jax.main() end-to-end on a saved tiny factored checkpoint (metrics + compound legality produced, n_games honoured).
- pawn/model.py: init_model rejects factored_embeddings configs — the symmetric twin of init_factored_model's guard. A uniform PAWNModel carrying a factored config lies to every cfg-driven dispatch: save_model picks the tensor schema from the runtime class but records the config flag, so load_model would expect embed_src/... and the checkpoint would be unloadable. (Codex round-4 P2) - pawn/checkpoint.py: _model_to_tensor_dict refuses a class↔config mismatch at save time (defense-in-depth for hand-constructed models that bypass the init guards) — better a loud CheckpointIntegrityError than an unloadable artifact. - pawn/factored_model.py: documented the cached-decode head-dtype adjudication (compute-dtype head under AMP = deliberate parity with PAWNModel.forward_with_cache; the fp32-head rule is a training-path recipe and the eval/AR harnesses drive the cache at the fp32 default) — re-flagged in three review rounds, now answered in the docstring. - tests: init_model factored-cfg rejection + save-time mismatch refusal.
Summary
Full framework swap from PyTorch to JAX/Equinox/Optax per the
from-scratch parity-bar plan in
docs/jax_migration_plan.md. Everyv1 user-facing surface is preserved with a v2 counterpart; the v1
GONE-BY-DESIGN list in plan §6 is fully retired.
The migration is structured as 16 sections (S1–S16) — see commit log
for the section-by-section squash history.
§3 acceptance criteria
All 20 criteria pass on this branch. See
final_smoke.mdat the reporoot for the verification command + pasted output for each one.
Highlights of the live runs in that artifact:
scripts/convert_published_checkpoints.pyruns throughthomas-schweich/pawn-{small,base,large}and produces three v2 JAXcheckpoints. The synthetic-v1 parity test asserts the converter is
numerically within mean Δlogit ≤ 1e-3 / max ≤ 1e-4.
train_jax.py --supernet tiny --total-steps 1000 --batch-size 16 --seq-len 64 --k 50— loss decreases from 19.7 →17.5, no NaNs,
.complete-sealed checkpoint at step 1000.with the held-out
validationsplit as val source.eval_jax.pyagainst converted pawn-base reports top-1accuracy 8.72%, within 0.15pp of v1's reported 8.57%.
{rank=4, targets='qkv', lr=0.0056}withval_loss=3.27.Test suite
uv run --extra rocm --extra wandb --extra dashboard --extra lab pytest tests/ -q -m "not slow"→ 619 passed, 0 failed.uv run --extra rocm pyright→ 0 errors acrosspawn/,scripts/,tests/.Deferrals
None.
DEFERRALS.mdat the repo root is empty — every §6 surfaceports forward. See the file for the explicit "No deferrals"
declaration.
What lands here
pawn/model.py— Equinox PAWNModel supernet (lax.scan overn_layers, plain attention, RoPE, SwiGLU, factored embeddings).
pawn/trainer.py— pretraining with K-steplax.scaninside one@eqx.filter_jitbody; supernet joint loss = sum of per-variantcross-entropies on the same batch.
pawn/adapter_trainer.py— two-tiereqx.partitionfrozen-backbone / trainable-adapter training; 10-key
STRATEGIESdispatch table including the three RoSA modes.
pawn/adapters/{lora,film,bottleneck,hybrid,sparse,rosa,unfreeze, specialized_clm}.py— full v1 adapter surface ported.pawn/eval.py+pawn/probes.py+pawn/generation.py+pawn/lichess_eval.py+pawn/eval_suite/diagnostics.py— evalsurface incl. 5 outcome-gated generation diagnostics + edge-case
Rust-engine integration.
pawn/legacy.py+pawn/_torch_legacy_fixture.py— single bridgefrom v1 PyTorch HF checkpoints to v2 JAX safetensors, with a
parity test against a synthetic v1 fixture.
pawn/lifecycle.py—HFPushTracker(async ThreadPoolExecutor),SIGTERM handler,
load_resume_statewith step-splicing.pawn/run_config.py— pydanticBaseRunConfig/PretrainConfig/AdapterConfig/SpecializedCLMConfig/RunConfigdiscriminated union, allextra="forbid".pawn/sweep.py+pawn/lab/+pawn/dashboard/— Optuna driver,FastMCP lab server, Solara dashboard (all torch-free).
pawn/_sentinel.py— stdlib-only.completeSHA-256 sentinelhelpers used by every save/load path.
pawn/corpus.py+pawn/lichess_data.py— Rust-engine randomcorpus + Lichess parquet → JAX
Corpus, on-disk cache, multi-epoch tiling.
scripts/train_jax.py+scripts/train_jax_adapter.py+scripts/eval_jax.py+scripts/eval_probes_jax.py+scripts/eval_generation_jax.py+scripts/eval_vs_stockfish.pyscripts/sweep.py+scripts/convert_published_checkpoints.pyscripts/run_evals_backbone.py— v2 entry points.scripts/benchmark.py— fully rewritten against JAX/Equinox/Optax(jit vs eager backbone + adapter steps, fresh-corpus-per-step vs
pre-staged data-pipeline bench, multi-process JAX concurrency
sweep).
engine/src/lib.rs— PAD-init at four PGN-token-init sites so a0-initialised tail no longer aliases a legal move token.
tests/test_jax_*.py— JAX-side test per module plustests/test_public_api.pypinning the post-swap public surface.What goes away (GONE BY DESIGN per §6)
pawn/cotrain.py→ replaced bypawn.trainer.supernet_joint_loss.pawn/gpu.py→ JAX handles device config;PAWN_ALLOW_CPU=1preserved as the operator escape hatch in both v2 entry points.
pawn/data.py+pawn/data_utils.py→pawn.corpus+pawn.lichess_data.pawn/adapter_training.py→pawn.adapter_trainer.pawn/specialized_clm.py→pawn.adapters.specialized_clm.pawn/lichess_cache.py→pawn.lichess_data's on-disk cache.pawn/eval_suite/{worker,generation,lichess,probes}.py→pawn.{generation,lichess_eval,probes}.pawn/lab/sweep.py→pawn.sweep.scripts/{train,eval_accuracy,eval_probes,export_hf_repo}.py→scripts/*_jax*.py.Multi-lane review
The S16 multi-lane review pass surfaced one Critical pile in the
adapter trainer (saving the wrong tensor, the unfreeze no-op, the
unused FiLM beta, the v1-vs-v2 kaiming bound divergence, the
generate_corpus-per-step performance regression) plus assortedtype / doc / dead-code findings — every item is addressed in the
S16-review fix commit (
6bf5dc7). The fifth-pass spec-alignmentgate returned PASS.
Test plan
uv sync --extra rocmexits 0 (criterion 1)uv run --extra rocm pytest tests/is green (criterion 3)uv run --extra rocm pyrightreports 0 errorsROCm pod / vast.ai instance; compare against
final_smoke.mdpawn-{small,base, large}) and verify the v2 forward path produces accuracywithin ±0.5pp of v1's published numbers
thomas-schweich/pawn-{small,base,large}-v2Plan: docs/jax_migration_plan.md