summaryrefslogtreecommitdiff
path: root/src/eval_tune/king_safety_data/HANDOFF.md
blob: b05e3d292af1ea6f3d1a350dd35705a6e7f7e005 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# King-safety defect regression: handoff

## Goal

`_EvalKing` (eval.c) sums a pile of hand-picked "defect" constants into one
scalar (`uCounter`), then runs that through a single lookup table
(`KING_SAFETY_BY_COUNTER`). The hand-picked weights assume every defect
counts equally, which is suspect -- e.g. a queen touching an undefended
ring square and a knight touching one add the exact same `+1`. This
project mines real games to see which defects actually predict getting
mated, so `eval.c`'s weights can be informed by data instead of guesswork.

**Method**: find real middlegame checkmates in human games, look at the
losing king's position N plies earlier, and compare its raw defect
pattern against a matched "sound" control position (drawn games) with the
same attacker material and development level. Fit a logistic regression;
features with a real, stable coefficient are candidates for reweighting
in `eval.c`. See `CLAUDE.md`'s "King-safety eval improvement project"
section (search for it) for the full narrative this file summarizes.

## Where everything is

- **Final training data**: `remine_v4/king_features_matched_v4.csv` --
  86,362 rows (43,181 matched positive/control pairs). **Use this one.**
- `remine_v4/merged.csv` -- raw mined FENs before feature extraction
  (172,735 rows: 43,181 positives + 129,554 control candidates).
- `remine_v4/king_features_v4.csv` -- all 172,735 rows with full
  `_EvalKing` defect breakdown, before matching trims it down to the
  86,362 confound-controlled rows.
- Pipeline scripts live in `eval_tune/` (sibling of this dir), not here:
  `mine_king_mates.py`, `shard_mine.py`, `extract_king_features.py`,
  `match_controls.py`, `train_king_safety_model.py`.
- Engine side: `chess.h`'s `KING_DEFECT_DUMP_INFO` struct,
  `_EvalKing`'s `#ifdef KING_DEFECT_DUMP` instrumentation in eval.c, and
  command.c's `evalkingdump` command -- all gated behind the
  `KING_DEFECT_DUMP=1` gmake build flag (NOT part of a normal build).
  `../typhoon_kingdump` is a saved binary built with that flag; rebuild
  it (`gmake clean` first -- flag changes don't trigger recompilation on
  their own, see "Gotchas" below) if `eval.c`/`chess.h`/`command.c`
  change.

## Final result (from `king_features_matched_v4.csv`)

Logistic regression, 80/20 train/test split, standardized coefficients.
Test AUC 0.91 (plausible for "engineered danger features vs. proven-sound
draws", not suspiciously perfect). Ranked by |coefficient|, most-to-least
trustworthy tier first (stability = full-population vs. top-tercile-army-
strength fits agreeing):

**Tier 1 -- dominant, very stable**: `filestormopenfile` (+0.57 to +0.80),
`filestormpawn` (+0.51 to +0.54). These are eval.c's `KingFileDefects[3]`
and `KingStormingPawnDefects[8]` tables (open/half-open file near the
king; how far an enemy pawn has advanced up that file), already
independent tables -- lowest-risk place to start a real retune.

**Tier 2 -- strong, stable**: `front_offboard`, `front_def`, `sq4_offboard`,
`front_undef`, `front_atk_minor`, `front_atk_rook`, `misscastleshort`,
`front_flight` (+0.33 to +0.45). "front" = the diagonal/vertical ring
squares one rank away, in the direction each side attacks toward (see
"Feature schema" below for the exact slot mapping -- do NOT assume this
means "in front of the king" the way a human would first guess; see the
correction note there). `sq3`/`sq4` are the two same-rank side squares,
kept individually resolved.

**Tier 3 -- moderate, less stable**: `back_offboard` (drifts the most of
any surviving feature, treat with suspicion), `sq3_undef`,
`attackerarmyscaler`, `queenproximity`, `back_atk_minor`, `front_atk_queen`.

**Confirmed noise -- do not retune based on this data**: `minorsathome`
(coefficient ~0.001, was previously the #1 "signal" before two sampling
bugs got fixed -- see "Bugs found" below), `initloc`, `castled` (exactly
0.0000, fully collinear with `misscastleshort`/`misscastlelong`).

## Translating this into eval.c -- confidence and plan

This is NOT a "throw away eval.c and use the model" situation. The plan
is targeted retuning of existing constants/tables, informed by relative
coefficient *ratios*, not literal log-odds-to-centipawn conversion:

1. **`KingFileDefects`/`KingStormingPawnDefects` deserve a real weight
   increase relative to everything else** -- they're the strongest
   signal by a clear margin and already live in their own small,
   isolated tables (`_ComputeFileStormDefects` in eval.c). Lowest risk,
   highest expected payoff, start here.
2. **`EmptyAttackedSquare`/`EmptyUnattackedSquare`'s front/back asymmetry
   is directionally right but probably too weak a spread** -- front-
   bucket features consistently outrank back-bucket ones here by a good
   margin.
3. **`misscastleshort`'s constant likely deserves a bump.**
4. **Don't touch `KING_INITIAL_COUNTER_BY_LOCATION` or the undeveloped-
   minors term** based on this data -- both came back as noise.
5. Neither `KingFileDefects`/`KingStormingPawnDefects` nor the
   ring-scan tables are currently `DNA_ARRAY`-registered (checked: only
   `KING_SAFETY_BY_COUNTER` and `KING_QUEEN_PROXIMITY_DANGER` are under
   king-safety). Retuning them means a manual edit (optionally promoting
   them to file scope + registering as DNA arrays for future tuning),
   not "rerun the tuner."
6. **Absolute magnitude has to be calibrated the normal way** -- run any
   change through `sd10` ECM suite comparison against `head_reference/`
   and `match_play.py`'s gated head-to-head match, same as any other
   hand-tuned eval constant. The regression tells you *shape*
   (which defects matter more relative to each other), not the right
   centipawn magnitude for a chess engine's scoring scale.
7. `CountKingSafetyDefects` (the cheap lazy-eval proxy) stays untouched
   until a real change lands and is validated -- agreed early in this
   project, still the right call.

## Feature schema (read this before extending the pipeline)

`KingSafetyDeltas` in eval.c (`-17,-16,-15,-1,+1,+15,+16,+17`) defines 8
ring slots, indices 0-7 in that order. **Correction made mid-project**:
slots 3 and 4 (deltas -1/+1) are the two SAME-RANK side squares, not
"front of the king" -- an earlier description in conversation got this
wrong. The diagonal/vertical slots (0,1,2 and 5,6,7) are the ones with a
real front/back asymmetry, matching `EmptyAttackedSquare`'s own existing
color-dependent weighting (WHITE's front is slots 0/1/2, BLACK's is
5/6/7 -- see `_EvalKing`'s comments and `KING_RING_FRONT`-style logic).

`extract_king_features.py` dumps full per-slot resolution (all 8 slots x
4 piece types x several state flags) since the engine-side cost of doing
so is free once you're calling `evalkingdump` anyway. `train_king_safety_model.py`
is where slots 0/1/2/5/6/7 get bucketed into "front"/"back" sums
(color-dependent per row's `mated_color_or_sampled_color`), while slots 3/4
stay individually resolved -- this bucketing was added in this session
after the diagonal per-slot coefficients turned out to be the noisiest,
most sign-flipping entries in every full-vs-subset drift comparison.
`filestorm` is deliberately NOT used as a training feature -- its two
independent sub-components (`filestormopenfile`, `filestormpawn`) are
used instead, since they map onto two already-separate eval.c tables and
there's no reason to make the regression re-derive their relative
importance from one pre-summed number.

## Bugs found and fixed this session (read before trusting old data)

If you find any `.csv` file in this project OTHER than
`remine_v4/*_v4.csv` and its merged.csv, IT PREDATES ONE OR MORE OF THESE
FIXES AND SHOULD NOT BE USED. (Older files were deleted during cleanup,
but if this pipeline gets rerun and produces intermediate files again,
this is the checklist for whether they're trustworthy.)

1. **Bare-king endgame mates leaking into "middlegame" positives.** Only
   checking the attacker's material (`>= 800cp`, roughly rook+bishop) let
   trivial K+Q-vs-lone-K technique mates through. Fixed by also requiring
   `total_non_pawn_piece_count(board) >= 8` (mirrors `_EvalKing`'s own
   `pos->uNonPawnCount[WHITE][0] + pos->uNonPawnCount[BLACK][0] < 8`
   endgame boundary exactly) -- see `is_middlegame()` in
   `mine_king_mates.py`.
2. **`min_ply` too low (10, i.e. move 5) for control sampling.** A large
   fraction of the pool's draws are short draws-by-agreement close to
   `filter_pgn.py`'s own 40-ply floor. Combined with a large end-guard
   (staying clear of the game's end), the only valid sampling window on a
   short draw was the early opening -- where minors are legitimately
   still undeveloped for completely ordinary reasons. This alone caused
   `minorsathome` to look like a massive, spurious "signal" (controls
   showed `minorsathome>=2` in ~85% of samples). Verified the engine's
   own computation was correct on a real developed-middlegame FEN --
   100% a sampling-window bug, not an eval bug. Fixed: `min_ply` raised
   to 20 (move 10), and a game too short to satisfy both `min_ply` and
   the end-guard is now skipped entirely rather than force-sampled early.
3. **Control-sampling ply selection secretly always picked the earliest
   plies in a game's valid window.** `rng.shuffle(candidates)` followed
   by `for idx in sorted(candidates)` completely undid the shuffle.
   Fixed in two steps (the first fix wasn't sufficient on its own --
   see the code comment in `mine_controls()` for why): randomly
   subsample which plies to try, replay ascending through all of them
   ONCE (required -- boards can only be walked forward), collect every
   result that passes filters into a list, THEN randomly choose which
   results to actually keep via `rng.sample` on that list. Verified:
   sampled-ply-as-fraction-of-game-length now has a median around 0.46
   with real tail into 60%+, instead of clustering at 20-30%.
4. **Matching-radius bug treated two very differently-scaled confound
   columns with one shared distance metric.** `attackerarmyscaler`
   spans 0-31 (`ASSERT`-confirmed hard clamp ceiling), `minorsathome`
   spans 0-4. A single Chebyshev radius meant a "tight" radius-2 match
   still let `minorsathome` drift by up to 2 -- literally half its
   entire range. This is why `minorsathome` was STILL the #1 "signal"
   even after an initial matching attempt. Fixed: `match_controls.py`
   now takes `--exact-cols` (must match exactly, no radius search --
   `minorsathome` defaults here) and `--radius-cols` (allowed to expand
   outward, `attackerarmyscaler` defaults here) as separate concepts.
5. **KDTree-based nearest-neighbor matching matched almost nothing** (308
   of 43,181 positives) because the match columns are low-cardinality
   integers -- huge exact-value clusters mean a KDTree's "k nearest" are
   massively tied, so the first few positives processed in a cluster
   exhaust the shared candidate set and everyone else in that cluster
   fails to match. Replaced with exact-bucket + expanding-integer-radius
   matching (see `match_controls.py`), which has no such collision
   problem.
6. **`_EvalKing`'s per-square ring scan implicitly assumed all 8 slots
   were geometrically equivalent for feature purposes**; slots 3/4
   (side squares) vs. the diagonal slots turned out to behave
   differently enough that per-slot resolution on the diagonals was
   pure noise at the sample sizes available -- see "Feature schema"
   above.
7. **Board-diagram spam.** `setboard` prints a full ASCII board dump on
   every call by default (`g_Options.fShouldPost`, see `DumpPosition` in
   gamelist.c), which dominated per-row I/O cost during feature
   extraction. Fixed by prepending `nopost` to every extraction script.
8. **Two separate concurrent-process races during interactive mining**,
   both self-inflicted mid-session: (a) `$!` after a `cd ... && nohup
   ...` pipeline captured the wrong PID, leaving a stale duplicate
   process racing on the same output file as a supposedly-fresh rerun;
   (b) killing that process didn't fully stop it before a new one
   started. No lasting damage (caught via `ps aux` before trusting any
   output), but: **always verify a background job is actually singular
   via `ps aux | grep <script>` before trusting its output**, especially
   after a kill+relaunch sequence.
9. **`sizeof(KING_DEFECT_DUMP)` name collision with the
   `-DKING_DEFECT_DUMP` build flag.** The build flag is defined as
   literal `1` by the compiler; naming the struct type the same thing
   caused the preprocessor to mangle the struct declaration wherever the
   type name appeared. Renamed the struct to `KING_DEFECT_DUMP_INFO`.
   If you ever add a new `#ifdef SOME_NAME` build flag AND a same-named
   type/variable in the same session, expect this failure mode.
10. **`gmake` doesn't recompile on a flag-only change.** After the struct
    rename (#9) and other `chess.h` edits, a `gmake KING_DEFECT_DUMP=1`
    without a preceding `gmake clean` silently reused stale `.o` files
    from an earlier flag-less build, producing a binary that didn't
    actually have `evalkingdump` registered, with no compile error at
    all. Always `gmake clean` first when a build-flag combination
    changes, not just when headers change (this is already called out
    in `CLAUDE.md` for header changes, but the flag-only case is easy to
    forget too).

## Performance notes for rerunning

- `pgn/twic_filtered.pgn` (642,268 games) mines in a few minutes when
  sharded 12-24 ways via `shard_mine.py` (byte-range sharding on real
  game boundaries, found via a fast raw-text `[Event ` scan). Don't run
  it unsharded -- a single-process full scan took ~40 minutes.
- Feature extraction (`extract_king_features.py`) is the slow stage:
  ~54 minutes for 172,735 rows sharded 18 ways on a contended 24-core
  box. `nopost` (fix #7 above) is essential -- without it, board-diagram
  printing dominates I/O cost.
- `match_controls.py` is pure in-memory bucket lookups, seconds even for
  the full dataset -- never worth sharding.
- If `twic_filtered.pgn` (642k games, >=2400 Elo / >40 ply filtered) ever
  proves insufficient for match density, `pgn/twic.pgn` is 7-8x larger
  (4,099,094 games, unfiltered) and available as a fallback source --
  not needed this run (100% match rate achieved on `twic_filtered.pgn`
  once the above bugs were fixed).

## Suggested next steps

1. Pick 2-3 concrete constant changes from Tier 1/2 above (start with
   `KingFileDefects`/`KingStormingPawnDefects` -- lowest risk).
2. Land them as a normal `eval.c` diff, run `precommit_check.sh`.
3. `sd10` comparison across the three curated suites against
   `head_reference/`.
4. `match_play.py` gated head-to-head match against the pre-change DNA.
5. Only keep the change if `LOWER95 >= 0.5`.
6. Once a change lands, consider whether `CountKingSafetyDefects` (lazy
   eval) needs corresponding rework -- deferred by design until a real
   change is validated (see project goal section above).