summaryrefslogtreecommitdiff
path: root/src/docs/TREEDUMP_FORMAT.md
blob: 47d1a8254e032ba144eda6ece03f7351903852e3 (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
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
286
# TREEDUMP: a shared search-tree trace format for typhoon vs. Stockfish

Both engines, when built with a `TREEDUMP` compile-time flag, write a
flat, line-oriented, always-on trace of search decisions to a file
named `treedump.log` in the current directory. There is no runtime
toggle or filtering -- keep a dump small by using a shallow `sd N`
(typhoon) / `go depth N` (Stockfish), not by restricting what gets
written.

The point is to run both engines on the *same FEN* at the *same shallow
depth*, then diff/meld the two `treedump.log` files to see exactly
where their trees diverge -- which move each engine reduces, prunes, or
searches in full, and by how much.

## Building

- typhoon: `gmake release PERF_COUNTERS=1 TREEDUMP=1` (or `debug`).
  Writes `treedump.log` in the working directory from process start to
  exit (`TDOpen`/`TDClose` in `main.c`). Single-threaded runs only
  (`--cpus 1`) -- the file write isn't locked against concurrent
  threads, matching this project's own `--cpus 1` convention for
  diagnostic/comparison runs.
- Stockfish: `gmake build ARCH=x86-64-avx2 COMP=clang EXTRACXXFLAGS=-DTREEDUMP`
  (FreeBSD: use `gmake`, not `make` -- the stock `make` chokes on this
  Makefile's GNU-only syntax; `COMP=clang` because there's no `gcc` on
  this box; `ARCH=x86-64-avx2`, not bare `x86-64` or `ARCH=native` --
  both of the latter either fail FreeBSD's arch-detection outright or
  silently build a popcnt-less binary that reproducibly returns a
  dead/empty search, 0 nodes, on every `go`, with no error -- see the
  gotcha below, this is a different failure mode from it and easy to
  conflate with it). Also writes `treedump.log` in the working
  directory, for the life of the process. Single-threaded only
  (`Threads` UCI option defaults to 1; don't raise it for a comparison
  run). **`EXTRACXXFLAGS` isn't tracked as a `make` dependency** -- if
  you rebuild the same ARCH with/without it back to back, `search.cpp`
  (the only file TREEDUMP touches) won't be recompiled and you'll
  silently get the previous binary; `touch search.cpp` first, or
  `gmake clean` first, whenever toggling the flag.

### A Stockfish-specific gotcha: piping input races the search thread

If you drive Stockfish non-interactively by piping commands in and
letting the pipe close right after (`printf '...\ngo depth N\n' |
./stockfish`), the engine's input-reading thread can see EOF and begin
shutting the engine down *before* the search thread has actually
started producing nodes -- the symptom is `info depth 1 ... nodes 0
... pv ` (empty) immediately followed by a `bestmove`, for every
UCI limit type (`depth`, `nodes`, `movetime` all equally ignored), with
exit code 0 (not a crash). This reproduced on this box on a **clean,
unmodified** Stockfish build too, so it's not a TREEDUMP artifact --
it's inherent to how this fork's shared-memory NNUE bootstrap
(`shm.h`/`shm_unix.h`, "Network replica N: Shared memory" in the
startup banner) races against stdin closing, and it was intermittent
enough (roughly 1 good run in ~15-18 attempts, observed empirically)
that blind retrying is not a reliable workaround.

**Fix: hold stdin open past the point the search should finish**,
e.g.:

```sh
(printf 'position fen <FEN>\ngo depth N\n'; sleep 2) | ./stockfish
```

An interactive terminal session (typing the commands directly, or
redirecting from a real tty) never hits this, which is why it's easy
to not reproduce it while debugging interactively and then hit it
again the next time a script pipes input in.

**Future-work idea, not yet built**: a small purpose-written xboard<->UCI
driver (distinct from `stockfish_xboard`, which hardcodes
`/usr/local/bin/stockfish` via PolyGlot for `match_play.py`'s xboard-only
engine pool, not this instrumented source build) would let this
TREEDUMP-build Stockfish be driven with the same `--batch --command`-style
invocation already used for typhoon and Crafty, sidestepping the
stdin-race gotcha entirely rather than working around it with `sleep`.
Worth doing if this three-engine TREEDUMP comparison becomes a repeated
workflow rather than a one-off batch.

## Building a third engine: Crafty

Crafty (`~/programming/chess/crafty-25.2`) also speaks this format now
(added 2026-10-23), giving a third data point alongside typhoon and
Stockfish -- useful specifically because Crafty's HCE is much "dumber"
than typhoon's NNUE, so if Crafty's pruning/reduction magnitudes still
line up with Stockfish's despite the weaker eval, that's a stronger
signal that the magnitude is calibration-driven (an engine-independent
"right amount") rather than an artifact of how strong the eval backing
the search happens to be.

Crafty compiles everything into one translation unit (`crafty.c`
`#include`s every other `.c` file), so the `TREEDUMP` instrumentation
lives directly in `search.c` (new `TDOpen`/`TDClose`/`TDMove`/`TDResult`
functions, prototyped in `chess.h` behind `#if defined(TREEDUMP)`) rather
than needing a new source file. `main.c` opens `treedump.log` at process
start and registers `TDClose` via `atexit()` so it's closed on every exit
path, not just a clean `quit`.

Build (single-threaded, matching typhoon's and Stockfish's TREEDUMP
convention -- `search.c`'s path-reconstruction helper assumes Crafty's
single-TREE-struct-per-thread model, where `tree->curmv[1..ply]` holds
every move played so far in *this* recursion; that invariant only holds
cleanly with one thread):

```sh
cd ~/programming/chess/crafty-25.2
rm -f *.o crafty
gmake target=UNIX CC=clang opt='-DSYZYGY -DCPUS=1 -DTREEDUMP' \
    CFLAGS='-Wall -Wno-array-bounds -pipe -O3 -mpopcnt' \
    LDFLAGS='-lstdc++ -lm' crafty-make
```

(`-j` can be dropped or kept; `crafty.o` depends on `*.c *.h` as one
glob so there's no partial-rebuild hazard the way Stockfish's
`EXTRACXXFLAGS` has -- no `touch` trick needed here. Rebuild the normal
multi-threaded profile afterward the same way the other two engines'
TREEDUMP builds get reverted: `rm -f *.o crafty && gmake target=UNIX
CC=clang opt='-DSYZYGY -DTEST -DCPUS=4' CFLAGS='-Wall -Wno-array-bounds
-pipe -O3 -mpopcnt -pthread' LDFLAGS='-pthread -lstdc++ -lm'
crafty-make` -- note the `-pthread` CFLAGS/LDFLAGS are required once
`CPUS` > 1, the TREEDUMP/`CPUS=1` build doesn't need them.)

Crafty speaks xboard natively (no PolyGlot/UCI adapter needed, unlike
driving it through `stockfish_xboard`-style tooling), so it can be
driven the same way as typhoon's own `--batch --command` invocations:

```sh
(printf 'force\nsetboard <FEN>\nsd 6\ngo\nquit\n'; sleep 2) | ./crafty
```

Hold stdin open past the search the same way Stockfish needs (see the
gotcha below) -- not yet confirmed whether Crafty has the identical
race, but cheap to guard against unconditionally rather than
re-discover it.

**Mapped `act=` values**: `FP-QUIET` (Crafty's `FP_margin[]` futility
check -- note this is a **raw-material** check,
`MaterialSTM(wtm) + FP_margin[depth] <= alpha`, not a full static-eval
call the way typhoon's/Stockfish's futility-flavored checks are, so
don't read "FP-QUIET volume" as directly comparable cost between
engines even where the tag matches), `LMP` (pure move-count/depth
threshold, no evidence gate at all -- matches the `search.c` comment
block this investigation started from), `NMP` (null-move probe, `r`
left `0.00` same convention as typhoon), `FULL`/`LMR`/`EXT` (the main
move loop, after Crafty's own `reduce`/`extend` locals are resolved).
Crafty has no ProbCut and no IID-as-a-traced-decision (IID's recursive
call reuses the *same* ply rather than descending one, so it doesn't fit
the path model and isn't traced).

**A Crafty-specific wrinkle worth remembering when reading a diff**:
Crafty's own `search.c` comment on this block (the one this
investigation started from) says forward pruning here "simply avoid[s]
searching this move at all, and skip[s] to the next move" -- but
functionally this is a `break`, not a `continue`: once FP or LMP fires on
one move, every *remaining* move at that node is abandoned too, not just
the one move. typhoon's and Stockfish's LMP-equivalents are evaluated
move-by-move (a `continue`, not a `break`) -- a given late move being
pruned doesn't prevent trying a later move that might not be. Don't read
Crafty's `act=LMP`/`act=FP-QUIET` *count* at a node as "N moves pruned
out of the move list" the way you might for the other two engines; it
usually means "the move list ended early starting here," which has a
different effect on tree shape (and EBF) than an equal count of
individually-pruned moves scattered across a wider move list.

## Why path-keyed, not node-numbered

Internal node counters are meaningless across two different engines --
their recursion order, node numbering, and even which positions get
revisited (transposition table hits) differ completely. The only thing
that identifies "the same position" in both engines is the sequence of
moves played from the root, since both start from the same FEN. Every
line is therefore keyed by `path="<uci moves from root, space-separated>"`
-- sort or `grep` on that field to line up corresponding decisions from
the two logs regardless of visit order.

A null move is spelled `0000` in a path (both engines already use this
UCI convention for `Move::null()`/`NULLMOVE` internally -- it's not an
invented token).

## Record types

Two kinds of line, both single-line, both flushed immediately (so a
killed/crashed process still leaves a readable partial log):

```
MOVE path="<path, ending in the move this line is about>" depth=<float> alpha=<int> beta=<int> kind=<str> act=<str> r=<float> ext=<float> score=<int|SKIPPED>

RESULT path="<path, NOT including any move at this node>" depth=<float> alpha=<int> beta=<int> eval=<int> bound=<EXACT|LOWER|UPPER> score=<int>
```

- `depth`: remaining search depth in whole plies (float, since
  typhoon's internal depth is fixed-point in `ONE_PLY`-sized ticks and
  extensions/reductions can be fractional ply amounts; Stockfish's is
  already integer plies and just prints with no fractional part).
- `alpha`/`beta`: the window at the node the move/result belongs to.
  For a `RESULT` line, this is the node's *original* (pre-aspiration-
  widening) window, so the `bound` field's classification is
  consistent.
- `kind` (MOVE only): rough move classification -- `FIRST`, `HASH`,
  `CAP`, `QUIET`, `NULL`, `PROBCUT`, freeform otherwise. Not identical
  granularity between the two engines (see Known Gaps below) --
  treat it as a hint, not a guaranteed-matching taxonomy.
- `act` (MOVE only): what happened to this move --
  `FULL` (searched at full/nominal depth), `LMR` (late-move-reduced),
  `LMR-RESEARCH` (Stockfish only: a reduced search failed high and got
  a full-depth re-search), `EXT` (extended), `LMP` (pruned without
  being searched, "late move pruning"), `FP-CAP`/`SEE-CAP`/
  `HIST-PRUNE`/`FP-QUIET`/`SEE-QUIET` (Stockfish's shallow-depth move
  pruning variants -- typhoon currently has no direct equivalent to
  several of these, which is itself one of the interesting things to
  look for in a diff), `NMP` (null-move probe), `PROBCUT` (Stockfish
  only -- typhoon has no ProbCut at all).
- `r`/`ext` (MOVE only): magnitude, in whole plies, of any reduction
  (`r`) or extension (`ext`) applied. Exactly one is normally nonzero.
- `score`: the score this move/node returned, or the literal `SKIPPED`
  on a `MOVE` line for a move that was pruned without ever being
  searched.
- `eval` (RESULT only): the node's static eval. May be stale/0 on a
  typhoon node that returned before `Eval()` ran (e.g. an immediate
  hash/recognizer cutoff) -- diagnostic only, don't trust it blindly.

## Known gaps / asymmetries (read before drawing conclusions from a diff)

- **Neither engine traces into qsearch.** Qsearch nodes vastly
  outnumber full-width nodes even at `sd 4`/`go depth 4` and would
  swamp a diff of the full-width move-ordering/pruning decisions that
  are actually of interest. This means a TREEDUMP diff only shows the
  full-width tree shape, not what happens once captures start
  resolving near the horizon -- keep comparisons shallow enough that
  qsearch isn't where most of the interesting divergence lives, or
  treat this as future work if it turns out qsearch matters too.
- **`kind` is not a matched taxonomy.** typhoon's `TDMove` call sites
  classify moves coarsely (`FIRST`/`CAP`/`QUIET`/`NULL`); Stockfish's
  do the same but the two engines' internal move-ordering concepts
  (killers, countermoves, TT move, etc.) don't line up 1:1. Don't
  diff on `kind` expecting an exact match -- diff on `act`, `r`/`ext`,
  and `score` for a given `path`, and use `kind` only as a sanity
  check.
- **typhoon has no ProbCut.** Any `act=PROBCUT` line will only ever
  appear in Stockfish's log. This is itself a structural difference
  worth noting, not a bug in the instrumentation.
- **Stockfish's null-move `depth` field is the full pre-reduction
  parent depth minus R; typhoon's null-move `MOVE` line's `depth`
  field is the already-reduced probe depth** (`TryNullmovePruning`
  never receives the pre-reduction parent depth) -- compare the
  *absolute* depth value in context, don't assume the two `r` fields
  for null moves are computed the same way (typhoon's is left `0.00`
  for null moves rather than faked).
- **typhoon's `RESULT.eval` can be stale (0 or a leftover value)** on
  any node that returned before `Eval()` ran. Stockfish's `eval` field
  comes from `ss->staticEval`, which has its own, different set of
  early-return paths that skip a real eval. Don't diff raw eval
  numbers across engines expecting them to be comparable at all (they
  use different evaluation functions) -- only use `eval` to sanity-
  check a single engine's own `RESULT` line against its own earlier
  `MOVE` lines.

## Suggested workflow

```sh
# typhoon side
cd /usr/home/scott/typhoon/src
gmake release PERF_COUNTERS=1 TREEDUMP=1
rm -f treedump.log
./typhoon --cpus 1 --hash 8m --logfile - --batch --command \
    "force; book name /nonexistent.book.bin; setboard <FEN>; sd 4; go"
cp treedump.log /tmp/typhoon/td_typhoon.log

# stockfish side
cd ~/programming/chess/stockfish/src
touch search.cpp  # EXTRACXXFLAGS isn't a tracked dependency -- force the recompile
gmake build ARCH=x86-64-avx2 COMP=clang EXTRACXXFLAGS=-DTREEDUMP
rm -f treedump.log
(printf 'position fen <FEN>\ngo depth 4\n'; sleep 2) | ./stockfish
cp treedump.log /tmp/typhoon/td_sf.log

# compare (sort by path first so corresponding positions line up
# regardless of each engine's own visit order)
sort -t'"' -k2 /tmp/typhoon/td_typhoon.log > /tmp/typhoon/td_typhoon.sorted
sort -t'"' -k2 /tmp/typhoon/td_sf.log > /tmp/typhoon/td_sf.sorted
meld /tmp/typhoon/td_typhoon.sorted /tmp/typhoon/td_sf.sorted
```

Rebuild each engine back to its normal release profile afterward
(`TREEDUMP` should never be the profile left in place for a real run --
it's diagnostic-only, same spirit as this project's other collection-
build flags).