# 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() -> 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 `/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 `/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 `/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.