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

A newer version of the Gradio SDK is available: 6.31.0

Upgrade

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:

  1. Plain REINFORCE collapses (entropy → 0.07, all games repetition-draws, gradient norm → 108, +338 Elo → −9). Cause diagnosed; PPO + adaptive entropy fixes it.
  2. 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.
  3. 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.
  4. Depth does not extrapolate: strength peaks at 8 steps and degrades at 16.
  5. 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

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:

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.