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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
|
# Move-prediction ("policy") model -- design notes
Status: design converged enough to start implementation. Not yet begun.
## Idea
Train a small model to predict, given a position, how likely each legal
move is to be the move a strong human player would choose (the
Lc0/Maia-chess "policy head" / human-move-prediction pattern). Candidate
uses:
- Move-ordering hint at the root (`root.c`) -- reorder/bias toward
plausible moves, cheap and low-risk: root is called once per search,
over ~20-40 moves, so cost is a non-issue regardless of model size.
- Possibly extend to the top 1-2 plies of internal search
(`generate.c`'s `_ScoreAllMoves`), not deeper. Cost there is
bounded by how many ply-2 nodes alpha-beta actually visits (root
branching factor many, not root-branching-factor^2), which is cheap
enough for a linear model but rules out anything heavier.
- Outright root move pruning/reduction based on low policy confidence --
explicitly flagged as the higher-risk version of this idea. A
human-implausible move can still be objectively best (that's part of
how engines out-play strong humans), so a hard prune here risks
capping search quality below what plain search would find. Start with
move-ordering use only; treat pruning as a later, carefully-gated
experiment, not a given.
- Every-node use is explicitly ruled out -- far too expensive given
`eval.c`'s existing sub-microsecond-per-node budget.
## Model choice
**Linear (logistic regression) model over handcrafted move features,
not a neural net, for the first version.** Rationale, by comparison
against the alternatives considered:
| | Linear/logistic (chosen) | Small MLP / NNUE-accumulator head | Conv policy head (AlphaZero/Lc0-style) |
|---|---|---|---|
| Params | hundreds | tens of thousands-~1M | millions+ |
| Root-only inference cost | microseconds total | tens of µs | hundreds of µs-ms |
| Training difficulty | trivial (sklearn, minutes) | moderate (small PyTorch job) | a real project (this is Maia's actual scope) |
| C port | dot product | matrix-vector + activation (same shape as NNUE forward pass) | not realistic hand-rolled, no ML framework at inference |
Conv-style board embeddings are off the table entirely for this codebase
(no ML framework at inference). An MLP on an NNUE-style board embedding
is a plausible *second* iteration once NNUE's accumulator exists (same
input representation, different output head) but isn't a dependency for
starting now.
**Don't encode raw move identity as one-hot from/to-square (4096
categories)** -- a linear model can't generalize across squares that
way (no shared weight helps "knight e4-f6" inform "knight e5-f7"); use
relative/derived features instead (see below). Raw square one-hot only
makes sense once there's a conv/attention architecture to generalize
across squares, which is explicitly not this version.
## Label and training objective
Label is categorical ("which legal move was played"), not a per-move
score -- this does **not** require search or eval annotation at all,
unlike the NNUE eval work. The label is extracted directly from PGN
move text.
**Variable candidate-set problem**: every position has a different
number of legal moves, so there's no fixed class space to hand to a
standard multiclass classifier (sklearn's `multi_class` machinery
assumes a fixed shared label set). Fix: flatten to one training row per
**(position, candidate move)** pair, not one row per position:
- Each legal move at a position becomes its own row with that move's
feature vector.
- Binary label: 1 if this move is the one actually played, 0 otherwise.
- Train one shared binary logistic regression (`class_weight='balanced'`
to handle the ~1-in-30-40 positive rate) across all rows from all
positions pooled together.
- At inference: score every legal move at the *current* position with
the one trained weight vector, then rank/softmax across just that
position's candidates.
This is the standard "pointwise learning-to-rank" formulation and is
directly compatible with plain `sklearn.linear_model.LogisticRegression`
-- no custom loss, no variable-length output layer needed.
**Top-k falls out for free, no multi-label training needed.** A
softmax/logistic score is already a full relative-ranking signal over
all legal moves at a position; `argsort` the per-move scores and take
the top 3-4. No architecture change, no different label format --
exactly the single-label training above already produces this. Note
these are *relative* ranking scores, not calibrated probabilities --
fine for move-ordering, don't assume "70% confidence" literally means
70% empirical accuracy without a separate calibration check.
**Held-out validation**: hold aside a slice of positions (not used in
training) to measure model quality -- top-k coverage against the
held-out human moves is the first-pass metric (top-1 "exact match" is
the wrong metric for a move-ordering use case; top-3/top-5 coverage is
what actually matters, since the goal is getting the eventually-best
move searched early, not predicting humans exactly). Once that looks
reasonable, validate the actual move-ordering-hint use case via
`match_play.py`'s existing head-to-head gate (same `LOWER95 >= 0.5`
threshold used for DNA tuning), not just offline accuracy.
## Features
### Global (position-level, shared across all candidate moves at a position)
- material balance
- game phase, derived from material-left
- side to move
- castling rights, both sides
- king safety, both sides -- reuse `eval.c`'s `iKingSafety[]`, which is
already computed per side after a full `Eval()` call; no new
computation needed
- in-check flag
### Per-move (recomputed per candidate move)
Built from the `MOVE` union (`chess.h`): `cFrom`, `cTo`, `pMoved`,
`pCaptured`, `pPromoted`, `bvFlags`.
- `pMoved` (piece type)
- SEE value, if a capture
- gives-check flag (computed the same way `generate.c` does, via
`ExposesCheck`/attack detection after making the move)
- relative square features instead of raw `cFrom`/`cTo`: file delta,
rank delta, distance, and a centrality-table lookup on `cTo` (reuse
whatever `eval.c` already has for this)
- **explicit disambiguated move-category, one-hot, not the raw
`bvFlags` bit**: `{NORMAL, CAPTURE, DOUBLE_PAWN_PUSH, EN_PASSANT,
PROMOTION, PROMOTION_CAPTURE, CASTLE_KINGSIDE, CASTLE_QUEENSIDE}`.
The raw "special" bit's meaning is context-dependent on `pMoved`
(special+king=castle, special+pawn=double-jump/en-passant/promotion
depending on `pPromoted`/`pCaptured`), so it must be decoded into
explicit disjoint categories *before* being handed to a linear model
-- feeding the raw bit directly would conflate castle-ness with
promotion-ness, etc. Decode mirrors the existing `IS_CASTLE`/
`IS_PROMOTION`/`IS_ENPASSANT`/`IS_DOUBLE_JUMP` macros exactly; castle
side (kingside/queenside) isn't in the union bits at all and must be
derived from `cTo` (g1/g8 vs. c1/c8).
**Explicitly excluded**: dynamic move-ordering signals (history table,
countermove table, continuation-history evidence). These don't exist
in this training setting at all -- they're accumulated *during* a
single search and reset between searches, so there's no equivalent
"has this move class worked before" signal minable from a static human
PGN position with no search running. (Could become relevant again if
ever training on self-play search traces instead of human games, but
that's a different, much more expensive data source -- not planned.)
## Training data pipeline
Driven by a persistent `typhoon` subprocess via the xboard protocol --
the same `Engine` class pattern `tune_eval_dna.py` and `match_play.py`
already use (one long-lived process, many `setboard`/command round
trips over stdin/stdout, not one process spawned per position -- the
per-call overhead that pattern avoids is exactly what the earlier
`EvalCommand` persistent-context fix was about).
**New engine command, `movefeatures`**: given the current position
(after `setboard`), generates the legal move list and dumps the full
feature vector for every legal move via `Trace()` (same style as
`evaldna dump`). Critically, this calls the *same* feature-building
code that real move-ordering will eventually call -- see "Shared
feature code" below -- so there is only ever one implementation of the
`MOVE`-union decode logic, never a second hand-ported copy in Python
that can silently drift from `chess.h` if the union's bit layout ever
changes.
Python driver loop: `setboard <fen>` / `movefeatures` / parse stdout
into feature rows, across the full training position set, against one
subprocess. Source positions: `../pgn/lichess_elite_*.pgn` and
`../pgn/twic_filtered.pgn`, both directly usable as (position,
move-played) pairs with no search/eval annotation needed.
**Label extraction correctness check**: for each extracted row, verify
the label-matched move, replayed, produces the same resulting FEN as
the PGN's next recorded position -- catches silent label corruption
from castling-notation/en-passant/promotion-parsing mismatches early,
before training on volume.
## Deployment in C (no Python/ML framework at inference)
Logistic regression's output is just a weight vector + bias (dozens of
floats) -- far simpler than NNUE's eventual quantization/loader needs.
Export `sklearn`'s `.coef_`/`.intercept_` as a flat array, baked
directly into a C source file the same way `bake_dna.py` writes tuned
values into `eval.c`'s initializers (simplest option given how small
this is; a runtime-loadable file, `evaldna`-style, is a fallback if
retraining turns out to be frequent enough that recompiling each time
is annoying).
**Shared feature code is the real risk, not the weight format.** There
must be exactly one function, e.g. `BuildMoveFeatures(ctx, move) ->
float[N]`, called by both the `movefeatures` dump command (training
data generation) and the real move-ordering caller (`root.c`, and
`generate.c` if extended to ply-2). Two independent implementations of
this will eventually drift -- the same bug class this project's own
history has already hit more than once (the `command.c` eval-caching
bugs, the killer-table backfill collision). Inference is then: for
each legal move, call `BuildMoveFeatures`, dot-product against the
baked weight array, add bias, rank/softmax across this position's
legal moves.
**Guard against silent drift between retrains**: give the baked
feature-weight array a length/version check against
`BuildMoveFeatures`'s actual feature count (same defensive pattern as
`bake_dna.py`'s line-count-mismatch abort), so a future feature-vector
change that forgets to retrain fails loudly at build/startup instead of
silently scoring garbage.
## Staged implementation plan
Each stage should produce something checkable before moving to the
next -- don't bundle these into one big change.
1. **`BuildMoveFeatures` + `movefeatures` command.** Write the shared
feature-building function in C (global features first -- material,
phase, side to move, castling rights, `iKingSafety[]` -- then the
per-move features, including the disambiguated move-category
decode). Wire it into a new `movefeatures` command that dumps every
legal move's feature vector via `Trace()`, driven by `setboard`.
Checkable output: by hand, confirm a few known positions (a castle
available, an en-passant-capturable position, a promotion) produce
the expected one-hot move-category for each special move.
2. **Python extraction driver.** Persistent-subprocess `Engine` loop
(reuse the `tune_eval_dna.py` pattern) over `lichess_elite_*.pgn` /
`twic_filtered.pgn`: `setboard` + `movefeatures` per position, parse
stdout into `(features, label)` rows. Run the replay-verification
check (label-matched move, replayed, matches the PGN's next FEN) on
a sample before trusting volume. Checkable output: a cached rows
file (parquet/csv) plus a verification-pass rate printed at the end.
3. **Train + offline validation.** Flatten to per-(position, move) rows
with binary labels, `sklearn.linear_model.LogisticRegression` with
`class_weight='balanced'`, train/held-out split. Checkable output: a
top-1/top-3/top-5 coverage number on the held-out slice. Stop here
and reassess if top-3 coverage looks weak before building any C
deployment path -- no point wiring up inference for a model that
doesn't predict anything.
4. **Export + C inference wiring, root only.** Bake `.coef_`/
`.intercept_` into a C array (`bake_dna.py`-style, with a
length/version guard against `BuildMoveFeatures`'s feature count).
Add the scoring loop to `root.c`: for each root move, call
`BuildMoveFeatures`, dot-product, rank. Gate this behind a flag so it
can be toggled off cleanly, not wired in live as the only ordering
path on the first pass.
5. **Head-to-head validation via `match_play.py`.** Candidate = root
ordering using the model's ranking; baseline = current root
ordering. Gate on `LOWER95 >= 0.5`, same as DNA tuning. This is the
real test -- offline top-k accuracy from stage 3 is a necessary
check, not a sufficient one, since move-ordering quality and human-
move-prediction accuracy aren't the same thing.
6. **If stage 5 clears the gate: extend to ply-2** in
`generate.c`/`_ScoreAllMoves`, same model, same `BuildMoveFeatures`
call, no architecture change -- just a second call site. Re-run the
`match_play.py` gate for this extension separately; a win at root
doesn't guarantee a win at ply-2 (different node population, deeper
in a quiescent-ish part of the tree).
7. **Out of scope until the above lands**: root-level pruning based on
low policy confidence (the explicitly higher-risk use case), and the
NNUE-accumulator-based second iteration described in "Future
direction" below.
## Future direction (not needed to start)
Once NNUE's accumulator exists (see `NNUE.md`), a second iteration
could replace the handcrafted global+per-move feature vector with a
learned board embedding (same accumulator, second output head) --
this trades interpretability (today's linear weights are individually
auditable, the way eval constants are) for potentially higher
accuracy. Not a dependency of the first version; revisit after NNUE
lands.
## Open questions, not yet resolved
- Exact `BuildMoveFeatures` signature and feature-ordering convention,
shared between the dump command and the real move-ordering caller --
deferred until implementation starts.
- Whether/how far to extend beyond root into ply-2 internal search --
cost looks affordable per the analysis above but not yet measured
against real search statistics (how many ply-2 nodes actually survive
alpha-beta pruning in practice).
- Calibration -- not needed for ranking/move-ordering use, but would be
needed if a future use case wants literal confidence thresholds (e.g.
for the higher-risk pruning idea).
|