summaryrefslogtreecommitdiff
path: root/src/CLAUDE.md
blob: 881b062b3e13cd04c95bf830214a589982690303 (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
255
256
257
# typhoon chess engine

A chess engine by Scott Gasch ([email protected]), originally started 2004.
Previously named "monsoon" -- any old references to that name mean this same
codebase. Speaks the xboard/WinBoard protocol. FreeBSD host, 64-bit.

## Building

`build.sh`'s OS detection (`expr $OSTYPE = "darwin"`) is broken in this shell
environment and falls through to a 32-bit `gcc`-based profile that doesn't
exist here (no `gcc`, and the resulting `.o` files are architecture-mismatched
against everything else). **Don't use build.sh as-is.** Build directly with:

```sh
gmake -j5 GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1
```

This uses `clang`/`clang++` (already the GNUmakefile default) and produces a
64-bit binary matching the historically-built one. After changing any header
touched by many translation units, `gmake clean` first to avoid 32/64-bit
object mismatches at link time.

## Running / xboard protocol basics

No command-line flags are required for interactive use; the binary drops into
an xboard-protocol REPL on stdin/stdout. Key commands used during development:

- `force` -- stop the engine from auto-playing; you control both sides' moves.
- `setboard <fen>` -- set the position.
- `eval` -- print `Static eval: <score>` (calls `Eval()` directly, no search).
- `evaldna dump` / `evaldna read <file>` / `evaldna write <file>` -- the
  eval-constant import/export system (`g_EvalDNA` in eval.c). `evaldna` alone
  (no subcommand) just prints usage -- you need `evaldna dump` explicitly.
- `sd <n>` / `st <n>` -- fixed depth vs. fixed time search limits.
- `go` -- search and play one move for whichever side is on move, then stop
  (when paired with `force` after). Repeated `go`/`force` cycles correctly
  drive a full self-play game -- the engine tracks its own position across
  calls, no need to feed the opponent's move back manually.
- `script <file>` -- run a list of commands from a file, used for test suites
  (see ECM below). Prints solved/unsolved stats at the end.
- `--dnafile <path>` (startup flag) or `evaldna read <path>` (runtime) load a
  DNA file produced by tuning; `--egtbpath <path>` or `set EGTBPath <path>`
  configure Syzygy tablebase lookup.

**Default EGTB path is hardcoded** in `main.c:297` to `/zscratch/egtb`, used
whenever `--egtbpath` isn't passed. That directory currently has full 5-man
WDL+DTZ Syzygy coverage but is **missing the WDL half of its 6-man set** (only
`.rtbz`/DTZ files exist for 6-man combos) -- `init_tb()` in
`fathom/tbprobe.c:752` requires the WDL file to exist before registering a
combination at all, so every 6-man entry is silently skipped and the engine
reports "up to 5 men" even though 6-man `.rtbz` files are sitting right there.
Not a config issue; the fix is downloading the missing 6-man `.rtbw` files.

## Testing: the ECM tactical suite

`tests/ecm.ep_` is a 3517-line / 881-problem EPD tactical test suite.
`test.sh <epd-file> <seconds-per-move>` runs it and diffs against
`lastrun.log` via `suite_diff.pl`. Two things to know:

1. **`suite_diff.pl`'s shebang (`#!/usr/bin/perl`) is wrong on this box** --
   perl lives at `/usr/local/bin/perl`. Invoke it as `perl ./suite_diff.pl
   <new.log> <lastrun.log>` rather than `./suite_diff.pl`.
2. **Prefer `sd <depth>` over `st <seconds>` for before/after eval
   comparisons.** A time-based limit lets identical positions reach different
   search depths across runs purely from incidental machine load (this
   session saw large timing swings from unrelated processes/scrubs) --
   `sd 10` (or similar) holds search effort constant so a solve-rate
   difference is actually attributable to the eval change being tested.

Current baseline (`lastrun.log`, `st 1`, unmodified eval constants):
**606/879 solved.** A first full DNA-tuning pass against 600k TWIC positions
raised this to **622/879** (see `eval_tune/`).

`Trace()` (used for almost all engine output, including ECM's per-problem
results) only `fflush()`s stdout, **not** the logfile -- `Log()`/`Bug()` do
flush the logfile every call, but `Trace()` doesn't. A `--logfile` file can
lag well behind actual progress while a run is in flight; it catches up fully
once the process exits. Don't trust a logfile's line count as a live
progress indicator for a running batch job.

## Eval tuning pipeline (`eval_tune/`)

`tune_eval_dna.py` is a Texel-style tuner: drives `typhoon` as an
xboard-protocol subprocess, fits `g_EvalDNA`'s ~1870 raw constants (reduced to
~468 after collapsing cells that share a starting value -- see caveat below)
against TWIC game outcomes via sigmoid-squared-error + coordinate descent.
`dna_diff.py` renders a human-readable, per-array diff between two `.dna`
files (with 8x8 grids for the board-shaped location tables) so you can
actually see what a tuning run changed. Position pools are cached under
`position_cache/` keyed on source-file mtime+size+filter args, since a
500k-600k position PGN scan takes ~20 minutes.

**Two real bugs were found and fixed this session, both in `command.c`, not
in the Python tooling:**

1. **`EvalCommand` used to malloc + fully zero (`InitializeSearcherContext`,
   which memsets the whole ~75MB `SEARCHER_THREAD_CONTEXT` -- `rgPawnHash`
   and `rgEvalHash` are embedded arrays inside that struct, not
   separately-managed heap buffers) on every single `eval` call, then free
   it.** Fixed to keep one persistent context and use the cheap
   `ReInitializeSearcherContext` (just repositions + zeros counters) on
   subsequent calls -- ~1000x throughput improvement for scripted eval
   scoring.
2. **That persistent context's embedded eval hash was never invalidated when
   new DNA was loaded**, so after the fix above, a position scored once under
   any DNA would return that *same cached score forever*, silently ignoring
   every later `evaldna read`. Fixed via a global `g_uDnaGeneration` counter
   (bumped in `EvalDnaCommand` on successful load, checked in `EvalCommand`)
   that forces exactly one full re-init per DNA change, not per position.
   **If this regresses, tuning results will look like it "converged" almost
   immediately with implausibly little movement -- that's the symptom, not
   genuine convergence.** Verify by perturbing a few known-live parameters
   and confirming the reported error actually changes.

**Known limitation, not yet fixed:** the distinct-value grouping used to
shrink 1870 raw cells to ~468 tunable parameters ties together *any* cells
that start out numerically equal, for whatever reason -- true mirror
symmetry, an intentional-but-asymmetric design choice (e.g.
`KING_INITIAL_COUNTER_BY_LOCATION`'s rank-8 row `1,0,0,0,1,0,0,1` flags
a1/e1/h1 specifically, not a geometric mirror), or pure incidental coincidence
(e.g. `PAWN_CENTRALITY_BONUS`'s legitimate "no bonus" squares happening to
share the value `0` with unrelated off-board padding cells). The padding case
is harmless (those indices are never read during real eval) but the
coincidental-equality case is a real, unaddressed source of noise in the
parameter space, most visible on small/noisy training runs (sentinel `-1`
placeholder cells in `TRADE_PIECES` drifted to an arbitrary shared value on a
25k-position smoke test; they stayed put on the full 600k run).

**`Engine`/`EnginePool` in `tune_eval_dna.py` must be used as a context
manager (`with Engine(...) as e:`) or have `.quit()` called explicitly.**
Each spawned engine holds a kernel SysV semaphore
(`semget(IPC_PRIVATE, ...)` in `unix.c`) for its input-dispatch loop, which is
**only released by a graceful `quit`, not by the OS when the process is
killed**. `kern.ipc.semmni` (max semaphore sets system-wide) is only 50 on
this box; a session's worth of force-killed test engines can leak past that
limit, after which *every new engine* silently falls back to a 100ms-per-
command polling path (`_WaitUntilTheresInputToRead` in `input.c:63`) instead
of the fast semaphore wait -- a ~1000x slowdown that looks exactly like "the
machine is under heavy load" and cost significant debugging time before the
real cause (`ipcs -s`, then `ipcrm -s <id>` for each leaked one) was found.

## Automated eval-tuning loop (`eval_tune/cycle.sh`)

A later session built a full wash/rinse/repeat pipeline on top of the manual
tuning tools above, so a cycle goes baseline -> tune -> head-to-head gate ->
bake-in-if-better, repeatable indefinitely against a growing game pool.

- **`filter_pgn.py <in.pgn> <out.pgn> --min-elo 2400 --min-ply 40`** -- fast
  text-only pre-filter (no python-chess board replay, so it runs in ~100s
  over a multi-GB pool) that drops short/low-rated games before they ever
  reach `tune_eval_dna.py`'s slower, board-replay-based filtering. Confirmed
  most of a raw TWIC pool's mass is short draws/low-rated games: filtering
  the full merged `twic.pgn` (4,099,094 games) down to >=2400 Elo / >40 ply
  kept 642,268 games (556MB) -- that's `twic_filtered.pgn`, the pool actually
  used for tuning and match play now.
- **`match_play.py <engine> <baseline.dna> <candidate.dna> --pgn <pool> --games N --sd D`**
  -- head-to-head gate match, N games (games/2 unique openings, each played
  twice with colors swapped, so opening luck cancels), fixed search depth
  (not clock time, same sd-over-st reasoning as ECM below). Prints
  `CANDIDATE_SCORE=... LOWER95=...` -- **gate on `LOWER95 >= 0.5`, not the raw
  score.** Search is non-deterministic (MP=1 multithreaded), confirmed live:
  two engines loaded with *identical* DNA still split games unevenly at
  small N, so a bare score >=0.5 is not evidence of a real improvement, only
  a 95%-confidence lower bound that still clears break-even is.
  Three real bugs were found and fixed getting this to give sane numbers:
    1. **The opening book was still live**, so early moves in every game
       were book lookups that never touched eval at all -- defeats the
       purpose of a match meant to compare eval quality. Fixed by pointing
       `book name` at a nonexistent file each game; `root.c`'s
       `g_uBookProbeFailures >= 3` gate then disables book probing for the
       rest of that game after 3 cheap failed opens.
    2. **Engines were spawned with no `--hash`/`--cpus` at all**, so every
       game ran on `main.c`'s memset-zero default (a 65536-entry hash table)
       instead of the 256m used everywhere else in this project's own
       testing -- worse move ordering, much more re-search in complex
       positions. Fixed by passing `--hash 256m --cpus 1` explicitly (added
       an `extra_args` param to `tune_eval_dna.py`'s `Engine` class for
       this).
    3. **Every engine process also defaulted to a *shared* `typhoon.log`**
       in cwd (same memset-zero default). With `--workers N` running
       `2*N` concurrent engines, they were all racing on
       `BackupFile()`-then-`fopen(wb+)` at startup and `fflush()`ing that
       one file on every `Log()`/`Bug()` call -- real lock contention,
       observed live as unexplained match slowdown. Fixed with
       `--logfile -`, which `main.c` special-cases to skip file logging
       entirely (no rename race, no fopen, no fflush).
    4. Separately (not a match_play.py bug but cost real wall-clock time
       chasing it as one): `sample_openings()`'s original implementation did
       a sequential `chess.pgn.read_game()` scan from byte 0 of the pool
       file to pick even a couple of random openings -- minutes of wasted
       I/O/parsing on a 556MB pool. Rewritten to seek to random byte
       offsets and parse just the one game found there, cached the same way
       as `tune_eval_dna.py`'s `position_cache/` (keyed on pool identity +
       params) under `opening_cache/`.
  **If a match run seems to hang or take far longer than a lone `sd N`
  search should:** check for leftover engine processes from a previous
  killed/interrupted run first (`ps aux | grep typhoon`) before assuming a
  new bug -- a stray process from an earlier debug session silently eating a
  full CPU core was mistaken for a pipeline bug for a while before being
  found and killed.
- **`bake_dna.py <candidate.dna> --eval-c eval.c --out eval.c`** -- writes a
  winning candidate's values into `eval.c`'s array initializers *in place*,
  preserving all formatting/comments/8x8 grid layout (regex-replaces only
  the numeric literals found outside comments, in `g_EvalDNA[]` order, so a
  resulting `git diff` shows only the numbers that actually changed).
  Round-trip verified: dumping the currently-compiled-in DNA and baking it
  right back produces a byte-identical `eval.c`. **Positional, not
  name-matched** -- a `.dna` file only works against the exact `eval.c`
  revision it was tuned against; a line-count mismatch against the current
  `g_EvalDNA[]` aborts loudly rather than silently misassigning values.
- **`dna_trend.py cycle1.dna cycle2.dna ... [--cycles-dir eval_tune/cycles]`**
  -- across a chronological sequence of *kept* candidates, flags whether
  each raw DNA cell is trending (every step moves it the same direction --
  real signal) or flip-flopping (moves a lot, nets out near zero -- chasing
  sampling noise in that cycle's position batch). Worth running every few
  kept cycles; a consistently flip-flopping cell is a candidate to exclude
  from future tuning passes rather than let it keep eating budget on noise.
- **`cycle.sh [pgn] [n_games] [workers] [max_positions] [max_passes] [sd_depth]`**
  -- wires all of the above into one cycle: dump baseline -> tune -> gate
  match -> bake-in-and-rebuild if `LOWER95 >= 0.5`, else discard and leave
  `eval.c` untouched. **Never runs `git commit`** -- a win leaves `eval.c`
  modified-but-uncommitted for manual review, by design.
  `sd_depth` defaults to 10; a dry run at that depth was observed still
  running after 15+ minutes on one game before the bugs above were found --
  use a shallow depth (4-6) for pipeline-wiring smoke tests, not 10.
- **`evaldna write <file>` refuses to overwrite an existing file** (silent
  `Error writing dna file.` with no explanation of why) -- `WriteEvalDNA` in
  eval.c does `if (SystemDoesFileExist(...)) goto end;` rather than
  clobbering. Delete the target first when re-dumping a baseline.
- **Watch for apples-to-oranges ECM comparisons across depths/time
  controls.** A baseline solved-count recorded at `st 1` is not a fair
  comparison point against a candidate scored at `sd 20` -- rescoring the
  baseline at the *same* fixed depth as the candidate revealed a real gap
  (711 vs 707 at `sd 20`, both far below the apparent 606-vs-711 jump you'd
  get comparing against the old `st 1` number) that was noise-level, not the
  large improvement it first looked like. Always diff ECM numbers taken at
  the same `sd` depth.

## Environment notes

- Shared, multi-user FreeBSD box with genuinely variable load (other Claude
  Code sessions, a `bhyve` VM, periodic `zpool scrub` on both `zroot` and
  `zscratch`). Don't trust a single throughput measurement without a
  back-to-back comparison under the same conditions; this session saw the
  same benchmark vary by 10x+ run to run for reasons that turned out to be
  the semaphore leak above, not genuine machine contention.
- `/zscratch/egtb` and `/usr/home/scott/typhoon/pgn/twic.pgn` are the two
  large external resources this codebase's tooling depends on. `twic.pgn`
  was originally 554,135 games (up through TWIC issue 608, fetched by the
  long-dead `pgn/twic.pl`); a later session fetched all subsequent issues
  (920-1658) directly from theweekinchess.com (its WAF blocks bare `wget`,
  needs a real browser `User-Agent` + `Accept` header via `curl`) and merged
  them in, growing it to 4,099,094 games (3.5GB). Note issues 609-919 are
  still a gap (fetched range starts at 920) if completeness ever matters.
  `pgn/twic_filtered.pgn` (642,268 games, 556MB, >=2400 Elo / >40 ply, via
  `filter_pgn.py`) is the pool actually used for tuning/match-play now, not
  the raw file.