Skip to content

feat: framework swap to JAX/Equinox/Optax (v2 stack)#115

Open
thomas-schweich wants to merge 134 commits into
mainfrom
jax_migration
Open

feat: framework swap to JAX/Equinox/Optax (v2 stack)#115
thomas-schweich wants to merge 134 commits into
mainfrom
jax_migration

Conversation

@thomas-schweich

Copy link
Copy Markdown
Owner

Summary

Full framework swap from PyTorch to JAX/Equinox/Optax per the
from-scratch parity-bar plan in docs/jax_migration_plan.md. Every
v1 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.md at the repo
root for the verification command + pasted output for each one.
Highlights of the live runs in that artifact:

  • §3.4: scripts/convert_published_checkpoints.py runs through
    thomas-schweich/pawn-{small,base,large} and produces three v2 JAX
    checkpoints. The synthetic-v1 parity test asserts the converter is
    numerically within mean Δlogit ≤ 1e-3 / max ≤ 1e-4.
  • §3.6: 1000-step 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.
  • §3.7: 200-step LoRA on v1-converted pawn-base — loss 3.42 → 3.30
    with the held-out validation split as val source.
  • §3.9: eval_jax.py against converted pawn-base reports top-1
    accuracy 8.72%, within 0.15pp of v1's reported 8.57%.
  • §3.14: 3-trial Optuna LoRA sweep, exits 0, best trial is
    {rank=4, targets='qkv', lr=0.0056} with val_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 pyright0 errors across pawn/,
scripts/, tests/.

Deferrals

None. DEFERRALS.md at the repo root is empty — every §6 surface
ports forward. See the file for the explicit "No deferrals"
declaration.

What lands here

  • pawn/model.py — Equinox PAWNModel supernet (lax.scan over
    n_layers, plain attention, RoPE, SwiGLU, factored embeddings).
  • pawn/trainer.py — pretraining with K-step lax.scan inside one
    @eqx.filter_jit body; supernet joint loss = sum of per-variant
    cross-entropies on the same batch.
  • pawn/adapter_trainer.py — two-tier eqx.partition frozen-
    backbone / trainable-adapter training; 10-key STRATEGIES
    dispatch 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 — eval
    surface incl. 5 outcome-gated generation diagnostics + edge-case
    Rust-engine integration.
  • pawn/legacy.py + pawn/_torch_legacy_fixture.py — single bridge
    from v1 PyTorch HF checkpoints to v2 JAX safetensors, with a
    parity test against a synthetic v1 fixture.
  • pawn/lifecycle.pyHFPushTracker (async ThreadPoolExecutor),
    SIGTERM handler, load_resume_state with step-splicing.
  • pawn/run_config.py — pydantic BaseRunConfig /
    PretrainConfig / AdapterConfig / SpecializedCLMConfig /
    RunConfig discriminated union, all extra="forbid".
  • pawn/sweep.py + pawn/lab/ + pawn/dashboard/ — Optuna driver,
    FastMCP lab server, Solara dashboard (all torch-free).
  • pawn/_sentinel.py — stdlib-only .complete SHA-256 sentinel
    helpers used by every save/load path.
  • pawn/corpus.py + pawn/lichess_data.py — Rust-engine random
    corpus + 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.py
    • scripts/sweep.py + scripts/convert_published_checkpoints.py
    • scripts/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 a
    0-initialised tail no longer aliases a legal move token.
  • tests/test_jax_*.py — JAX-side test per module plus
    tests/test_public_api.py pinning the post-swap public surface.

What goes away (GONE BY DESIGN per §6)

  • pawn/cotrain.py → replaced by pawn.trainer.supernet_joint_loss.
  • pawn/gpu.py → JAX handles device config; PAWN_ALLOW_CPU=1
    preserved as the operator escape hatch in both v2 entry points.
  • pawn/data.py + pawn/data_utils.pypawn.corpus + pawn.lichess_data.
  • pawn/adapter_training.pypawn.adapter_trainer.
  • pawn/specialized_clm.pypawn.adapters.specialized_clm.
  • pawn/lichess_cache.pypawn.lichess_data's on-disk cache.
  • pawn/eval_suite/{worker,generation,lichess,probes}.py
    pawn.{generation,lichess_eval,probes}.
  • pawn/lab/sweep.pypawn.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 assorted
type / doc / dead-code findings — every item is addressed in the
S16-review fix commit (6bf5dc7). The fifth-pass spec-alignment
gate returned PASS.

Test plan

  • CI: uv sync --extra rocm exits 0 (criterion 1)
  • CI: uv run --extra rocm pytest tests/ is green (criterion 3)
  • CI: uv run --extra rocm pyright reports 0 errors
  • Manual: rerun a full set of §3 verification commands on a
    ROCm pod / vast.ai instance; compare against final_smoke.md
  • Manual: convert published v1 HF checkpoints (pawn-{small,base, large}) and verify the v2 forward path produces accuracy
    within ±0.5pp of v1's published numbers
  • Manual: publish v2 supernet-derived checkpoints to
    thomas-schweich/pawn-{small,base,large}-v2

Plan: docs/jax_migration_plan.md

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.
Comment thread pawn/lifecycle.py Outdated
step = 0
import equinox as eqx

opt_state = optimizer.init(eqx.filter(model, eqx.is_inexact_array))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Serialise the opt state via optax.tree_utils.tree_map_params + save_file and pass it to save_model(…, optimizer_state=…).
  2. Load it here via st_load + reconstruct with eqx.apply_updates style.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/train_jax.py Outdated
Comment on lines +192 to +202
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pawn/legacy.py Outdated
Comment on lines +60 to +64
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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Hash the source file content (pull model.safetensors and hash it) — expensive for large files but truly content-addressed.
  2. Hash a cheap proxy (e.g., the HF commit SHA via HfApi().model_info(repo_id).sha) — cheap and accurate.
  3. Keep current behaviour but rename to _source_id_hash and update the docstring to say "key is the source identifier, not its content; use force=True if the upstream checkpoint has been updated."

Option 3 is the smallest diff if the semantics are intentional.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pawn/trainer.py
Comment on lines +281 to +288
return optax.join_schedules(
[
optax.linear_schedule(0.0, peak, warmup),
optax.constant_schedule(peak),
decay,
],
[warmup, total_steps - decay_steps],
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pawn/corpus.py Outdated
Comment on lines +200 to +207
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) = 0

loss_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 <= threshold

This is a latent edge case but worth hardening since pack_corpus is documented as a general packing helper.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/train_jax.py Outdated
Comment on lines +138 to +141
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 self

This also removes the need for the redundant runtime check in the script.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pawn/lifecycle.py Outdated
return failures

def shutdown(self) -> None:
self._executor.shutdown(wait=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented May 23, 2026

Copy link
Copy Markdown

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 issues

1. Optimizer state is never saved or loaded on resume (lifecycle.py:263, scripts/train_jax.py:192-202)

_save_checkpoint never passes optimizer_state to save_model, and load_resume_state always calls optimizer.init(...) from scratch. Adam's first/second moment estimates are silently discarded on every resume. For long pretraining runs resumed mid-cosine schedule, the cold restart causes a multi-hundred-step loss spike. The v1 checkpoint format saved optimizer state; the v2 format has the optimizer.safetensors slot but neither end wires it up.

2. WSD/infinite LR schedules can produce non-monotonic boundaries when warmup_steps is explicitly overridden (trainer.py:281-288)

_check_lr_schedule_fractions validates fractions, but _warmup_steps() can return a resolved integer that ignores warmup_frac. Example: total_steps=1000, warmup_steps=600, decay_frac=0.5 → boundary total_steps - decay_steps = 500 falls before the warmup ends at 600. Optax join_schedules with non-monotonic transition points is undefined behaviour. The same gap exists for the infinite schedule (warmup + cooldown + decay can exceed total in step counts even when fractions pass). The fix is to validate the resolved step counts inside make_lr_schedule.


🟡 Design/correctness concerns

3. Legacy converter cache key is a source-identifier hash, not a content hash (legacy.py:60-64)

_content_hash(source) hashes the string "1|thomas-schweich/pawn-base". If the upstream checkpoint is republished with new weights, the same key is returned and the stale conversion is used silently. The module docstring and function name both say "source-content hash" which is misleading. Either hash a cheap proxy (HF commit SHA) or rename/document clearly that force=True is required when upstream changes.

4. Zero-length game edge case in pack_corpus (corpus.py:200-207)

For prepend_outcome=False with capped_lengths[i] = 0, threshold = (0-1).clip(min=0) = 0, so loss_mask[:,0] is True even though tokens[:,0] is PAD. The model is trained to predict PAD→PAD. The engine never produces zero-length games, but pack_corpus is a public API. Fix: use max(capped_lengths - 1, -1) as the threshold so zero-length games get an empty loss mask.


🟠 Validation / UX

5. total_steps=None for PretrainConfig is caught at runtime, not parse time (scripts/train_jax.py:138-141)

Every other required-but-missing field raises a pydantic ValueError. This one is caught by a print + return 2 in the script. A @model_validator in PretrainConfig would make the error message consistent and machine-readable.


🟢 Minor

6. HFPushTracker.shutdown(wait=False) can abandon in-flight uploads (lifecycle.py:109)

join calls fut.cancel() on timed-out futures; if the upload thread is already running, cancel() is a no-op and shutdown(wait=False) then abandons the live thread. Consider shutdown(wait=True, cancel_futures=False) so in-flight uploads always finish, while queued ones are dropped.


What looks good

  • Checkpoint atomicity (_sentinel.py, checkpoint.py): the .tmp → rename → .complete pattern is solid; the SHA-256 manifest covers all required payloads and the defense against manifest-omission is explicit.
  • Supernet slicing (model.py:sliced): inner [:dv, :dv] block semantics are applied consistently across all 16 fields including the non-square FFN weights and the lm_head input axis.
  • RoPE in fp32 then downcast, RMSNorm in fp32 then downcast — both match the plan's v2 cast order and are consistent with the parity test contract.
  • lax.scan over the layer axis — stacking weights with a leading n_layers axis is the right call at seq=512 where compile time matters.
  • LoRA init and scaling (lora.py) — kaiming-uniform A, zero B, alpha/rank scaling, and correct (n_layers, in, out) einsum conventions throughout.
  • extra="forbid" on all pydantic models — stale flags surface immediately at config load.
  • SIGTERM handler (lifecycle.py:install_sigterm_handler) — idempotent, non-blocking, and correctly decoupled from the training loop.

The DEFERRALS.md "No deferrals" claim is credible: the acceptance-criteria surface (619 passing tests + final_smoke.md live run receipts) is unusually well-documented.

…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
…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).
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant