|
Download docs/RESEARCH_LOG.md from cazyundee/training: direct link, hf CLI and curl.
- Browser
- Download file 141 kB
-
https://huggingface.co/spaces/cazyundee/training/resolve/main/docs/RESEARCH_LOG.md
- Command line
-
hf download hf://spaces/cazyundee/training/docs/RESEARCH_LOG.md
-
curl -L -o RESEARCH_LOG.md https://huggingface.co/spaces/cazyundee/training/resolve/main/docs/RESEARCH_LOG.md
141 kB
| # TinyChess research log | |
| Status vocabulary, used strictly: | |
| | tag | meaning | | |
| |---|---| | |
| | **IMPLEMENTED** | code exists and runs | | |
| | **TESTED** | covered by an automated correctness test | | |
| | **MEASURED** | a number was produced by an actual run recorded in `runs/` | | |
| | **HYPOTHESISED** | believed, not yet measured | | |
| Nothing in this document is estimated or extrapolated. Every number has a | |
| corresponding `runs/*/metrics.jsonl` or `runs/*.json` artifact. | |
| --- | |
| ## Phase 0 — environment audit (MEASURED) | |
| Two environments were inspected, and the brief's assumption turned out to be | |
| wrong in an important way. | |
| | | intended research machine | reality | | |
| |---|---|---| | |
| | HF Space `cazyundee/training` | 16 cores, 104 GB, ZeroGPU | **unavailable** | | |
| | sandbox | dev only, 2 vCPU / 2 GB | **the actual research machine** | | |
| Sandbox, measured: | |
| * CPU: Intel Xeon @ 2.60 GHz, **2 logical / 1 physical core** (SMT), KVM guest | |
| * SIMD: AVX-512 (`avx512f/dq/cd/bw/vl/vnni/vbmi2`), `sha_ni`, `vaes` | |
| * RAM: ~2 GB total; disk 25 GB (20 GB free) | |
| * PyTorch 2.14.0+cpu, **CPU capability AVX512**, BLAS = MKL 2024.2, | |
| MKL-DNN v3.12, ATen parallel backend = OpenMP, `hardware_concurrency=2` | |
| ### UPDATE — the Space is now RUNNING (root cause: the 2-Space limit) | |
| The 402 was **not** a billing problem. The account had hit its **limit of 2 | |
| Spaces**, so HF refused every hardware request *and* new-Space creation with | |
| `402 Payment Required` — a misleading error. Deleting an unused Space | |
| (`tiny_shakespeare`) freed the slot, after which `request_space_hardware( | |
| 'zero-a10g')` succeeded immediately. | |
| A second fix was required: ZeroGPU Spaces must `import spaces` **before** torch | |
| and expose at least one `@spaces.GPU` function, or the scheduler never attaches | |
| hardware. After adding both, the Space reports `stage=RUNNING hw=zero-a10g`. | |
| ### The real research machine (MEASURED, far larger than the brief stated) | |
| Audited from inside the running Space: | |
| | | brief said | **actually measured** | | |
| |---|---|---| | |
| | CPU | 16 cores | **192 logical / 96 physical** (2x Xeon Platinum 8559C) | | |
| | RAM | 104 GB | **2,000 GB** | | |
| | disk | — | 1,991 GB free (no persistent `/data`) | | |
| | GPU | ~5 min cap | CUDA available (torch 2.13.0+cu130) | | |
| | BLAS / SIMD | — | MKL, AVX-512, L3 640 MiB | | |
| Python 3.10.13. All 12 correctness gates re-run **on the Space: 12/12 pass**. | |
| ### CPU scaling benchmark on the real machine (MEASURED) | |
| `scripts/bench_threads.py`, 64-core budget, self-play throughput: | |
| | processes x threads | moves/s | | |
| |---|---:| | |
| | 1 x 64 | 123.0 | | |
| | 2 x 32 | 240.3 | | |
| | 4 x 16 | 498.5 | | |
| | 8 x 8 | 1,025.1 | | |
| | 16 x 4 | 1,730.7 | | |
| | **32 x 2** | **2,258.0** | | |
| | 64 x 1 | 1,485.8 | | |
| **18x better to run many small processes than one wide one.** The model is far | |
| too small to fill 64 threads — intra-op parallelism is pure overhead, while | |
| process-level parallelism scales nearly linearly to 32 procs. All subsequent | |
| experiments use `--threads 2` and parallelise across arms/seeds. | |
| Throughput vs the old 2-vCPU sandbox: **531 -> 2,258 moves/s (4.3x)**, and the | |
| grid now runs 27 arms *simultaneously* instead of sequentially. | |
| ### The Space was previously blocked at the account level | |
| The Space was deployed successfully (code uploads fine, Gradio boots), but it | |
| cannot be scheduled: | |
| ``` | |
| runtime: stage=RUNTIME_ERROR, hardware=None, requested_hardware='zero-a10g' | |
| request_space_hardware('cpu-upgrade') -> 402 Payment Required | |
| request_space_hardware('cpu-basic') -> 402 Payment Required | |
| create_repo(<new space>) -> 402 Payment Required | |
| ``` | |
| Two Gradio faults were found and fixed along the way (both real bugs): | |
| 1. `gradio==4.44` + preinstalled `huggingface_hub>=1.0` → | |
| `ImportError: cannot import name 'HfFolder'`. Fixed by moving to Gradio 5. | |
| 2. Gradio 5 experimental SSR spawns a Node server that crashed the container. | |
| Fixed with `ssr_mode=False`. | |
| After both fixes the app logs `Running on local URL: http://0.0.0.0:7860` | |
| cleanly and *then* the Space is killed for lack of allocated hardware. The | |
| blocker is billing, not code. **Consequence: all experiments below ran on | |
| 2 vCPU.** The code is unchanged for the 16-core case — `--threads` and | |
| `scripts/bench_threads.py` exist for it. | |
| --- | |
| ## Phase 1 — correctness gate (TESTED) | |
| `python tests/test_phase1.py` — **12/12 pass**. No training was started before | |
| this was green. | |
| | test | result | | |
| |---|---| | |
| | candidate set == `python-chess` legal moves | 120 boards, exact match | | |
| | canonicalisation lossless (mirror ⇒ same legal set) | 200 boards | | |
| | move packing round-trip | 4,551 moves, 0 mismatches | | |
| | policy masking | illegal probability mass **exactly 0.0**; rows sum to 1 | | |
| | sampling never selects a padded slot | 50 rounds | | |
| | value side-to-move invariance | max Δ = **0.00e+00** | | |
| | outcome→z perspective (W win / B win / draw / inversion) | exact | | |
| | parameter budget ≤ 100K | **99,923** | | |
| | depth costs no parameters | +28 steps = +1,792 params (step embedding only) | | |
| | baseline config really disables features | no halt/router/mem/refine tensors | | |
| | termination: mate, stalemate, insufficient, 50-move, threefold | all detected | | |
| | checkpoint/resume | **bit-exact** weights, optimizer and RNG | | |
| ### Parameter accounting (MEASURED) | |
| Full architecture, `d_model=64`, `d_ff=128`, `d_move=32`, `n_mem=8`: | |
| | component | params | note | | |
| |---|---:|---| | |
| | `core` | 56,417 | **shared, reused every recurrent step** | | |
| | `encoder` | 24,192 | factorised file×rank square embedding | | |
| | `refine` | 6,336 | GRUCell in move space | | |
| | `move_emb` | 5,696 | compositional (7 field tables) | | |
| | `value` | 3,121 | | | |
| | `readout` | 2,048 | | | |
| | `sq_to_move` | 2,048 | | | |
| | `norm_out` + `move_scale` | 65 | | | |
| | **total** | **99,923** | trainable = persistent = 99,923 | | |
| Baseline config (no memory/router/halting/refine): **79,630**. | |
| The central constraint is satisfied *and* the key property holds: **recurrent | |
| depth is nearly free in parameters**. 1 step vs 24 steps differs by 512 | |
| parameters (a step-embedding row), while compute scales linearly: | |
| | steps | params | forward (B=8) | | |
| |---:|---:|---:| | |
| | 1 | 99,219 | 16.4 ms | | |
| | 4 | 99,411 | 47.9 ms | | |
| | 8 | 99,667 | 82.3 ms | | |
| | 16 | 100,179 | 176.3 ms | | |
| | 24 | 100,691 | 238.5 ms | | |
| --- | |
| ## Phase 2 — self-play environment (TESTED + MEASURED) | |
| `tinychess/selfplay.py`. Games are batched across boards so one NN call serves | |
| many games. Full rule coverage; **0 illegal move attempts** in every run so far. | |
| ### Reward sparsity: the first real research problem (MEASURED) | |
| A randomly initialised policy almost never mates, so the outcome signal is | |
| nearly empty: | |
| | max plies | decisive rate | terminations | | |
| |---:|---:|---| | |
| | 120 | 0.06 | 15 ply-cap, 1 mate | | |
| | 300 | 0.06 | 12 ply-cap, 3 stalemate, 1 mate | | |
| | 600 | 0.19 | 9 insufficient material, 3 mate, 3 fifty-move | | |
| At the ply caps affordable on 2 vCPU this is ~0–6% — effectively no gradient. | |
| Two mitigations were implemented and measured (16 games, random net): | |
| | setting | decisive rate | | |
| |---|---:| | |
| | pure outcome, standard start | **0.00** | | |
| | **+ material adjudication at ply cap** | **0.69** | | |
| | endgame curriculum, pure outcome | 0.00 | | |
| | mixed curriculum + adjudication | 0.31 | | |
| **Material adjudication** (verdict to whoever is ≥3 pawns up when the cap is | |
| hit) raises the decisive rate from 0% to 69%. This is honestly labelled: | |
| it **injects the prior "material is valuable"** and is *environment shaping*, | |
| not learned knowledge. It is a flag (`--adjudicate none|material`) so the | |
| shaped-vs-pure comparison is a first-class experiment, not a hidden default. | |
| The endgame curriculum **failed** (0.00 decisive): random kings simply shed the | |
| extra piece and reach insufficient material. Recorded as a negative result; | |
| deprioritised. | |
| --- | |
| ## Phase 3/4 — baseline + does it learn? (MEASURED, in progress) | |
| `runs/p4_baseline` — baseline config, **79,630 params**, random init, seed 0, | |
| 4 recurrent steps, 48 games/iter, 20 grad steps/iter, 2 threads. | |
| Algorithm: REINFORCE + learned value baseline, entropy bonus 0.01. | |
| First 11 iterations (~528 games): | |
| | iter | decisive | mean plies | entropy | value MAE | loss | | |
| |---:|---:|---:|---:|---:|---:| | |
| | 0 | 0.83 | 100.0 | 3.33 | 0.836 | +0.832 | | |
| | 3 | 0.83 | 97.6 | 3.01 | 0.662 | +0.524 | | |
| | 5 | 0.77 | 92.8 | 2.45 | 0.640 | +0.474 | | |
| | 8 | 0.54 | 92.9 | 1.81 | 0.611 | +0.417 | | |
| | 10 | 0.33 | 86.0 | 1.49 | **0.566** | +0.353 | | |
| **Head-to-head vs its own frozen random initialisation** (40 games, colours | |
| balanced, same architecture/seed — only the weights differ): | |
| > **iter 9: score 0.662, +117 Elo** | |
| This is the Phase 4 gate. Learning is real and measurable: the value head is | |
| fitting outcomes (MAE 0.836→0.566) and the policy is sharpening away from | |
| uniform (entropy 3.33→1.49; uniform over ~30 legal moves ≈ 3.4). | |
| Entropy is falling fast, which is the risk to watch — if it collapses the model | |
| stops exploring. The entropy bonus and temperature schedule are the controls. | |
| --- | |
| ### Run 1 collapsed at iteration ~40 (MEASURED — negative result, retained) | |
| `runs/p4_baseline` (plain REINFORCE) improved strongly and then destroyed | |
| itself. Evaluation vs its own frozen random init, 40 games each: | |
| | iter | score | Elo | | |
| |---:|---:|---:| | |
| | 9 | 0.662 | **+117** | | |
| | 19 | 0.800 | **+241** | | |
| | 29 | 0.875 | **+338** | | |
| | 39 | 0.487 | **−9** | | |
| The failure cascade, read off `metrics.jsonl`: | |
| | iter | entropy | decisive | plies | grad norm | L_pi | | |
| |---:|---:|---:|---:|---:|---:| | |
| | 0 | 3.33 | 0.83 | 100.0 | 1.0 | +0.04 | | |
| | 20 | 1.06 | 0.71 | 92.5 | 5.5 | −1.04 | | |
| | 28 | 0.51 | 0.06 | 52.7 | 30.8 | −3.87 | | |
| | 36 | 0.43 | 0.08 | 53.9 | 84.1 | −29.5 | | |
| | 48 | **0.07** | **0.00** | 24.6 | **108.6** | −26.7 | | |
| Mechanism (diagnosed, not guessed): | |
| 1. The entropy bonus was a **fixed** 0.01 — far too weak once the policy sharpened. | |
| 2. Entropy → 0.07 makes play deterministic, so both sides repeat moves: | |
| the final iteration ended **48/48 games by repetition**, i.e. all draws. | |
| 3. All-draw batches ⇒ advantage ≈ 0 with tiny spread, but advantage | |
| normalisation divided by that near-zero std, **amplifying pure noise**. | |
| 4. Unclipped REINFORCE then took huge steps: gradient norm 1 → 108. | |
| Three fixes, all implemented and smoke-tested: | |
| * **PPO-style ratio clipping** (`--ppo-clip 0.2`) using the stored behaviour | |
| log-probs, so one batch cannot move the policy far. | |
| * **Skip advantage normalisation when the spread is degenerate** (std < 1e-3); | |
| only centre it. | |
| * **Adaptive entropy coefficient**: a feedback controller targeting | |
| `entropy ≈ 0.45 · log(mean legal moves)` instead of a fixed constant. | |
| MEASURED after the fix (`runs/p4_ppo`, first iterations): gradient norm stays | |
| **1.0–1.3** (was 108), approx-KL ~0.005, entropy held at ~3.3. | |
| This is exactly the "if exploration collapses, diagnose it" case from the | |
| brief — the cause was the RL objective, not model capacity. | |
| ### Run 2 with the fixes: stable (MEASURED) | |
| `runs/p4_ppo` — identical budget and seed, only the RL objective changed | |
| (PPO clip 0.2, adaptive entropy target 0.45, safe advantage normalisation). | |
| Score vs its own frozen random init (40 games, colours balanced): | |
| | iter | plain REINFORCE (run 1) | **PPO + entropy control (run 2)** | | |
| |---:|---:|---:| | |
| | 9 | 0.662 (+117 Elo) | **0.850 (+301 Elo)** | | |
| | 19 | 0.800 (+241) | **0.975 (+636)** | | |
| | 29 | 0.875 (+338) | **0.950 (+512)** | | |
| | 39 | **0.487 (−9)** ← collapsed | **0.950 (+512)** | | |
| At iteration 39 run 2 shows entropy 3.11 (vs 0.07), decisive rate 0.75 | |
| (vs 0.00), mean game length 99.5 plies (vs 24.6), gradient norm 0.98 (vs 108). | |
| **Conclusion (MEASURED):** the collapse was an artefact of the optimiser, not of | |
| the 79.6K parameter budget. The same architecture reaches ~+500–640 Elo over its | |
| initialisation and stays there. Run 1 is retained as a negative result. | |
| ## Performance engineering (MEASURED) | |
| Profiling self-play showed `_local` (structured chess mixing) was **70% of | |
| total compute** — 0.93 s of 1.55 s, far more than attention (0.11 s), because | |
| the sliding-ray op materialised a `[B,64,4,7,d]` tensor (28× memory traffic). | |
| Two rewrites were attempted: | |
| 1. **flat `index_select` + einsum** — numerically identical (diff 0.0) but | |
| **0.77–0.84× i.e. SLOWER** at every batch size. *Negative result, reverted.* | |
| 2. **fold the rays into 4 precomputed `[64,64]` mixing matrices** and apply as | |
| batched matmuls (the decay weight depends on square/axis/slot but not on | |
| channels, so it folds into the matrix; the per-channel gate applies after): | |
| | batch | gather (ms) | matrix (ms) | speedup | | |
| |---:|---:|---:|---:| | |
| | 16 | 7.10 | 2.87 | **2.48×** | | |
| | 32 | 15.57 | 6.59 | **2.36×** | | |
| | 64 | 33.87 | 18.88 | 1.79× | | |
| | 128 | 70.86 | 30.33 | **2.34×** | | |
| Numerically identical (max diff **0.0**) and `ray_decay` stays differentiable — | |
| both locked in by `test_local_mixing_matches_reference`. | |
| End-to-end self-play throughput: **234 → 531 moves/s** (2.3×) on 2 vCPU. | |
| --- | |
| --- | |
| ## Phase 5 — does persistent memory help? (MEASURED, 2 seeds) | |
| `runs/abl_memory` — arms differ ONLY in `use_memory`. Identical budget: | |
| 30 iterations x 32 games, 16 updates/iter, 4 recurrent steps, PPO + entropy | |
| control, seeds 0 and 1. Evaluation: 50 games per match, colours balanced. | |
| | arm | params | vs own random init | vs random-legal | vs material heuristic | | |
| |---|---:|---:|---:|---:| | |
| | baseline | 79,630 | 1.000 / 1.000 | 0.990 / 0.940 | 0.150 / 0.060 | | |
| | **memory (8 slots)** | 92,430 | 0.990 / 0.980 | 0.980 / 0.960 | 0.140 / 0.120 | | |
| **Direct head-to-head, baseline vs memory (score for baseline):** | |
| | seed | score | 95% CI | Elo | | |
| |---|---:|---|---:| | |
| | 0 | 0.320 | [0.21, 0.46] | **−131** | | |
| | 1 | 0.320 | [0.21, 0.46] | **−131** | | |
| > **Memory wins, identically in both seeds: +131 Elo.** The CI excludes 0.5 in | |
| > both cases, so this is a real effect, not seed noise. | |
| Two honest caveats: | |
| 1. `vs own random init` is **saturated** (baseline already scores 1.000), so it | |
| cannot rank the arms — the head-to-head is the informative measurement. This | |
| is why the ablation harness plays arms against each other directly. | |
| 2. Memory costs 12,800 parameters (79.6K → 92.4K). The comparison is therefore | |
| *not* parameter-matched; part of the gain may be capacity, not the memory | |
| mechanism. **The parameter-matched control was run — see below, and it | |
| overturns the conclusion.** | |
| ### Parameter-matched control: the memory effect does NOT survive (MEASURED) | |
| `runs/abl_parammatch` — `memory` (92,430 params, 8 slots) vs `wide` | |
| (92,530 params, `d_ff=228`, no memory). Parameter counts differ by **0.1%**. | |
| Same budget, seeds 0 and 1, 50-game matches. | |
| | seed | memory vs wide | 95% CI | Elo | | |
| |---|---:|---|---:| | |
| | 0 | 0.470 | [0.34, 0.61] | **−21** | | |
| | 1 | 0.730 | [0.59, 0.83] | **+173** | | |
| > **The seeds disagree in direction and both CIs contain 0.5.** | |
| > Against a parameter-matched control, memory shows **no reliable benefit**. | |
| Reconciling this with Phase 5: the earlier `baseline vs memory` result | |
| (+131 Elo, identical in both seeds) is best explained as a **capacity effect**, | |
| not a memory-mechanism effect. Spending the same 12,800 parameters on a wider | |
| MLP does just as well. | |
| Per the brief — *"do not claim an architectural feature works unless its | |
| ablation demonstrates a measurable effect"* — the claim "persistent memory | |
| helps" is **NOT SUPPORTED** at this scale and training budget. Two seeds is | |
| also too few; the honest statement is "no reliable effect detected", not | |
| "memory does not work". | |
| **This is why parameter-matched controls matter: the unmatched ablation would | |
| have produced a confident and wrong conclusion.** | |
| ### Both models are far weaker than a one-ply material heuristic | |
| Every arm loses heavily to `MaterialAgent` (0.06–0.15). After ~1,000 self-play | |
| games on 2 vCPU, the models beat random play and their own initialisation but | |
| have **not** learned material value. This is the honest strength ceiling of the | |
| current compute budget and is not disguised by the self-play-relative numbers. | |
| --- | |
| ## Phase 9 — internal-state transcription (MEASURED, with a negative headline) | |
| `tinychess/thought.py`. A read-only decoder over the trajectory. Two properties | |
| are enforced in code rather than asserted in prose: | |
| **Gradient isolation is PROVEN, not claimed.** `assert_no_gradient_leak()` runs a | |
| transcription loss backward and inspects every chess parameter: | |
| ``` | |
| leaked chess params: 0 (decoder params with gradient: 8 tensors) | |
| decoder size: 27,036 params -- reported SEPARATELY, outside the chess budget | |
| ``` | |
| **The transcript describes computational events, not chess concepts.** Labels are | |
| measured from the model's own trajectory (|Δh| behaviour, router argmax, | |
| confidence), never from human commentary. | |
| ### The event vocabulary was broken, and measurement caught it | |
| First version used absolute thresholds. Measured base rates showed | |
| **7 of 12 events never fired**, so a decoder scored 99.1% by predicting | |
| constants (majority baseline **94.5%**). Rewritten to use per-trajectory | |
| *relative* definitions (median splits). After the fix the majority baseline | |
| falls to **79.9%** and 7/12 events are informative for a baseline-config model | |
| (the 5 still constant are `local_dominant`, `mlp_dominant`, `memory_active`, | |
| `reorganising`, `late_shift` — correct, since this checkpoint has no | |
| memory/router and its routing is uniform). | |
| ### Trajectory access does NOT help (MEASURED) | |
| The decisive control, both decoders predicting the *same* full event sequence | |
| with the same architecture and parameter count (the final-state decoder is fed | |
| `h_n` repeated T times): | |
| | decoder | all-event acc | informative-event acc | BCE | | |
| |---|---:|---:|---:| | |
| | trajectory (h0..hn, Δh) | 0.9768 | 0.9603 | 0.0752 | | |
| | **final state only (h_n)** | **0.9956** | **0.9924** | **0.0271** | | |
| | majority-class baseline | — | 0.7992 | — | | |
| > **Trajectory advantage: −0.032.** The final-state decoder is *better*. | |
| Both clear the majority baseline, so the events are genuinely being read from | |
| the state. But for this model, **h_n alone suffices to reconstruct the event | |
| sequence** — trajectory access exposes nothing extra. Plausible cause: with | |
| uniform routing and monotone |h| growth, the trajectory is highly predictable, | |
| so h_n implicitly encodes its own history. This is exactly the control the | |
| brief demanded, and it argues *against* the trajectory-transcription hypothesis | |
| in the current regime. It should be re-tested on a router-enabled model whose | |
| per-step behaviour actually varies. | |
| ### Interventions (MEASURED) | |
| | test | result | | |
| |---|---| | |
| | ablate latent channel (0/7/23/41) | event distribution shifts (0.028–0.033 mean change) | | |
| | board counterfactual (remove a piece) | trajectory L2 = 23.1, events change | | |
| | forced early halt | event sequence truncates 5 → 4 steps | | |
| | memory mask | n/a for baseline config (no memory) | | |
| --- | |
| ## Phase 10 — structural plasticity (TESTED) | |
| `tinychess/plasticity.py`. Fixed tensors + binary active/inactive masks, so | |
| topology changes never reallocate and maximum capacity is a hard ceiling. | |
| Pruning is **not** magnitude-based. A composite utility combines gradient | |
| sensitivity (weighted 1.5x), activation magnitude, diversity and firing | |
| frequency; candidates then need **intervention evidence** — the unit is | |
| temporarily deactivated and only stays pruned if measured loss does not degrade. | |
| Measured on a trained baseline checkpoint (128 hidden units, 48 positions): | |
| ``` | |
| step 1000 | |
| pruned units: 10 | |
| spawned units: 6 | |
| kept after intervention: 0 | |
| active units: 124/128 | |
| active parameters: 79,114 (max 79,630) | |
| ``` | |
| Every mutation is logged with its reason, e.g. | |
| `prune unit 2 | redundant: loss 3.2048->3.2053`. Spawning re-activates a dead | |
| slot from a high-utility donor plus perturbation, halving the donor's outgoing | |
| weight so the function is approximately preserved. | |
| Active-parameter accounting is exact: a dead MLP unit removes | |
| `2*d_model + 1 = 129` parameters; 10 pruned − 6 spawned = 4 net → 516 fewer | |
| active parameters, matching 79,630 → 79,114. | |
| **Not yet measured:** whether plasticity improves *self-play strength*. It is a | |
| working mechanism with correct accounting, nothing more. | |
| --- | |
| ## Phase 11 — adaptive precision (MEASURED) | |
| `experiments/exp_precision.py`. All four regimes the brief distinguishes, each | |
| validated numerically against the FP32 reference (memory-config checkpoint, | |
| 48 positions, 6 steps). | |
| **A. Static activation precision** (one level everywhere): | |
| | level | top-1 agreement | KL from FP32 | max logit error | | |
| |---|---:|---:|---:| | |
| | bf16 | 1.000 | 0.00000 | 0.0003 | | |
| | fp16 | 1.000 | 0.00000 | 0.0000 | | |
| | int8 | 1.000 | 0.00000 | 0.0011 | | |
| | int4 | 1.000 | 0.00001 | 0.0253 | | |
| | int3 | 0.979 | 0.00011 | 0.0694 | | |
| | int2 | 0.812 | 0.00217 | 0.3584 | | |
| **A2. Static weight quantisation** — much less forgiving than activations: | |
| | level | top-1 | mean weight rel. error | | |
| |---|---:|---:| | |
| | int8 | 1.000 | 0.005 | | |
| | int4 | 0.938 | 0.086 | | |
| | int3 | 0.688 | 0.200 | | |
| | int2 | **0.062** | 0.594 | | |
| **B. Operation-dependent:** int3/int4 on local+MLP with FP32 attention keeps | |
| top-1 at 1.000 — i.e. precision can be spent unevenly across operations for free. | |
| **C. Step-dependent:** int3 for steps < 3 then FP32 keeps top-1 at 1.000 | |
| (KL 0.00007). Early recurrent steps tolerate very low precision. | |
| **D. Position-dependent:** positions split at the median policy entropy. | |
| int3 on *easy* positions → top-1 **1.000**; the same int3 on *hard* positions → | |
| top-1 **0.958**. **Cheap precision demonstrably costs more on hard positions**, | |
| which is the empirical justification for making precision adaptive rather than | |
| static. | |
| ### Honest caveat on speed | |
| This is *fake* quantisation (quantise→dequantise in FP32), so it measures | |
| **numerical** behaviour, not throughput. Wall-clock was measured anyway and | |
| confirms there is no speedup: bf16 144 ms, int3 156 ms, fp32 168 ms, int4 | |
| 175 ms per forward — differences are noise, not arithmetic savings. Real gains | |
| would need INT kernels (fbgemm/oneDNN). **Claiming a speed benefit here would be | |
| fabrication; none is claimed.** | |
| --- | |
| ## THE CENTRAL EXPERIMENT — does inference-time compute buy strength? (MEASURED) | |
| `experiments/exp_compute_scaling.py`. One checkpoint (memory config, **92,430 | |
| params**, trained at 4 recurrent steps). **No weight is changed.** Only the | |
| number of recurrent steps at inference varies. 30 games per match, colours | |
| balanced. | |
| ### Deeper vs shallower, identical weights | |
| | match | score for deeper | 95% CI | Elo | | |
| |---|---:|---|---:| | |
| | 2 vs 1 | 0.700 | [0.52, 0.83] | **+147** | | |
| | 4 vs 1 | 0.800 | [0.63, 0.90] | **+241** | | |
| | **8 vs 1** | **0.967** | [0.83, 0.99] | **+585** | | |
| | 8 vs 2 | 0.633 | [0.46, 0.78] | +95 | | |
| | 8 vs 4 | 0.583 | [0.41, 0.74] | +58 | | |
| | 16 vs 8 | **0.300** | [0.17, 0.48] | **−147** | | |
| | 16 vs 1 | 0.533 | [0.36, 0.70] | +23 | | |
| Latency scales as expected: 6.17 ms/position at 8 steps, 12.76 ms at 16 steps. | |
| Illegal probability mass is **exactly 0.0** at every depth. | |
| ### Verdict | |
| > **Supported, with a ceiling.** The same 92,430 parameters play ~585 Elo | |
| > stronger purely by thinking 8 steps instead of 1. This is the core claim of | |
| > the project and it is measured, not asserted. | |
| > | |
| > **But depth does not extrapolate indefinitely.** Strength peaks at 8 steps | |
| > and *degrades* at 16 (−147 Elo vs 8 steps). The model was trained at 4 steps; | |
| > it generalises to roughly 2x its training depth and then drifts out of | |
| > distribution — consistent with the monotonically growing |h| seen in the | |
| > traces (no normalisation pins the state's scale across unrolled depth). | |
| This makes the honest version of the headline: *inference-time computation is a | |
| real and large source of capability for a fixed tiny parameter set, within a | |
| band around the trained depth* — not an unbounded substitute for parameters. | |
| ### The strength ceiling is still low | |
| At every depth the model scores 0.98–1.00 vs random-legal but only | |
| **0.08–0.18 vs the one-ply material heuristic**. Extra thinking does not | |
| rescue a policy that has not yet learned material value on ~1,000 self-play | |
| games. Depth amplifies what was learned; it does not replace training. | |
| ## Component status | |
| | component | status | | |
| |---|---| | |
| | board encoding, canonicalisation, move packing | **TESTED** | | |
| | legal-move masking, sampling | **TESTED** — illegal prob mass exactly 0.0 in every run | | |
| | self-play env + all termination rules | **TESTED** | | |
| | replay buffer (FEN-based, ~60 B/position) | **TESTED** (used in all runs) | | |
| | REINFORCE + value baseline | **MEASURED** — learns, then collapses (run 1) | | |
| | PPO clip + adaptive entropy control | **MEASURED** — stable, +512 Elo (run 2) | | |
| | checkpoint / resume / HF push | **TESTED** (bit-exact incl. RNG) | | |
| | shared recurrent core | **MEASURED** — depth is ~free in params, +585 Elo at inference | | |
| | compositional move embeddings | IMPLEMENTED; ablation **NOT RUN** | | |
| | persistent memory (8 slots) | **MEASURED — no reliable effect** vs param-matched control | | |
| | move-latent refinement | IMPLEMENTED; ablation **NOT RUN** | | |
| | adaptive halting (ACT) | IMPLEMENTED, gradient flow verified; self-play ablation **NOT RUN** | | |
| | learned routing | IMPLEMENTED, traces recorded; ablation **NOT RUN** | | |
| | trajectory observability | **TESTED** via `inspect_trace.py` | | |
| | thought transcription | **MEASURED — trajectory access gives no advantage** | | |
| | adaptive precision | **MEASURED** (numerics); no speedup, and none claimed | | |
| | structural plasticity | **TESTED** (prune/spawn with intervention evidence); strength effect **NOT MEASURED** | | |
| | diffusion refinement | **NOT IMPLEMENTED** | | |
| | Gradio console | IMPLEMENTED; blocked by Space billing | | |
| --- | |
| ## Summary of measured findings | |
| **Positive, reproducible:** | |
| 1. A 79.6K-parameter recurrent net trained purely by self-play from random init | |
| reaches **+512 Elo** over its own frozen initialisation (`runs/p4_ppo`), | |
| with entropy stable and zero illegal moves. | |
| 2. **Inference-time computation is a large, real source of strength**: | |
| the same weights play **+585 Elo** stronger at 8 recurrent steps than at 1. | |
| This is the project's central hypothesis and it is supported. | |
| 3. Depth costs almost nothing in parameters (+512 params for 1→24 steps). | |
| **Negative / corrective — kept because they are the actual findings:** | |
| 4. Plain REINFORCE **collapses** (entropy → 0.07, all games repetition-draws, | |
| gradient norm → 108, +338 Elo → −9). Cause diagnosed; PPO + adaptive entropy | |
| fixes it. | |
| 5. **Memory shows no reliable benefit** against a parameter-matched control | |
| (seeds disagree: −21 and +173 Elo). The unmatched ablation would have given | |
| a confident wrong answer. | |
| 6. **Trajectory access gives the thought decoder no advantage** over seeing only | |
| the final state (−0.032 accuracy). Argues against the transcription premise | |
| in this regime. | |
| 7. **Depth does not extrapolate**: strength peaks at 8 steps and degrades at 16. | |
| 8. Fake quantisation gives **no CPU speedup** (measured, not assumed). | |
| **The honest ceiling:** every model beats random-legal (0.94–1.00) but loses | |
| badly to a one-ply material heuristic (0.06–0.18). On ~1,000 self-play games at | |
| 2 vCPU the system has not learned material value. Nothing here should be read | |
| as "a strong chess engine"; it is a correct, measured research substrate. | |
| ## Reproducing | |
| ```bash | |
| python tests/test_phase1.py # 12 correctness gates | |
| python train_selfplay.py --config baseline --iters 200 --games 48 --threads 2 \ | |
| --eval-every 10 --out runs/mine | |
| python experiments/exp_ablation.py --arms memory,wide --seeds 0,1 --iters 30 | |
| python experiments/exp_compute_scaling.py --checkpoint runs/mine/latest.pt | |
| python experiments/exp_faithfulness.py --checkpoint runs/mine/latest.pt | |
| python experiments/exp_precision.py --checkpoint runs/mine/latest.pt | |
| python inspect_trace.py --checkpoint runs/mine/latest.pt --steps 8 | |
| ``` | |
| --- | |
| ## Conversion failure: why the "material heuristic ceiling" was partly an artifact | |
| **Status: MEASURED.** Artifacts: `runs/material_recheck.json`, | |
| `runs/conversion_failure.json`. | |
| ### Finding 1 — the ceiling moved, and the old number was adjudication-dependent | |
| Earlier the project reported that every checkpoint "loses badly to a one-ply | |
| material heuristic" (0.083-0.183). Re-measured on a grid-trained baseline | |
| (85 iters, 96 games/iter) with 40 colour-balanced games: | |
| | checkpoint | opponent | adjudicate | score | CI95 | W-D-L | | |
| |---|---|---|---|---|---| | |
| | grid baseline_s0 | material | material | **0.825** | [0.68, 0.91] | 27-12-1 | | |
| | grid baseline_s0 | material | none | **0.512** | [0.36, 0.66] | 1-39-0 | | |
| | grid baseline_s0 | random | material | 0.812 | [0.67, 0.90] | 25-15-0 | | |
| | grid baseline_s0 | random | none | 0.525 | [0.37, 0.67] | 2-38-0 | | |
| | old abl_memory_s0 | material | material | 0.212 | [0.11, 0.36] | 1-15-24 | | |
| | old abl_memory_s0 | material | none | 0.463 | [0.32, 0.61] | 0-37-3 | | |
| Two distinct conclusions, both important: | |
| 1. **More self-play genuinely helped.** Under the *same* `adjudicate=material` | |
| protocol the old checkpoint scored 0.212 and the grid checkpoint scores | |
| **0.825** against the same opponent. That is a real improvement. | |
| 2. **But the headline number is protocol-dependent.** Switch off material | |
| adjudication and the same grid checkpoint drops to **0.512** — statistically | |
| indistinguishable from a draw. The model wins on *adjudicated material*, not | |
| by actually winning games. | |
| **Any future strength claim must state its adjudication mode.** Reporting only | |
| the `adjudicate=material` figure would have overstated capability. | |
| ### Finding 2 — the real deficiency is conversion, not material | |
| Games were run to 400 plies with `adjudicate=none` to rule out truncation. | |
| Mean game length settles at ~200 plies even with a 600-ply cap, so these are | |
| genuine rule-draws, not unfinished games. Termination breakdown, 30 games each: | |
| | checkpoint | opponent | model mates | stalemates | dominant terminations | | |
| |---|---|---:|---:|---| | |
| | grid baseline_s0 | random | 1 | 6 | repetition 13, max_plies 9 | | |
| | grid baseline_s0 | material | 1 | 1 | repetition 14, insufficient_material 8 | | |
| | grid mlp_s0 | random | 4 | **15** | stalemate 15 | | |
| | grid mlp_s0 | material | 0 | 2 | **insufficient_material 19** | | |
| | old memory_s0 | random | 3 | **11** | stalemate 11 | | |
| | old memory_s0 | material | 0 | **10** | insufficient_material 17 | | |
| **In every case the stalemate count meets or exceeds the checkmate count.** | |
| Against the material opponent the models trade down into | |
| `insufficient_material` (8/30, 19/30, 17/30). | |
| **Interpretation (MEASURED for the symptom, HYPOTHESISED for the cause):** the | |
| models learn to *win material* but not to *convert it into mate*. They stalemate | |
| the opponent king or liquidate into dead-drawn endings. This is consistent with | |
| outcome-only rewards plus material adjudication: if a run is scored on material, | |
| there is no gradient pressure to find mate, and stalemate is not penalised | |
| relative to a draw. | |
| **This reframes the central research question.** The earlier "model loses to a | |
| one-ply heuristic" ceiling is better described as: *the model reaches material | |
| superiority and then cannot finish.* Whether additional inference-time compute | |
| helps conversion is a separate question from whether it helps move selection. | |
| **NEGATIVE RESULT retained:** the `adjudicate=material` protocol used throughout | |
| the earlier ablations (including Phase 5) inflates apparent strength relative to | |
| playing games out. The Phase 5 conclusion (memory unsupported under a | |
| param-matched control) is unaffected, because both arms used the same protocol, | |
| but absolute numbers from those runs should not be read as playing strength. | |
| --- | |
| ## Inference-depth clamp: a bug that invalidates naive depth sweeps | |
| **Status: MEASURED (bug), artifact `runs/fixed_point_probe.json`.** | |
| `TinyChess.forward` contains: | |
| ```python | |
| n = steps if steps is not None else cfg.max_steps | |
| n = max(cfg.min_steps, min(n, cfg.max_steps)) # <-- hard clamp | |
| ``` | |
| **Any request for more steps than `cfg.max_steps` is silently reduced.** A | |
| checkpoint trained with `max_steps=4` asked for 8, 16 or 32 steps runs exactly 4. | |
| Evidence — mean legal-move logit by requested depth, weights fixed: | |
| | depth | memory_s0 | delta vs prev | grid baseline_s0 | delta vs prev | | |
| |---|---|---|---|---| | |
| | 1 | -0.33759 | -- | -0.46984 | -- | | |
| | 2 | -0.36971 | 5.16e-02 | -0.53486 | 1.03e-01 | | |
| | 4 | -0.38203 | 4.41e-02 | -0.52344 | 5.22e-02 | | |
| | 8 | -0.38203 | **0.00e+00** | -0.52344 | **0.00e+00** | | |
| | 16 | -0.38203 | **0.00e+00** | -0.52344 | **0.00e+00** | | |
| | 32 | -0.38203 | **0.00e+00** | -0.52344 | **0.00e+00** | | |
| Output beyond depth 4 is **bit-identical**. Additionally `step_emb` row 4 — the | |
| row the clamp would reuse for all deeper steps — is **exactly zero** (never | |
| trained); the trained rows have norms of only ~0.03-0.08. | |
| ### Does this invalidate the central compute-scaling result? NO. | |
| `experiments/exp_compute_scaling.py` already lifts the cap before evaluating: | |
| it grows `step_emb` to `max(steps)+1`, copies the last trained row into the new | |
| rows, and raises `model.cfg.max_steps`. With the cap lifted the output does keep | |
| changing with depth (delta 2.0e-01 at d=8, 5.7e-01 at d=16), so the | |
| **+585 Elo at 8 steps result was measured on a model genuinely running 8 steps** | |
| and stands. | |
| ### What it did invalidate | |
| 1. **My first run of `exp_step_embedding.py` was meaningless** — it lacked the | |
| cap lift, so `clamped`, `zeroed` and `frozen` produced identical entropy, | |
| top-1 probability and depth-1 agreement at every depth >= 4, because all three | |
| were running exactly 4 steps. The apparent "no difference between conditions" | |
| was an artifact. Fixed by lifting the cap; experiment re-running. | |
| 2. **A reporting bug:** `exp_compute_scaling.py` mutates `model.cfg` in place and | |
| `cfg` is the same object, so it recorded `"trained_steps": 16` when the | |
| checkpoint was actually trained at `max_steps=4`. The session notes and the | |
| status report inherited this error. **The correct trained depth for | |
| `runs/compute_scaling_memory.json` is 4, not 16.** The measured scores are | |
| unaffected; only the "trained at" label was wrong. | |
| **Consequence for interpretation — this makes the headline result stronger, not | |
| weaker.** The +585 Elo at 8 steps was obtained from a model trained at only | |
| **4** steps, i.e. it is genuine extrapolation beyond the training depth, and the | |
| degradation at 16 occurs at 4x the trained depth rather than at the trained depth. | |
| **Lesson recorded:** any depth sweep must assert that the model actually ran the | |
| requested number of steps. A cheap invariant — consecutive-depth output delta | |
| must be non-zero — would have caught this immediately. | |
| ### Blast radius of the clamp bug across existing results | |
| Audited every script in `experiments/` for "requests depth > trained max_steps | |
| without lifting the cap": | |
| | script | depth sweep | lifts cap | affected result | | |
| |---|---|---|---| | |
| | `exp_compute_scaling.py` | yes | **yes** | none — central result is valid | | |
| | `exp_step_embedding.py` | yes | **yes** (after fix) | first run invalid, re-run | | |
| | `exp_faithfulness.py` | `steps=6` on `max_steps=4` | no | ran 4 steps, not 6 | | |
| | `exp_precision.py` | `steps=6` on `max_steps=4` | no | ran 4 steps, not 6 | | |
| | `exp_ablation.py` | `--steps 4` | no | unaffected (4 <= 4) | | |
| | `exp_learning.py` | `--steps 4` | no | unaffected | | |
| | `analyse_grid.py` | `--steps 4` | no | unaffected | | |
| **Phase 9 (faithfulness) and Phase 11 (precision) each requested 6 steps and | |
| silently got 4.** Their conclusions are not necessarily wrong, but they describe | |
| a 4-step model while claiming 6. Specifically: | |
| - Phase 9's finding (gradient isolation proven; trajectory advantage -0.032) was | |
| computed over a 4-step trajectory h0..h4, not h0..h6. The gradient-isolation | |
| result (0 leaked chess params) is structural and unaffected. The trajectory | |
| advantage number should be relabelled as 4-step. | |
| - Phase 11's precision conclusions were measured at 4 steps. The | |
| *step-dependent* schedule result ("int3 early then fp32 late -> 1.000") is | |
| therefore over 4 steps, and the "step-dependent" claim is weaker than it | |
| appeared because there were fewer steps to vary over than reported. | |
| **Neither is being deleted or quietly corrected — both are relabelled here.** | |
| Re-running them at a genuinely lifted depth is on the outstanding list. | |
| --- | |
| ## Phase 7 — adaptive halting: MEASURED, and it is a NEGATIVE RESULT | |
| Artifact: `runs/halting_routing.json`. Checkpoint `runs/grid/halting_s0/latest.pt` | |
| (85 iters self-play), `use_halting=True`, `max_steps=4`, `min_steps=1`, | |
| `halt_threshold=0.95`, 128 held-out positions. | |
| ### Step distribution — degenerate | |
| | statistic | value | | |
| |---|---| | |
| | mean steps | **2.000** | | |
| | median | 2.0 | | |
| | p90 | 2.0 | | |
| | p95 | 2.0 | | |
| | min / max | 2 / 2 | | |
| | std | **0.000** | | |
| | histogram | `{2: 128}` | | |
| | allowed max | 4 | | |
| **Every single position halts at exactly 2 steps.** Correlation between halting | |
| depth and all four difficulty proxies (`n_legal`, `abs_material`, `n_pieces`, | |
| `in_check`) is **exactly +0.000** — necessarily, since the output is constant. | |
| The ACT mechanism is IMPLEMENTED and runs, but it **has not learned | |
| position-dependent computation**. It collapsed to a constant depth. | |
| ### Strength — adaptive is WORSE than a fixed depth | |
| Same weights, 30 colour-balanced games, `adjudicate=material`: | |
| | opponent | adaptive | fixed 1 | fixed 2 | fixed 4 | | |
| |---|---|---|---|---| | |
| | random_legal | 0.900 [0.74,0.97] | 0.900 [0.74,0.97] | 0.800 [0.63,0.90] | 0.883 [0.72,0.96] | | |
| | material_greedy | **0.333** [0.19,0.51] | 0.283 [0.15,0.46] | 0.450 [0.29,0.62] | **0.567** [0.39,0.73] | | |
| Against the material opponent, adaptive halting (0.333) is beaten by simply | |
| running a fixed 4 steps (0.567). The CIs overlap, so this is suggestive rather | |
| than conclusive, but there is **no evidence that halting helps**, and the point | |
| estimate is worse than the fixed control it is supposed to improve on. | |
| **Conclusion (MEASURED): adaptive halting did not learn to allocate computation | |
| and costs strength relative to fixed-depth inference. Retained as a negative | |
| result.** Note the interaction with the clamp bug: `max_steps=4` gives ACT very | |
| little room to vary. Whether halting behaves differently with a genuinely larger | |
| budget is HYPOTHESISED, not tested. | |
| --- | |
| ## Phase 8 — learned routing: MEASURED, and it WORKED | |
| Artifact: `runs/halting_routing.json`. Checkpoint `runs/grid/router_s0/latest.pt`, | |
| `use_router=True`, `router_temp=1.0`, `topk=0`, ops = `[attn, local, mlp]`, | |
| 128 positions. | |
| ### The router specialised | |
| | op | mean weight | uniform would be | | |
| |---|---|---| | |
| | attn | 0.2708 | 0.3333 | | |
| | local | 0.2413 | 0.3333 | | |
| | **mlp** | **0.4878** | 0.3333 | | |
| max deviation from uniform **0.1545**; routing entropy **1.0470** vs max 1.0986. | |
| This is a real departure from uniform, not noise: across-position std is | |
| attn 0.0583, mlp 0.0640 (local 0.0305), so weights genuinely vary by position. | |
| ### Routing is position-dependent in an interpretable way | |
| Correlation of each op's weight with difficulty proxies: | |
| | op | n_legal | abs_material | **n_pieces** | in_check | | |
| |---|---:|---:|---:|---:| | |
| | attn | +0.178 | +0.570 | **-0.894** | +0.135 | | |
| | local | -0.032 | +0.059 | +0.051 | -0.150 | | |
| | mlp | -0.147 | -0.548 | **+0.791** | -0.052 | | |
| **The router uses global attention more as the board empties (-0.894 with piece | |
| count) and the MLP more when the board is crowded (+0.791).** It also leans on | |
| attention when material is imbalanced (+0.570). | |
| This was **not hard-coded** — no chess semantics were given to the router; the | |
| op set is generic (`attn`/`local`/`mlp`) and the correlation is a post-hoc | |
| measurement against proxies the model never saw. This satisfies the brief's | |
| requirement to let the model discover routing roles rather than assigning them. | |
| ### Drift across recurrent steps | |
| | step | attn | local | mlp | | |
| |---|---:|---:|---:| | |
| | 0 | 0.304 | 0.208 | 0.488 | | |
| | 1 | 0.273 | 0.237 | 0.490 | | |
| | 2 | 0.257 | 0.255 | 0.487 | | |
| | 3 | 0.249 | 0.266 | 0.485 | | |
| Attention usage falls and local mixing rises monotonically with depth while the | |
| MLP stays flat — an early-global / late-local pattern. | |
| **Status: MEASURED.** Routing decisions are now recorded and analysed, not merely | |
| implemented. **Playing-strength impact of routing is NOT established here** — that | |
| requires the cross-arm head-to-head (router vs baseline), which is pending. | |
| --- | |
| ## Step-embedding extrapolation vs genuine saturation: HYPOTHESIS REFUTED | |
| Artifact: `runs/step_embedding_memory.json`. Script | |
| `experiments/exp_step_embedding.py`. Checkpoint `runs/abl_memory/memory_s0` | |
| (92,430 params, **trained at max_steps=4**), weights FIXED and identical in every | |
| condition, 30 colour-balanced games per cell, temperature 0.3, | |
| `adjudicate=material`, same seed and opponent throughout. Only inference depth | |
| and the step-embedding policy vary. | |
| ### The hypothesis under test | |
| The core applies `step_emb[min(step, max_steps)]`, so every step beyond the | |
| trained depth reuses the last trained row. **Hypothesis B:** the degradation at | |
| depth 16 is caused by this out-of-distribution step signal, not by the recurrence | |
| itself. **Hypothesis A:** the recurrence genuinely saturates and then degrades. | |
| ### Conditions (all at identical weights) | |
| | condition | step embedding | | |
| |---|---| | |
| | `clamped` | stock: `step_emb[min(step, max_steps)]` | | |
| | `zeroed` | all rows zeroed -> genuinely step-invariant core | | |
| | `frozen` | every step uses one fixed in-distribution row (k = trained max) | | |
| If B were true, `zeroed` and `frozen` should keep improving where `clamped` | |
| degrades. | |
| ### Result vs one-ply material heuristic | |
| | depth | clamped | zeroed | frozen | | |
| |---|---|---|---| | |
| | 1 | 0.150 | 0.100 | 0.067 | | |
| | 2 | 0.217 | 0.150 | 0.133 | | |
| | 4 | 0.200 | 0.217 | 0.217 | | |
| | **8** | **0.267** | **0.317** | **0.300** | | |
| | 16 | 0.167 | 0.117 | 0.150 | | |
| | 32 | 0.083 | 0.150 | 0.167 | | |
| Pooled across the three embedding modes (90 games per depth), which is the | |
| statistically meaningful view: | |
| | depth | score | CI95 | W-D-L | | |
| |---|---|---|---| | |
| | 1 | 0.106 | [0.057, 0.186] | 0-19-71 | | |
| | 2 | 0.167 | [0.104, 0.257] | 2-26-62 | | |
| | 4 | 0.211 | [0.140, 0.306] | 2-34-54 | | |
| | **8** | **0.294** | **[0.210, 0.395]** | 5-43-42 | | |
| | 16 | 0.144 | [0.086, 0.232] | 0-26-64 | | |
| | 32 | 0.133 | [0.078, 0.219] | 1-22-67 | | |
| ### Conclusion: HYPOTHESIS B IS REFUTED (MEASURED) | |
| **All three conditions show the same inverted-U with a peak at depth 8 and | |
| degradation at 16 and 32.** Removing the step embedding entirely (`zeroed`) does | |
| not rescue deep inference — it peaks at 8 (0.317) and collapses at 16 (0.117) | |
| exactly like the stock model. `frozen`, which supplies a purely in-distribution | |
| step signal at every depth, behaves the same way (0.300 at 8, 0.150 at 16). | |
| Therefore the degradation is **NOT** caused by out-of-distribution step | |
| embeddings. This is consistent with Hypothesis A: the recurrent core genuinely | |
| saturates around 8 iterations and then drifts away from a useful state. It is | |
| also consistent with the measured magnitudes of the step embedding itself — the | |
| trained rows have norms of only ~0.03 and the row used for all deep steps is | |
| **exactly zero**, so the embedding was never a strong enough signal to explain | |
| the effect. | |
| **The earlier "extrapolated step_emb is a likely cause" note in this log is | |
| withdrawn and replaced by this measurement.** | |
| ### Secondary confirmation of the central hypothesis | |
| The depth-8 pooled score (0.294, CI [0.210, 0.395]) does not overlap the depth-1 | |
| pooled score (0.106, CI [0.057, 0.186]). At **identical weights**, running the | |
| recurrent core 8 times instead of once produces a statistically separated | |
| improvement against a fixed external opponent. This reproduces the central | |
| compute-scaling result on a different opponent and a different protocol, and it | |
| does so on a model trained at only 4 steps — i.e. genuine extrapolation beyond | |
| training depth. | |
| **Remaining open question (HYPOTHESISED):** the saturation point may itself be a | |
| function of training depth. A model trained with a larger step budget might peak | |
| later than 8. Testing that requires training a checkpoint at higher `max_steps`, | |
| which is a training experiment, not an inference-time one. | |
| --- | |
| ## Phase 9 re-run on a router-enabled checkpoint, at a genuinely lifted depth | |
| Artifact: `runs/faith_router_s0.json`. Checkpoint `runs/grid/router_s0/latest.pt` | |
| (85 iters), `--steps 8` with the step cap explicitly lifted 4 -> 8 (the original | |
| Phase 9 run asked for 6 steps and silently got 4; see the clamp-bug section). | |
| | measurement | original (baseline_s0, effective 4 steps) | re-run (router_s0, true 8 steps) | | |
| |---|---|---| | |
| | leaked chess params | **0** | **0** | | |
| | decoder params | 27,036 | 27,036 | | |
| | majority baseline | 0.7992 | 0.7758 | | |
| | informative events | -- | 9/12 | | |
| | trajectory decoder (informative) | 0.9603 | 0.9248 | | |
| | final-state-only (informative) | 0.9924 | 0.9338 | | |
| | **trajectory advantage** | **-0.032** | **-0.009** | | |
| **Both conclusions replicate (MEASURED):** | |
| 1. **Gradient isolation holds.** `leaked=0` on a different architecture (router | |
| enabled) at a different depth. The thought channel provably cannot influence | |
| chess play — this satisfies the brief's stop-gradient requirement and is now | |
| verified twice, independently. | |
| 2. **The trajectory still provides no decoding advantage** (-0.009). Reading the | |
| full h0..h8 sequence is no better than reading the final state h_n alone. The | |
| effect is smaller than before but still negative, i.e. in the same direction. | |
| Supporting intervention evidence on the router checkpoint: ablating individual | |
| latent channels changes decoded events by 3.7-5.4%; a board counterfactual moves | |
| the trajectory by L2 62.28; forcing an early halt shortens the event sequence | |
| 9 -> 5 steps. So the decoder is genuinely reading the model's state, not | |
| confabulating — it simply gains nothing from the history. | |
| **NEGATIVE RESULT retained and now replicated across two checkpoints and two | |
| depths.** | |
| --- | |
| ## Phase 10 — plasticity: mechanism vs structure vs STRENGTH, finally separated | |
| Artifact: `runs/plasticity_strength.json`, script | |
| `experiments/exp_plasticity_strength.py`. Checkpoint `runs/grid/baseline_s0` | |
| (85 iters). Prune-only (`--max-spawn 0`) so any strength delta is attributable to | |
| pruning alone. Pruning used **intervention evidence** (temporarily deactivate the | |
| unit, re-measure, keep the prune only if loss does not worsen beyond tolerance), | |
| never average activation. | |
| ### Claim 1 — mechanism runs: CONFIRMED | |
| `{pruned: 8, spawned: 0, kept_after_intervention: 0, active_units: 120/128, | |
| active_parameters: 78,598, max_parameters: 79,630}` | |
| ### Claim 2 — structure changes: CONFIRMED | |
| | | before | after | | |
| |---|---|---| | |
| | active units | 128/128 | **120/128** | | |
| | active params | 79,630 | **78,598** | | |
| | max (structural capacity) | 79,630 | 79,630 | | |
| 8 units removed, 1,032 parameters deactivated, fixed capacity respected. | |
| ### Claim 3 — playing strength changes: NO EVIDENCE (the new result) | |
| 60 colour-balanced games per cell, `adjudicate=material`, same seed: | |
| | opponent | control | treatment (pruned) | delta | | |
| |---|---|---|---| | |
| | random_legal | 0.842 [0.73,0.91] | 0.850 [0.74,0.92] | +0.008 | | |
| | material_greedy | 0.708 [0.58,0.81] | 0.642 [0.52,0.75] | -0.066 | | |
| Direct head-to-head, pruned model vs its own unpruned self: | |
| **0.533 for the pruned model, CI95 [0.41, 0.65] — NOT SIGNIFICANT.** | |
| **Conclusion (MEASURED): removing 8 of 128 hidden units (1.3% of parameters) | |
| produces no detectable change in playing strength.** The CI comfortably contains | |
| 0.5 and the two reference-opponent deltas point in opposite directions. | |
| **This is the honest reading:** the plasticity mechanism works and provably | |
| changes the network's structure, but there is **no evidence it improves play**, | |
| and equally no evidence it harms play. The correct summary is *"the model has | |
| ~6% redundant capacity that can be removed for free"*, not *"plasticity helps"*. | |
| Mechanical success has NOT been converted into a strength claim, per the brief. | |
| ### Phase 7/8 replication on the `full` arm | |
| Artifact: `runs/halting_routing_full.json`. Checkpoint `runs/grid/full_s0` | |
| (all features enabled: memory + refine + halting + router), 128 positions. | |
| **Halting is degenerate again — and in a different place.** Every one of 128 | |
| positions halts at exactly **4** steps (mean 4.0, median 4.0, p90 4.0, p95 4.0, | |
| std **0.000**, histogram `{4: 128}`). On `halting_s0` the constant was 2; here it | |
| is 4 (the maximum). Correlation with all difficulty proxies is exactly +0.000. | |
| So across two independent checkpoints, ACT collapses to a constant depth — once | |
| to the floor-ish value 2, once to the ceiling value 4. **It never learned | |
| position-dependent computation.** Strength is again not improved by adaptivity: | |
| vs material, adaptive 0.667 [0.49,0.81] vs fixed-1 0.650 [0.47,0.79] — overlapping. | |
| **The router on the `full` arm essentially switched the memory op OFF.** | |
| | op | mean weight | uniform | | |
| |---|---:|---:| | |
| | mlp | **0.8278** | 0.25 | | |
| | attn | 0.1103 | 0.25 | | |
| | local | 0.0593 | 0.25 | | |
| | **mem** | **0.0026** | 0.25 | | |
| Routing entropy 0.5825 of a possible 1.3863 — strongly specialised. The memory | |
| read/write operation receives **0.26% of the routing weight, ~100x below | |
| uniform**. | |
| **This is independent corroboration of the Phase 5 negative result.** Phase 5 | |
| showed that memory slots give no strength benefit once parameters are matched. | |
| Here a *learned* router, given free choice over four operations and no | |
| supervision about their roles, converges to almost never using memory. Two | |
| different methods, same conclusion: **the memory mechanism is not earning its | |
| place in this model.** | |
| --- | |
| ## Grid results part 1: strength vs fixed references (MEASURED) | |
| Artifact: `runs/grid_analysis.json` (in progress), script | |
| `experiments/analyse_grid.py`. 9 arms x 3 seeds, each trained ~85 iterations of | |
| self-play from random init (96 games/iter). 60 colour-balanced games per arm-seed | |
| per opponent = **180 games per cell**, `adjudicate=material`, steps=4. | |
| | arm | vs random-legal | vs one-ply material | per-seed (material) | | |
| |---|---:|---:|---| | |
| | router | 0.856 | **0.706** | [0.63, 0.71, 0.78] | | |
| | memory | 0.847 | **0.681** | [0.68, 0.68, 0.68] | | |
| | baseline | 0.861 | 0.664 | [0.65, 0.61, 0.73] | | |
| | full | 0.839 | 0.664 | [0.61, 0.72, 0.66] | | |
| | refine | 0.900 | 0.661 | [0.66, 0.62, 0.71] | | |
| | transformer | 0.906 | 0.644 | [0.66, 0.68, 0.59] | | |
| | wide | 0.864 | 0.636 | [0.68, 0.59, 0.64] | | |
| | mlp | 0.881 | 0.619 | [0.48, 0.69, 0.68] | | |
| | halting | 0.878 | 0.603 | [0.49, 0.60, 0.72] | | |
| ### The headline: the material ceiling is broken | |
| Every arm now **beats** the one-ply material heuristic (0.603-0.706), where the | |
| earlier ~1,000-game checkpoints scored 0.083-0.212 against the same opponent | |
| under the same protocol. More self-play was the binding constraint for that | |
| particular ceiling, and the project's earlier "cannot learn material value" | |
| statement is now **superseded**. | |
| ### But the ranking is not trustworthy, and I am not claiming a winner | |
| 1. **The two reference opponents disagree.** `transformer` is 1st vs random | |
| (0.906) but 6th vs material (0.644); `router` is 7th vs random (0.856) but | |
| 1st vs material (0.706). A single-opponent ranking would be an artifact. | |
| 2. **All CIs overlap heavily.** The vs-material spread is 0.603-0.706 with CIs | |
| roughly +/-0.07 -- top and bottom are ~1.5 CI widths apart at best. | |
| 3. **Seeds are noisy within arms.** `mlp` ranges 0.48-0.69, `halting` 0.49-0.72 | |
| across seeds -- wider than the entire between-arm spread. | |
| **No arm is declared superior on this evidence.** The cross-arm head-to-head with | |
| the seed-consistency filter (`experiments/summarise_grid.py`) is the deciding | |
| test, and only differences that are both pooled-significant *and* consistent in | |
| direction across all three seeds will be reported as credible. | |
| --- | |
| ## CENTRAL QUESTION RE-TESTED: does compute scaling get stronger once the model has learned chess? | |
| **Answer: NO. It gets WEAKER. (MEASURED, and this is a negative result for the | |
| project's most attractive hypothesis.)** | |
| Artifacts: `runs/compute_scaling_router_s0.json`, | |
| `runs/compute_scaling_memory_grid.json`. Both on grid checkpoints trained ~85 | |
| iterations (96 games/iter) that **do** beat the one-ply material heuristic, | |
| unlike the original ~1,000-game checkpoint. 60 games per match, weights fixed, | |
| only inference depth varies. | |
| ### Deeper-vs-1-step score, three checkpoints of increasing skill | |
| | match | weak ckpt (old, loses to material) | router_s0 (0.706 vs material) | memory_s0 grid (0.681) | | |
| |---|---:|---:|---:| | |
| | 2 vs 1 | 0.700 | 0.542 | 0.600 | | |
| | 4 vs 1 | 0.800 | **0.700** | 0.592 | | |
| | 8 vs 1 | **0.967** | 0.683 | 0.533 | | |
| | 16 vs 1 | 0.533 | 0.467 | 0.375 | | |
| | 32 vs 1 | -- | 0.400 | 0.258 | | |
| | checkpoint | peak gain over 1 step | peak depth | | |
| |---|---|---| | |
| | weak (~1,000 games) | **+0.467** | 8 | | |
| | router_s0 (85 iters) | +0.200 | 4 | | |
| | memory_s0 (85 iters) | +0.100 | 2 | | |
| **The better the model plays, the less extra inference-time computation helps, | |
| and the earlier the peak arrives.** | |
| ### Absolute strength still shows the effect, and it is still real | |
| router_s0 vs one-ply material by depth (identical weights): | |
| | steps | 1 | 2 | 4 | 8 | 16 | 32 | | |
| |---|---|---|---|---|---|---| | |
| | score | 0.508 | 0.625 | **0.675** | 0.658 | 0.558 | 0.350 | | |
| memory_s0 grid vs material: 0.783 / 0.633 / 0.700 / **0.833** / 0.592 / 0.217. | |
| So extra depth still buys real strength (router: 0.508 -> 0.675, CIs | |
| [0.39,0.63] vs [0.55,0.78]), and deep inference still collapses at 32 steps in | |
| both checkpoints. The inverted-U is reproduced a third and fourth time, now on | |
| well-trained models, with the same qualitative shape and an earlier peak. | |
| ### Interpretation | |
| **MEASURED:** inference-time compute scaling is real but its magnitude is | |
| *inversely* related to how much the weights already know. On a weak policy, extra | |
| recurrence acts as a substitute for missing knowledge (+0.467). Once the policy | |
| has learned material value, the same mechanism adds much less (+0.100 to +0.200). | |
| **HYPOTHESISED (not tested):** extra recurrent steps mostly sharpen an | |
| under-trained policy toward a decision it would eventually have made anyway, so | |
| the headroom shrinks as training reduces that gap. Distinguishing this from | |
| "the fixed point is reached sooner in a better-trained net" would need a | |
| per-position analysis of how many steps the output takes to stabilise, across | |
| checkpoints of different skill. | |
| **This substantially qualifies the project's headline result.** The +585 Elo at | |
| 8 steps is genuine, but it was measured on a model that could not beat a one-ply | |
| heuristic. Presenting that number as the project's main finding without this | |
| caveat would overstate the case for inference-time compute. | |
| --- | |
| ## Grid results part 2: cross-arm head-to-head with a seed-consistency filter (MEASURED) | |
| Artifacts: `runs/grid_analysis.json`, `runs/grid_verdict.json`, | |
| `docs/GRID_RESULTS.md`. Script `experiments/summarise_grid.py`. | |
| 36 arm pairs, 60 games per arm-pair-seed x 3 seeds = **180 games per pair**. | |
| A difference is called **credible** only if (a) the pooled 95% Wilson CI excludes | |
| 0.5 **and** (b) all three seeds point the same direction. Criterion (b) exists | |
| because of the Phase 5 failure, where pooling two disagreeing seeds would have | |
| implied an effect that does not exist. | |
| - credible differences: **8** | |
| - pooled-significant but seeds disagree (**rejected**): **3** | |
| - not significant: **25** | |
| ### The filter earned its keep | |
| Three pairs would have been reported as real effects by a naive pooled test: | |
| | pair | pooled | CI95 | per-seed | | |
| |---|---|---|---| | |
| | full vs halting | 0.589 | [0.516, 0.658] | [0.667, 0.683, **0.417**] | | |
| | baseline vs full | 0.417 | [0.347, 0.490] | [**0.525**, 0.308, 0.417] | | |
| | full vs refine | 0.581 | [0.508, 0.650] | [**0.500**, 0.625, 0.617] | | |
| Each has one seed on the wrong side of parity. **Rejected.** | |
| ### Credible differences (8) | |
| | pair | score for first | CI95 | per-seed | | |
| |---|---:|---|---| | |
| | full vs mlp | 0.633 | [0.561, 0.700] | [0.675, 0.600, 0.625] | | |
| | full vs wide | 0.622 | [0.550, 0.690] | [0.517, 0.717, 0.633] | | |
| | memory vs mlp | 0.608 | [0.535, 0.677] | [0.767, 0.550, 0.508] | | |
| | full vs transformer | 0.589 | [0.516, 0.658] | [0.533, 0.683, 0.550] | | |
| | router vs wide | 0.589 | [0.516, 0.658] | [0.525, 0.608, 0.633] | | |
| | mlp vs router | 0.381 | [0.313, 0.453] | [0.283, 0.450, 0.408] | | |
| | refine vs router | 0.414 | [0.344, 0.487] | [0.433, 0.342, 0.467] | | |
| | baseline vs router | 0.425 | [0.355, 0.498] | [0.492, 0.375, 0.408] | | |
| ### Credible win-loss record | |
| | arm | params | credible wins | credible losses | beats | | |
| |---|---:|---:|---:|---| | |
| | **router** | **80,017** | **4** | **0** | baseline, mlp, refine, wide | | |
| | full | 99,411 | 3 | 0 | mlp, transformer, wide | | |
| | memory | 92,430 | 1 | 0 | mlp | | |
| | baseline | 79,630 | 0 | 1 | -- | | |
| | refine | 85,966 | 0 | 1 | -- | | |
| | transformer | 207,010 | 0 | 1 | -- | | |
| | wide | 92,530 | 0 | 2 | -- | | |
| | mlp | 103,730 | 0 | 3 | -- | | |
| | halting | 79,759 | 0 | 0 | -- | | |
| ### Conclusions I am willing to defend | |
| 1. **Learned routing is the only research feature with credible, seed-consistent | |
| evidence of benefit.** `router` wins 4 credible matchups and loses none, and | |
| it is the *second-smallest* arm at 80,017 params — only 387 more than the | |
| baseline. It also ranks first vs the material heuristic (0.706). Combined with | |
| the Phase 8 measurement that the router genuinely specialises and its choices | |
| correlate with position sparsity (attn vs n_pieces = -0.894), this is the | |
| project's strongest positive mechanism result. | |
| 2. **Parameters are not the explanation.** `transformer` has 207,010 params — | |
| 2.6x the baseline and well over the 100K budget — and still loses a credible | |
| matchup to `full` (99,411) while ranking 6th vs material. `wide` (92,530) | |
| loses two. Capacity did not buy strength here. | |
| 3. **The non-recurrent `mlp` control is the worst arm** (3 credible losses, last | |
| vs material at 0.619). This is the clearest evidence in the project that the | |
| **recurrent core matters**, which is the architectural premise of the whole | |
| design. | |
| 4. **Adaptive halting is not merely unhelpful, it is the weakest scoring arm** | |
| (0.603 vs material) — consistent with the Phase 7 finding that it collapsed to | |
| a constant depth. | |
| 5. **Memory remains unsupported as a mechanism.** It wins only against `mlp`, | |
| loses to nothing, but never beats its own param-matched control `wide` | |
| (memory vs wide: not significant). This is the third independent line of | |
| evidence against memory, after Phase 5 and the `full`-arm router assigning it | |
| 0.26% weight. | |
| ### What this does NOT show | |
| 25 of 36 pairs are statistically indistinguishable. The arms are separated by at | |
| most ~0.10 in score against a fixed opponent, with CIs of +/-0.07. **The grid | |
| establishes a weak ordering, not a decisive one**, and nothing here should be | |
| read as "feature X is worth N Elo". | |
| --- | |
| ## Main long run: strongest model so far, same conversion failure (MEASURED) | |
| Artifact: `runs/main_eval.json`. `runs/main_baseline` = baseline config, **79,630 | |
| params**, 105 iterations x 256 games = **~26,880 self-play games** from random | |
| init, PPO + adaptive entropy, league opponents every 25 iters. This is ~3x the | |
| games of any grid arm (85 x 96 = 8,160). | |
| 60 colour-balanced games per cell, steps=4: | |
| | opponent | adjudicate=material | adjudicate=none | | |
| |---|---|---| | |
| | random_legal | 0.858 [0.75,0.92] (44-15-1) | 0.508 [0.39,0.63] (1-59-0) | | |
| | material_greedy | **0.817** [0.70,0.89] (40-18-2) | 0.500 [0.38,0.62] (1-58-1) | | |
| Head-to-head against the best grid checkpoints (adjudicate=material): | |
| | match | score | CI95 | Elo | | |
| |---|---|---|---| | |
| | main vs grid router_s0 | **0.758** | [0.64, 0.85] | **+199** | | |
| | main vs grid baseline_s0 | **0.792** | [0.67, 0.88] | **+232** | | |
| ### Two conclusions, pulling in opposite directions | |
| **Positive (MEASURED): self-play scale is the dominant factor.** The main run | |
| beats every grid arm decisively — +199 Elo over `router_s0`, which was the | |
| grid's best. 3x more games produced a larger, cleaner improvement than any | |
| architectural feature in the 9-arm grid, where the entire credible spread was | |
| ~0.10 in score. **Games >> features, at this scale.** | |
| **Negative (MEASURED): more training did not fix conversion.** Played out with | |
| `adjudicate=none`, the strongest model in the project scores **0.500 against the | |
| one-ply material heuristic with 58 of 60 games drawn**, and 0.508 vs random-legal | |
| with 59 of 60 drawn. It wins on adjudicated material and then cannot finish. | |
| This is the same failure documented earlier on weaker checkpoints, now confirmed | |
| at ~27,000 games. **Scaling self-play from ~8,000 to ~27,000 games improved | |
| material acquisition but produced no measurable improvement in checkmate | |
| conversion.** The limitation is therefore unlikely to be "insufficient self-play" | |
| alone. | |
| **HYPOTHESISED causes, none yet tested:** (a) outcome-only reward with material | |
| adjudication never pays for mate, so there is no gradient toward converting; | |
| (b) 4 recurrent steps may be too little to represent a mating net; (c) the | |
| value head learns material, and the policy follows the value head. Distinguishing | |
| these is the single most valuable next experiment, and the `adjudicate=none` | |
| reward ablation is the cheapest probe. | |
| ### Does extra inference depth fix conversion? Partly, but it also causes more stalemates | |
| Artifact: `runs/conversion_vs_depth.json`. Main checkpoint (105 iters), weights | |
| fixed, step cap lifted, `adjudicate=none`, 30 games vs random-legal, 400-ply cap. | |
| | steps | model mates | stalemates | score | dominant terminations | | |
| |---:|---:|---:|---:|---| | |
| | 1 | 0 | 6 | 0.500 | repetition 18 | | |
| | 2 | 2 | 5 | 0.500 | repetition 18 | | |
| | 4 | 1 | 12 | 0.517 | stalemate 12, repetition 12 | | |
| | **8** | **6** | 11 | **0.583** | checkmate 7, stalemate 11 | | |
| | 16 | 3 | **18** | 0.550 | **stalemate 18** | | |
| **MEASURED:** deeper inference does improve conversion — 0 mates at depth 1 vs 6 | |
| at depth 8, and the best playing-out score is also at depth 8 (0.583). This is | |
| the inverted-U again, now visible in a *termination-level* metric rather than an | |
| adjudicated score, which is stronger evidence that the depth effect is real. | |
| **But the failure mode is not cured, it is transformed.** Stalemates rise | |
| monotonically with depth (6 -> 5 -> 12 -> 11 -> **18**), and at depth 16 the | |
| model stalemates its opponent in 60% of games. The model uses extra computation | |
| to press its material advantage into the opponent's king, and then traps it | |
| without check. | |
| **Conclusion (MEASURED): inference-time compute improves conversion but cannot | |
| substitute for the missing training signal.** Even at its best depth the model | |
| converts only 6 of 30 winnable positions. This supports the reward-shaping | |
| hypothesis over the pure-compute hypothesis, and motivates the `adjudicate=none` | |
| training ablation now running. | |
| ### Reward-shaping ablation (RUNNING): `adjudicate=none` vs `adjudicate=material` | |
| 4 jobs: baseline config, 60 iters x 96 games, max 200 plies, seeds 0/1, arms | |
| `none` and `material`, outputs `runs/adj/{arm}_s{seed}`. | |
| **Iteration-0 contrast already shows the mechanism under test (MEASURED):** | |
| | arm | decisive rate | adjudicated games | max_plies games | mates | | |
| |---|---:|---:|---:|---:| | |
| | `adjudicate=none` | **0.07** | 0 | 88/96 | 7 | | |
| | `adjudicate=material` | **0.84** | 74/96 | 14 | 7 | | |
| With material adjudication the model receives a decisive outcome in 84% of games; | |
| without it, only 7% — a **12x sparser reward signal**, with 88 of 96 games hitting | |
| the ply cap as draws. | |
| This quantifies the suspected cause of the conversion failure: under the default | |
| protocol, nearly all training signal comes from *material adjudication*, so the | |
| policy is rewarded for acquiring material and essentially never for delivering | |
| mate. The ablation tests whether removing that crutch forces conversion to be | |
| learned, or simply starves the run of gradient. **Both outcomes are informative | |
| and both will be reported.** Prediction is deliberately not recorded as a result. | |
| ### Early ablation result: low value-MAE can be a DEGENERATE solution (MEASURED) | |
| Two iterations in, the two arms look like this: | |
| | arm | draw rate | decisive rate | **value MAE** | mean plies | move diversity | | |
| |---|---:|---:|---:|---:|---:| | |
| | `adjudicate=none` | **0.969** | 0.031 | **0.0439** | 196 | 0.086 | | |
| | `adjudicate=material` | 0.177 | 0.823 | 0.7145 | 193 | 0.093 | | |
| **The `none` arm has a 16x lower value-head error than the `material` arm — and | |
| this is worthless.** 96.9% of its games are draws, so a value head that outputs | |
| "draw" for every position is almost always right. The low MAE measures the | |
| degeneracy of the outcome distribution, not the quality of the value function. | |
| **This is a concrete vindication of the standing rule that strength claims must | |
| come from playing results, never from training metrics.** Had the arms been | |
| ranked by value MAE — a natural-looking choice — `adjudicate=none` would have | |
| been declared a massive improvement when it is in fact a reward-signal collapse. | |
| The earlier grid observation that all nine arms had near-identical value MAE | |
| (0.450-0.537) should be read the same way: that spread carried essentially no | |
| information about playing strength, and the head-to-head results confirmed it. | |
| **Status: the ablation continues.** Whether the `none` arm eventually escapes the | |
| all-draw regime and learns conversion, or stays starved of gradient, is not yet | |
| known and is not being predicted here. | |
| ### Phase 11 re-run at a genuinely lifted depth (8 steps, router_s0) | |
| Artifact: `runs/precision_router_s8.json`. The original Phase 11 run requested 6 | |
| steps and silently got 4 (clamp bug). This re-run lifts the cap to a true 8 steps | |
| on a well-trained grid checkpoint. | |
| | quantity | original (effective 4 steps) | re-run (true 8 steps, router_s0) | | |
| |---|---|---| | |
| | activations int4 | top1 **1.000** | top1 **0.969** | | |
| | activations int3 | 0.979 | 0.906 | | |
| | activations int2 | 0.813 | 0.625 | | |
| | weights int8 | 1.000 | **1.000** | | |
| | weights int4 | 0.938 | 0.844 | | |
| | weights int3 | 0.688 | 0.828 | | |
| | weights int2 | 0.062 | 0.359 | | |
| | step-dependent (int3 early -> fp32 late) | 1.000 | 0.922 | | |
| | position-dependent easy vs hard @int3 | 1.000 vs 0.958 | **0.938 vs 0.875** | | |
| **Qualitative conclusions all replicate (MEASURED):** | |
| 1. **Activations tolerate low precision better than weights.** int8 weights are | |
| lossless (1.000) while int4 weights lose 16 points; activations survive int4 | |
| at 0.969. | |
| 2. **Position-dependent precision is justified:** cheap precision hurts hard | |
| positions more than easy ones (0.938 easy vs 0.875 hard at int3), reproducing | |
| the original direction on a different checkpoint and a different depth. | |
| 3. **Operation-dependent precision is viable:** keeping attention precise while | |
| quantising local mixing gives top1 **1.000**. | |
| **Quantitative values are all lower at 8 steps than at 4** — quantisation error | |
| compounds across recurrent iterations, which is expected for a weight-shared core | |
| and is a new observation enabled by fixing the clamp. **The original Phase 11 | |
| numbers should be read as 4-step figures**, as noted in the blast-radius audit. | |
| **NEGATIVE RESULT retained:** fake quantisation still yields no CPU speedup | |
| (336-345 ms across all activation precisions vs fp32 reference). Any throughput | |
| claim would require real INT kernels. | |
| --- | |
| ## Reward-shaping ablation COMPLETE: `adjudicate=none` does NOT fix conversion (NEGATIVE, FALSIFIED) | |
| **Hypothesis (H-conv-1):** the conversion failure is caused by material | |
| adjudication. Under `adjudicate=material` the policy is paid for acquiring | |
| material and essentially never for delivering mate, so removing adjudication | |
| should force conversion to be learned. | |
| **Design:** baseline config, 60 iters x 96 games, max 200 plies, seeds 0 and 1, | |
| two arms (`adjudicate=none` vs `adjudicate=material`), everything else identical. | |
| Artifacts `runs/adj/*`, evaluation `runs/adj_conversion.json`. Evaluation always | |
| with `adjudicate=none`, 400-ply cap, 40 games per cell, so only *real* results count. | |
| ### Result: no effect on conversion | |
| Pooled over 2 seeds (80 games per cell): | |
| | training reward | opponent | score | CI95 | **mates** | stalemates | | |
| |---|---|---:|---|---:|---:| | |
| | `none` | random | 0.581 | [0.472, 0.683] | 14/80 | 17 | | |
| | `material` | random | 0.550 | [0.441, 0.654] | 9/80 | 16 | | |
| | `none` | material | 0.444 | [0.340, 0.553] | **1/80** | 33 | | |
| | `material` | material | 0.463 | [0.357, 0.571] | **1/80** | 7 | | |
| **Every CI overlaps its control.** Against the material heuristic both arms | |
| convert **1 game in 80**. Removing material adjudication did not teach conversion. | |
| ### The seed dominates the treatment | |
| | | seed 0 | seed 1 | | |
| |---|---:|---:| | |
| | trained `none` | 4 mates | **11 mates** | | |
| | trained `material` | 2 mates | **8 mates** | | |
| Seed 1 produces ~3x the mates of seed 0 *in both arms*. The between-seed | |
| difference is larger than the between-treatment difference, so a 2-seed design | |
| cannot resolve this effect even if one existed. **H-conv-1 is falsified.** | |
| ### Side effect worth recording | |
| Training without adjudication **increased** stalemates against the material | |
| opponent (33 vs 7 per 80). Starving the reward did not produce cleaner wins; it | |
| produced more botched endings. Combined with the 12x sparser decisive signal | |
| (0.07 vs 0.84 decisive rate at iteration 0) and the degenerate value head | |
| (MAE 0.035-0.051 from a ~94% draw rate), `adjudicate=none` is **worse as a | |
| training signal**, not better. | |
| ### Revised interpretation | |
| The conversion failure is **not** primarily a reward-shaping artifact. Remaining | |
| candidate explanations, now the active hypotheses: | |
| - **H-conv-2 (representation/credit assignment):** outcome-only returns spread | |
| over ~200-ply games give almost no credit to the specific mating sequence. | |
| A mate is 1-2 plies of a 200-ply trajectory. | |
| - **H-conv-3 (capability):** delivering mate requires coordinating multiple | |
| pieces against an evading king — plausibly beyond a 4-step, ~80K-param policy. | |
| - **H-conv-4 (exploration):** mating nets are rare under temperature sampling, so | |
| the policy may never sample enough of them to learn from. | |
| H-conv-2 and H-conv-4 are testable cheaply with a mate-position curriculum: if | |
| the model *can* learn mate when positions start near-mate, capability (H-conv-3) | |
| is ruled out and credit assignment/exploration is implicated. | |
| --- | |
| ## Conversion investigation: the VALUE head is fine; the failure is in the POLICY | |
| **Hypothesis (H-conv-5):** the model cannot convert because its value head does | |
| not recognise winning positions, leaving the policy with no signal to steer | |
| toward mate. | |
| **Design:** bucket random positions by material from the side-to-move's view | |
| (crushing >= +6, even |m| <= 1, lost <= -6), 40 positions each, and read the | |
| value head directly. Two checkpoints. | |
| | bucket | main (170 iters) | router_s0 (85 iters) | | |
| |---|---|---| | |
| | crushing (+6 or more) | **+0.5148** (std 0.149) | **+0.5091** (std 0.132) | | |
| | even | +0.0580 (std 0.125) | -0.0296 (std 0.096) | | |
| | lost (-6 or worse) | **-0.3900** (std 0.179) | -0.3813 (std 0.191) | | |
| **H-conv-5 is FALSIFIED (MEASURED).** The value head separates the three buckets | |
| cleanly and in the correct order, on both checkpoints, with a ~0.9 spread between | |
| crushing and lost and non-overlapping means at these sample sizes. | |
| **This substantially narrows the diagnosis.** The model: | |
| - correctly *evaluates* that it is winning (value +0.51 when up 6+ material), and | |
| - reliably *acquires* material (0.817 vs the material heuristic under adjudication), | |
| - yet converts ~1 game in 80 into checkmate when games are played out. | |
| So the deficit is not evaluation and not material acquisition. It is the | |
| **policy's ability to execute a mating sequence**, which is exactly the | |
| capability-vs-credit-assignment question now being tested by | |
| `experiments/exp_mate_capability.py`. | |
| **Note on why this matters methodologically:** the value head is *well | |
| calibrated on material* — which is also what it is trained on, given that | |
| material adjudication supplies ~84% of decisive outcomes. A value head that has | |
| learned "material = winning" will happily report +0.5 in a position the policy | |
| cannot actually win. **Value calibration against material is not evidence of | |
| value calibration against game outcome**, and the two have been conflated in | |
| earlier readings of `tr_value_mae`. | |
| --- | |
| ## MATE BLINDNESS: the strongest model cannot see mate-in-1 (MEASURED, and it inverts the strength ranking) | |
| **Hypothesis (H-conv-3):** the conversion failure is a capability limit — the | |
| policy cannot execute a mating net. | |
| **Design:** generate 150 positions with a verified mate-in-1 available to the | |
| side to move (exhaustive legal-move search used ONLY to label test positions — | |
| never as a training target and never to pick the model's move). Ask each | |
| checkpoint for its top legal move and check whether it delivers mate. Evaluate at | |
| recurrent depths 1/2/4/8/16, weights fixed. **Control: a uniformly random legal | |
| move**, which is the chance rate for this suite. | |
| Suite: 150 positions from 180,585 random walks, mean **27.1 legal moves**. | |
| **Random-legal control: 7/150 = 0.047.** | |
| | model | params | d=1 | d=2 | d=4 | d=8 | d=16 | | |
| |---|---:|---:|---:|---:|---:|---:| | |
| | **main** (170 iters, strongest) | 80,398 | 0.040 | 0.047 | 0.060 | 0.073 | 0.067 | | |
| | router_s0 | 80,785 | 0.107 | 0.100 | 0.113 | 0.133 | **0.153** | | |
| | baseline_s0 | 80,398 | 0.060 | 0.073 | 0.080 | 0.127 | 0.127 | | |
| | mlp_s0 | 103,730 | 0.147 | 0.147 | 0.147 | 0.147 | 0.147 | | |
| Two-proportion z-test against the random control: | |
| | model | depth | acc | z | significant | | |
| |---|---|---:|---:|---| | |
| | main | 1 | 0.040 | **-0.28** | no | | |
| | main | 4 | 0.060 | +0.51 | no | | |
| | main | 16 | 0.067 | +0.75 | **no** | | |
| | router_s0 | 4 | 0.113 | +2.13 | yes | | |
| | router_s0 | 16 | 0.153 | +3.08 | yes | | |
| | baseline_s0 | 16 | 0.127 | +2.46 | yes | | |
| | mlp_s0 | any | 0.147 | +2.93 | yes | | |
| ### Findings | |
| 1. **The best-playing model is indistinguishable from random at mate-in-1.** | |
| `main` — which beats the material heuristic 0.817 and beats the best grid arm | |
| by +199 Elo — never separates from chance at any depth (max z = +0.75). | |
| **H-conv-3 is SUPPORTED for the strongest model: it is mate-blind.** | |
| 2. **The ranking INVERTS relative to playing strength.** `mlp_s0` is the *worst* | |
| arm in head-to-head play (3 credible losses, last vs material) yet has the | |
| best mate-in-1 accuracy (0.147). `main` is the strongest player and the worst | |
| mate-solver. Playing strength in this system is **anti-correlated** with | |
| tactical mate detection. | |
| 3. **Depth helps mate detection where the model has any signal at all.** | |
| `router_s0` improves 0.107 -> 0.153 from d=1 to d=16 and `baseline_s0` | |
| 0.060 -> 0.127, but `main` stays flat. Extra computation can only amplify a | |
| capability that exists. | |
| 4. **`mlp_s0` is perfectly flat across depth (0.147 at every depth)** — exactly | |
| as expected for a non-recurrent architecture. This is a clean internal | |
| validity check that the depth axis is being applied correctly. | |
| ### Revised interpretation (this changes the project's model of what is happening) | |
| Extended self-play did not make the model better at chess in a general sense. It | |
| made it better at **the adjudicated-material game it was actually trained on**, | |
| and in doing so it appears to have *lost* whatever weak mate-detection the | |
| shorter runs had. This is consistent with the reward analysis: ~84% of decisive | |
| training outcomes come from material adjudication, so the gradient overwhelmingly | |
| rewards material acquisition, and mate is a rare, low-credit event. | |
| **This reframes the conversion failure.** It is not that the model wins material | |
| and then fails to find the mating move under pressure — it is that the model has | |
| **no mate-detection capability to deploy**, and more of the current training | |
| signal makes this worse, not better. | |
| **Active hypotheses now:** | |
| - **H-conv-6 (reward dilution):** mate-blindness is caused by material | |
| adjudication dominating the return. Note this is *not* the same as the | |
| falsified H-conv-1: removing adjudication starved the signal entirely | |
| (96.9% draws), whereas the right test is a reward that keeps dense material | |
| feedback but *adds* an explicit terminal bonus for mate. | |
| - **H-conv-7 (horizon):** a 4-step recurrent core with no search cannot | |
| represent "this move ends the game" because the mate condition is a property | |
| of the *resulting* position, which the model never encodes. | |
| H-conv-7 is directly testable and cheap: the encoder sees only the current | |
| board, so mate-in-1 detection requires simulating each candidate move internally. | |
| --- | |
| ## H-conv-7: is mate blindness caused by the move representation? (MEASURED — PARTIALLY FALSIFIED) | |
| `pack_move` encodes from_sq/to_sq/promo/piece/capture/castle/ep and has **no | |
| `gives_check` bit**. Hypothesis: mate-in-1 is undetectable because the move | |
| representation lacks any "what does this do to the enemy king" information. | |
| Test (`experiments/exp_check_feature.py`, artifact `runs/check_feature_probe.json`): | |
| train a small probe to predict `is_checkmate` for each legal move, using only | |
| mechanical features the rules engine already computes. No model weights involved | |
| — this measures the **representation**, not the trained policy. 400 train / 200 | |
| test positions per seed, 3 seeds, 50% of positions contain a mate-in-1 | |
| (enrichment needed: uniform random positions gave only 1–4 positives per test | |
| set and unusable variance). `top1` = per-position, over positions that contain a | |
| mate, does the probe's highest-scoring legal move deliver mate. Directly | |
| comparable to the model's mate-in-1 solve rate. **Random-legal control = 0.047.** | |
| | probe level | features | AUC | top-1 | | |
| |---|---|---:|---:| | |
| | A `current_encoding` | what the model already sees (+ file/rank deltas) | 0.8007 ± 0.0261 | **0.2300 ± 0.0283** | | |
| | B `plus_gives_check` | A + `gives_check` | 0.9750 ± 0.0012 | 0.4300 ± 0.0294 | | |
| | C `plus_check_and_replycount` | B + opponent reply count after the move | **0.9993 ± 0.0004** | **0.9767 ± 0.0205** | | |
| **Findings:** | |
| 1. **H-conv-7 is PARTIALLY FALSIFIED.** The current move encoding is *not* | |
| information-free about mate: probe A reaches top-1 **0.23**, ~5× the random | |
| control (0.047) and ~3–6× what the trained models actually achieve | |
| (main 0.040–0.073, best arm router_s0 0.153). **The representation already | |
| supports several times more mate detection than any trained model extracts.** | |
| Adding a `gives_check` bit would therefore be treating a symptom. | |
| 2. **A single `gives_check` bit is worth a lot but is not sufficient** (0.23 → | |
| 0.43 top-1). Mate is "check **and** no legal reply"; check alone cannot | |
| distinguish mate from an ordinary check. | |
| 3. **The decisive feature is the opponent's reply count** (0.43 → 0.98). This is | |
| exactly the one-ply lookahead quantity the model has no access to: it is a | |
| property of the *resulting* position, and the encoder only ever sees the | |
| *current* board. | |
| 4. Consequently the failure decomposes into two independent deficits: | |
| a **readout deficit** (0.047 achieved vs 0.23 available from current | |
| features) and a **lookahead deficit** (0.23 available now vs 0.98 with a | |
| one-ply reply count). | |
| **This reframes the fix.** Adding a `gives_check` field to `pack_move` is *not* | |
| the highest-value change, and it edges toward hard-coding chess knowledge into | |
| the representation. The prior question is why the trained policy extracts only | |
| 0.047 when 0.23 is linearly available. Successor hypotheses: | |
| - **H-conv-8** — the model's internal features *do* encode "this move mates", | |
| but the trained readout (`z · cand`) does not weight that direction, because | |
| self-play reward never paid for mate (adjudication ends games first). | |
| => a **training-signal** problem, no architecture change needed. | |
| - **H-conv-9** — the internal features do not encode it at all => a capacity or | |
| architecture problem. | |
| Discriminated by `experiments/exp_mate_readout.py`: freeze the model, extract its | |
| own `z` and per-candidate `cand` vectors, and refit (a) a bilinear form — the | |
| same function class as the model's own readout — and (b) an MLP, to predict | |
| mate. If the refit bilinear probe greatly exceeds the model's own logits, the | |
| representation is adequate and the *learned weighting* is the fault (H-conv-8). | |
| Status: `exp_check_feature.py` MEASURED; `exp_mate_readout.py` RUNNING. | |
| --- | |
| ## H-conv-8 CONFIRMED: mate blindness is a READOUT/TRAINING-SIGNAL failure, not a representation failure (MEASURED) | |
| `experiments/exp_mate_readout.py` → `runs/mate_readout.json`. The model is | |
| **frozen**. We extract its own internal vectors for each legal candidate — the | |
| global move-intent vector `z` and the per-candidate vector `cand` (= | |
| `move_emb(fields) + sq_to_move[from] + sq_to_move[to]`) — i.e. exactly the two | |
| things the model's own scorer consumes, since its policy is the bilinear form | |
| `logits = z · cand`. We then compare the model's *own* logits against a refit | |
| bilinear form (same function class, retrained for mate) and an MLP. 400 train / | |
| 200 test positions per seed, 3 seeds, labels from the rules engine only. | |
| | model | readout | AUC | top-1 | | |
| |---|---|---:|---:| | |
| | main | **its own logits** | **0.449 ± 0.014** | 0.050 ± 0.008 | | |
| | main | refit bilinear on its own frozen features | **0.810 ± 0.018** | 0.177 ± 0.013 | | |
| | main | refit MLP on its own frozen features | 0.771 ± 0.019 | 0.180 ± 0.000 | | |
| | router_s0 | its own logits | 0.620 ± 0.033 | 0.100 ± 0.008 | | |
| | router_s0 | refit bilinear | 0.793 ± 0.033 | 0.160 ± 0.042 | | |
| | router_s0 | refit MLP | 0.778 ± 0.035 | 0.213 ± 0.053 | | |
| (random-legal control top-1 = 0.047; raw-feature probe A = 0.230) | |
| **Findings:** | |
| 1. **H-conv-8 CONFIRMED, H-conv-9 falsified.** `main`'s own logits score | |
| **AUC 0.449 — statistically *below* chance (0.5)**, while a bilinear form | |
| over its *own frozen features* reaches **0.810**. The information needed to | |
| rank mating moves first is already present inside the trained network. The | |
| network simply does not use it. **The deficit is in the learned weighting, | |
| not in the features and not in the capacity.** | |
| 2. **The refit MLP does not beat the refit bilinear** (0.771 vs 0.810 for main). | |
| The model's existing bilinear readout is a sufficiently expressive function | |
| class. Nothing is gained by a richer scorer head. | |
| 3. Refit performance (0.81 AUC / ~0.18 top-1) lands close to the raw-feature | |
| ceiling of probe A (0.80 AUC / 0.23 top-1). The encoder preserves roughly all | |
| the mate-relevant signal that was available in the input; it neither adds | |
| lookahead nor destroys much. | |
| ### Mechanism: the policy has learned an anti-check prior | |
| Per-move policy percentile on the same 200-position suite | |
| (`runs/move_class_bias.json`, 7,297 legal moves, 110 mating moves): | |
| | model | capture | non-capture | **check** | non-check | mate | **quiet mate** | | |
| |---|---:|---:|---:|---:|---:|---:| | |
| | main | 0.858 | 0.440 | **0.389** | 0.510 | 0.462 | **0.234** | | |
| | router_s0 | 0.925 | 0.429 | 0.539 | 0.497 | 0.651 | 0.480 | | |
| | adj_material_s0 | 0.877 | 0.437 | 0.448 | 0.504 | 0.512 | 0.295 | | |
| | adj_none_s0 | 0.781 | 0.453 | 0.539 | 0.497 | 0.607 | 0.446 | | |
| | adj_material_s1 | 0.770 | 0.455 | 0.609 | 0.491 | 0.685 | 0.571 | | |
| | adj_none_s1 | 0.751 | 0.458 | 0.573 | 0.494 | 0.549 | 0.467 | | |
| - `main`, the strongest player, ranks **checking moves at the 0.389 percentile — | |
| actively *below* the average legal move**, and **quiet (non-capturing) mates at | |
| 0.234**, i.e. in the bottom quartile of its own move ordering. It is not | |
| failing to find mates; it is **ranking them down**. | |
| - Mating moves only survive at all when they happen to be captures: `main` scores | |
| mating-captures at 0.846 but quiet mates at 0.234. 37.3% of mates are captures | |
| vs 14.4% of all moves, so the apparent near-chance mate rate is almost entirely | |
| carried by the capture prior, not by any mate detection. | |
| - Across the 6 checkpoints, **r(check_bias, mate percentile) = +0.855**, while | |
| **r(capture_bias, mate percentile) = −0.172**. What predicts mate ability is | |
| whether the model likes giving check — not how much it likes material. | |
| **Why this happens, and why it explains the inverted ranking.** Under material | |
| adjudication, a long game that is stopped and scored on material rewards | |
| accumulating material and punishes speculative checks, which usually give away | |
| tempo/material when they do not mate. Self-play therefore trains an explicitly | |
| *anti-check* prior, and the more training a model receives the stronger that | |
| prior becomes. This resolves the paradox recorded in the MATE BLINDNESS section: | |
| playing strength is anti-correlated with mate detection because **the very | |
| gradient that produces playing strength also suppresses checks.** `main` | |
| (most training) has the most negative check bias (−0.121) and the worst mate | |
| rate; the lightly-trained 60-iteration `adj_*` arms all have less negative or | |
| positive check bias and detect mates better. | |
| **Consistency with the falsified H-conv-1.** Removing material adjudication | |
| (`adjudicate=none`) *does* move the check bias in the predicted direction | |
| (s0: −0.057 → +0.043) — the mechanism is real — but at 60 iterations this did | |
| not translate into more converted wins, because the 12× sparser reward left the | |
| arm too weak overall and it produced *more* stalemates. The reward change | |
| addresses the right variable but is not sufficient on its own. | |
| **Implication for the fix.** Do **not** add a `gives_check` bit to `pack_move` | |
| (H-conv-6/7): probe A shows the information is already there, the refit-bilinear | |
| result shows the readout can already express it, and hard-coding it would inject | |
| chess knowledge the project's constraints forbid. The binding constraint is that | |
| **terminal outcomes are too rare in the training signal for the policy to learn | |
| that check-with-no-reply is terminal**. Candidate interventions, to be tested as | |
| controlled ablations: | |
| - **H-conv-10** — keep material adjudication for the *value* target but make the | |
| *policy* loss see true terminal states (train longer with `adjudicate=none` | |
| rather than 60 iterations, or adjudicate only after a much longer ply cap). | |
| - **H-conv-11** — the terminal-state credit is diluted because a mate at ply 150 | |
| is one sample among ~150; test a terminal-proximity weighting of the policy | |
| loss (no chess knowledge: weight by distance to game end, not by "is check"). | |
| Status: `exp_mate_readout.py` MEASURED; move-class bias MEASURED; H-conv-8 | |
| CONFIRMED; H-conv-9 FALSIFIED; H-conv-6/H-conv-7 fix path REJECTED on evidence. | |
| --- | |
| ## Causal intervention on the check prior (MEASURED — mechanism CONFIRMED, fix NOT sufficient) | |
| The +0.855 correlation between check bias and mate ability is observational. | |
| `experiments/exp_check_intervention.py` is the intervention: freeze the model and | |
| add one scalar to the logit of every checking move at inference, | |
| `logits' = logits + alpha * 1[gives check]`. Nothing is trained; no chess | |
| knowledge enters the weights or `pack_move`. 150 rules-engine-verified mate-in-1 | |
| positions; games vs MaterialAgent at `adjudicate=none`, 400-ply cap, 60 games. | |
| Artifacts: `runs/check_intervention.json`, `runs/check_intervention_control.json`. | |
| | alpha | mate-in-1 top-1 (`main`) | score vs material | W/D/L | mates for | stalemates | | |
| |---:|---:|---:|---|---:|---:| | |
| | 0 | 0.040 | 0.392 | 3/41/16 | 3 | 4 | | |
| | 0.5 | 0.067 | — | | | | | |
| | 1 | 0.080 | 0.467 | 8/40/12 | **8** | 2 | | |
| | 2 | 0.100 | 0.467 | 4/48/8 | 4 | 2 | | |
| | 4 | 0.207 | 0.467 | 2/52/6 | 2 | 1 | | |
| | 8 | 0.413 | 0.408 | 2/45/13 | 2 | 0 | | |
| | 16 | **0.473** | — | | | | | |
| **Required control.** A uniform bonus on *all* checks does not require the model | |
| to know which check mates, so the headline 0.040 → 0.473 must be compared | |
| against simply playing a **uniformly random checking move**: | |
| | condition | mate-in-1 top-1 | | |
| |---|---:| | |
| | random legal move | 0.047 | | |
| | `main`, unmodified | **0.040** | | |
| | **uniformly random *checking* move** | **0.380** | | |
| | `main`, checks forced (alpha→inf, model ranks within checks) | 0.473 | | |
| | `router_s0`, unmodified | 0.100 | | |
| | `router_s0`, checks forced | 0.453 | | |
| (mean 4.5 checking moves available per position) | |
| **Findings:** | |
| 1. **The mechanism is causally confirmed, and it is worse than "blindness".** | |
| The unmodified strongest model scores **0.040 — below the 0.047 random-legal | |
| control and a full 0.34 below the trivial random-check policy (0.380)**. A | |
| model that picked checks at random would be ~9× better at mate-in-1. The | |
| anti-check prior is not a missing capability, it is an **actively harmful | |
| learned bias**, and it alone accounts for essentially the entire mate-in-1 | |
| deficit. | |
| 2. **But the model has almost no discrimination *within* checks.** Forcing | |
| checks and letting the model rank among them gives 0.473 vs 0.380 for | |
| choosing a check at random: **z = +1.63, not significant** (`router_s0` | |
| z = +1.29, ns). So the model cannot meaningfully tell a mating check from a | |
| harmless one. Correcting the prior recovers the easy part of the gap; the | |
| remaining "which check actually mates" capability is genuinely absent, which | |
| is consistent with the probe result that the decisive feature is the | |
| opponent's reply count — a property of the *resulting* position the model | |
| never sees. | |
| 3. **Fixing the check prior does NOT fix conversion.** This is the important | |
| negative. Despite mate-in-1 improving 12×, score vs MaterialAgent stays flat | |
| at ~0.39–0.47 across every alpha, and converted mates peak at 8/60 (alpha=1) | |
| then *fall* to 2/60 as alpha rises and the model starts throwing material at | |
| spite checks. Stalemates drop monotonically (4 → 0), so the intervention is | |
| doing something real, but **mate-in-1 ability is not the bottleneck for | |
| converting won games.** A tactic solved in a constructed position does not | |
| transfer to steering a full game into that position. | |
| **Conclusion.** H-conv-8 is confirmed as the explanation of *mate blindness* | |
| (the anti-check prior, learned from material adjudication, is the cause), and it | |
| is simultaneously shown to be **insufficient as an explanation of the conversion | |
| failure**. These are two different problems. Conversion requires reaching mating | |
| positions, not only recognising them. The conversion failure therefore remains | |
| open, and the reward-signal hypotheses (H-conv-10 terminal-state training, | |
| H-conv-11 terminal-proximity credit weighting) remain the live candidates — | |
| now with the added constraint that any fix must be validated on *game outcomes*, | |
| not on tactic suites, because this experiment demonstrates the two dissociate. | |
| Status: MEASURED. Mechanism CONFIRMED; proposed fix REJECTED as insufficient on | |
| its own evidence. The alpha-biased model is a diagnostic only and is never | |
| reported as a strength result. | |
| ### H-conv-11 implemented (IMPLEMENTED — not yet run) | |
| `train_selfplay.py` gains `--terminal-weight G` / `--terminal-tau TAU` | |
| (default 0 = off, behaviour bit-identical to all previous runs). Samples are | |
| up-weighted in the **policy loss only** by | |
| `w = 1 + G * exp(-(n_plies - 1 - ply) / TAU)`, renormalised to mean 1 so the loss | |
| scale is unchanged. `Sample` gained `n_plies` to make distance-to-termination | |
| available. Rationale: a mate at ply 150 is one sample among ~150, so the moves | |
| that actually caused the terminal state get the same credit as opening moves. | |
| The weight depends only on distance to game end — **no chess knowledge, no | |
| check/mate feature** — which is what distinguishes it from the rejected | |
| H-conv-6/7 fix path. | |
| Verified: value loss and entropy are computed on the unweighted mask, so this is | |
| a policy-only intervention (asserted in a unit test); `--terminal-weight 0` | |
| reproduces the old `l_pi` exactly; a 2-iteration run logs `tr_terminal_w_max` | |
| ≈ 3.5–3.7 at G=4, TAU=10. Phase-1 gate **12/12**. | |
| Incidental fix: `tests/test_phase1.py::test_local_mixing_matches_reference` was | |
| failing because `ray_gate` initialises to exactly 0 and ray terms are scaled by | |
| `tanh(ray_gate)`, so `d(out)/d(ray_decay)` is legitimately 0 *at the init point*. | |
| Not a broken graph: perturbing the gate restores a gradient of 5.56, and trained | |
| checkpoints show `ray_decay` has moved well off its 0 init (`main`: |ray_gate| | |
| sum 6.52, |ray_decay| sum 1.53). The test now perturbs off that measure-zero | |
| point before asserting. This was a pre-existing false alarm, not a regression. | |
| Next: run H-conv-10 (`adjudicate=none`, long) and H-conv-11 (terminal weighting) | |
| as a controlled 2×2 against the material/G=0 control, and judge them on | |
| **converted mates in real games**, not on mate-in-1 suites — the intervention | |
| experiment above showed those two dissociate. | |
| --- | |
| ## CONVERSION ISOLATED: the model cannot win K+Q vs K (MEASURED — headline negative) | |
| `experiments/exp_endgame_conversion.py` → `runs/endgame_conversion.json`. | |
| Everything else is stripped away: the model is handed positions that are already | |
| trivially won, with a lone enemy king and zero counterplay, and asked only | |
| whether it can deliver mate. 50 random legal positions per suite, 160-ply cap, | |
| defender = lone king playing the reply that **maximises its own mobility** | |
| (hardest case). Controls: random-legal mover and MaterialAgent on identical | |
| positions. "edge" = mean distance of the defending king from the board edge | |
| (0 = on the edge/mate-able, 3 = centre); progress > 0 means the king is being | |
| driven outward. | |
| | suite | agent | mated | stalemate | rep/50-move | edge start→end | progress | | |
| |---|---|---:|---:|---:|---|---:| | |
| | KQvK | random | 0.00 | 0.02 | 0.78 | 0.96→0.68 | +0.28 | | |
| | KQvK | material | 0.10 | 0.10 | 0.64 | 0.96→0.62 | +0.34 | | |
| | KQvK | **main** | **0.00** | 0.00 | **1.00** | 0.96→0.96 | **+0.00** | | |
| | KQvK | router_s0 | 0.00 | 0.00 | 1.00 | 0.96→0.88 | +0.08 | | |
| | KRvK | random | 0.00 | 0.00 | 0.98 | 1.14→0.96 | +0.18 | | |
| | KRvK | material | 0.04 | 0.00 | 0.96 | 1.14→1.04 | +0.10 | | |
| | KRvK | **main** | 0.00 | 0.00 | 1.00 | 1.14→0.78 | +0.36 | | |
| | KRRvK | random | 0.04 | 0.04 | 0.86 | 1.00→0.92 | +0.08 | | |
| | KRRvK | **material** | **0.74** | 0.00 | 0.22 | 1.00→0.30 | **+0.70** | | |
| | KRRvK | **main** | **0.00** | 0.02 | 0.98 | 1.00→1.18 | **−0.18** | | |
| | KRRvK | router_s0 | 0.00 | 0.00 | 1.00 | 1.00→1.06 | −0.06 | | |
| | KQRvK | random | 0.10 | 0.28 | 0.46 | 0.82→0.56 | +0.26 | | |
| | KQRvK | **material** | **0.86** | 0.02 | 0.10 | 0.82→0.16 | **+0.66** | | |
| | KQRvK | **main** | **0.02** | 0.02 | 0.96 | 0.82→1.08 | **−0.26** | | |
| | KQRvK | router_s0 | 0.00 | 0.02 | 0.98 | 0.82→0.96 | −0.14 | | |
| **Required control — is 100% repetition just argmax determinism?** No. | |
| Re-run with temperature sampling (`runs/endgame_temperature_control.json`): | |
| | suite | T=0 | T=0.3 | T=0.7 | T=1.0 | | |
| |---|---|---|---|---| | |
| | KQvK mated | 0.00 | 0.00 | 0.02 | 0.00 | | |
| | KQvK rep/50 | 1.00 | 1.00 | 0.96 | 0.98 | | |
| | KQRvK mated | 0.00 | 0.04 | 0.00 | 0.04 | | |
| | KQRvK stalemate | 0.04 | 0.08 | 0.18 | **0.24** | | |
| Stochasticity does not rescue conversion; it converts repetitions into | |
| **stalemates** (KQRvK 0.04 → 0.24). The draw is a property of the policy, not of | |
| tie-breaking. | |
| **Findings:** | |
| 1. **The strongest model converts K+Q vs K at a rate of 0.00, and K+Q+R vs K at | |
| 0.02 — worse than a random legal mover (0.00 / 0.10) and ~40× worse than the | |
| trivial material heuristic (0.10 / 0.86).** This is the clearest negative | |
| result in the project. | |
| 2. **With more material it gets actively worse.** On KRRvK and KQRvK, `main`'s | |
| edge-progress is **negative** (−0.18, −0.26): it drives the enemy king *back | |
| toward the centre*. MaterialAgent, with no search and no learning, scores | |
| +0.70/+0.66 and mates 74–86% of the time. The deficit is not subtle. | |
| 3. **The failure mode is repetition, at 0.96–1.00 across every suite.** The model | |
| shuffles. Combined with the earlier value-head probe (which reports +0.51, | |
| "I am winning", in exactly these positions), the picture is: **the model knows | |
| it is winning, and has no policy for making progress.** | |
| 4. This **rules out the remaining innocent explanations** for the conversion | |
| failure. It is not an inability to reach winning positions (they were handed | |
| over), not mate-in-1 blindness (confirmed separately to dissociate), and not | |
| miscalibrated evaluation (H-conv-5 falsified). It is an inability to make | |
| progress in a won position. | |
| **Why self-play + material adjudication produces exactly this.** A game stopped | |
| at the ply cap and scored on material gives an identical reward for mating on | |
| move 20 and for shuffling a queen for 160 plies. Shuffling is strictly safer: it | |
| never hangs material and never stalemates. The training signal therefore has | |
| **no gradient toward termination at all**, and the optimal policy under it is | |
| precisely the one observed — hold material, avoid risk, never finish. The | |
| anti-check prior (r = +0.855 with mate blindness) is one visible symptom of the | |
| same root cause. | |
| This makes H-conv-10/11 the correct and now well-motivated next test, and it | |
| supplies the metric they must be judged on: **KQvK/KQRvK conversion rate and | |
| edge-progress**, which are fast, unambiguous, and have a known-good reference | |
| (MaterialAgent 0.86) and a floor (random 0.10). Note the target to beat is the | |
| *heuristic*, not zero. | |
| Status: MEASURED. Suites, controls and temperature control all recorded. | |
| --- | |
| ## H-conv-10/11/12 tested: reward-signal fixes do NOT fix conversion (MEASURED — NEGATIVE) | |
| The endgame result above showed the reward contains **no gradient toward | |
| termination**: `outcome_to_z` returns ±1 with **no ply discounting anywhere in | |
| the codebase** (verified by grep: the only `gamma`/`discount` hit is the new | |
| code), so mating on move 20 and an adjudicated material win at ply 200 earn | |
| *identical* reward, while shuffling is strictly safer. Two interventions were | |
| implemented and tested against a matched control. | |
| **H-conv-12 `--win-discount GAMMA`** (new, `tinychess/replay.py`): | |
| `z_t = outcome * gamma^(plies_remaining)`. Standard RL discounting on the game | |
| outcome — encodes "win sooner", contains no chess knowledge, says nothing about | |
| checks, mates or material. Unit-verified: `gamma=1.0` is bit-identical to all | |
| previous runs, draws stay exactly 0, and a 10-ply win is worth 0.914 at ply 0 | |
| versus 0.370 for a 100-ply win. | |
| **H-conv-11 `--terminal-weight`**: policy-loss-only credit weighting by distance | |
| to game end (value loss and entropy use the unweighted mask). | |
| Controlled 3-arm run, `config=baseline`, 50 iters, 24 games/iter, seed 0, | |
| identical in every other respect. Evaluated on the isolated conversion suites | |
| (50 positions, 160-ply cap, mobility-maximising lone king): | |
| | arm | gamma | term-w | KQvK mated | KQvK progress | KQRvK mated | KQRvK progress | | |
| |---|---:|---:|---:|---:|---:|---:| | |
| | ctrl | 1.0 | 0 | 0.00 | −0.24 | 0.00 | −0.02 | | |
| | disc | 0.99 | 0 | 0.00 | −0.16 | 0.00 | +0.06 | | |
| | both | 0.99 | 4 | 0.00 | −0.20 | 0.00 | −0.10 | | |
| | *random* | — | — | *0.02* | *+0.26* | *0.08* | *+0.12* | | |
| | *material* | — | — | *0.06* | *−0.02* | **0.74** | **+0.60** | | |
| **All three arms convert 0.00 on both suites — still below the random-legal | |
| control.** Self-play training metrics are also indistinguishable (mates/game | |
| 0.029 / 0.042 / 0.040; mean plies 191.5 / 190.3 / 189.7; decisive 0.525 / 0.535 | |
| / 0.550). **H-conv-10/11/12 FALSIFIED at this scale.** | |
| **Why — and this is the useful part.** The discount *was* binding | |
| (0.99^190 ≈ 0.15, so it was not a no-op), and it creates a large differential: | |
| sampling 64 self-play games from the `disc` checkpoint gives mate at a mean of | |
| 53 plies (z = 0.587) versus adjudication at 200 plies (z = 0.134), a **+0.453 | |
| advantage for mating**. The incentive is real and large. But the same sample | |
| shows the mate rate is **1/64 = 0.016**, with the rest split across repetition | |
| (25), adjudication (20) and the ply cap (17). | |
| **A discount can only reward faster wins if fast wins occur in the data.** At a | |
| 1.6% mate rate the policy gradient almost never sees the reinforced event, so a | |
| correctly-specified reward produces no measurable learning. This is an | |
| *exploration* failure, not a reward-specification failure — and it explains why | |
| H-conv-1 (removing adjudication) also failed: both fix the reward while leaving | |
| the terminal state unreachable. | |
| **Revised diagnosis of the conversion failure.** Three hypotheses are now | |
| falsified (H-conv-1 reward shaping, H-conv-5 value head, H-conv-10/11/12 reward | |
| signal) and one confirmed but insufficient (H-conv-8 anti-check prior). What | |
| survives: **the model never experiences enough checkmates to learn how to cause | |
| one.** Terminal states are too rare under self-play from random init at this | |
| scale for outcome-based RL to acquire mating technique, regardless of how the | |
| outcome is scored. | |
| Successors, in priority order: | |
| - **H-conv-13 (exploration/curriculum)** — train *from* won endgames (KQvK, | |
| KRvK) where mate is reachable by chance, so the terminal event actually enters | |
| the buffer. Note the earlier endgame curriculum gave a 0.00 decisive rate and | |
| was deprioritised; that result should be revisited, because it was measured | |
| before the discount existed and a 0.00 decisive rate is exactly the symptom | |
| now understood. | |
| - **H-conv-14 (ply cap)** — 200 plies with ~0.5 adjudication means half of all | |
| games are truncated before any endgame is reached; the model may simply never | |
| see the phase of the game where mating happens. | |
| Status: MEASURED, NEGATIVE, retained. `--win-discount` and `--terminal-weight` | |
| are correct, unit-tested, default-off, and kept in the codebase as tools; they | |
| are not beneficial on their own evidence and are not enabled in any reported run. | |
| --- | |
| ## H-conv-13 (reachability curriculum) — MEASURED, NEGATIVE (with one positive signal) | |
| If the conversion failure is an *exploration* failure (terminal states too rare | |
| to reinforce), then starting games from positions where mate is reachable should | |
| fix it. First, why the existing endgame curriculum failed: | |
| **Diagnosis of the old `endgame` curriculum.** `_random_endgame` gives ONE extra | |
| queen/rook to a **random** colour. Measured at random init (48 games, 120-ply | |
| cap): **0.0% checkmates, 56.2% insufficient material.** The lone piece is hung | |
| almost immediately and K-v-K is a draw, so the buffer receives no terminal wins. | |
| This explains the previously-recorded "endgame curriculum gave 0.00 decisive | |
| rate" — it was not that endgames are useless, it was that the generator produced | |
| positions that stop being won after one blunder. | |
| **Fix (`won_endgame` / `mixed_won`, `tinychess/selfplay.py`).** Give the winning | |
| side TWO heavy pieces, so a single blunder still leaves a won K+heavy-vs-K. | |
| Only the starting material changes; no technique is supplied. Checkmate rate at | |
| random init, 48 games: | |
| | curriculum | mate% | stalemate% | insufficient% | | |
| |---|---:|---:|---:| | |
| | `endgame` (old) | **0.0** | 6.2 | 56.2 | | |
| | `won_endgame` | 6.2 | 22.9 | 35.4 | | |
| | `mixed_won` | **16.7** | 10.4 | 20.8 | | |
| Terminal states now enter the buffer, which is the precondition H-conv-12 lacked. | |
| **Training result** (`mixed_won`, 50 iters, 24 games, seed 0, ± `--win-discount | |
| 0.99`), evaluated on the isolated suites: | |
| | arm | KQvK mated | KQvK progress | KQRvK mated | KQRvK progress | | |
| |---|---:|---:|---:|---:| | |
| | curric | 0.00 | **+0.38** | 0.00 | −0.04 | | |
| | curric_disc | 0.00 | +0.18 | 0.00 | +0.14 | | |
| | *random* | *0.00* | *+0.08* | *0.14* | *+0.18* | | |
| | *material* | *0.08* | *+0.14* | **0.78** | *+0.48* | | |
| **H-conv-13 FALSIFIED for conversion.** Both arms still mate 0.00 on both | |
| suites, and in-training mate rate stays ~0.046–0.062/game. Making the terminal | |
| event reachable is **necessary but not sufficient**: the buffer now contains | |
| mates, and the policy still does not learn to produce them at 50 iterations. | |
| **One genuinely positive signal, reported because it is the only movement seen | |
| on this metric.** `curric` achieves KQvK edge-progress **+0.38**, the best of | |
| any model measured (main +0.00, router_s0 +0.08, material +0.14, random +0.08). | |
| It drives the defending king toward the edge better than the material heuristic | |
| — then draws by repetition 100% of the time. So the curriculum *did* teach the | |
| first half of mating technique (restrict the king) and none of the second half | |
| (deliver mate). This is consistent with the mate-blindness result: it can make | |
| progress but cannot recognise or execute the terminal move. Caveat: n=50 on one | |
| suite, one seed — suggestive, not established. | |
| **Cumulative status of the conversion investigation.** Falsified: H-conv-1 | |
| (reward shaping), H-conv-5 (value head), H-conv-7 (representation, partially), | |
| H-conv-10/11/12 (reward signal), H-conv-13 (reachability curriculum). Confirmed | |
| but insufficient: H-conv-8 (anti-check prior). The failure is robust to every | |
| reward-level and data-level intervention tried. The remaining explanation is | |
| **scale**: at 50-230 iterations with ~100K params, the policy learns material | |
| and king-restriction but not termination. That is a legitimate finding about | |
| what this budget buys, not a bug — and it should be reported as such rather than | |
| patched with hand-written mating heuristics, which the project constraints | |
| forbid and which would not answer the research question. | |
| ### Scale does not fix conversion either (MEASURED) | |
| Direct test of the remaining "it's just scale" explanation, using the main run at | |
| two training depths (identical architecture, same run, 100 iterations apart): | |
| | model | KQvK mated | KQvK progress | KQRvK mated | KQRvK progress | | |
| |---|---:|---:|---:|---:| | |
| | main @170 iters | 0.00 | −0.10 | 0.00 | −0.04 | | |
| | main @269 iters | 0.00 | −0.26 | 0.02 | +0.10 | | |
| | *random* | *0.02* | *+0.24* | *0.04* | *+0.50* | | |
| | *material* | *0.10* | *+0.06* | **0.74** | *+0.62* | | |
| **100 additional iterations of the strongest model produce no conversion | |
| improvement** (0.00 → 0.00 on KQvK; 0.00 → 0.02 on KQRvK, within noise at n=50). | |
| The scale explanation is therefore *also* unsupported over the range tested: | |
| more self-play at this parameter budget buys playing strength (main beats | |
| grid_router_s0 by +199 Elo) **without buying any ability to finish a won game**. | |
| This is the sharpest statement of the project's central negative result: | |
| **under self-play from random init with outcome-based RL, ~100K persistent | |
| parameters learn material and positional play but do not learn termination, and | |
| this is invariant to reward shaping, reward discounting, credit weighting, | |
| curriculum reachability, and 100+ extra iterations of training.** Every | |
| intervention tried is recorded above with its artifact, including the ones that | |
| made things worse. | |
| --- | |
| ## INFRASTRUCTURE + PPO CORRECTNESS (MEASURED) | |
| Prompted by the observation that the research machine (192 cores, 2 TB) was | |
| being used at ~1 core: the main run was launched with `threads: 2` and a | |
| single-process self-play loop. | |
| ### 1. Parallel self-play (`tinychess/parallel.py`) | |
| `play_games` batches NN calls but runs in one process. Added process-level | |
| sharding plus a persistent pool (`SelfPlayPool`) that spawns workers once and | |
| re-broadcasts weights per iteration. | |
| Two real bugs found while building it, both recorded because they will recur: | |
| - **fork + libgomp deadlock.** With `fork`, workers inherit the parent's already | |
| initialised OpenMP pool and hang. Observed exactly: the `1w x 1t` bench row | |
| completed, then the first multi-worker row hung **17+ minutes**. Fixed by | |
| using the `spawn` start method. `spawn` then requires (a) a `if __name__ == | |
| "__main__"` guard in any script that creates a pool, and (b) payloads passed | |
| via a temp file, not `initargs` (a multi-MB state_dict raced the pipe buffer | |
| → `BrokenPipeError`). | |
| - **Sandbox OOM.** 4 spawn workers exceed the 2 GB sandbox (exit 137). This is a | |
| sandbox limit only; the Space has 2 TB. Local smoke tests must use ≤2 workers. | |
| Measured on the Space (`runs/bench_parallel3/4.json`), baseline config: | |
| | games | 1w×1t | 16w×2t | 32w×2t | 48w×2t | 64w×2t | 96w×2t | | |
| |---|---:|---:|---:|---:|---:|---:| | |
| | 256 | 1166 | **2715** | 1052 | 1271 | 899 | 539 | | |
| | 1024 | — | **4345** | 3330 | 2668 | 2152 | — | | |
| **Best = 16 workers × 2 threads = 4345 moves/s, 3.7× the single-process rate.** | |
| More workers *hurt*: each worker gets fewer games, so the per-call batch shrinks | |
| and per-process overhead dominates. In the real loop self-play went from 53 s | |
| for 256 games to **33 s for 1024 games (~6× throughput)**, and learn time fell | |
| from 105 s to 19 s once `--threads 32` was actually used. | |
| ### 2. Three genuine PPO bugs (pre-existing, not introduced by the above) | |
| The main run had `approx_kl` ≈ 200–300 and `grad_norm` ≈ 5,500 with `l_pi` ≈ | |
| +60…+130 — i.e. **it had been training with a broken objective for 400 | |
| iterations**. Three independent causes: | |
| 1. **`logp_old` was the raw policy, not the behaviour policy.** Moves are sampled | |
| from a temperature-scaled distribution mixed with Dirichlet root noise, but | |
| `selfplay.py` stored `log_softmax(logits)`. Measured consequence: 0.9% of | |
| samples had `logp_old ≈ −114`, **all at ply 3–19**, i.e. inside | |
| `dirichlet_plies=20` — noise forced moves the policy assigned ~e^−114. The | |
| PPO ratio then explodes. Fixed to store `log(probs)` of the actual behaviour | |
| distribution: `logp_old` min went **−124 → −7.77**, fraction below −20 went | |
| **1.5% → 0.0%**. | |
| 2. **Opponent positions were trained on as if they were ours.** `rec.player` was | |
| recorded but never used, so when playing a league opponent **half** the | |
| buffer carried the *opponent's* `logp_old`, making the importance ratio | |
| compare two different networks. Fixed via `only_player` (verified: exactly | |
| 50% of positions dropped in a two-model game). | |
| 3. **The `approx_kl` metric itself was wrong** — it mixed the *clamped* ratio | |
| with the *unclamped* log-difference, reporting 307 while `l_pi` was 0.03. | |
| Fixed to Schulman's k3 estimator on the same clamped `logdiff`. | |
| Also added: `--max-gen-age` (PPO staleness bound on replay sampling) and | |
| `--target-kl` with a `--min-updates` floor (standard PPO early stopping; without | |
| the floor it fired on the first update and froze learning at `loss 0.000`). | |
| **Result:** `l_pi` **+59 → +0.27**, `approx_kl` **307 → 0.02–0.09**. Phase-1 | |
| gate 12/12 throughout. | |
| ### 3. NEGATIVE: the fixed objective did not immediately yield a stronger model | |
| Resuming the healthy iter-269 checkpoint with the corrected objective and a | |
| raised entropy target (0.5) **degraded** strength within 5 iterations: | |
| | checkpoint | vs random | vs material | W/D/L vs material | | |
| |---|---:|---:|---| | |
| | iter 269 (pre-fix) | 0.537 | **0.588** | 7/33/0 | | |
| | +5 iters (post-fix, ent-target 0.5) | 0.475 | **0.438** | **0/35/5** | | |
| Repetition terminations grew 102 → 384 per 1024 games over those iterations, and | |
| entropy was held artificially high (2.05 vs 0.39 in the collapsed run). Holding | |
| entropy up prevents the all-draw collapse but also prevents the policy from | |
| committing, producing 0 wins. An earlier attempt at the *old* entropy settings | |
| collapsed the other way (ent 1.68 → 0.42, decisive 0.37 → 0.01, games 130 → 42 | |
| plies within 5 iterations). | |
| **Conclusion: fixing the PPO objective is necessary but exposes an unresolved | |
| exploration/exploitation trade-off.** The entropy controller is a blunt | |
| instrument — too low collapses to forced draws, too high prevents committal | |
| play. `main_baseline/latest.pt` has been restored to the best measured | |
| checkpoint (iter 269, 0.588 vs material). The infrastructure work stands on its | |
| own (3.7× self-play throughput, correct PPO), but **no stronger model has been | |
| produced yet**; the entropy schedule needs to be tuned as a proper sweep rather | |
| than guessed, which is the immediate next step. | |
| ### 4. The repetition collapse: diagnosed, partially fixed, strength NOT recovered | |
| With the PPO objective corrected, every resumed run collapsed the same way: | |
| **511/512 games ending in threefold repetition within 3 iterations**, mean game | |
| length falling 195 → 27 plies. | |
| **Hypothesis A (draw penalty) — FALSIFIED.** Reasoning: a draw scores z=0 while | |
| a loss scores −1, so a policy that can force repetition banks a guaranteed 0. | |
| Added `--draw-penalty`. Swept 0.05 / 0.15 / 0.30 (+ a win-discount arm), 512 | |
| games × 4 arms. **All four still collapsed to 511/512 repetitions.** The metrics | |
| show why: `mean_z` moved to −0.16 as intended, but `tr_value_mean` tracked it to | |
| −0.246 and `tr_adv_abs` fell 0.86 → 0.47. **A constant shift applied to every | |
| draw is absorbed by the value baseline, so the advantage — and hence the policy | |
| gradient — is unchanged.** Recorded as a negative result: this is a general | |
| lesson about outcome-shifting in actor-critic, not a chess fact. | |
| **Hypothesis B (claimed draws bypass adjudication) — CONFIRMED and fixed.** | |
| `game_result` auto-claims threefold repetition and the 50-move rule the instant | |
| they occur, and `adjudicate_result` was only consulted **at the ply cap**. So a | |
| side that is up a queen gets 0 the moment the position repeats. Repetition was a | |
| *free escape for the losing side* and cost the winning side its win. Added | |
| `--adjudicate-claimed`, applying the same material verdict to claimed draws. | |
| Effect over 20 iterations, 512 games/iter: | |
| | arm | decisive rate | terminations | | |
| |---|---:|---| | |
| | `ac0` (control, off) | **0.004** | repetition 510 | | |
| | `ac1` (on) | **0.137** | repetition 442, adjudicated_repetition 70 | | |
| | `ac1dp` (on + draw-penalty) | **0.154** | repetition 433, adj_rep 77 | | |
| | `ac1wd` (on + win-discount) | 0.109 | repetition 456, adj_rep 54 | | |
| **A 38× increase in decisive rate** (0.004 → 0.154) confirms the mechanism was | |
| real and was silently nullifying the training signal. | |
| **But strength did NOT improve — it regressed.** Head-to-head, 40 games, | |
| `adjudicate=none`: | |
| | checkpoint | vs random | vs material | | |
| |---|---|---| | |
| | **iter 269 (pre-existing best)** | **0.613** (W9/D31/L0) | **0.550** (W4/D36/L0) | | |
| | ac1 (+20 iters) | 0.412 (W0/D33/L7) | 0.425 (W0/D34/L6) | | |
| | ac1dp (+20 iters) | 0.475 (W0/D38/L2) | 0.400 (W0/D32/L8) | | |
| Mean game length stayed ~27 plies in all arms. The model still shuffles into a | |
| repetition immediately; adjudication now *scores* those games correctly, but the | |
| policy has not yet learned to avoid them, and 20 iterations of a corrected but | |
| very different objective was enough to undo the strength of a 269-iteration run. | |
| **Honest status.** `main_baseline/latest.pt` remains the **iter-269 checkpoint | |
| (0.550 vs material)** — the strongest model measured, and it predates all of | |
| today's fixes. The infrastructure and correctness work is real and verified | |
| (3.7× self-play throughput; three genuine PPO bugs fixed; a 38× decisive-rate | |
| recovery), but **it has not yet produced a stronger model.** Two of four | |
| hypotheses tested today were falsified by their own ablations. What the evidence | |
| now says is that the short 20–30 iteration probes are far too short to re-converge | |
| after an objective change: the next step is a single long run (≥300 iterations) | |
| on the corrected objective with `--adjudicate-claimed 1`, judged against the | |
| iter-269 baseline, rather than more short parallel sweeps. | |
| ### 5. SELF-CORRECTION: the `logp_old` "fix" was wrong and CAUSED the collapse | |
| Section 2 above reported storing the *behaviour* log-prob (temperature + | |
| Dirichlet) instead of the raw policy log-prob as a PPO correctness fix. **A | |
| direct ablation falsified it.** Identical config, 60 iters, 512 games/iter, only | |
| `--logp-raw` differing: | |
| | iter | `logp_raw=1` (raw policy) | `logp_raw=0` (behaviour) | | |
| |---|---|---| | |
| | 0 | 193 plies, decisive 0.801 | 193 plies, decisive 0.797 | | |
| | 4 | 194 plies, decisive 0.666 | 106 plies, decisive 0.133 | | |
| | 9 | **185 plies, decisive 0.432** | **58 plies, decisive 0.014** | | |
| **Storing the behaviour log-prob is what produced the repetition collapse.** | |
| Mechanism: Dirichlet root noise and temperature inflate the sampling probability | |
| of moves the policy dislikes. With `logp_old = log pi_behav`, the PPO ratio | |
| becomes `pi_theta / pi_behav`; for a noise-forced move `pi_behav` is large and | |
| `pi_theta` tiny, so whenever the advantage is positive PPO pushes the policy | |
| *toward its own exploration noise*. The policy flattens toward uniform shuffling | |
| and every game ends in threefold repetition. Textbook PPO's behaviour/target | |
| distinction assumes the behaviour policy is the thing you want to improve; | |
| **Dirichlet root noise is exploration scaffolding, not the target policy.** | |
| This also explains the `approx_kl` "floor" of 0.265 measured on an *unchanged* | |
| policy: that number was never drift, it was the exploration noise itself | |
| (0.014 with no Dirichlet and temp 1.0, rising to 0.265 at temp 1.1 + Dirichlet | |
| 0.3). Chasing it with `--target-kl` was chasing an artifact. | |
| Default reverted to `logp_raw=True`; the flag is retained for ablation. The | |
| original `logp_old ≈ −114` samples are a real but *benign* artifact — they are | |
| noise-forced moves, and PPO's ratio clamp already handles them. | |
| **Corrected status of section 2:** of the three "PPO bugs", the opponent-position | |
| leak (`only_player`) and the `approx_kl` estimator are genuine fixes; the | |
| `logp_old` change was **wrong and has been reverted**. The lesson is the one the | |
| project already mandates: no claim without an ablation. This one was reported as | |
| a fix before it was ablated, and the ablation overturned it. | |
| ### 6. `main_v4`: stable training restored, strength NOT yet recovered (IN PROGRESS) | |
| With `logp_raw=1` (reverted default) plus the two genuine fixes (`only_player`, | |
| corrected `approx_kl`) and `--adjudicate-claimed 1`, training is stable again — | |
| no repetition collapse: | |
| | iter | mean plies | decisive | entropy | | |
| |---|---:|---:|---:| | |
| | 0 | 195 | 0.794 | 3.22 | | |
| | 9 | 189 | 0.520 | 2.76 | | |
| | 19 | 168 | 0.408 | 2.47 | | |
| | 34 | 158 | 0.604 | 2.33 | | |
| | 44 | **148** | **0.771** | 2.29 | | |
| Games stay long, the decisive rate dips then recovers to 0.77, entropy declines | |
| smoothly. This is the healthy trajectory that every earlier attempt today failed | |
| to produce. | |
| **But it is still not stronger than the pre-existing checkpoint.** Head-to-head, | |
| 80 games, `adjudicate=none`: | |
| | comparison | score | W/D/L | Elo | | |
| |---|---:|---|---:| | |
| | v4 @20 iters vs iter269 | 0.438 | 1/68/11 | −44 | | |
| | v4 @45 iters vs iter269 | 0.456 | 0/73/7 | **−30** | | |
| The gap is closing (−44 → −30) but has not crossed zero. Note the very high draw | |
| rate in these matches (73/80): the two models are close and both draw-prone, so | |
| ±30 Elo at n=80 is near the resolution limit of this test. | |
| **Canonical model remains `main_baseline/latest.pt` = iter 269** (0.558 vs | |
| material, 0.617 vs random). `main_v4` continues training; it should be promoted | |
| only if it crosses the head-to-head baseline, not because it is newer. | |
| ### 7. `main_v4` final verdict + the games/updates ratio error | |
| `main_v4` ran to 75 iterations, stable throughout (156 plies, decisive 0.778). | |
| Under `adjudicate=none` the head-to-head gap against iter269 appeared to close | |
| steadily: −44 → −30 → **−9** Elo. That was misleading. Those matches were | |
| 78/80 draws, i.e. below the resolution of the test. | |
| Re-measured with material adjudication (mode stated, per project rule), which | |
| separates draw-prone models: | |
| | model | vs MaterialAgent (ADJ=material) | W/D/L | | |
| |---|---:|---| | |
| | **iter269** | **0.731** | 38/41/1 | | |
| | v4 @75 | 0.550 | 8/72/0 | | |
| Head-to-head v4 vs iter269 (ADJ=material, 120 games): **0.429, W2/D99/L19, | |
| −50 Elo.** v4 is clearly weaker; the `adjudicate=none` "convergence" was | |
| draw-masking. **This is a reusable lesson: for two draw-prone models, | |
| `adjudicate=none` cannot resolve strength — it compresses everything to 0.5.** | |
| **Probable cause identified — a scaling error of mine.** iter269 was trained with | |
| 256 games/iter and 48 updates; v4 used **1024 games/iter with the same 48 | |
| updates**, i.e. 4× more data per gradient step and therefore ~4× *fewer* | |
| gradient steps per game played. Scaling games without scaling updates reduced | |
| the learning applied per unit of experience. | |
| `main_v5` launched with `--updates 192` (48 × 4), restoring the gradient-steps- | |
| per-game ratio of the iter269 recipe while keeping the 4× throughput. Early | |
| iterations are stable (194 plies, decisive 0.79). Note the bottleneck has now | |
| flipped: learn 390 s vs self-play 45 s per iteration, so the next throughput | |
| work belongs in the update loop, not self-play. | |
| **Canonical model is still iter269** (`main_baseline/latest.pt`, 0.731 vs | |
| material adjudicated / 0.558 unadjudicated). No run today has beaten it. | |
| --- | |
| ## Analysis of `main_v4` (75 iters) — what the existing data supports | |
| No new training was run for this section; it analyses the 75 iterations already | |
| on the hub plus the two local checkpoints. | |
| ### Training trajectory (15-iteration block means) | |
| | iters | plies | decisive | entropy | mates/iter | repetitions/iter | value MAE | | |
| |---|---:|---:|---:|---:|---:|---:| | |
| | 0–14 | 189 | 0.568 | 2.86 | 44.3 | 27 | 0.549 | | |
| | 15–29 | 168 | 0.466 | 2.45 | 27.4 | 79 | 0.423 | | |
| | 30–44 | 156 | 0.637 | 2.32 | 25.1 | 86 | 0.461 | | |
| | 45–59 | 151 | 0.748 | 2.19 | 21.7 | 58 | 0.455 | | |
| | 60–74 | 152 | 0.782 | 2.10 | 24.3 | 38 | 0.486 | | |
| Training is stable (no repetition collapse). But over iters 55–74 the decisive | |
| rate slope is **+0.0018/iter** and mean plies **+0.11/iter** — i.e. **flat**. | |
| The run had plateaued; continuing it unchanged was unlikely to help, which | |
| retrospectively justifies stopping it. | |
| ### Strength verdict at n=100 with CIs (both adjudication modes) | |
| | model | opponent | adj | score | W/D/L | CI95 | | |
| |---|---|---|---:|---|---| | |
| | iter269 | random | none | 0.570 | 14/86/0 | [0.472, 0.663] | | |
| | iter269 | random | material | 0.655 | 32/67/1 | [0.558, 0.741] | | |
| | iter269 | material | none | 0.550 | 10/90/0 | [0.452, 0.644] | | |
| | **iter269** | **material** | **material** | **0.705** | 41/59/0 | **[0.609, 0.785]** | | |
| | v4_75 | random | none | 0.510 | 3/96/1 | [0.413, 0.606] | | |
| | v4_75 | random | material | 0.515 | 5/93/2 | [0.418, 0.611] | | |
| | v4_75 | material | none | 0.505 | 1/99/0 | [0.409, 0.601] | | |
| | **v4_75** | **material** | **material** | **0.565** | 13/87/0 | **[0.467, 0.658]** | | |
| **iter269 > v4_75 is credible** under material adjudication (0.705 vs 0.565, | |
| CIs barely overlap at the bounds and the win counts differ 41 vs 13). Under | |
| `adjudicate=none` both compress toward 0.5 and nothing is resolvable — | |
| re-confirming that mode cannot rank draw-prone models. | |
| ### NEW FINDING: the "strongest" model also repeats 100% under greedy play | |
| Measured over 30 games of pure self-play from randomised openings: | |
| | model | temp 0.0 | temp 0.5 | temp 1.0 | | |
| |---|---|---|---| | |
| | iter269 | **33 plies, repetition 30/30** | 128 plies, rep 15, mate 4 | 153 plies, rep 6, mate 1 | | |
| | v4_75 | **23 plies, repetition 30/30** | 128 plies, rep 28 | 182 plies, rep 22, mate 1 | | |
| **Both models repeat in 100% of greedy games.** The repetition pathology I | |
| attributed to specific training configurations is a property of *every* | |
| checkpoint in this project, including the canonical one; sampling temperature | |
| is what has been hiding it. All reported "strength" numbers come from | |
| temperature-0 arena play, which is exactly the regime where both models shuffle | |
| — they score above 0.5 only because the opponent blunders material first, and | |
| the material adjudicator then awards the win. | |
| Policy sharpness differs, and explains the strength gap: iter269 top-1 prob | |
| **0.801** (entropy 0.494) vs v4_75 **0.561** (entropy 1.154). v4 is *less | |
| committed*, so it converts fewer won positions before the ply cap. The | |
| `--entropy-target 0.45` controller held v4's entropy up and that cost it | |
| decisiveness. | |
| ### v4 is not uniformly worse | |
| On the isolated endgame suites v4 is **better** at making progress: | |
| | suite | model | mated | edge progress | | |
| |---|---|---:|---:| | |
| | KQvK | iter269 | 0.00 | **−0.10** | | |
| | KQvK | v4_75 | 0.00 | **+0.28** | | |
| | KQRvK | iter269 | 0.00 | −0.18 | | |
| | KQRvK | v4_75 | 0.00 | −0.10 | | |
| Neither mates; v4 drives the enemy king toward the edge better on KQvK. So the | |
| two checkpoints differ along a **commitment/exploration axis**, not a simple | |
| strength axis, and "iter269 is stronger" holds only for adjudicated material | |
| outcomes. | |
| ### Bug fixed: in-loop eval never ran on resumed runs | |
| `--eval-every` requires `<out>/init.pt`, which is only written by a fresh run. | |
| Every `--resume` run therefore skipped evaluation **silently**: `main_v4` ran 75 | |
| iterations with `--eval-every 25` and produced **0 eval rows**, which is why the | |
| plateau was not visible from inside the run. Fixed: a resumed run now seeds | |
| `init.pt` from the `--resume` checkpoint (so progress is measured against where | |
| the run started) and prints an explicit SKIPPED warning otherwise. The eval line | |
| now also states the adjudication mode. Gate 12/12. | |
| **Implication for the next run:** the target is not "more iterations" but | |
| **commitment without collapse** — the entropy controller currently trades one | |
| for the other. And because greedy play repeats for every checkpoint, repetition | |
| avoidance must be measured at temperature 0, not inferred from self-play stats | |
| collected at temperature 1.2. | |
| ## Train/inference mismatch: the repetition feature was zeroed during training (MEASURED, FIXED) | |
| **Status: MEASURED (bug reproduced), IMPLEMENTED (fix), learning effect NOT YET MEASURED.** | |
| The encoder has always carried a global feature `repetition_n` | |
| (`GLOBAL_FEATS[7]`, value `min(rep,3)/3`). It is populated in `encode_batch` via | |
| `board.is_repetition(2)`. | |
| `board.is_repetition()` is a function of the board's **move stack**, not of the | |
| position. The replay buffer stores each sample as a bare **FEN**, and a FEN | |
| cannot represent repetition history. Reproduction: | |
| live board (8 shuffle moves) is_repetition(2)=True feature = 0.667 | |
| chess.Board(same_fen) is_repetition(2)=False feature = 0.000 | |
| Consequence: during **self-play** the model saw `repetition_n = 0.667` and could | |
| in principle react to it; during **training** the identical position was | |
| re-encoded from its FEN with `repetition_n = 0.0`. Every gradient step the | |
| project has ever taken was computed with the one input that says *"you are | |
| repeating"* set to zero. The feature was effectively dead weight, and the policy | |
| could never learn a repetition-avoiding response, no matter what the reward did. | |
| This is a plausible mechanism for a standing negative result: `--draw-penalty` | |
| was FALSIFIED (511/512 repetitions persisted at 0.05/0.15/0.30). A penalty on | |
| repetition is unlearnable if the state the policy conditions on cannot | |
| distinguish a repetition from a first visit. | |
| **Fix (implemented):** `GameRecord.reps` records the repetition count at each | |
| position during self-play; `ReplaySample.rep` carries it into the buffer; | |
| `encode_batch(..., reps=[...])` accepts explicit values so FEN-restored boards | |
| are encoded with the value observed at play time. | |
| Verified on iter269 greedy self-play (16 games, temp 0): | |
| samples with rep>0 stored : 83 / 1343 | |
| train-time feature for those samples : 0.667 (was 0.000) | |
| Sensitivity check on iter269 (same position, rep 0 -> 2): value moves by | |
| -0.015/-0.013/+0.003 and max |logit| change 0.87 — the feature is wired into the | |
| forward pass, but iter269 never learned to use it, which is expected given it was | |
| zero in every training batch that model ever saw. | |
| **Caveat:** this fix is a *necessary* condition for learning repetition | |
| avoidance, not evidence that it works. Whether it changes temp-0 behaviour is an | |
| open question for `main_v6`. Do not claim it fixed the repetition pathology | |
| without a temp-0 measurement against the iter269 baseline below. | |
| ## Greedy (temperature-0) health baseline | |
| `experiments/exp_greedy_health.py`, 12 games, randomised openings, 200-ply cap: | |
| | model | temp | plies | rep rate | mate | unique-pos ratio | top-1 | | |
| |---|---:|---:|---:|---:|---:|---:| | |
| | iter269 | 0.0 | 27.6 | 1.00 | 0.00 | 0.72 | 0.378 | | |
| | iter269 | 0.3 | 47.7 | 0.92 | 0.00 | 0.84 | 0.341 | | |
| | v4_75 | 0.0 | 22.1 | 1.00 | 0.00 | 0.66 | 0.219 | | |
| | v4_75 | 0.3 | 48.2 | 1.00 | 0.00 | 0.81 | 0.254 | | |
| This is the acceptance target for any future checkpoint: **rep rate < 1.00 at | |
| temp 0** would be the first genuine improvement on this axis. | |
| ## Learn-phase thread benchmark (MEASURED, on the 192-core Space) | |
| `scripts/bench_learn.py`, batch 1024, buffer 39,568 samples, 3 reps: | |
| | torch threads | ms/update | | |
| |---:|---:| | |
| | 8 | 2018.5 | | |
| | 16 | **1244.1** | | |
| | 32 | 1570.7 | | |
| | 48 | 2340.2 | | |
| | 96 | 7196.8 | | |
| **16 threads is optimal.** `main_v5` used `--threads 48`, which is **1.9x | |
| slower** per update, and 96 threads is 5.8x slower — the update step is small | |
| enough that thread-synchronisation overhead dominates past 16. More cores do not | |
| help this workload; the 192-core machine is best used for parallel self-play | |
| (16 workers x 2 threads) rather than for wider intra-op parallelism. | |
| ## `main_v6` is INVALID: `--resume` silently fell back to random init (MEASURED, FIXED) | |
| **Status: MEASURED. `main_v6` must not be cited as a result.** | |
| `main_v6` was launched with `--resume main_baseline/latest.pt`, a path inside the | |
| **hub model repo**, not a local file. The container-rebuild recovery block only | |
| knew how to pull `<basename(--out)>/latest.pt` — i.e. `main_v6/latest.pt`, which | |
| did not exist yet on a first launch. Both lookups failed, the `if args.resume and | |
| os.path.exists(...)` guard was simply false, and training **started from random | |
| init with no warning**. The metrics file confirms it: an `init` event is present | |
| and the first iteration is `iter 0`, not `iter 270`. | |
| The damage was not merely a wasted run. `--eval-every` evaluates the current model | |
| against `<out>/init.pt`, and `init.pt` was now a **random network**. The loop | |
| therefore reported, at iter 9: | |
| EVAL vs init (adj=material): score=0.850 W42/D18/L0 elo+301 | |
| which reads as a large improvement and is really "a 10-iteration model beats a | |
| random one". Direct head-to-head against the real baseline says the opposite: | |
| | comparison | opening plies | T | score | W/D/L | Wilson CI | | |
| |---|---:|---:|---:|---|---| | |
| | v6 vs iter269 | 6 | 0.0 | 0.500 | 0/120/0 | [0.412,0.588] | | |
| | v6 vs iter269 | 6 | 0.3 | **0.317** | 5/66/49 | [0.240,0.404] | | |
| | v6 vs iter269 | 10 | 0.0 | 0.500 | 0/120/0 | [0.412,0.588] | | |
| | v6 vs iter269 | 10 | 0.3 | **0.325** | 3/72/45 | [0.248,0.413] | | |
| | exact in-loop repro (seed 99, n=60) | 2 | 0.3 | 0.325 | 1/37/22 | — | | |
| Reproducing the in-loop call exactly but with iter269 as the opponent yields | |
| 0.325, not 0.850 — confirming the opponent, not the protocol, was the difference. | |
| v6 (10 iterations from scratch) is **clearly weaker** than iter269, as expected. | |
| Two lessons, both now enforced in code: | |
| 1. **An in-loop "EVAL vs init" number is only meaningful if `init.pt` is the | |
| intended reference.** It silently redefines itself on a fresh run. | |
| 2. **A missing `--resume` target must be fatal.** `train_selfplay.py` now resolves | |
| a hub-style path by downloading it, and raises | |
| `FATAL: --resume ... Refusing to start from random init silently` otherwise. | |
| Verified both branches: bad path -> exit 1; `main_baseline/latest.pt` -> | |
| `[recover] pulled ckpt` then `[resume] iter=270`. | |
| Note the temp-0 rows: 120/120 identical draws. At temperature 0 with only 2-10 | |
| random opening plies, two deterministic policies replay **the same game**, so a | |
| temp-0 arena of any size carries almost no information. Comparisons must use | |
| T>0 or heavily randomised openings. | |
| ### `main_v7` (relaunch, correct resume) | |
| Genuinely continues from iter269 (`[resume] iter=270`), 1024 games/192 updates, | |
| `--threads 16`. Learn phase **197-220 s vs v6's 359-381 s** — the thread | |
| benchmark transferred to the real loop (~1.75x faster per iteration). | |
| Early iterations: plies ~144, decisive 0.58-0.67, entropy 1.90 -> 1.76. | |
| **No strength claim yet** — pending an eval against iter269 at iter 280. | |
| ### `main_v7` @ iter 292 — independent verification (MEASURED, NOT SIGNIFICANT) | |
| In-loop eval vs iter269 (n=60): iter 280 **0.542** (+29 Elo), iter 290 **0.558** | |
| (+41 Elo). Independent arena, larger n, several conditions: | |
| | condition | score | W/D/L | Wilson CI | | |
| |---|---:|---|---| | |
| | T=0.3, open 6 | 0.506 | 13/136/11 | [0.430,0.583] | | |
| | T=0.3, open 10 | 0.491 | 10/137/13 | [0.414,0.567] | | |
| | T=0.5, open 6 | 0.591 | 41/107/12 | [0.513,0.664] | | |
| | **POOLED n=480** | **0.529** | — | **[0.484,0.573]** | | |
| **The pooled CI contains 0.5: `main_v7` is NOT yet measurably stronger than | |
| iter269.** Only the T=0.5 condition is nominally above 0.5, and selecting it | |
| alone would be cherry-picking across three conditions. The in-loop n=60 numbers | |
| (+29/+41 Elo) are inside their own noise and must not be quoted as progress. | |
| `runs/mainval269/latest.pt` and hub `main_baseline/latest.pt` remain canonical. | |
| Self-play trend so far is favourable (decisive 0.61 -> 0.77, entropy 1.90 -> 1.46 | |
| over iters 270-292) but self-play statistics are not strength. | |
| ## Optimisation-stability campaign v7-v12: three real bugs fixed, NO strength gain (MEASURED) | |
| All runs resume from iter269 (`main_baseline/latest.pt`), 1024 games/iter. | |
| ### Bugs found and fixed (all verified in isolation) | |
| 1. **`--resume` silently fell back to random init** for hub-style paths. `main_v6` | |
| is invalid; its "+301 Elo" was against a random `init.pt`. Now FATAL. Verified. | |
| 2. **Repetition feature destroyed by the FEN round-trip.** Fired at play time | |
| (0.667), always 0.0 at train time. Now recorded per position. Verified: 83/1343 | |
| samples restored. | |
| 3. **Last-move feature destroyed by the same round-trip.** Play time encodes with | |
| the last move, training encoded with `None`. Now recorded. Verified: mean | |
| |logp_old - logp_recomputed| on an UNCHANGED model drops **0.0222 -> 0.0080 | |
| (2.8x)** — that residual was pure spurious PPO ratio error. | |
| 4. **Entropy-controller windup.** `ent_coef *= 1.5` every iteration while the | |
| target was unreachable (0.01 -> 0.17) as approx_kl went 1.3 -> 59.8 and | |
| grad_norm 6 -> 1918. Added `--ent-kl-ceiling` (back off when KL diverges). | |
| 5. **`--updates 192` was the dominant divergence driver.** `main_v4` (48 updates) | |
| held approx_kl at 0.01 for 75 iterations; v7/v8 (192 updates) reached 70. | |
| ### Config search over the KL controls (MEASURED) | |
| | run | updates | KL controls | outcome | | |
| |---|---:|---|---| | |
| | v7 | 192 | none | KL 1.9 -> 70, grad 1918, decisive 0.77 -> 0.53 | | |
| | v8 | 192 | ent-kl-ceiling 6 | ent_coef pinned to min, KL still 63 -> entropy was NOT the cause | | |
| | v9 | 48 | ent-kl-ceiling 6 | learn 47 s/iter (8x faster), KL ~6-8, then drift to 33 | | |
| | v10 | 48 | + target-kl 1.0, min-updates 8 | KL **0.03-0.13, loss 0.4** for ~30 iters, then collapse (plies 144->78, ent 1.60->0.55) | | |
| | v11 | 48 | + kl-hard 3.0, min-updates 4 | **nup=0 on most iterations** — silent no-ops, the known failure mode | | |
| | v12 | 48 | kl-hard 3.0, min-updates 8 | no no-ops, but the 8 forced updates keep KL at 18-25; entropy 1.14 -> 0.94 | | |
| ### Strength: flat throughout | |
| In-loop eval vs iter269 (n=60) across v7, v9, v10, v11, v12: | |
| +29, +41, +17, -41, +12, -0, -12, +17, -23, +6, -35, -6, +12, -12, +17, -0 Elo. | |
| **Every value is inside n=60 noise and the sequence has no trend.** | |
| Independent arena, `main_v7` @292 vs iter269, n=480 pooled over three conditions: | |
| **0.529, Wilson CI [0.484, 0.573]** — contains 0.5. | |
| **Conclusion (MEASURED, negative): the three encoding/optimisation bugs were real | |
| and are fixed, but fixing them did not make the model stronger.** `main_v10` | |
| demonstrates that the loop *can* be held in a well-conditioned regime | |
| (KL ~0.05, loss ~0.4) — and in that regime the model simply does not improve; it | |
| holds until the entropy controller lets it collapse again. This reproduces, with | |
| clean optimisation, the standing result that ~100K params plateau at this level. | |
| `runs/mainval269/latest.pt` (iter 269) REMAINS THE CANONICAL BEST MODEL. | |
| No checkpoint from v6-v12 beats it. `main_baseline/latest.pt` was not overwritten. | |
| ### Did the repetition fix cure temp-0 shuffling? NO (MEASURED, negative) | |
| `experiments/exp_greedy_health.py`, 30 games, randomised openings | |
| (`runs/greedy_v12.json`): | |
| | model | temp | plies | rep rate | mate | uniq | top-1 | | |
| |---|---:|---:|---:|---:|---:|---:| | |
| | iter269 | 0.0 | 33.1 | **1.00** | 0.00 | 0.75 | 0.381 | | |
| | iter269 | 0.3 | 75.0 | 0.87 | 0.00 | 0.87 | 0.328 | | |
| | v12_314 | 0.0 | 33.2 | **1.00** | 0.00 | 0.75 | 0.843 | | |
| | v12_314 | 0.3 | 41.3 | **1.00** | 0.00 | 0.78 | 0.807 | | |
| Restoring the repetition feature at training time was **necessary but not | |
| sufficient**: 45 iterations of training with a working signal did not reduce the | |
| temp-0 repetition rate at all (still 30/30). v12 is far more *committed* than | |
| iter269 (top-1 0.381 -> 0.843) and is actually **worse at T=0.3** (rep 0.87 -> | |
| 1.00, plies 75 -> 41) — the same commitment/collapse trade-off seen in v4, now | |
| running in the opposite direction. | |
| So the hypothesis "the policy cannot learn to avoid repetition because the input | |
| never told it that it was repeating" is **FALSIFIED as a sufficient explanation**. | |
| The model can see the repetition flag and still shuffles. | |
| ## RETRACTION + root cause: the approx_kl "divergence" of v7-v12 was a metric artifact | |
| **Status: MEASURED. This retracts the central claim of the v7-v12 section above.** | |
| That section concluded "`--updates 192` was the dominant divergence driver" and | |
| that v9-v12 "diverged". **That conclusion was wrong.** The reported `approx_kl` | |
| was a corrupted statistic, and the whole v7-v12 config search was tuning against it. | |
| ### The artifact | |
| `train_step` reported the batch **MEAN** of the Schulman k3 estimator | |
| `(ratio-1) - logdiff`, computed on `logdiff` clamped to +/-10. k3 is | |
| exp-tailed, so a single saturated sample contributes `(e^10-1-10)/batch`: | |
| batch 128 -> 172.0 from ONE sample. Observed max was 172.07. Exact match. | |
| Per-update distribution over 160 real updates (iter269, temp 1.2, dirichlet 0.3): | |
| | statistic | value | | |
| |---|---:| | |
| | median | **0.0308** | | |
| | p75 | 0.0583 | | |
| | p90 | 0.1175 | | |
| | p99 | 172.0 | | |
| | max | 172.07 | | |
| | fraction of updates with kl > 1.0 | **3.1%** | | |
| | mean **with** outliers | 2.4257 | | |
| | mean **excluding** kl>1 | **0.0476** | | |
| **97% of updates were perfectly healthy.** `l_pi` was never affected: PPO clips | |
| the ratio to `[1-eps, 1+eps]`, so those samples' gradients are bounded. This was | |
| a **diagnostic** bug, not a training bug. | |
| ### The source of the saturated samples | |
| Not staleness. Of 2048 sampled positions only 1 had |logdiff| > 5, it came from | |
| gen 0, and its `logp_old` was **-200.16** against a median of **-2.02**. | |
| With `--logp-raw 1` (the measured-correct default) `logp_old` is the **raw** | |
| policy log-prob, but Dirichlet root noise sometimes forces a move the raw policy | |
| gives ~0 probability: | |
| | setting | median logp_old | min | frac < -10 | frac < -50 | | |
| |---|---:|---:|---:|---:| | |
| | `logp_raw=1` | -2.021 | **-229.44** | 0.942% | 0.899% | | |
| | `logp_raw=0` | -1.959 | -7.74 | 0.000% | 0.000% | | |
| This is the same 0.9% already noted in `selfplay.py`. Flipping `--logp-raw` is | |
| NOT the fix (measured previously: `logp_raw=0` collapses play, 58 vs 185 plies | |
| at iter 9). The fix is to exclude the forced samples from the ratio term. | |
| ### Fixes (both verified) | |
| 1. **Robust reporting.** `approx_kl` is now the batch **median**; the mean, max | |
| and `kl_tail_frac` are logged alongside. Max reported value over a run fell | |
| from **172.0** to **0.0070**, so `--target-kl` / `--kl-hard` finally control | |
| something real. | |
| 2. **`--logp-floor` (default -20)** drops samples with `logp_old` below the floor | |
| from the PPO ratio. Controlled A/B, identical seeds, 90 updates: | |
| | | median kl | worst-sample kl | grad_norm p95 | grad_norm max | | |
| |---|---:|---:|---:|---:| | |
| | floor OFF (old) | 0.0018 | **22015.5** | 2.3 | 254.3 | | |
| | `--logp-floor -20` | 0.0019 | **34.7** | 2.1 | **44.5** | | |
| Worst-sample kl **635x lower**, max grad_norm **5.7x lower**, median unchanged — | |
| healthy samples are untouched. 22015.5 = e^10-1-10 exactly, i.e. the clamp bound. | |
| ### What this means for the earlier conclusions | |
| - The v7-v12 "KL divergence" ranking is **void**; those runs were compared on a | |
| metric dominated by a 3% tail. | |
| - `--updates 192` vs 48 is **unresolved**, not settled. v4's KL 0.006 vs v9-v12's | |
| 3.4-10.4 is largely the artifact plus v4's `steps=4`/`max_gen_age=2` config. | |
| - **Still standing and unaffected:** the flat strength results. Every eval across | |
| v7-v12 sat inside n=60 noise, and `main_v7` @292 measured 0.529 CI [0.484,0.573] | |
| over n=480. Fixing the metric does not make the model stronger; it only means | |
| the loop was never as sick as the logs implied. | |
| - The genuine encoding bugs (repetition, last-move, silent random init) remain | |
| real and fixed. | |