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