summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-15 13:11:22 -0700
committerScott Gasch <[email protected]>2026-10-15 13:11:22 -0700
commit993b6eeb2a9ac7b659c473b6883fa53af1ad3a0b (patch)
treec5388824dec15a7691ce9952a6eb4af66e837127
parent53b380c658f828c4c0e096be2763e6b012c23abd (diff)
Phase A NNUE: feature-indexing spec, annotation/training pipeline, dump nnuefeatures
Builds out the NNUE Phase A prototype per docs/NNUE.md: - src/nnuefeatures.c: standalone HalfKAv2_hm feature-index reference implementation (new `dump nnuefeatures` command), sourced from Stockfish's half_ka_v2_hm.{h,cpp} (sf_16 tag), including the 0x88->64 square conversion typhoon's own board representation needs. - annotate_pgns/: sharded TWIC/self-play annotation harness (annotate.py), position extraction, quiet-position filtering (flat and game-shaped, plus an upstream --prefilter-quiet option), train/holdout game-level stratified split, WDL-sigmoid K fitting, the Python HalfKAv2_hm reference encoder (nnue_feature_spec.py) and its bit-for-bit cross-checker against the C side (cross_check_features.py), and a first PyTorch training pipeline (nnue_model.py, nnue_train.py, build_training_tensors.py) -- a 256x2-32-32-1-ish net trained locally on CPU against ~1.85M quiet TWIC positions as a pipeline/framework validation pass. - Finish an in-progress InitMagic/InitLMRTable/InitEval -> InitializeBitboardMagic/InitializeLMRTable/InitializeEval rename: chess.h's prototypes hadn't been updated to match the already-renamed definitions/call sites, leaving 4 implicit-function-declaration warnings. - tests/arasan.epd: new curated EPD suite. - docs/: add the project's design docs (previously untracked). Co-Authored-By: Claude Sonnet 5 <[email protected]>
-rw-r--r--annotate_pgns/annotate.py246
-rw-r--r--annotate_pgns/build_games.py124
-rw-r--r--annotate_pgns/build_training_tensors.py117
-rw-r--r--annotate_pgns/check_see_against_engine.py160
-rw-r--r--annotate_pgns/cross_check_features.py122
-rw-r--r--annotate_pgns/extract_positions.py148
-rw-r--r--annotate_pgns/fit_k_and_label.py146
-rw-r--r--annotate_pgns/nnue_feature_spec.py93
-rw-r--r--annotate_pgns/nnue_model.py69
-rw-r--r--annotate_pgns/nnue_train.py208
-rw-r--r--annotate_pgns/quiet_common.py68
-rw-r--r--annotate_pgns/quiet_filter.py124
-rw-r--r--annotate_pgns/quiet_filter_games.py102
-rw-r--r--annotate_pgns/split_holdout.py113
-rw-r--r--src/GNUmakefile3
-rwxr-xr-xsrc/chess.h9
-rw-r--r--src/command.c15
-rw-r--r--src/data.c12
-rw-r--r--src/docs/ANALYZE_GAME.md254
-rw-r--r--src/docs/EFP_LMP_MERGE.md234
-rw-r--r--src/docs/EXTENDED_PRUNING_DEPTH.md493
-rw-r--r--src/docs/MOBILITY_BITBOARD_REWRITE.md150
-rw-r--r--src/docs/MOVE_GUESSER.md285
-rw-r--r--src/docs/MOVE_ORDERING_IDEAS.md226
-rw-r--r--src/docs/NNUE.md1153
-rw-r--r--src/docs/NULLMOVE_SKIP_CALIBRATION.md213
-rw-r--r--src/docs/PROFILE.md222
-rw-r--r--src/docs/TESTING_PLAN.md298
-rw-r--r--src/docs/UNIFY_SEARCH.md406
-rw-r--r--src/dynamic.c2
-rw-r--r--src/eval.c4
-rw-r--r--src/main.c60
-rw-r--r--src/nnuefeatures.c248
-rw-r--r--tests/arasan.epd201
34 files changed, 6281 insertions, 47 deletions
diff --git a/annotate_pgns/annotate.py b/annotate_pgns/annotate.py
new file mode 100644
index 0000000..240b55f
--- /dev/null
+++ b/annotate_pgns/annotate.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+"""Shard a PGN file across N single-threaded typhoon engine instances,
+score every position in every game at a fixed search depth, and
+reassemble an annotated PGN with { +score/depth PV } comments matching
+the self-play annotation format already used in
+../pgn/self_play_games.pgn.
+
+Reusable across depths: --depth is a plain CLI argument, not baked into
+any file name or code path, so re-running at sd12/sd16/etc. is just a
+different --depth and --outdir.
+
+Usage:
+ python3 annotate.py --input ../pgn/twic_filtered.pgn \
+ --depth 10 --shards 16 --hash 32m --outdir runs/sd10_twic
+
+See the module-level functions below for the four stages: shard, build
+engine scripts, run engines in parallel, parse + reassemble.
+"""
+import argparse
+import re
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import chess
+import chess.pgn
+
+from quiet_common import position_is_quiet
+
+TELLOTHERS_RE = re.compile(
+ r"^tellothers d(\d+), ([+-]?[\d.]+), [\d.]+s,\s*\d+knps, PV=(.*)$"
+)
+
+
+def load_games(input_paths, max_games=None):
+ """Load games, skipping ones with an unknown/unterminated result
+ ('*') -- can't derive a WDL training label without a real outcome.
+ Short-agreed-draw games are not separately filtered here: the
+ >40-ply / >=2400-Elo filter already applied when twic_filtered.pgn
+ was built (eval_tune/filter_pgn.py) rules those out by construction
+ (confirmed empirically: no games below 40 plies in a 5000-game
+ sample)."""
+ games = []
+ for path in input_paths:
+ with open(path, encoding="utf-8", errors="replace") as f:
+ while max_games is None or len(games) < max_games:
+ game = chess.pgn.read_game(f)
+ if game is None:
+ break
+ if game.headers.get("Result", "*") == "*":
+ continue
+ games.append(game)
+ if max_games is not None and len(games) >= max_games:
+ break
+ return games
+
+
+def shard_games(games, n_shards):
+ shards = [[] for _ in range(n_shards)]
+ for i, g in enumerate(games):
+ shards[i % n_shards].append(g)
+ return shards
+
+
+def positions_for_game(game, prefilter_quiet=False):
+ """Yield the FEN *before* each move is played, skipping any ply at
+ or after the game is already over (no legal moves to search).
+
+ If prefilter_quiet, also skip any ply whose position fails the
+ quiet-position test (quiet_common.position_is_quiet) -- checked
+ against the move actually played in this game, since that's the
+ only move available before a search has run. This is the same
+ check/exclude criteria as quiet_filter.py's post-hoc filter, but
+ SEE-checked against the *played* move rather than the engine's own
+ PV first move -- see quiet_common.py's module docstring for why
+ those are different training distributions, not interchangeable.
+
+ ply numbering (pi, used by write_shard_script/annotate_shard/
+ build_games.py to re-align results) still counts only the plies
+ actually yielded, so this must be called with the same
+ prefilter_quiet value everywhere a run's ply indices are
+ recomputed (build_games.py in particular)."""
+ board = game.board()
+ for move in game.mainline_moves():
+ if board.is_game_over():
+ break
+ if not prefilter_quiet or position_is_quiet(board, move):
+ yield board.fen()
+ board.push(move)
+
+
+def write_shard_script(shard_games_list, script_path, depth, prefilter_quiet=False):
+ """Write the engine command script for one shard, plus the parallel
+ index recording which (game_idx_in_shard, ply_idx) each 'go' line
+ corresponds to -- the engine processes 'go' commands strictly in
+ script order with no interleaving, so a flat index list zips back
+ onto the flat sequence of tellothers output lines."""
+ index = []
+ with open(script_path, "w") as out:
+ for gi, game in enumerate(shard_games_list):
+ for pi, fen in enumerate(positions_for_game(game, prefilter_quiet)):
+ out.write(f"setboard {fen}\n")
+ out.write(f"sd {depth}\n")
+ out.write("go\n")
+ index.append((gi, pi))
+ return index
+
+
+def run_shard(engine_path, script_path, out_path, hash_size, cpus):
+ cmd = [
+ str(engine_path),
+ "--cpus", str(cpus),
+ "--hash", hash_size,
+ "--logfile", "-",
+ "--batch",
+ "--command",
+ f"force; book name /nonexistent.book.bin; script {script_path}",
+ ]
+ with open(out_path, "w") as out:
+ return subprocess.Popen(cmd, stdout=out, stderr=subprocess.STDOUT)
+
+
+def parse_shard_output(out_path):
+ """Return a list of (depth, score, pv_string) in script order."""
+ results = []
+ with open(out_path, encoding="utf-8", errors="replace") as f:
+ for line in f:
+ m = TELLOTHERS_RE.match(line.rstrip("\n"))
+ if m:
+ depth, score, pv = m.groups()
+ results.append((int(depth), float(score), pv.strip()))
+ return results
+
+
+def annotate_shard(shard_games_list, index, results):
+ """Attach comments to each game's mainline nodes in place. index and
+ results are both in script order and the same length; index maps
+ each result back to (game_idx_in_shard, ply_idx)."""
+ by_game = {}
+ for (gi, pi), res in zip(index, results):
+ by_game.setdefault(gi, {})[pi] = res
+
+ for gi, game in enumerate(shard_games_list):
+ scores = by_game.get(gi, {})
+ node = game
+ pi = 0
+ while node.variations:
+ node = node.variations[0]
+ if pi in scores:
+ depth, score, pv = scores[pi]
+ node.comment = f"{score:+.2f}/{depth} {pv}"
+ pi += 1
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--input", nargs="+", required=True,
+ help="one or more input PGN files")
+ ap.add_argument("--depth", type=int, required=True,
+ help="fixed search depth (sd N) for every position")
+ ap.add_argument("--shards", type=int, default=16)
+ ap.add_argument("--hash", default="32m",
+ help="hash table size per shard engine, e.g. 32m")
+ ap.add_argument("--cpus", type=int, default=1,
+ help="--cpus passed to each shard's engine")
+ ap.add_argument("--engine", default="../src/typhoon")
+ ap.add_argument("--outdir", required=True)
+ ap.add_argument("--max-games", type=int, default=None,
+ help="limit to the first N games total, for smoke testing")
+ ap.add_argument("--prefilter-quiet", action="store_true",
+ help="skip in-check positions and SEE-positive-capture "
+ "played moves before ever sending them to the "
+ "engine, instead of searching everything and "
+ "filtering after the fact (see quiet_filter.py / "
+ "quiet_filter_games.py). Changes the quiet "
+ "criterion's move source from the engine's own PV "
+ "to the move actually played -- see quiet_common.py.")
+ args = ap.parse_args()
+
+ outdir = Path(args.outdir)
+ outdir.mkdir(parents=True, exist_ok=True)
+ engine_path = Path(args.engine).resolve()
+ if not engine_path.exists():
+ sys.exit(f"engine not found: {engine_path}")
+
+ print(f"Loading games from {args.input} ...")
+ games = load_games(args.input, args.max_games)
+ print(f" {len(games)} games loaded")
+
+ shards = shard_games(games, args.shards)
+
+ if args.prefilter_quiet:
+ print("Pre-filtering: skipping in-check / SEE-positive-played-move "
+ "positions before search.")
+ print(f"Writing {args.shards} shard scripts at sd {args.depth} ...")
+ indices = []
+ for i, shard in enumerate(shards):
+ script_path = outdir / f"shard_{i:02d}.script"
+ idx = write_shard_script(shard, script_path, args.depth,
+ args.prefilter_quiet)
+ indices.append(idx)
+ print(f" shard {i:02d}: {len(shard)} games, {len(idx)} positions")
+
+ print("Launching shard engines in parallel ...")
+ t0 = time.time()
+ procs = []
+ for i in range(args.shards):
+ script_path = outdir / f"shard_{i:02d}.script"
+ out_path = outdir / f"shard_{i:02d}.out"
+ if indices[i]:
+ procs.append((i, run_shard(engine_path, script_path, out_path,
+ args.hash, args.cpus)))
+
+ # Wait for the slowest shard.
+ for i, p in procs:
+ rc = p.wait()
+ if rc != 0:
+ print(f" WARNING: shard {i:02d} exited with code {rc}", file=sys.stderr)
+ elapsed = time.time() - t0
+ total_positions = sum(len(idx) for idx in indices)
+ print(f"All shards done in {elapsed:.1f}s "
+ f"({total_positions / elapsed:.2f} positions/sec aggregate)")
+
+ print("Parsing output and reassembling annotated PGN ...")
+ final_path = outdir / "annotated.pgn"
+ total_annotated = 0
+ with open(final_path, "w") as out:
+ for i, shard in enumerate(shards):
+ out_path = outdir / f"shard_{i:02d}.out"
+ results = parse_shard_output(out_path) if indices[i] else []
+ if len(results) != len(indices[i]):
+ print(f" WARNING: shard {i:02d} expected {len(indices[i])} "
+ f"results, got {len(results)} -- some positions may be "
+ f"unannotated", file=sys.stderr)
+ annotate_shard(shard, indices[i], results)
+ total_annotated += len(results)
+ for game in shard:
+ print(game, file=out, end="\n\n")
+
+ print(f"Wrote {final_path} ({len(games)} games, "
+ f"{total_annotated} annotated positions)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/build_games.py b/annotate_pgns/build_games.py
new file mode 100644
index 0000000..8cac092
--- /dev/null
+++ b/annotate_pgns/build_games.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""Reassemble an annotate.py run's flat per-position output back into
+game-shaped containers: one record per source game, holding its
+ordered list of annotated positions.
+
+Why this exists: extract_positions.py's positions.jsonl (fen, score,
+depth, pv, exact per row) is a flat list -- the row order follows each
+shard's script order, which happens to group consecutive rows by game
+(write_shard_script writes one game's positions fully before moving to
+the next), but there's no game_id recorded, so that grouping is
+unrecoverable from positions.jsonl alone. That matters for anything
+that needs to avoid splitting one game's (highly correlated,
+near-duplicate) positions across a train/holdout boundary.
+
+This script recovers it deterministically, without re-running any
+search: re-load the same PGN file with the same max_games (pure
+parsing, no engine involved -- cheap), re-shard it the same way
+annotate.py did (same n_shards, same deterministic round-robin), and
+walk each game's known position count (positions_for_game) to slice
+the already-extracted flat rows back into per-game chunks.
+
+Output shape -- deliberately the same "a game containing an ordered
+list of annotated positions" shape a self-play PGN with per-move
+score/PV comments already has, so a future loader for self-play games
+can produce the same games.jsonl schema and both sources mix freely at
+training-set-assembly time:
+
+ {"game_id": "...", "result": "1-0", "positions": [
+ {"fen": ..., "depth": ..., "score": ..., "score_raw": ...,
+ "pv": ..., "exact": ..., "exact_reason": ...},
+ ...
+ ]}
+
+Usage:
+ python3 build_games.py --rundir runs/sd10_twic_2M \
+ --pgn ../pgn/twic_filtered.pgn --max-games 30000 --shards 16 \
+ --out runs/sd10_twic_2M/games.jsonl
+"""
+import argparse
+import json
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from annotate import load_games, shard_games, positions_for_game
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--rundir", required=True,
+ help="directory holding positions.jsonl (extract_positions.py output)")
+ ap.add_argument("--positions", default=None,
+ help="defaults to <rundir>/positions.jsonl")
+ ap.add_argument("--pgn", nargs="+", required=True,
+ help="same PGN file(s) annotate.py was originally run against")
+ ap.add_argument("--max-games", type=int, required=True,
+ help="same --max-games (or games actually used) as the original run")
+ ap.add_argument("--shards", type=int, default=16,
+ help="same --shards as the original run")
+ ap.add_argument("--prefilter-quiet", action="store_true",
+ help="must match whether the original annotate.py run "
+ "used --prefilter-quiet -- changes positions_for_game's "
+ "per-game ply count, which this script relies on to "
+ "re-slice the flat positions.jsonl")
+ ap.add_argument("--out", required=True)
+ args = ap.parse_args()
+
+ rundir = Path(args.rundir)
+ positions_path = Path(args.positions) if args.positions else rundir / "positions.jsonl"
+
+ print(f"Loading all rows from {positions_path} ...")
+ flat_rows = []
+ with open(positions_path) as f:
+ for line in f:
+ flat_rows.append(json.loads(line))
+ print(f" {len(flat_rows)} rows")
+
+ print(f"Re-loading {args.pgn} (max_games={args.max_games}) to recover game boundaries ...")
+ games = load_games(args.pgn, args.max_games)
+ print(f" {len(games)} games")
+ shards = shard_games(games, args.shards)
+
+ # Per-shard row ranges: shard_NN's rows are a contiguous slice of
+ # flat_rows in the same order extract_positions.py wrote them
+ # (shard 00 first, then 01, ...), each shard's own length equal to
+ # its total position count.
+ shard_lengths = []
+ for shard in shards:
+ shard_lengths.append(sum(
+ 1 for g in shard
+ for _ in positions_for_game(g, args.prefilter_quiet)))
+ if sum(shard_lengths) != len(flat_rows):
+ sys.exit(f"row count mismatch: recomputed shards expect "
+ f"{sum(shard_lengths)} total positions, "
+ f"positions.jsonl has {len(flat_rows)} -- wrong --pgn/"
+ f"--max-games/--shards for this run?")
+
+ n_games_written = 0
+ n_positions_written = 0
+ with open(args.out, "w") as out:
+ row_cursor = 0
+ for shard_idx, shard in enumerate(shards):
+ for game_idx, game in enumerate(shard):
+ n_positions = sum(1 for _ in positions_for_game(game, args.prefilter_quiet))
+ game_rows = flat_rows[row_cursor:row_cursor + n_positions]
+ row_cursor += n_positions
+
+ rec = {
+ "game_id": f"{shard_idx:02d}_{game_idx:05d}",
+ "result": game.headers.get("Result", "*"),
+ "positions": game_rows,
+ }
+ out.write(json.dumps(rec) + "\n")
+ n_games_written += 1
+ n_positions_written += len(game_rows)
+
+ assert row_cursor == len(flat_rows)
+
+ print(f"Wrote {args.out}: {n_games_written} games, "
+ f"{n_positions_written} total positions")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/build_training_tensors.py b/annotate_pgns/build_training_tensors.py
new file mode 100644
index 0000000..60e51e6
--- /dev/null
+++ b/annotate_pgns/build_training_tensors.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+"""Precompute HalfKAv2_hm feature tensors from a game-shaped labeled
+file (quiet_filter_games.py output) into a cached, CSR-style on-disk
+format that nnue_train.py can mmap and sample from directly --
+avoids re-parsing FEN / recomputing SEE / recomputing feature indices
+on every training epoch (the expensive part, done once here).
+
+Each position's active feature indices (per nnue_feature_spec.py,
+already cross-checked bit-for-bit against the C reference -- see
+cross_check_features.py) are stored CSR-style (one flat concatenated
+index array + a row-offset array per perspective), exactly the input
+shape torch.nn.EmbeddingBag expects -- no fixed-width padding needed
+since bag sizes vary (3-32 active features per position).
+
+Skips exact rows (no p_search/r_outcome label -- see NNUE.md: a
+mate/EGTB/draw-claim score is a game-theoretic fact, not something to
+fit a sigmoid-WDL target against).
+
+Usage:
+ python3 build_training_tensors.py \
+ --in runs/sd10_twic_2M/games.train.labeled.quiet.jsonl \
+ --outdir runs/sd10_twic_2M/tensors_train \
+ --lambda 1.0
+"""
+import argparse
+import array
+import json
+import sys
+from pathlib import Path
+
+import numpy as np
+
+from nnue_feature_spec import active_feature_indices, chess
+
+
+def iter_positions(path):
+ with open(path) as f:
+ for line in f:
+ game = json.loads(line)
+ for pos in game["positions"]:
+ yield pos
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--in", dest="infile", required=True)
+ ap.add_argument("--outdir", required=True)
+ ap.add_argument("--lambda", dest="lam", type=float, default=1.0,
+ help="target = lambda*p_search + (1-lambda)*r_outcome "
+ "(Phase A default 1.0, pure p_search -- see NNUE.md)")
+ ap.add_argument("--limit", type=int, default=None,
+ help="stop after this many usable positions (smoke testing)")
+ args = ap.parse_args()
+
+ outdir = Path(args.outdir)
+ outdir.mkdir(parents=True, exist_ok=True)
+
+ # array.array('i') is a packed C int buffer -- avoids the ~28
+ # bytes/element overhead of a Python list of boxed ints, which
+ # matters once nnz reaches tens of millions.
+ white_idx = array.array("i")
+ black_idx = array.array("i")
+ white_off = [0]
+ black_off = [0]
+ stm = [] # 1 = white to move, 0 = black
+ target = []
+
+ n_total = 0
+ n_used = 0
+ n_skipped = 0
+
+ for pos in iter_positions(args.infile):
+ n_total += 1
+ if args.limit is not None and n_used >= args.limit:
+ break
+ if pos.get("exact") or "p_search" not in pos:
+ n_skipped += 1
+ continue
+
+ board = chess.Board(pos["fen"])
+ w = active_feature_indices(board, chess.WHITE)
+ b = active_feature_indices(board, chess.BLACK)
+
+ white_idx.extend(w)
+ white_off.append(white_off[-1] + len(w))
+ black_idx.extend(b)
+ black_off.append(black_off[-1] + len(b))
+
+ stm.append(1 if board.turn == chess.WHITE else 0)
+ p_search = pos["p_search"]
+ r_outcome = pos.get("r_outcome", p_search)
+ target.append(args.lam * p_search + (1.0 - args.lam) * r_outcome)
+
+ n_used += 1
+ if n_used % 200000 == 0:
+ print(f" ...{n_used} positions processed", file=sys.stderr)
+
+ print(f"Read {n_total} rows ({args.infile})")
+ print(f" {n_skipped} skipped (exact / no p_search label)")
+ print(f" {n_used} usable positions")
+
+ np.save(outdir / "white_idx.npy", np.frombuffer(white_idx, dtype=np.int32))
+ np.save(outdir / "white_off.npy", np.array(white_off, dtype=np.int64))
+ np.save(outdir / "black_idx.npy", np.frombuffer(black_idx, dtype=np.int32))
+ np.save(outdir / "black_off.npy", np.array(black_off, dtype=np.int64))
+ np.save(outdir / "stm.npy", np.array(stm, dtype=np.uint8))
+ np.save(outdir / "target.npy", np.array(target, dtype=np.float32))
+
+ nnz_white = len(white_idx)
+ nnz_black = len(black_idx)
+ print(f"Wrote tensors to {outdir}: {n_used} positions, "
+ f"{nnz_white} white / {nnz_black} black nonzero feature entries "
+ f"({nnz_white / max(n_used, 1):.1f} avg features/position)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/check_see_against_engine.py b/annotate_pgns/check_see_against_engine.py
new file mode 100644
index 0000000..4f6aab3
--- /dev/null
+++ b/annotate_pgns/check_see_against_engine.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+"""Cross-check eval_tune/tune_eval_dna.py's Python static_exchange_eval()
+(a classic swap-list SEE that admittedly ignores pins/x-rays/
+discoveries -- see its docstring) against the engine's own real SEE(),
+on real positions from an annotate.py run.
+
+This has never been done before: static_exchange_eval() has been used
+by the eval-tuning pipeline's quiet-position filter since that
+existed, but nothing has ever actually verified it agrees with the
+engine it's trying to approximate. Worth doing once, as reusable
+tooling, since this pipeline (annotate -> quiet-filter -> train) is
+going to be re-run many times as the project continues.
+
+Mechanism: the engine already has a bulk oracle for exactly this,
+`seescores <filename>` (command.c) -- takes a file of "setboard <fen>"
+lines (the same format shard_NN.script already uses) and prints
+"SEESCORES <fen> | <san> | <see-value>" for every legal capture in
+every position in the file, no search involved. Sample N FENs from an
+existing positions.jsonl, write them to a scratch "setboard"-lines
+file, run `seescores` on it, and diff every (fen, move) pair's engine
+SEE value against the Python one.
+
+Usage:
+ python3 check_see_against_engine.py --positions runs/sd10_twic_2M/positions.jsonl \
+ --sample 2000
+"""
+import argparse
+import json
+import os
+import random
+import re
+import subprocess
+import sys
+import tempfile
+from collections import Counter
+from pathlib import Path
+
+_EVAL_TUNE_DIR = Path(__file__).resolve().parent.parent / "src" / "eval_tune"
+sys.path.insert(0, str(_EVAL_TUNE_DIR))
+import chess
+
+# See quiet_filter.py's comment: dna_diff (imported by tune_eval_dna)
+# reads "../eval.c" relative to CWD at import time, not to its own file.
+_cwd = os.getcwd()
+os.chdir(_EVAL_TUNE_DIR)
+try:
+ from tune_eval_dna import static_exchange_eval
+finally:
+ os.chdir(_cwd)
+
+SEESCORES_RE = re.compile(r"^SEESCORES (\S+(?: \S+)*?) \| (\S+) \| (-?\d+)$")
+
+
+def sample_fens(positions_path, n, seed):
+ fens = []
+ with open(positions_path) as f:
+ for line in f:
+ rec = json.loads(line)
+ if rec.get("exact"):
+ continue
+ fens.append(rec["fen"])
+ random.Random(seed).shuffle(fens)
+ return fens[:n]
+
+
+def run_seescores(engine_path, fens, scratch_dir):
+ script_path = Path(scratch_dir) / "see_check.script"
+ out_path = Path(scratch_dir) / "see_check.out"
+ with open(script_path, "w") as f:
+ for fen in fens:
+ f.write(f"setboard {fen}\n")
+
+ cmd = [str(engine_path), "--batch", "--command", f"seescores {script_path}"]
+ with open(out_path, "w") as out:
+ subprocess.run(cmd, stdout=out, stderr=subprocess.STDOUT, check=True)
+ return out_path
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--positions", required=True,
+ help="positions.jsonl to sample FENs from")
+ ap.add_argument("--sample", type=int, default=2000)
+ ap.add_argument("--seed", type=int, default=0)
+ ap.add_argument("--engine", default="../src/typhoon")
+ args = ap.parse_args()
+
+ engine_path = Path(args.engine).resolve()
+ if not engine_path.exists():
+ sys.exit(f"engine not found: {engine_path}")
+
+ print(f"Sampling {args.sample} non-exact positions from {args.positions} ...")
+ fens = sample_fens(args.positions, args.sample, args.seed)
+ print(f" got {len(fens)} FENs")
+
+ with tempfile.TemporaryDirectory() as scratch:
+ print("Running engine seescores ...")
+ out_path = run_seescores(engine_path, fens, scratch)
+
+ n_compared = 0
+ n_exact_match = 0
+ n_sign_match = 0
+ n_sign_mismatch = 0
+ worst = [] # (abs diff, fen, san, engine_val, python_val)
+ diff_histogram = Counter()
+
+ board_cache = {}
+ with open(out_path) as f:
+ for line in f:
+ m = SEESCORES_RE.match(line.rstrip("\n"))
+ if not m:
+ continue
+ fen, san, engine_val = m.groups()
+ engine_val = int(engine_val)
+
+ board = board_cache.get(fen)
+ if board is None:
+ board = chess.Board(fen)
+ board_cache[fen] = board
+ try:
+ move = board.parse_san(san)
+ except ValueError:
+ continue
+ python_val = static_exchange_eval(board, move)
+
+ n_compared += 1
+ diff = engine_val - python_val
+ if diff == 0:
+ n_exact_match += 1
+ bucket = "0" if diff == 0 else (
+ "<=50" if abs(diff) <= 50 else
+ "<=200" if abs(diff) <= 200 else ">200")
+ diff_histogram[bucket] += 1
+
+ engine_sign = (engine_val > 0) - (engine_val < 0)
+ python_sign = (python_val > 0) - (python_val < 0)
+ if engine_sign == python_sign:
+ n_sign_match += 1
+ else:
+ n_sign_mismatch += 1
+
+ worst.append((abs(diff), fen, san, engine_val, python_val))
+
+ worst.sort(reverse=True)
+
+ print(f"\nCompared {n_compared} (fen, capture) pairs")
+ print(f" exact match: {n_exact_match} ({n_exact_match/max(n_compared,1):.1%})")
+ print(f" sign match: {n_sign_match} ({n_sign_match/max(n_compared,1):.1%})")
+ print(f" sign MISmatch:{n_sign_mismatch} ({n_sign_mismatch/max(n_compared,1):.1%})")
+ print(f" |diff| histogram: {dict(diff_histogram)}")
+
+ if worst:
+ print(f"\nWorst {min(10, len(worst))} disagreements "
+ f"(|engine - python|, fen, san, engine_see, python_see):")
+ for d, fen, san, ev, pv in worst[:10]:
+ print(f" {d:5d} {fen} {san} engine={ev} python={pv}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/cross_check_features.py b/annotate_pgns/cross_check_features.py
new file mode 100644
index 0000000..f336cda
--- /dev/null
+++ b/annotate_pgns/cross_check_features.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""Bit-for-bit cross-checker: diffs nnue_feature_spec.py's (Python)
+HalfKAv2_hm feature indices against ../src/nnuefeatures.c's (C,
+`dump nnuefeatures`) indices, for a set of hand-picked FENs chosen to
+exercise every piece type, both king-file halves (a-d / e-h), both
+perspectives, and non-symmetric positions (the starting position
+alone can't catch a perspective-swap bug, since it's symmetric under
+the exact transform between perspectives -- see NNUE.md).
+
+Must pass before any training data is trusted to be feature-indexed
+consistently between the Python trainer and the C inference code.
+
+Usage:
+ python3 cross_check_features.py [--engine ../src/typhoon]
+"""
+import argparse
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+from nnue_feature_spec import active_feature_indices, chess
+
+NNUE_RE = re.compile(r"^NNUE_FEATURES (white|black) (.*)$")
+
+# (label, FEN) -- deliberately includes:
+# - the symmetric starting position (sanity baseline only, see above)
+# - both kings on the queenside half (a-d files) and kingside half (e-h)
+# - a mixed case: White king queenside, Black king kingside (and the
+# reverse), to catch a perspective/orientation mixup
+# - every piece type present at least once, including promoted/extra
+# queens and multiple same-type pieces
+# - a near-empty king-and-pawns endgame (sparse feature set, edge of
+# the active-feature-count range)
+TEST_POSITIONS = [
+ ("startpos",
+ "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"),
+ ("both_kings_queenside",
+ "2kr4/8/8/8/8/8/8/2KR4 w - - 0 1"),
+ ("both_kings_kingside",
+ "4rk2/8/8/8/8/8/8/4RK2 w - - 0 1"),
+ ("white_queenside_black_kingside",
+ "4rk2/8/8/8/8/8/8/2KR4 w - - 0 1"),
+ ("white_kingside_black_queenside",
+ "2kr4/8/8/8/8/8/8/4RK2 w - - 0 1"),
+ ("midgame_mixed_material",
+ "r1bqk2r/pp1nbppp/2p1pn2/3p4/2PP4/2N1PN2/PP2BPPP/R1BQK2R w KQkq - 0 8"),
+ ("sparse_king_pawn_endgame",
+ "8/5k2/8/3K4/8/8/5P2/8 w - - 0 1"),
+ ("extra_queens",
+ "3qk3/3q4/8/8/8/8/3Q4/3QK3 w - - 0 1"),
+ ("black_to_move_midgame",
+ "r2q1rk1/ppp2ppp/2n1bn2/2b1p3/4P3/2NPB3/PPP2PPP/R2Q1RK1 b - - 0 10"),
+]
+
+
+def run_engine_dump(engine, fen):
+ cmd = [
+ str(engine), "--cpus", "1", "--hash", "8m", "--logfile", "-",
+ "--batch", "--command",
+ f"force; setboard {fen}; dump nnuefeatures",
+ ]
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=30).stdout
+ result = {}
+ for line in out.splitlines():
+ m = NNUE_RE.match(line.strip())
+ if m:
+ side, rest = m.groups()
+ result[side] = sorted(int(x) for x in rest.split()) if rest else []
+ return result
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--engine", default="../src/typhoon")
+ args = ap.parse_args()
+
+ engine = Path(args.engine).resolve()
+ if not engine.exists():
+ sys.exit(f"engine not found: {engine} (build it first -- see CLAUDE.md)")
+
+ n_ok = 0
+ n_fail = 0
+ for label, fen in TEST_POSITIONS:
+ board = chess.Board(fen)
+ py = {
+ "white": active_feature_indices(board, chess.WHITE),
+ "black": active_feature_indices(board, chess.BLACK),
+ }
+ c_out = run_engine_dump(engine, fen)
+
+ ok = True
+ for side in ("white", "black"):
+ if side not in c_out:
+ print(f"[{label}] FAIL: engine produced no '{side}' line at all")
+ ok = False
+ continue
+ if py[side] != c_out[side]:
+ ok = False
+ py_set, c_set = set(py[side]), set(c_out[side])
+ print(f"[{label}] FAIL ({side}): "
+ f"python={len(py[side])} idx, C={len(c_out[side])} idx")
+ only_py = sorted(py_set - c_set)
+ only_c = sorted(c_set - py_set)
+ if only_py:
+ print(f" only in python: {only_py}")
+ if only_c:
+ print(f" only in C: {only_c}")
+
+ if ok:
+ n_ok += 1
+ print(f"[{label}] OK ({len(py['white'])} features/perspective)")
+ else:
+ n_fail += 1
+ print(f" FEN: {fen}")
+
+ print(f"\n{n_ok} passed, {n_fail} failed, out of {len(TEST_POSITIONS)} positions")
+ sys.exit(1 if n_fail else 0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/extract_positions.py b/annotate_pgns/extract_positions.py
new file mode 100644
index 0000000..539419a
--- /dev/null
+++ b/annotate_pgns/extract_positions.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+"""Extract (FEN, score, depth, PV, exact-flag) tuples directly from a
+completed annotate.py run's raw shard logs -- shard_NN.script (ordered
+FENs) zipped positionally against shard_NN.out (ordered tellothers
+results), bypassing PGN reconstruction entirely.
+
+Why this exists: annotate.py's TELLOTHERS_RE only matched plain-float
+scores, silently dropping +MATE<N>/-MATE<N> lines from the parsed
+results list while leaving them in the position index -- a positional
+zip() between the two then misaligned every later position in that
+shard. This script fixes the parse (captures MATE and <EGTB>/<REC>
+lines as real, index-aligned results, tagged exact=True) and reads
+straight from the logs already on disk, so the 12-hour run does not
+need to be repeated.
+
+Exact positions (mate-distance scores, or any <EGTB>/<REC> PV --
+EGTB/REC lines are game-theoretic/exact regardless of what their score
+field looks like, e.g. the engine prints +0.00 or +-MATE100 for EGTB
+hits, never a real centipawn value) are written out but flagged
+exact=True rather than silently dropped, per NNUE.md's existing
+decision to exclude exact-value positions from the sigmoid-fit WDL
+loss at training-set-assembly time -- not at extraction time, so the
+raw corpus keeps standalone analysis value.
+
+Usage:
+ python3 extract_positions.py --rundir runs/sd10_twic_2M \
+ --out runs/sd10_twic_2M/positions.jsonl
+"""
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+TELLOTHERS_RE = re.compile(
+ r"^tellothers d(\d+), ([+-]?[\w.]+), [\d.]+s,\s*\d+knps, PV=(.*)$"
+)
+FLOAT_RE = re.compile(r"^[+-]?\d+\.\d+$")
+MATE_RE = re.compile(r"^([+-])MATE(\d+)$")
+
+
+def parse_score(token):
+ """Returns (numeric_score_or_None, exact, reason)."""
+ if FLOAT_RE.match(token):
+ return float(token), False, None
+ m = MATE_RE.match(token)
+ if m:
+ sign, n = m.groups()
+ return (1 if sign == "+" else -1) * float(n), True, f"mate{n}"
+ return None, True, f"unrecognized:{token}"
+
+
+def extract_shard(script_path, out_path):
+ fens = []
+ with open(script_path, encoding="utf-8") as f:
+ for line in f:
+ if line.startswith("setboard "):
+ fens.append(line[len("setboard "):].strip())
+
+ results = []
+ with open(out_path, encoding="utf-8", errors="replace") as f:
+ for line in f:
+ m = TELLOTHERS_RE.match(line.rstrip("\n"))
+ if m:
+ depth, score_tok, pv = m.groups()
+ pv = pv.strip()
+ score, exact, reason = parse_score(score_tok)
+ # <EGTB>/<REC>/<=>/<#> are terminal sentinel moves appended
+ # to the PV (UpdatePV(), search.c) -- the PV may carry real
+ # moves before the sentinel (e.g.
+ # "Kf6 Bc3+ Kxg6 Kf8 Kxh5 <EGTB>"), and in that case the
+ # score field often looks like an ordinary centipawn number,
+ # but the position is still exact/game-theoretic, not
+ # sigmoid-fit training signal -- check the PV's last token,
+ # not the whole PV string. <=> (DRAWMOVE) specifically is a
+ # claimed repetition/50-move draw -- a fact about the game's
+ # move history, not the position itself (the same position
+ # reached a different way isn't a draw at all), so it's
+ # excluded for the same reason <EGTB>/<REC> are: not
+ # position-dependent training signal.
+ last_move = pv.rsplit(" ", 1)[-1] if pv else ""
+ if last_move in ("<EGTB>", "<REC>", "<=>", "<#>"):
+ exact = True
+ reason = {"<=>": "draw_recognizer",
+ "<#>": "mate_recognizer"}.get(
+ last_move, last_move.strip("<>").lower())
+ elif pv == "only":
+ # root.c: exactly one legal move at the root skips the
+ # search entirely (iRootScore/uRootDepth hardcoded to
+ # 0), so depth/score here are placeholders, not a real
+ # evaluation -- not indeterminate, definitely not
+ # trainable.
+ exact = True
+ reason = "unsearched_only_move"
+ results.append((int(depth), score, score_tok, pv, exact, reason))
+
+ if len(fens) != len(results):
+ print(f" MISALIGNED: {script_path.name} has {len(fens)} positions "
+ f"but {out_path.name} produced {len(results)} tellothers lines "
+ f"-- skipping this shard, needs manual inspection",
+ file=sys.stderr)
+ return []
+
+ return list(zip(fens, results))
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--rundir", required=True)
+ ap.add_argument("--out", required=True)
+ ap.add_argument("--shards", type=int, default=16)
+ args = ap.parse_args()
+
+ rundir = Path(args.rundir)
+ total = 0
+ total_exact = 0
+ with open(args.out, "w") as out:
+ for i in range(args.shards):
+ script_path = rundir / f"shard_{i:02d}.script"
+ out_path = rundir / f"shard_{i:02d}.out"
+ if not script_path.exists() or not out_path.exists():
+ continue
+ rows = extract_shard(script_path, out_path)
+ for fen, (depth, score, score_tok, pv, exact, reason) in rows:
+ rec = {
+ "fen": fen,
+ "depth": depth,
+ "score": score,
+ "score_raw": score_tok,
+ "pv": pv,
+ "exact": exact,
+ }
+ if reason:
+ rec["exact_reason"] = reason
+ out.write(json.dumps(rec) + "\n")
+ total += 1
+ if exact:
+ total_exact += 1
+ print(f" shard {i:02d}: {len(rows)} positions "
+ f"({sum(1 for _, r in rows if r[4])} exact)")
+
+ print(f"Wrote {args.out}: {total} positions total, "
+ f"{total_exact} exact (mate/EGTB/REC), "
+ f"{total - total_exact} usable for sigmoid-fit training")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/fit_k_and_label.py b/annotate_pgns/fit_k_and_label.py
new file mode 100644
index 0000000..9fd09b0
--- /dev/null
+++ b/annotate_pgns/fit_k_and_label.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""Fit the WDL-sigmoid scale K on the TRAIN split only, then label
+every non-exact position in both train and holdout with:
+ - p_search: sigmoid(score / K) -- the search-score-derived win
+ probability estimate.
+ - r_outcome: that position's own game's actual final result,
+ converted to side-to-move-relative (matching the score's own
+ convention: 1.0 = the side to move at this FEN eventually won
+ their game, 0.0 = eventually lost, 0.5 = drawn).
+
+Does NOT blend p_search/r_outcome into a single training target --
+that mixing weight (lambda) is left as a free parameter to sweep
+later; storing both components separately means that sweep never
+needs to re-derive anything from here.
+
+K is fit by ternary search minimizing MSE(sigmoid(score/K), r_outcome)
+over every non-exact TRAIN position (not holdout -- fitting K on
+holdout data would leak information into what's supposed to be an
+untouched generalization check). The same fitted K is then applied
+(never refit) to label holdout positions too.
+
+Usage:
+ python3 fit_k_and_label.py --train runs/sd10_twic_2M/games.train.jsonl \
+ --holdout runs/sd10_twic_2M/games.holdout.jsonl \
+ --train-out runs/sd10_twic_2M/games.train.labeled.jsonl \
+ --holdout-out runs/sd10_twic_2M/games.holdout.labeled.jsonl
+"""
+import argparse
+import json
+import math
+
+
+def result_to_white_score(result):
+ """1.0/0.5/0.0 from White's perspective, or None if unknown ('*')."""
+ return {"1-0": 1.0, "0-1": 0.0, "1/2-1/2": 0.5}.get(result)
+
+
+def stm_relative_outcome(white_score, fen):
+ """Flip a White-perspective game result to the side-to-move's own
+ perspective at this FEN, matching the score field's own
+ side-to-move-relative convention."""
+ stm_is_white = fen.split()[1] == "w"
+ return white_score if stm_is_white else (1.0 - white_score)
+
+
+def collect_calibration_pairs(games_path):
+ """(score, r_outcome) for every non-exact position across all
+ games in this file."""
+ pairs = []
+ n_unknown_result = 0
+ with open(games_path) as f:
+ for line in f:
+ g = json.loads(line)
+ white_score = result_to_white_score(g["result"])
+ if white_score is None:
+ n_unknown_result += 1
+ continue
+ for pos in g["positions"]:
+ if pos.get("exact"):
+ continue
+ r = stm_relative_outcome(white_score, pos["fen"])
+ pairs.append((pos["score"], r))
+ return pairs, n_unknown_result
+
+
+def mse(pairs, k):
+ err = 0.0
+ for score, r in pairs:
+ p = 1.0 / (1.0 + math.exp(-score / k))
+ err += (r - p) ** 2
+ return err / max(len(pairs), 1)
+
+
+def fit_k(pairs, lo=0.05, hi=20.0, iterations=40):
+ for _ in range(iterations):
+ m1 = lo + (hi - lo) / 3
+ m2 = hi - (hi - lo) / 3
+ if mse(pairs, m1) < mse(pairs, m2):
+ hi = m2
+ else:
+ lo = m1
+ return (lo + hi) / 2
+
+
+def label_file(in_path, out_path, k):
+ n_games = 0
+ n_positions = 0
+ n_exact_skipped = 0
+ n_unknown_result = 0
+ with open(in_path) as fin, open(out_path, "w") as fout:
+ for line in fin:
+ g = json.loads(line)
+ white_score = result_to_white_score(g["result"])
+ if white_score is None:
+ n_unknown_result += 1
+ fout.write(json.dumps(g) + "\n")
+ continue
+ for pos in g["positions"]:
+ n_positions += 1
+ if pos.get("exact"):
+ n_exact_skipped += 1
+ continue
+ r = stm_relative_outcome(white_score, pos["fen"])
+ p_search = 1.0 / (1.0 + math.exp(-pos["score"] / k))
+ pos["p_search"] = p_search
+ pos["r_outcome"] = r
+ fout.write(json.dumps(g) + "\n")
+ n_games += 1
+ return n_games, n_positions, n_exact_skipped, n_unknown_result
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--train", required=True)
+ ap.add_argument("--holdout", required=True)
+ ap.add_argument("--train-out", required=True)
+ ap.add_argument("--holdout-out", required=True)
+ args = ap.parse_args()
+
+ print(f"Collecting calibration pairs from {args.train} (TRAIN only) ...")
+ pairs, n_unknown = collect_calibration_pairs(args.train)
+ print(f" {len(pairs)} (score, outcome) pairs "
+ f"({n_unknown} games skipped: unknown result)")
+
+ print("Fitting K (ternary search, minimizing sigmoid-MSE against "
+ "actual game outcomes) ...")
+ k = fit_k(pairs)
+ final_mse = mse(pairs, k)
+ print(f" K = {k:.2f} (MSE at fit = {final_mse:.5f})")
+
+ print(f"\nLabeling {args.train} -> {args.train_out} ...")
+ n_g, n_p, n_exact, n_unk = label_file(args.train, args.train_out, k)
+ print(f" {n_g} games, {n_p} positions, {n_exact} exact (unlabeled), "
+ f"{n_unk} unknown-result games (unlabeled)")
+
+ print(f"Labeling {args.holdout} -> {args.holdout_out} (same K, not refit) ...")
+ n_g, n_p, n_exact, n_unk = label_file(args.holdout, args.holdout_out, k)
+ print(f" {n_g} games, {n_p} positions, {n_exact} exact (unlabeled), "
+ f"{n_unk} unknown-result games (unlabeled)")
+
+ print(f"\nFitted K = {k:.4f} -- record this; applying a different K "
+ f"later (e.g. after labeling more data) is not the same model.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/nnue_feature_spec.py b/annotate_pgns/nnue_feature_spec.py
new file mode 100644
index 0000000..b121c82
--- /dev/null
+++ b/annotate_pgns/nnue_feature_spec.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Python reference implementation of the HalfKAv2_hm feature index
+formula -- see ../src/docs/NNUE.md's "Canonical HalfKAv2_hm
+feature-indexing spec" section, sourced verbatim from Stockfish's
+src/nnue/features/half_ka_v2_hm.{h,cpp} (git tag sf_16).
+
+This module's only job is to be an independent, from-scratch
+implementation of the same math as ../src/nnuefeatures.c
+(`dump nnuefeatures`), using python-chess's own (already
+rank*8+file, 0-63, a1=0) square numbering directly -- no 0x88
+conversion needed on this side, that's purely a typhoon-C-side
+concern.
+
+See cross_check_features.py for the actual Python-vs-C diff driver.
+"""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src" / "eval_tune"))
+import chess # noqa: E402
+
+SQUARE_NB = 64
+
+PS_W_PAWN, PS_B_PAWN = 0 * SQUARE_NB, 1 * SQUARE_NB
+PS_W_KNIGHT, PS_B_KNIGHT = 2 * SQUARE_NB, 3 * SQUARE_NB
+PS_W_BISHOP, PS_B_BISHOP = 4 * SQUARE_NB, 5 * SQUARE_NB
+PS_W_ROOK, PS_B_ROOK = 6 * SQUARE_NB, 7 * SQUARE_NB
+PS_W_QUEEN, PS_B_QUEEN = 8 * SQUARE_NB, 9 * SQUARE_NB
+PS_KING = 10 * SQUARE_NB
+PS_NB = 11 * SQUARE_NB # 704
+
+DIMENSIONS = (SQUARE_NB * PS_NB) // 2 # 22528
+
+_OWN_PLANE = {
+ chess.PAWN: PS_W_PAWN,
+ chess.KNIGHT: PS_W_KNIGHT,
+ chess.BISHOP: PS_W_BISHOP,
+ chess.ROOK: PS_W_ROOK,
+ chess.QUEEN: PS_W_QUEEN,
+}
+_ENEMY_PLANE = {
+ chess.PAWN: PS_B_PAWN,
+ chess.KNIGHT: PS_B_KNIGHT,
+ chess.BISHOP: PS_B_BISHOP,
+ chess.ROOK: PS_B_ROOK,
+ chess.QUEEN: PS_B_QUEEN,
+}
+
+
+def king_bucket(perspective, sq):
+ """sq, perspective: python-chess 0-63 square / chess.WHITE|BLACK."""
+ file = chess.square_file(sq)
+ rank0 = chess.square_rank(sq) # 0..7
+ file_quartet = file if file < 4 else (7 - file)
+ rank_group = (7 - rank0) if perspective == chess.WHITE else rank0
+ return rank_group * 4 + file_quartet
+
+
+def orient_mask(perspective, ksq):
+ file = chess.square_file(ksq)
+ if perspective == chess.WHITE:
+ return 7 if file < 4 else 0 # SQ_H1 : SQ_A1
+ return 63 if file < 4 else 56 # SQ_H8 : SQ_A8
+
+
+def piece_plane(perspective, piece_color, piece_type):
+ if piece_type == chess.KING:
+ return PS_KING
+ table = _OWN_PLANE if piece_color == perspective else _ENEMY_PLANE
+ return table[piece_type]
+
+
+def active_feature_indices(board, perspective):
+ """board: chess.Board. perspective: chess.WHITE or chess.BLACK.
+ Returns a sorted list of active feature indices (len <= 32)."""
+ ksq = board.king(perspective)
+ mask = orient_mask(perspective, ksq)
+ bucket_offset = king_bucket(perspective, ksq) * PS_NB
+
+ indices = []
+ for sq, piece in board.piece_map().items():
+ idx = (sq ^ mask) + piece_plane(perspective, piece.color, piece.piece_type) + bucket_offset
+ assert 0 <= idx < DIMENSIONS, idx
+ indices.append(idx)
+ indices.sort()
+ return indices
+
+
+if __name__ == "__main__":
+ # Quick manual smoke test against the starting position.
+ board = chess.Board()
+ print("white:", active_feature_indices(board, chess.WHITE))
+ print("black:", active_feature_indices(board, chess.BLACK))
diff --git a/annotate_pgns/nnue_model.py b/annotate_pgns/nnue_model.py
new file mode 100644
index 0000000..acdc8fd
--- /dev/null
+++ b/annotate_pgns/nnue_model.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""The Phase A prototype net: a small, known HalfKAv2_hm architecture
+per NNUE.md's architecture decisions -- 256x2-32-32-1-ish, a single
+shared feature-transformer weight matrix applied independently to
+each perspective's active-feature set (that sharing, not two
+separate tables, is what makes the two 256-wide "accumulator" halves
+comparable before concatenation), own-perspective-half-first
+concatenation keyed by side-to-move so the net's single scalar
+output is side-to-move-relative by construction (per the "Output
+convention" architecture decision), and a sigmoid-WDL loss against
+the already-fitted K=1.3123 (per "WDL-sigmoid target conversion
+built and run", fit_k_and_label.py) rather than raw centipawn MSE.
+
+Deliberately NOT doing yet (explicitly deferred, see NNUE.md): clipped
+activations / quantization-aware scaling (this is a float32-only
+Phase A prototype, no export-for-C-inference step exists yet), the
+lazy-eval PSQT-term fast path, and the super-lazy material-only
+prefilter. All irrelevant to proving the training pipeline works.
+"""
+import torch
+import torch.nn as nn
+
+from nnue_feature_spec import DIMENSIONS
+
+L1 = 256 # feature-transformer output width (per perspective)
+L2 = 32
+L3 = 32
+
+DEFAULT_K = 1.3123 # pawns; fit_k_and_label.py's Phase-A train-split fit
+
+
+class NnueNet(nn.Module):
+ def __init__(self, dimensions=DIMENSIONS, l1=L1, l2=L2, l3=L3):
+ super().__init__()
+ # Single shared feature transformer: one [dimensions, l1]
+ # weight matrix, applied once per perspective to that
+ # perspective's own active-feature set. mode="sum" matches
+ # Stockfish's own feature-transformer semantics (accumulate
+ # one weight row per active feature).
+ self.ft = nn.EmbeddingBag(dimensions, l1, mode="sum")
+ self.ft_bias = nn.Parameter(torch.zeros(l1))
+
+ self.fc1 = nn.Linear(2 * l1, l2)
+ self.fc2 = nn.Linear(l2, l3)
+ self.fc3 = nn.Linear(l3, 1)
+
+ def forward(self, white_idx, white_off, black_idx, black_off, stm):
+ """stm: float/long tensor of shape [B], 1.0 if white to move
+ else 0.0. Returns raw score in pawn-ish units, shape [B]."""
+ white_acc = self.ft(white_idx, white_off) + self.ft_bias
+ black_acc = self.ft(black_idx, black_off) + self.ft_bias
+
+ white_acc = torch.relu(white_acc)
+ black_acc = torch.relu(black_acc)
+
+ stm = stm.view(-1, 1).float()
+ # own-perspective-first concatenation, per side to move.
+ own = stm * white_acc + (1.0 - stm) * black_acc
+ enemy = stm * black_acc + (1.0 - stm) * white_acc
+ x = torch.cat([own, enemy], dim=1)
+
+ x = torch.relu(self.fc1(x))
+ x = torch.relu(self.fc2(x))
+ x = self.fc3(x)
+ return x.view(-1)
+
+ def win_probability(self, white_idx, white_off, black_idx, black_off, stm, k=DEFAULT_K):
+ raw = self.forward(white_idx, white_off, black_idx, black_off, stm)
+ return torch.sigmoid(raw / k)
diff --git a/annotate_pgns/nnue_train.py b/annotate_pgns/nnue_train.py
new file mode 100644
index 0000000..6112ab3
--- /dev/null
+++ b/annotate_pgns/nnue_train.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+"""Phase A training loop: local CPU, PyTorch, over the cached CSR
+tensors build_training_tensors.py produces. Goal is pipeline/
+framework validation (per NNUE.md) -- prove the data loader, model,
+loss, and checkpointing work end-to-end and get fast local iteration
+when something breaks, not to produce the best possible net. The
+real, larger Phase B run moves to a rented GPU against more data.
+
+CSR batching: each position is a variable-length "bag" of feature
+indices (3-32 of them). To build a batch for nn.EmbeddingBag without
+padding, gather the relevant CSR row-slices for a batch of position
+indices, concatenate them, and recompute offsets -- see
+_gather_batch() below.
+
+Usage:
+ python3 nnue_train.py \
+ --train-dir runs/sd10_twic_2M/tensors_train \
+ --holdout-dir runs/sd10_twic_2M/tensors_holdout \
+ --outdir runs/sd10_twic_2M/nnue_ckpt \
+ --epochs 20 --batch-size 8192 --threads 8
+"""
+import argparse
+import json
+import time
+from pathlib import Path
+
+import numpy as np
+import torch
+import torch.nn as nn
+
+from nnue_model import NnueNet, DEFAULT_K
+
+
+class CsrTensors:
+ """mmap-backed CSR feature tensors for one split (train or
+ holdout), as written by build_training_tensors.py."""
+
+ def __init__(self, tensor_dir):
+ tensor_dir = Path(tensor_dir)
+ self.white_idx = np.load(tensor_dir / "white_idx.npy", mmap_mode="r")
+ self.white_off = np.load(tensor_dir / "white_off.npy")
+ self.black_idx = np.load(tensor_dir / "black_idx.npy", mmap_mode="r")
+ self.black_off = np.load(tensor_dir / "black_off.npy")
+ self.stm = np.load(tensor_dir / "stm.npy")
+ self.target = np.load(tensor_dir / "target.npy")
+ self.n = len(self.target)
+ assert len(self.white_off) == self.n + 1
+ assert len(self.black_off) == self.n + 1
+ assert len(self.stm) == self.n
+
+ def __len__(self):
+ return self.n
+
+
+def _gather_batch(idx_arr, off_arr, row_ids):
+ """Build (flat_idx, batch_offsets) for an nn.EmbeddingBag call
+ covering exactly the bags named by row_ids (a 1D array of row
+ indices into off_arr). row_ids need not be sorted or contiguous."""
+ pieces = []
+ batch_off = np.empty(len(row_ids), dtype=np.int64)
+ cursor = 0
+ for i, r in enumerate(row_ids):
+ start, end = off_arr[r], off_arr[r + 1]
+ pieces.append(idx_arr[start:end])
+ batch_off[i] = cursor
+ cursor += end - start
+ flat = np.concatenate(pieces) if pieces else np.empty(0, dtype=np.int32)
+ return flat, batch_off
+
+
+def iterate_batches(data: CsrTensors, batch_size, shuffle, rng):
+ order = rng.permutation(data.n) if shuffle else np.arange(data.n)
+ for start in range(0, data.n, batch_size):
+ rows = order[start:start + batch_size]
+ rows_sorted = np.sort(rows) # sequential mmap reads, not required but friendlier
+ w_idx, w_off = _gather_batch(data.white_idx, data.white_off, rows_sorted)
+ b_idx, b_off = _gather_batch(data.black_idx, data.black_off, rows_sorted)
+ stm = data.stm[rows_sorted]
+ target = data.target[rows_sorted]
+ yield (
+ torch.from_numpy(w_idx.astype(np.int64, copy=False)),
+ torch.from_numpy(w_off),
+ torch.from_numpy(b_idx.astype(np.int64, copy=False)),
+ torch.from_numpy(b_off),
+ torch.from_numpy(stm.astype(np.int64, copy=False)),
+ torch.from_numpy(target.copy()),
+ )
+
+
+def evaluate(model, data, batch_size, k):
+ model.eval()
+ total_loss = 0.0
+ total_n = 0
+ rng = np.random.default_rng(0)
+ with torch.no_grad():
+ for w_idx, w_off, b_idx, b_off, stm, target in iterate_batches(
+ data, batch_size, shuffle=False, rng=rng):
+ pred = model.win_probability(w_idx, w_off, b_idx, b_off, stm, k=k)
+ loss = nn.functional.mse_loss(pred, target, reduction="sum")
+ total_loss += loss.item()
+ total_n += len(target)
+ model.train()
+ return total_loss / max(total_n, 1)
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--train-dir", required=True)
+ ap.add_argument("--holdout-dir", required=True)
+ ap.add_argument("--outdir", required=True)
+ ap.add_argument("--epochs", type=int, default=20)
+ ap.add_argument("--batch-size", type=int, default=8192)
+ ap.add_argument("--lr", type=float, default=1e-3)
+ ap.add_argument("--k", type=float, default=DEFAULT_K)
+ ap.add_argument("--threads", type=int, default=8,
+ help="torch CPU thread count -- kept modest by default "
+ "since this is a shared multi-user box")
+ ap.add_argument("--eval-every-steps", type=int, default=2000)
+ ap.add_argument("--resume", default=None,
+ help="checkpoint .pt to resume from")
+ ap.add_argument("--seed", type=int, default=0)
+ args = ap.parse_args()
+
+ torch.set_num_threads(args.threads)
+ torch.manual_seed(args.seed)
+
+ outdir = Path(args.outdir)
+ outdir.mkdir(parents=True, exist_ok=True)
+ log_path = outdir / "train_log.jsonl"
+
+ print("Loading tensors ...")
+ train_data = CsrTensors(args.train_dir)
+ holdout_data = CsrTensors(args.holdout_dir)
+ print(f" train: {len(train_data)} positions, holdout: {len(holdout_data)} positions")
+
+ model = NnueNet()
+ optimizer = torch.optim.Adam(model.parameters(), lr=args.lr)
+ start_epoch = 0
+ global_step = 0
+
+ if args.resume:
+ ckpt = torch.load(args.resume, map_location="cpu", weights_only=True)
+ model.load_state_dict(ckpt["model"])
+ optimizer.load_state_dict(ckpt["optimizer"])
+ start_epoch = ckpt["epoch"] + 1
+ global_step = ckpt.get("global_step", 0)
+ print(f"Resumed from {args.resume} at epoch {start_epoch}")
+
+ rng = np.random.default_rng(args.seed)
+ t_start = time.time()
+
+ with open(log_path, "a") as log_f:
+ for epoch in range(start_epoch, args.epochs):
+ epoch_loss = 0.0
+ epoch_n = 0
+ t_epoch = time.time()
+
+ for w_idx, w_off, b_idx, b_off, stm, target in iterate_batches(
+ train_data, args.batch_size, shuffle=True, rng=rng):
+ optimizer.zero_grad()
+ pred = model.win_probability(w_idx, w_off, b_idx, b_off, stm, k=args.k)
+ loss = nn.functional.mse_loss(pred, target)
+ loss.backward()
+ optimizer.step()
+
+ bs = len(target)
+ epoch_loss += loss.item() * bs
+ epoch_n += bs
+ global_step += 1
+
+ if global_step % args.eval_every_steps == 0:
+ holdout_mse = evaluate(model, holdout_data, args.batch_size, args.k)
+ elapsed = time.time() - t_start
+ rec = {
+ "step": global_step, "epoch": epoch,
+ "train_mse_running": epoch_loss / max(epoch_n, 1),
+ "holdout_mse": holdout_mse,
+ "elapsed_s": round(elapsed, 1),
+ "positions_seen": epoch_n + epoch * len(train_data),
+ }
+ print(f" step {global_step} epoch {epoch} "
+ f"train_mse={rec['train_mse_running']:.5f} "
+ f"holdout_mse={holdout_mse:.5f} "
+ f"elapsed={elapsed:.0f}s")
+ log_f.write(json.dumps(rec) + "\n")
+ log_f.flush()
+
+ holdout_mse = evaluate(model, holdout_data, args.batch_size, args.k)
+ epoch_elapsed = time.time() - t_epoch
+ print(f"Epoch {epoch} done: train_mse={epoch_loss / max(epoch_n, 1):.5f} "
+ f"holdout_mse={holdout_mse:.5f} ({epoch_elapsed:.0f}s)")
+
+ ckpt_path = outdir / f"ckpt_epoch{epoch:03d}.pt"
+ torch.save({
+ "model": model.state_dict(),
+ "optimizer": optimizer.state_dict(),
+ "epoch": epoch,
+ "global_step": global_step,
+ "holdout_mse": holdout_mse,
+ "args": vars(args),
+ }, ckpt_path)
+ print(f" wrote {ckpt_path}")
+
+ print(f"Training done in {time.time() - t_start:.0f}s")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/quiet_common.py b/annotate_pgns/quiet_common.py
new file mode 100644
index 0000000..77544ef
--- /dev/null
+++ b/annotate_pgns/quiet_common.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+"""Shared quiet-position predicate, per NNUE.md: exclude positions
+where the side to move is in check, or where a given move is an
+SEE-positive (>=50cp) capture.
+
+Used two ways by this pipeline, with a deliberately different choice
+of *which* move gets SEE-checked in each case:
+
+- Pre-search filtering (annotate.py): checked against the move actually
+ played in the source human game, since that's the only move known
+ before the engine has searched anything -- the whole point is to
+ skip the sd-N search entirely for positions that would be discarded
+ anyway.
+- Post-hoc filtering (quiet_filter.py, quiet_filter_games.py): checked
+ against the first move of the already-recorded sd-N search PV (the
+ engine's own considered-best reply), per NNUE.md's original filter
+ design. This remains the definition used for data already annotated
+ before pre-filtering existed, and is NOT the same training
+ distribution as the game-move variant above -- do not mix rows
+ produced under the two definitions into one training run without
+ accounting for that.
+
+Both call the same `position_is_quiet` predicate once a concrete move
+is in hand; only how that move is obtained differs.
+
+Reuses eval_tune/tune_eval_dna.py's existing static_exchange_eval() --
+a classic swap-list SEE (ignores pins/x-rays/discoveries, a
+documented, accepted simplification; see check_see_against_engine.py
+for a cross-check against the engine's real SEE()) -- rather than
+reimplementing it.
+"""
+import os
+import sys
+from pathlib import Path
+
+_EVAL_TUNE_DIR = Path(__file__).resolve().parent.parent / "src" / "eval_tune"
+sys.path.insert(0, str(_EVAL_TUNE_DIR))
+import chess # noqa: E402
+
+# tune_eval_dna imports dna_diff, which reads "../eval.c" relative to
+# the *current working directory* at import time, not relative to its
+# own file -- chdir there just for the import, then restore, so this
+# module can be imported from any directory.
+_cwd = os.getcwd()
+os.chdir(_EVAL_TUNE_DIR)
+try:
+ from tune_eval_dna import static_exchange_eval, has_hanging_material # noqa: E402,F401
+finally:
+ os.chdir(_cwd)
+
+SEE_THRESHOLD = 50 # matches has_hanging_material's own default
+
+
+def is_see_positive_capture(board, move, threshold=SEE_THRESHOLD):
+ """board is the position *before* move. Does not mutate board."""
+ if not board.is_capture(move):
+ return False
+ return static_exchange_eval(board, move) >= threshold
+
+
+def position_is_quiet(board, move, threshold=SEE_THRESHOLD):
+ """board is the position *before* move; move is whichever move the
+ caller has decided to SEE-check (played move or PV's first move --
+ see module docstring). True means: keep this position in the
+ training set."""
+ if board.is_check():
+ return False
+ return not is_see_positive_capture(board, move, threshold)
diff --git a/annotate_pgns/quiet_filter.py b/annotate_pgns/quiet_filter.py
new file mode 100644
index 0000000..177452e
--- /dev/null
+++ b/annotate_pgns/quiet_filter.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""Apply the quiet-position filter (NNUE.md: exclude positions where
+the side to move is in check, or where the best/played move is an
+SEE-positive capture) to an already-extracted positions.jsonl, after
+the fact -- this run already paid the full sd-N search cost regardless
+of whether a position turns out to be quiet, so filtering now just
+trims the training set. A *future* annotate.py run should apply this
+filter before writing a position into a shard script at all, so search
+time is never spent on positions that would be discarded anyway.
+
+Reuses eval_tune/tune_eval_dna.py's existing static_exchange_eval() --
+a classic swap-list SEE (ignores pins/x-rays/discoveries, a documented,
+accepted simplification) -- rather than reimplementing it. That
+function has never been cross-checked against the engine's own real
+SEE(); see check_see_against_engine.py for that.
+
+"Best move" here means the first move of the already-recorded sd-N
+PV (positions.jsonl's "pv" field), not the move actually played in the
+source human game -- annotate.py doesn't currently record the latter,
+and the PV's first move is the engine's own considered-best reply,
+which is what NNUE.md's filter is actually trying to exclude (a
+position captured mid-exchange shouldn't carry a settled-looking
+label).
+
+Usage:
+ python3 quiet_filter.py --in runs/sd10_twic_2M/positions.jsonl \
+ --out runs/sd10_twic_2M/positions.quiet.jsonl
+"""
+import argparse
+import json
+import sys
+
+from quiet_common import chess, has_hanging_material, is_see_positive_capture, SEE_THRESHOLD
+
+
+def pv_first_move(board, pv):
+ """None if indeterminate (empty PV, illegal/unparseable SAN --
+ shouldn't happen for a non-exact row but don't crash the whole run
+ over one bad line), else the parsed Move."""
+ if not pv:
+ return None
+ san = pv.split(" ", 1)[0]
+ try:
+ return board.parse_san(san)
+ except ValueError:
+ return None
+
+
+def first_move_is_see_positive_capture(board, pv):
+ """None if indeterminate, else True/False."""
+ move = pv_first_move(board, pv)
+ if move is None:
+ return None
+ return is_see_positive_capture(board, move)
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--in", dest="infile", required=True)
+ ap.add_argument("--out", dest="outfile", required=True)
+ ap.add_argument("--limit", type=int, default=None,
+ help="stop after this many input rows (smoke testing)")
+ args = ap.parse_args()
+
+ n_total = 0
+ n_exact_skipped = 0
+ n_indeterminate = 0
+ n_excluded_check = 0
+ n_excluded_capture = 0
+ n_hanging_material = 0 # diagnostic only, not an exclusion criterion here
+ n_quiet = 0
+
+ with open(args.infile) as fin, open(args.outfile, "w") as fout:
+ for line in fin:
+ if args.limit is not None and n_total >= args.limit:
+ break
+ n_total += 1
+ if n_total % 200000 == 0:
+ print(f" ...{n_total} rows processed "
+ f"({n_quiet} kept so far)", file=sys.stderr)
+
+ rec = json.loads(line)
+ if rec.get("exact"):
+ n_exact_skipped += 1
+ continue
+
+ board = chess.Board(rec["fen"])
+
+ if board.is_check():
+ n_excluded_check += 1
+ continue
+
+ is_see_positive = first_move_is_see_positive_capture(
+ board, rec.get("pv", ""))
+ if is_see_positive is None:
+ n_indeterminate += 1
+ # Can't evaluate the filter criterion -- err on the
+ # side of excluding rather than silently keeping a row
+ # that was never actually checked.
+ continue
+ if is_see_positive:
+ n_excluded_capture += 1
+ continue
+
+ if has_hanging_material(board, threshold=SEE_THRESHOLD):
+ n_hanging_material += 1 # reported, not excluded
+
+ fout.write(json.dumps(rec) + "\n")
+ n_quiet += 1
+
+ print(f"Read {n_total} rows ({args.infile})")
+ print(f" {n_exact_skipped} already exact (mate/EGTB/REC) -- skipped")
+ print(f" {n_indeterminate} indeterminate (empty/unparseable PV) -- excluded")
+ print(f" {n_excluded_check} excluded: side to move in check")
+ print(f" {n_excluded_capture} excluded: PV's first move is an "
+ f"SEE-positive (>={SEE_THRESHOLD}cp) capture")
+ print(f" {n_quiet} quiet positions written to {args.outfile}")
+ print(f" (of those, {n_hanging_material} still have *some* other "
+ f"SEE-positive capture available on the board -- reported for "
+ f"visibility, not excluded by this filter)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/quiet_filter_games.py b/annotate_pgns/quiet_filter_games.py
new file mode 100644
index 0000000..f4de31e
--- /dev/null
+++ b/annotate_pgns/quiet_filter_games.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+"""Apply the same post-hoc quiet-position filter as quiet_filter.py,
+but to a game-shaped labeled file (build_games.py / fit_k_and_label.py
+output: games.train.labeled.jsonl, games.holdout.labeled.jsonl) rather
+than the flat positions.jsonl.
+
+Deliberately does NOT join against positions.quiet.jsonl -- that file
+has no row index or game_id, and FENs can repeat across different
+games/plies, so matching by FEN content would silently misattribute
+rows. Instead this re-applies the identical predicate
+(quiet_common.position_is_quiet, checked against each row's own PV's
+first move) directly to each position already embedded in the game
+record, so the two filtered views (flat and game-shaped) are
+guaranteed consistent by construction rather than by a fragile join.
+
+A game that loses all of its positions to filtering is dropped
+entirely (an empty "positions": [] list is not useful downstream).
+
+Usage:
+ python3 quiet_filter_games.py \
+ --in runs/sd10_twic_2M/games.train.labeled.jsonl \
+ --out runs/sd10_twic_2M/games.train.labeled.quiet.jsonl
+"""
+import argparse
+import json
+import sys
+
+from quiet_common import chess, position_is_quiet
+from quiet_filter import pv_first_move
+
+
+def filter_game_positions(game_rec):
+ """Returns a new list of kept position rows (exact rows are always
+ kept -- the quiet filter only applies to ordinary, non-exact
+ search scores, same as quiet_filter.py)."""
+ kept = []
+ n_excluded_check = n_excluded_capture = n_indeterminate = 0
+ for pos in game_rec["positions"]:
+ if pos.get("exact"):
+ kept.append(pos)
+ continue
+ board = chess.Board(pos["fen"])
+ move = pv_first_move(board, pos.get("pv", ""))
+ if move is None:
+ n_indeterminate += 1
+ continue
+ if board.is_check():
+ n_excluded_check += 1
+ continue
+ if not position_is_quiet(board, move):
+ n_excluded_capture += 1
+ continue
+ kept.append(pos)
+ return kept, n_excluded_check, n_excluded_capture, n_indeterminate
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--in", dest="infile", required=True)
+ ap.add_argument("--out", dest="outfile", required=True)
+ args = ap.parse_args()
+
+ n_games_in = n_games_out = n_games_dropped = 0
+ n_pos_in = n_pos_out = 0
+ n_excluded_check = n_excluded_capture = n_indeterminate = 0
+
+ with open(args.infile) as fin, open(args.outfile, "w") as fout:
+ for line in fin:
+ game_rec = json.loads(line)
+ n_games_in += 1
+ n_pos_in += len(game_rec["positions"])
+
+ kept, c, p, i = filter_game_positions(game_rec)
+ n_excluded_check += c
+ n_excluded_capture += p
+ n_indeterminate += i
+
+ if not kept:
+ n_games_dropped += 1
+ continue
+
+ game_rec["positions"] = kept
+ fout.write(json.dumps(game_rec) + "\n")
+ n_games_out += 1
+ n_pos_out += len(kept)
+
+ if n_games_in % 2000 == 0:
+ print(f" ...{n_games_in} games processed "
+ f"({n_pos_out} positions kept so far)", file=sys.stderr)
+
+ print(f"Read {args.infile}: {n_games_in} games, {n_pos_in} positions")
+ print(f" {n_excluded_check} positions excluded: side to move in check")
+ print(f" {n_excluded_capture} positions excluded: PV's first move is "
+ f"an SEE-positive capture")
+ print(f" {n_indeterminate} positions excluded: indeterminate "
+ f"(empty/unparseable PV)")
+ print(f" {n_games_dropped} games dropped entirely (lost all positions)")
+ print(f"Wrote {args.outfile}: {n_games_out} games, {n_pos_out} positions")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/split_holdout.py b/annotate_pgns/split_holdout.py
new file mode 100644
index 0000000..277c1bf
--- /dev/null
+++ b/annotate_pgns/split_holdout.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Stratified game-level train/holdout split over a games.jsonl
+(build_games.py output).
+
+Splits by GAME, not by individual position -- every position from one
+game lands on the same side of the split, avoiding the leakage a flat
+position-level shuffle would have (adjacent plies / recurring
+middlegame structures from the same game are highly correlated, not
+independent samples; see eval_tune/tune_eval_dna.py's own holdout
+split, which doesn't do this).
+
+Stratified on two axes so the holdout's composition mirrors the full
+pool's rather than a flat 10% sample risking (by chance) too many
+decisive games, too few draws, or skewing toward short/long games:
+ - result: White win / Black win / Draw
+ - length: quartile bin of the game's annotated ply count (quartiles
+ of the actual corpus, not guessed fixed cutoffs)
+10% (configurable) is sampled independently within each of the
+resulting (result, length-quartile) strata, not 10% of the pool
+overall.
+
+Usage:
+ python3 split_holdout.py --in runs/sd10_twic_2M/games.jsonl \
+ --train-out runs/sd10_twic_2M/games.train.jsonl \
+ --holdout-out runs/sd10_twic_2M/games.holdout.jsonl \
+ --holdout-frac 0.1 --seed 0
+"""
+import argparse
+import json
+import math
+import random
+from collections import defaultdict
+
+
+def result_bucket(result):
+ if result == "1-0":
+ return "white_win"
+ if result == "0-1":
+ return "black_win"
+ if result == "1/2-1/2":
+ return "draw"
+ return "unknown"
+
+
+def quartile_bin(length, cuts):
+ for i, c in enumerate(cuts):
+ if length <= c:
+ return i
+ return len(cuts)
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--in", dest="infile", required=True)
+ ap.add_argument("--train-out", required=True)
+ ap.add_argument("--holdout-out", required=True)
+ ap.add_argument("--holdout-frac", type=float, default=0.1)
+ ap.add_argument("--seed", type=int, default=0)
+ args = ap.parse_args()
+
+ games = []
+ with open(args.infile) as f:
+ for line in f:
+ games.append(json.loads(line))
+ print(f"Loaded {len(games)} games from {args.infile}")
+
+ lengths = sorted(len(g["positions"]) for g in games)
+ def pct(p):
+ idx = min(int(p * len(lengths)), len(lengths) - 1)
+ return lengths[idx]
+ cuts = [pct(0.25), pct(0.50), pct(0.75)]
+ print(f"Length quartile cutpoints (annotated plies): {cuts}")
+
+ strata = defaultdict(list)
+ for g in games:
+ key = (result_bucket(g["result"]),
+ quartile_bin(len(g["positions"]), cuts))
+ strata[key].append(g)
+
+ rng = random.Random(args.seed)
+ train_games = []
+ holdout_games = []
+ print(f"\n{'stratum':<24} {'total':>7} {'holdout':>8} {'train':>8}")
+ for key in sorted(strata.keys()):
+ bucket = strata[key]
+ rng.shuffle(bucket)
+ n_holdout = math.ceil(len(bucket) * args.holdout_frac)
+ holdout_games.extend(bucket[:n_holdout])
+ train_games.extend(bucket[n_holdout:])
+ label = f"{key[0]}/q{key[1]}"
+ print(f"{label:<24} {len(bucket):>7} {n_holdout:>8} "
+ f"{len(bucket) - n_holdout:>8}")
+
+ print(f"\n{'TOTAL':<24} {len(games):>7} {len(holdout_games):>8} "
+ f"{len(train_games):>8}")
+
+ with open(args.train_out, "w") as f:
+ for g in train_games:
+ f.write(json.dumps(g) + "\n")
+ with open(args.holdout_out, "w") as f:
+ for g in holdout_games:
+ f.write(json.dumps(g) + "\n")
+
+ n_train_pos = sum(len(g["positions"]) for g in train_games)
+ n_holdout_pos = sum(len(g["positions"]) for g in holdout_games)
+ print(f"\nWrote {args.train_out}: {len(train_games)} games, "
+ f"{n_train_pos} positions")
+ print(f"Wrote {args.holdout_out}: {len(holdout_games)} games, "
+ f"{n_holdout_pos} positions")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/GNUmakefile b/src/GNUmakefile
index 49582a5..66455e6 100644
--- a/src/GNUmakefile
+++ b/src/GNUmakefile
@@ -344,7 +344,8 @@ OBJS = main.o root.o search.o searchsup.o draw.o dynamic.o \
generate.o see.o move.o movesup.o command.o script.o \
input.o vars.o util.o unix.o gamelist.o mersenne.o \
sig.o piece.o ics.o san.o fen.o book.o bench.o board.o \
- data.o probe.o fathom.o recogn.o list.o x64.o
+ data.o probe.o fathom.o recogn.o list.o x64.o \
+ nnuefeatures.o
ifdef TEST
# ---> .o, not .c! <---
diff --git a/src/chess.h b/src/chess.h
index 3a1f842..05928a3 100755
--- a/src/chess.h
+++ b/src/chess.h
@@ -3319,7 +3319,7 @@ void
InitializePawnAttackOriginTable(void);
void
-InitMagic(void);
+InitializeBitboardMagic(void);
#ifdef DEBUG
ULONG CheckVectorWithIndex(int i, ULONG uColor);
@@ -3774,7 +3774,7 @@ ThreadUnderTerminatingSplit(SEARCHER_THREAD_CONTEXT *);
extern SCORE g_iLMRQuietReduction[MAX_PLY_PER_SEARCH + 1][LMR_TABLE_MAX_MOVES + 1];
void
-InitLMRTable(void);
+InitializeLMRTable(void);
FLAG
CanWeReduceOrPruneMove(IN SEARCHER_THREAD_CONTEXT *ctx,
@@ -4255,11 +4255,14 @@ WriteEvalDNA(char *szFilename);
FLAG
ImportEvalDNA(char *p);
+void
+DumpNnueFeatures(POSITION *pos);
+
FLAG
ReadEvalDNA(char *szFilename);
void
-InitEval();
+InitializeEval();
SCORE
Eval(SEARCHER_THREAD_CONTEXT *, SCORE, SCORE, SCORE (*)[2]);
diff --git a/src/command.c b/src/command.c
index b30de2b..715c989 100644
--- a/src/command.c
+++ b/src/command.c
@@ -313,6 +313,7 @@ Routine description:
dump sizes
dump eval [optional coor]
dump evaldna
+ dump nnuefeatures
This command is used to dump internal information to the
stdout.
@@ -335,6 +336,12 @@ Routine description:
provided then the eval terms DIRECTLY affected by the piece at
the supplied coordinate is produced. Note: to use dump eval
the engine must have been built with -DEVAL_DUMP.
+
+ When used with the 'nnuefeatures' argument, prints the sorted
+ set of active HalfKAv2_hm feature indices for both
+ perspectives at the current position -- see nnuefeatures.c
+ and docs/NNUE.md. Reference-implementation-only, not wired
+ into eval/search.
Parameters:
@@ -376,12 +383,16 @@ Return value:
{
DumpSizes();
}
- else if (!STRCMPI(argv[1], "evaldna"))
+ else if (!STRCMPI(argv[1], "evaldna"))
{
char *p = ExportEvalDNA();
Trace("EvalDNA: %s\n", p);
free(p);
}
+ else if (!STRCMPI(argv[1], "nnuefeatures"))
+ {
+ DumpNnueFeatures(pos);
+ }
#ifdef EVAL_DUMP
else if (!STRCMPI(argv[1], "eval"))
{
@@ -737,7 +748,7 @@ Return value:
// ROOK_ON_HALF_OPEN_WITH_FRIEND, kept static rather than
// rebuilt every _EvalRook call since it's speed-critical
// code -- a DNA reload has to explicitly refresh it.
- InitEval();
+ InitializeEval();
// EvalCommand's persistent SEARCHER_THREAD_CONTEXT keeps
// its embedded eval hash alive across calls for speed; a
diff --git a/src/data.c b/src/data.c
index 9d8be7b..8755426 100644
--- a/src/data.c
+++ b/src/data.c
@@ -895,7 +895,7 @@ Return value:
// board_representation/MOVEGEN_MIGRATION.md sections 2a/3 for the
// full design writeup. Everything here (occupancy masks, magic
// numbers, and the attack tables they index into) is computed once at
-// startup by InitMagic(), never hardcoded -- a validation prototype
+// startup by InitializeBitboardMagic(), never hardcoded -- a validation prototype
// measured the full search+build+verify cost at ~0.22s for both piece
// types combined, cheap enough to just pay at every process launch
// rather than maintaining hand-pasted constants that could silently
@@ -911,7 +911,7 @@ Return value:
// count, and therefore the attack table size, minimal).
//
// g_RookMagic[c] / g_BishopMagic[c] and g_RookMagicShift[c] /
-// g_BishopMagicShift[c]: found by InitMagic() via a random
+// g_BishopMagicShift[c]: found by InitializeBitboardMagic() via a random
// sparse-candidate search, fixed-seeded (see g_MagicRngState below)
// so a given build reproduces the exact same magics on every run --
// deliberately NOT using libc's rand()/srand(), since main.c's
@@ -968,7 +968,7 @@ _MagicSparseRandom64(void)
}
// Slow, obviously-correct reference used both to build each magic
-// table's contents and to verify it before InitMagic() accepts it:
+// table's contents and to verify it before InitializeBitboardMagic() accepts it:
// walk each of the 4 directions from c until (and including) the
// first occupied square, given a full occupancy bitboard covering
// both sides' pieces.
@@ -1066,7 +1066,7 @@ _MagicFindAndBuildForSquare(COOR c, BITBOARD mask, const int iDelta[4],
if ((NULL == rgbbOccupancy) || (NULL == rgbbAttacks) ||
(NULL == rgbbTable) || (NULL == rgfFilled))
{
- Bug("InitMagic: out of memory building table for square %d\n", c);
+ Bug("InitializeBitboardMagic: out of memory building table for square %d\n", c);
}
for (i = 0; i < uSize; i++)
@@ -1128,7 +1128,7 @@ _MagicFindAndBuildForSquare(COOR c, BITBOARD mask, const int iDelta[4],
if (rgbbTable[uIndex] != bbExpected)
{
- Bug("InitMagic: verification failed for square %d, "
+ Bug("InitializeBitboardMagic: verification failed for square %d, "
"occupancy subset %lu\n", c, i);
}
}
@@ -1142,7 +1142,7 @@ _MagicFindAndBuildForSquare(COOR c, BITBOARD mask, const int iDelta[4],
}
void
-InitMagic(void)
+InitializeBitboardMagic(void)
/**
Routine description:
diff --git a/src/docs/ANALYZE_GAME.md b/src/docs/ANALYZE_GAME.md
new file mode 100644
index 0000000..2576a1e
--- /dev/null
+++ b/src/docs/ANALYZE_GAME.md
@@ -0,0 +1,254 @@
+# Analyzing a game from blunders.pgn
+
+A working playbook for pulling a specific game out of `blunders.pgn` and
+figuring out *where the eval/search actually went wrong*, as opposed to
+just noticing that the final result was bad. Written after a session spent
+doing exactly this on the 2026-10-06 `typhoon vs Crafty-25.2` loss (a
+KRP-vs-KQ endgame that looked like it had a scoring bug but turned out to
+be legitimately lost, plus two real engine bugs found along the way).
+
+## 0. What's actually in blunders.pgn
+
+Each game's move comments are **only** `{score/depth time}`, e.g.
+`{-5.09/34 6}` -- score in pawns from White's perspective (not side-to-move;
+confirmed by the sign trending consistently negative as White loses, across
+both White's and Black's own move comments), search depth reached, and
+wall-clock seconds spent on that move. **There is no PV text and no engine
+tag in the PGN at all** -- not `<REC>`, not `<EGTB>`, not `<#>`, nothing.
+Whatever wrapper plays these self-play/vs-Crafty games strips the PV down to
+just the eval number before writing the comment. If you want to see *why*
+a move was chosen -- the actual PV, or whether a recognizer/tablebase hit
+produced the score -- you have to replay the position through a live engine
+yourself; the PGN alone can't tell you.
+
+Don't go looking for `<EGTB>`/`<#>` tags in old PGNs for another reason too:
+those sentinels (`EGTBMOVE` plus the `RECOGN_EGTB` wiring) are new, added in
+the 2026-10-06 session that produced this document. Any game recorded before
+that commit landed couldn't have produced them even if the PGN format did
+carry PV text.
+
+## 1. Pulling the game and exact FENs with python-chess
+
+`python3 -c "import chess"` works in this environment (python-chess is
+installed) -- use it rather than hand-parsing SAN, especially for games deep
+enough that move numbers and ply counts diverge from intuition.
+
+```python
+import chess.pgn
+f = open('blunders.pgn')
+games = []
+g = chess.pgn.read_game(f)
+while g:
+ games.append(g)
+ g = chess.pgn.read_game(f)
+last = games[-1] # or games[N] for a specific game
+board = last.board()
+moves = list(last.mainline_moves())
+```
+
+**Getting a FEN at a specific PGN move number**: ply count and PGN move
+number are NOT the same thing and it's easy to get this wrong (did exactly
+this once already this session -- cost a wasted round-trip). The mapping:
+
+```python
+def ply(movenum, color): # color: 'w' or 'b'
+ return 2*(movenum-1) + (1 if color == 'w' else 2)
+```
+
+`moves[ply(69, 'w') - 1]` is White's 69th move (0-indexed list, 1-indexed
+ply). Push moves up through the target ply and read `board.fen()` --
+`board.halfmove_clock` is directly available too and matters a lot (see
+"fifty-move-rule proximity" below).
+
+To scan a whole stretch and watch halfmove_clock / material change move by
+move (useful for finding exactly where a pawn/capture resets the fifty-move
+counter, or where material transitions from one tablebase signature to
+another):
+
+```python
+for i, m in enumerate(moves, start=1):
+ before = board.copy()
+ board.push(m)
+ if lo <= i <= hi:
+ print(i, before.san(m), 'halfmove_clock=', board.halfmove_clock,
+ 'fen=', board.fen())
+```
+
+## 2. Replaying a position through typhoon
+
+Use `--batch --command` with `setboard`, not interactive stdin piping (per
+CLAUDE.md, piping commands over stdin to a live REPL doesn't reliably
+complete a multi-step script). Always pass `--egtbpath` explicitly if you
+want to be sure which EGTB directory is in play (the hardcoded default is
+`/zscratch/egtb`, which currently has full WDL+DTZ coverage through 6-man --
+see CLAUDE.md -- but don't assume that silently when writing up findings;
+state it).
+
+```sh
+./typhoon --cpus 1 --hash 256m --egtbpath /zscratch/egtb \
+ --logfile /tmp/typhoon/<name>.log \
+ --batch --command "force; setboard <fen>; eval; sd <depth>; go"
+```
+
+- `eval` alone (no search) gives the raw static eval -- useful as a sanity
+ baseline before trusting a search score.
+- `sd <depth>` fixes search depth; prefer this over `st <seconds>` for
+ reproducibility (machine load varies a lot on this shared box -- see
+ CLAUDE.md's ECM section).
+- Watch stdout for these diagnostic lines, which tell you *how* the move was
+ chosen, not just what it was:
+ - `ONLY MOVE --> stop searching now` -- forced, no real search happened.
+ - `TABLEBASE MOVE --> stop searching now` -- root EGTB (DTZ) root-move
+ short-circuit; score will show as `PV=<EGTB>` and a flat `±NMATE`-ish
+ "mate" display that is **not** a real mate distance (see section 4).
+ - `FORCED MATE --> stop searching now` -- the iterative loop detected a
+ mate score at the current depth and is stopping; the printed PV comes
+ from `szLastPV`, which on a **root fail-high** is just the bare move
+ (`MoveToSan`), not a real PV walk -- see section 5, don't expect `<#>`
+ here.
+ - `DEPTH LIMIT --> stop searching now` -- ordinary completion, nothing
+ special.
+- Grep the output/log for `tellothers` to get the final
+ `score, time, nps, PV=...` summary line in one place.
+
+## 3. Cross-checking with Crafty
+
+`/usr/local/bin/crafty` is available and gives a genuinely independent
+second opinion -- different engine, different (or absent) EGTB usage,
+different search. Use it exactly the way you'd use typhoon:
+
+```sh
+echo "setboard <fen>
+sd <depth>
+go
+quit" | timeout 20 /usr/local/bin/crafty 2>&1 | tail -30
+```
+
+Crafty's output is far chattier (full eval component breakdown, PV with
+move count, nodes/nps) -- look for the `depth ... score ... variation`
+block and the `note: scores are for the white side` line (confirms sign
+convention, which is directly comparable to typhoon's White-POV scores).
+
+**Why this matters**: if typhoon reports something wild (a huge mate-ish
+score with a PV that goes nowhere, or a score frozen for 20+ plies in a row)
+and Crafty reports an ordinary, non-mate score with a PV that's actually
+converging, that's your signal the *typhoon* number is suspect, not that the
+position itself is unusual. This exact comparison is what caught the
+`ProbeEGTBRoot` DTZ-as-mate-distance bug (section 4) -- typhoon said
+`-MATE4`, Crafty's independent 16-ply search said `-7.38` with no mate
+anywhere in a 13-move PV.
+
+## 4. Things that look like bugs but are fifty-move-rule artifacts (and one that was a real bug)
+
+**Watch `halfmove_clock` whenever a long endgame grind produces a
+suspicious score.** Syzygy tables report **WDL** (win/draw/loss) and
+**DTZ** (distance to the next fifty-move-rule-*zeroing* move -- a capture or
+pawn push), not DTM (distance to mate). This project's EGTB directory has
+**zero `.rtbm` files** -- DTM tables were never downloaded, and Fathom's DTM
+code (`probe_dtm_table`, `TB_probe_dtm`, etc.) is present in the source but
+dead weight without data behind it. So:
+
+- A genuinely won endgame can sit at a **small DTZ** while still being many
+ plies from actual mate, especially once `halfmove_clock` is climbing
+ toward 100 (a reset becomes forced soon regardless of how far mate
+ actually is).
+- A score that stays flat for many consecutive moves, or a score that
+ jumps to a huge "mate-ish" magnitude with a PV that doesn't resolve
+ anywhere close to that many plies later, is the signature of this --
+ **not** evidence of missing/corrupt tablebase files by itself. Check
+ `ls /zscratch/egtb | grep <material-signature>` to rule out a genuine
+ coverage gap first, but if the files are present, suspect the
+ DTZ-vs-mate-distance conflation next.
+- This was a **real, fixed bug** as of 2026-10-06: `probe.c`'s
+ `ProbeEGTBRoot` used to compute `*piScore = INFINITY_SCORE ∓ uDtz` and
+ report it through the mate-score display path -- i.e. it fabricated a
+ "mate in N" number out of a quantity that was never a mate distance. Fixed
+ to report a flat `+NMATE`/`-NMATE` (same convention `ProbeEGTB`/
+ `RecognLookup` already use at interior nodes) instead. If you're
+ diagnosing a *pre-fix* game (anything before this fix landed -- check
+ `git log probe.c`), expect to see exactly this artifact and don't chase
+ it as a new finding.
+- The **move** Fathom picks at the root is unaffected by any of this --
+ `tb_probe_root()` does its own internal DTZ-based ranking before
+ returning, so the chosen move is already DTZ-optimal regardless of
+ whether the *score* we derived from it was sane. Don't second-guess the
+ move choice because the displayed score looked wrong; check the score
+ logic and the move logic as separate questions.
+
+## 5. Reading PV sentinels (and when you won't see them)
+
+`WalkPV` (`util.c`) terminates a printed PV early and appends a tag when it
+hits one of five sentinel "moves" (`data.c`): `HASHMOVE` → `<TT>`,
+`RECOGNMOVE` → `<REC>`, `DRAWMOVE` → `<=>`, `MATEMOVE` → `<#>`, `EGTBMOVE` →
+`<EGTB>`. These mark *why the PV stopped*, not what the move was:
+
+- `<REC>` / `<EGTB>`: an interior-node `RecognLookup` hit resolved the
+ score outright. `RECOGN_EGTB` (→ `<EGTB>`) means an actual on-disk Syzygy
+ WDL probe succeeded; `RECOGN_EXACT` (→ `<REC>`) means one of `recogn.c`'s
+ hand-written classic recognizers (KPK, KBKP, etc.) fired instead. Before
+ 2026-10-06 these were indistinguishable in the PV (both reported
+ `RECOGNMOVE`) -- `RecognLookup` had an `RECOGN_EGTB` enum value sitting
+ unused the whole time, always returning `RECOGN_EXACT` even on a genuine
+ Fathom hit. If you're looking at an old log/build, `<REC>` could mean
+ either; `<EGTB>` specifically didn't exist yet.
+- `<#>`: search found an actual forced checkmate (zero legal moves + in
+ check) at a PV leaf.
+- `<=>`: a drawn leaf (zero legal moves, not in check == stalemate; this
+ sentinel is also used for recognized/forced draws elsewhere).
+- **Root tablebase moves get `<EGTB>` in `szLastPV` directly** (as a literal
+ string, not via `WalkPV` -- `ProbeEGTBRoot`'s success path short-circuits
+ the whole iterative loop and never calls `WalkPV` at all for that move).
+
+**The big gotcha**: `PrintPV` (`util.c`) only calls the real `WalkPV` walk
+-- the one that can surface any of these tags -- when the reported score is
+a genuine PV node, `iAlpha < iScore < iBeta`. On a **root fail-high**
+(`iScore > iBeta`, i.e. the aspiration window is still widening upward),
+it falls back to printing just the bare best move via `MoveToSan`, with no
+PV walk and therefore no sentinel, ever -- even if the position is a
+dead-obvious mate-in-1 whose leaf genuinely wrote `MATEMOVE` two plies down.
+So: **don't read "no `<#>` in the output" as "no mate was found"** -- check
+whether the line says `FORCED MATE` (a fail-high-driven stop) vs. a normal
+depth-complete report before concluding anything about whether the engine
+actually resolved the mating line as an exact PV.
+
+## 6. The timer-elapsed-time gotcha
+
+If you see `Searched for -<huge number> seconds` in output, that's not a
+real timing anomaly to chase -- or rather, it *was* a real bug (fixed
+2026-10-06): `Iterate()` (`root.c`) used to stamp
+`g_MoveTimer.dEndTime = SystemTimeStamp()` partway through the function,
+*before* the shared `end:` label that every early-exit path (`ONLY MOVE`,
+`TABLEBASE MOVE`, pre-move terminal states) jumps to via `goto end`. Those
+paths skipped the stamp entirely, leaving `dEndTime` stale (0, or whatever
+the previous unrelated search last set it to), which produces exactly this
+kind of nonsense negative "elapsed time" when the end-of-search report
+computes `dEndTime - dStartTime`. Fixed by moving the stamp to right after
+the `end:` label itself. If you're diagnosing a pre-fix build and see this,
+it's cosmetic (doesn't affect move choice or score), but it's also a tell
+that you're looking at a root short-circuit path (tablebase or only-move),
+which is useful context for *why* no real iterative search happened at that
+node.
+
+## 7. General workflow summary
+
+1. Identify the game (usually "last game in the file") and get the exact
+ FEN at the move(s) you care about via python-chess -- don't eyeball SAN
+ move numbers against ply counts by hand.
+2. Check `board.halfmove_clock` at that FEN before anything else if it's an
+ endgame -- rules out/in the fifty-move-rule class of artifact immediately.
+3. Replay the FEN through `./typhoon --batch --command "force; setboard
+ <fen>; sd <depth>; go"`, read the diagnostic `--> stop searching now`
+ line to understand *which* code path produced the move, and check
+ `tellothers`'s `PV=` field.
+4. Get a second opinion from `/usr/local/bin/crafty` on the same FEN at
+ comparable depth. Large disagreement (especially "my engine claims near-
+ mate, Crafty's PV goes nowhere near that far") is the signal to dig into
+ the score-computation code, not the position.
+5. If something still looks wrong, read the actual source for the code path
+ the diagnostic line pointed at (`root.c`'s `Iterate`, `probe.c`'s
+ `ProbeEGTB`/`ProbeEGTBRoot`, `recogn.c`'s `RecognLookup`) rather than
+ guessing from behavior alone -- every bug found this way so far
+ (`ProbeEGTBRoot`'s DTZ-as-mate-score, the `dEndTime` placement, the
+ dead `RECOGN_EGTB` branch) was a straightforward, small, findable-in-
+ minutes fix once the actual code was in front of us; none of them
+ required deep speculation.
diff --git a/src/docs/EFP_LMP_MERGE.md b/src/docs/EFP_LMP_MERGE.md
new file mode 100644
index 0000000..e2bc27a
--- /dev/null
+++ b/src/docs/EFP_LMP_MERGE.md
@@ -0,0 +1,234 @@
+# Retiring EFP in favor of LMP
+
+Status: **plan only, not started.** Supersedes item 4 ("EFP-vs-LMP
+substitution experiment") in `EXTENDED_PRUNING_DEPTH.md`, which flagged this
+as the next step but didn't have the concrete numbers below. Do not start
+until the current uncommitted working-tree changes (presently being
+evaluated against HEAD via `match_play.py`, score trending positive) are
+either committed or reverted -- this work should start from a clean,
+committed baseline, not stack on top of an in-flight, unresolved candidate.
+
+## Motivation
+
+EFP (`search.c`'s `fTryEFPHere`/`fThisMoveEFPPruned`, ~line 1440-1522) and
+LMP (`ShouldLatePrune`, `searchsup.c`) are two separate forward-pruning
+mechanisms that fire on heavily overlapping populations at remaining depth
+0-2 ply, maintained independently, with no shared tuning discipline between
+them. EFP uses a hand-picked margin (Heinz's paper: `VALUE_KNIGHT`/
+`VALUE_ROOK` by 2-tier depth) plus two fail-high-history vetoes (static
+table, dynamic per-move). LMP uses the same underlying 5D joint fail-high
+table (`g_uJointFHThousandths`, `data.c`) directly, with no margin test and
+its own, differently-calibrated pair of vetoes. Goal: retire EFP entirely,
+let LMP's table-driven decision govern that depth range on its own, without
+losing EFP's pruning volume (tree size must not grow) and without adopting
+Crafty's `break`-the-whole-node semantics (every mechanism here stays
+per-move, `continue`-based -- rejected explicitly, see "What we are
+deliberately not doing" below).
+
+## What's already been verified (no code changes made yet)
+
+1. **The joint table has no sentinels.** `g_uJointFHThousandths` (`data.c`)
+ is fully backoff-smoothed (Wilson-upper-bound, shrinkage to coarser
+ marginals) as of the mid-run splice documented in
+ `../dual_table_run/README.md` -- "No 0xffff sentinels remain." Both
+ `ShouldLatePrune`'s and EFP's sentinel checks are currently dead code,
+ not a live concern.
+
+2. **The table's collection was censored by EFP/LMP themselves, and that's
+ expected, not a flaw.** `search.c`'s `FH_TABLE_COLLECTION` instrumentation
+ sits textually *after* the `if (fThisMoveEFPPruned || fThisMoveLMPPruned)
+ { ...; continue; }` block (~line 1550-1613) during the collection run
+ that built this table. A move either mechanism decided to prune never
+ reached the collection counters. This means the table is accurate for
+ the population it actually saw (eligible moves that survived both
+ mechanisms) but has **zero data** on the population EFP has been
+ discarding -- not biased data, no data. There is no way to query "what
+ was the real FH% of EFP's historically-pruned moves" from existing logs;
+ it was never measured. (Corrects an earlier, imprecise claim in this
+ investigation that bucket[0]/[1] were "thin" there -- they aren't thin,
+ they're simply about a different population.)
+
+3. **EFP's own margin, read through our own table, is already far more
+ conservative than its nominal static-veto bar.** Looking up EFP's tier-1
+ margin (`VALUE_KNIGHT`≈325cp, depth bucket 1, gap bucket 4) in the live
+ table gives a measured FH% of 0.01-0.05% across lateness buckets --
+ roughly 10x *under* EFP's own `uPredictedFH < 300` (0.3%) static veto.
+ That veto has essentially never been the operative gate; the margin test
+ alone already does the real filtering, more conservatively than its own
+ stated threshold.
+
+4. **LMP's existing static ceiling already has slack to cover EFP's old
+ population, unmodified.** Comparing EFP's old population's real
+ table-measured FH% against LMP's current `uLMPStaticMaxPctThousandths[]`
+ ceiling by depth bucket:
+
+ | depth bucket | LMP's current ceiling | EFP's-old-population measured FH% |
+ |---|---|---|
+ | 0 | 0.200% | 0.010% |
+ | 1 | 0.200% | 0.020% |
+ | 2 | 0.150% | 0.036% |
+ | 3 | 0.075% | 0.063% |
+ | 4 | 0.050% | 0.071% (now exceeds ceiling -- correct, by design) |
+
+ At depth buckets 0-2 (EFP's actual operating range), LMP's own ceiling
+ already permits 5-20x more risk than EFP's population actually carries.
+ The static side of LMP should, on its own, already prune *at least* as
+ much as EFP did there. By depth bucket 3+, LMP's own schedule correctly
+ starts refusing -- that tightening-with-depth behavior is intentional,
+ not a gap to fix.
+
+5. **The real asymmetry is the DYNAMIC veto, not the static one, and this
+ is the mechanism most likely to cause a tree-size regression on naive
+ retirement.** Both EFP and LMP gate on real, per-move-identity observed
+ fail-high history in addition to the static table, but at wildly
+ different strictness:
+
+ | | min samples | max tolerated dynamic FH% |
+ |---|---|---|
+ | EFP (`search.c`) | 5 | **10%** (`EFP_FH_PRUNE_THRESHOLD_PCT_THOUSANDTHS=10000`) |
+ | LMP (`searchsup.c`) | 1 | **0.001%** (`LMP_DYNAMIC_MAX_PCT_THOUSANDTHS=1`) |
+
+ A 10,000x gap. `LMP_DYNAMIC_MAX_PCT_THOUSANDTHS=1` is, in practice, a
+ "this move identity has never once failed high, for any nonzero sample
+ count" gate -- not a calibrated percentage. Any move identity with real,
+ nonzero fail-high history anywhere in the tree (common for moves tried
+ more than a handful of times) gets exempted from LMP pruning even when
+ the static table is fully confident. EFP rarely hit this problem because
+ its own dynamic bar (10%) almost never tripped. **Predicted outcome if
+ EFP is retired without also touching this constant: LMP's static side
+ alone is not sufficient to match EFP's old pruning volume, because this
+ gate silently vetoes a meaningful fraction of what the static table
+ would otherwise allow.** This is the paper's central, falsifiable
+ prediction -- see Plan step 2.
+
+6. **The apparent "Crafty's ply-2 margin predicts a LOWER fail-high rate
+ than ply-3, despite a smaller margin" oddity is a bucket-resolution
+ artifact, not a real inversion.** Crafty's 150cp (remaining ply 2) and
+ 200cp (remaining ply 3) both land in our 100cp-wide gap bucket 2.
+ Holding gap bucket fixed and varying depth shows the expected
+ monotonic-with-depth increase (0.039% -> 0.053% -> 0.089% -> 0.176% ...
+ at gap bucket 2, late bucket 4, dumb=0, improve=neutral) -- "more depth
+ remaining = more chance to recover" holds throughout; our 100cp gap
+ bucket is just too coarse to resolve Crafty's 50cp margin steps at the
+ shallow end. Not something this plan needs to fix to proceed, but worth
+ remembering if a future finer-grained gap axis is ever considered.
+
+7. **Reference points from Crafty/Stockfish, translated into our table
+ (dumb=0, improve=neutral, several lateness buckets), for calibration
+ context only -- not adopted as targets:**
+ - Crafty's `FP_margin[]` at remaining ply 1 (100cp) implies **0.2-3.2%**
+ FH% in our table depending on lateness -- the single largest
+ "implies more room exists" data point of this whole investigation,
+ concentrated at the shallowest depth and earliest move numbers.
+ Crafty's deeper tiers (150-400cp, ply 2-6) land close to our own
+ existing 0.05-0.3% range -- roughly agreeing with us already.
+ - Stockfish's quiet-move futility margin translates to **0.01-0.13%**
+ throughout -- more conservative than Crafty in our terms, not less;
+ consistent with SF leaning on other layered mechanisms (move-count
+ quiet-move skip, continuation-history pruning, SEE-based pruning) to
+ do work Crafty's single margin does alone. SF's own margin test is
+ therefore not a fair apples-to-apples number by itself.
+ - Neither engine's number is being proposed as a direct target --
+ they're priors for where real engines have found a workable
+ risk/reward point, used to sanity-check, not dictate, what we test.
+
+## What we are deliberately not doing
+
+**No `break`.** Crafty `break`s its entire move loop on either its
+futility or LMP check -- abandons every remaining move at that node, not
+just the one under consideration. Stockfish's futility tests are `continue`
+(per-move, like ours); its one break-like mechanism (`skip_quiet_moves()`)
+is narrower still -- it only stops trying further *quiet* moves, captures
+and checks still get considered afterward. Both EFP and LMP already use
+`continue`, evaluating every move's prune/no-prune decision independently,
+and that is staying exactly as-is. Nothing in this plan should introduce a
+node-wide bailout of any kind.
+
+## Plan
+
+1. **Baseline.** Confirm `head_reference/` is current at a clean, committed
+ HEAD (rebuild if the in-flight uncommitted changes mentioned above get
+ committed first). Capture `sd 10` and `sn 5000000` logs for all three
+ curated suites (`ecm_ringers`, `ecm_confident_quick`, `ecm_hard_quick`)
+ if not already fresh.
+
+2. **Two-variant node-count experiment, before touching `match_play.py` at
+ all.** Build and compare two variants against the baseline, fixed `sd`,
+ all three curated suites, node count + first-move-cutoff-rate as the
+ signal (per CLAUDE.md's existing move-ordering-experiment methodology --
+ measure the pick, not the game):
+ - **Variant A**: delete EFP (`fTryEFPHere`, `fThisMoveEFPPruned`, and its
+ contribution to the final prune-or-continue check) entirely.
+ `LMP_DYNAMIC_MAX_PCT_THOUSANDTHS` left untouched at `1`.
+ **Predicted result**: tree size grows relative to baseline, confirming
+ finding 5 above -- the dynamic veto asymmetry is real and costs
+ pruning volume LMP's static side alone doesn't recover.
+ - **Variant B**: same EFP deletion, plus raise
+ `LMP_DYNAMIC_MAX_PCT_THOUSANDTHS` toward EFP's old bar (start at
+ `10000` to match EFP exactly, as the cleanest single-variable swap;
+ narrower values can be swept later if this overshoots).
+ **Predicted result**: tree size lands close to or tighter than
+ baseline, since finding 4 already showed the static side has headroom
+ once the dynamic gate stops over-vetoing.
+ - If both predictions hold, that's strong, cheap (no `match_play.py`
+ games needed yet) confirmation of the mechanism before spending any
+ self-play budget. If either prediction is wrong, stop and re-diagnose
+ before proceeding -- a wrong prediction here means the mental model of
+ which gate is load-bearing is incomplete, and tuning further on top of
+ a wrong model wastes the expensive match-play stage.
+
+3. **`match_play.py` gate on Variant B only** (not A -- A is a diagnostic,
+ not a candidate; it's not expected to be an improvement on its own).
+ No cheap/fast elimination stage exists in this project's tooling --
+ there's no shortcut cheaper than a full run, so don't plan around one.
+ Use step 2's node-count + fail-high-rate-by-bucket diagnostic as the
+ only pre-filter (lean on it harder than a minimal single-value check --
+ compare across all three suites and several buckets, not just
+ aggregate node count, since it's the last free signal before paying
+ for self-play). Then commit to **one full-length, long-run
+ `match_play.py` game count** (matching the run already used for the
+ current unrelated candidate) directly on Variant B at
+ `LMP_DYNAMIC_MAX_PCT_THOUSANDTHS=10000` -- don't pre-plan a sweep.
+ Gate on `LOWER95 >= 0.5`, not raw score, per existing project
+ convention. Only spend a second full run sweeping a narrower
+ `LMP_DYNAMIC_MAX_PCT_THOUSANDTHS` value if this first run comes back
+ close-but-not-passing; if it misses badly, that's a signal the
+ dynamic-veto model itself is off (go to step 5), not that a nearby
+ value would have worked.
+
+4. **If Variant B wins**: this becomes the EFP-retirement commit. Remove
+ the now-dead EFP code paths, `EFP_FH_MIN_SAMPLES`/
+ `EFP_FH_PRUNE_THRESHOLD_PCT_THOUSANDTHS` macros, and update
+ `ShouldLatePrune`'s docstring again to drop the "second gate, applied
+ after EFP" framing (it's no longer applied after anything -- it's the
+ only gate). Rebuild `head_reference/` as the new baseline.
+
+5. **If Variant B does not win** (tree size recovers per the prediction but
+ playing strength doesn't hold, or the dynamic-veto fix overshoots into
+ real tactical misses): fall back to sweeping
+ `LMP_DYNAMIC_MAX_PCT_THOUSANDTHS` between `1` and `10000` rather than
+ either extreme, same one-variable-at-a-time discipline, before
+ concluding EFP's margin test was catching something the table's axes
+ genuinely can't see (in which case EFP's own tiers should be recalibrated
+ against measured surprise rate instead of retired, per
+ `EXTENDED_PRUNING_DEPTH.md`'s original fallback plan).
+
+## Open items, not part of this plan's critical path
+
+- **Horizon-tier loosening beyond matching EFP** (bucket 0/1's static
+ ceiling raised past its current 200/1000, toward Crafty's own
+ ~1-3%-at-early-lateness risk tolerance) is a separate, later experiment --
+ only worth running once EFP retirement itself is confirmed at parity.
+ Don't conflate "retire EFP without regressing" with "also get more
+ aggressive than EFP ever was" in the same test; they have different,
+ separable risk profiles and should be gated independently.
+- **`split.c` has neither EFP nor LMP today** (confirmed: no
+ `fTryEFPHere`/`fThisMoveEFPPruned`/`ShouldLatePrune` references in
+ `split.c`). This plan doesn't change that; `UNIFY_SEARCH.md` already
+ tracks bringing LMP (and, after this plan, LMP-without-EFP) to the split
+ path as its own separate work.
+- **Gap-bucket resolution** (currently 100cp-wide, per finding 6) is coarser
+ than Crafty's own margin schedule moves in. Not a blocker for this plan,
+ but flagged for whoever eventually tackles the horizon-tier-loosening
+ follow-on above, since a coarse bucket there would blur exactly the
+ region that follow-on cares about most.
diff --git a/src/docs/EXTENDED_PRUNING_DEPTH.md b/src/docs/EXTENDED_PRUNING_DEPTH.md
new file mode 100644
index 0000000..f916715
--- /dev/null
+++ b/src/docs/EXTENDED_PRUNING_DEPTH.md
@@ -0,0 +1,493 @@
+# Extended pruning depth: closing the ebf gap to Crafty/Stockfish
+
+## Background
+
+Typhoon's effective branching factor is worse than Crafty's and Stockfish's
+despite having the same core forward-pruning toolkit (EFP, LMP, LMR,
+nullmove, same R factor). An experiment that forced the main search loop to
+abandon all remaining moves the instant one looked prune-worthy (`continue`
+-> `goto end`) cut node counts at low depth but made deep searches *worse*
+-- the single most informative result driving this plan, since it shows
+the gap isn't about move ordering (already measured as competitive, see
+the 2026-08-29 dynamic-move-ordering session) and isn't fixed by recklessness
+alone. It's about margin size and depth-extent of existing mechanisms, and
+possibly a mechanism we don't have at all.
+
+Reading both reference engines' actual source settled several questions
+that would otherwise have been guesswork:
+
+- `~/programming/chess/crafty-25.2/search.c` + `data.c`: Crafty's
+ `FP_margin[16] = {0,100,150,200,250,300,400,500,...}` with `FP_depth = 7`
+ prunes with a flat, hand-picked margin schedule out to **6 plies of
+ remaining depth**, and on a prune it executes `break` -- abandons every
+ remaining move at that node, not just the one under consideration. No
+ reverse/static-null mechanism exists in Crafty; what looked like it might
+ be one is actually "razoring" (Kent et al.), which drops to qsearch when
+ *eval is far below alpha*, the opposite side of the board from what's
+ relevant here. Margins are tuned by whole-game cluster testing, not
+ per-node surprise-rate measurement.
+- `~/programming/chess/stockfish/src/search.cpp`: Stockfish has **both**
+ mechanisms, cleanly separated, and both use `continue`/return-based
+ pruning rather than Crafty's `break`:
+ - "Step 9. Futility pruning: child node" (`eval - futilityMargin >= beta`,
+ no nullmove verification, depth < 19) -- a true reverse-futility/
+ static-null check, evaluated once per node before the move loop even
+ starts.
+ - "Futility pruning: parent node" (`futilityValue <= alpha`, gated on
+ `lmrDepth < 12` where `lmrDepth` is itself adjusted by that move's own
+ history score) -- the same idea as Typhoon's EFP, but extended much
+ deeper and with a per-move, history-sensitive margin instead of a flat
+ depth-indexed one.
+
+Typhoon's own EFP (`search.c` ~line 1310) is exactly 2 tiers (remaining
+depth <= 1.75 ply: margin `VALUE_KNIGHT`; <= 2.75 ply: margin `VALUE_ROOK`),
+sourced from Ernst Heinz's "Scalable Search in Computer Chess" and
+calibrated against measured surprise rates via `CALIBRATE_QSEARCH_FUTILITY`
+(QSearch only -- this calibration harness does not currently cover main
+search's EFP). It already uses `continue`, matching Stockfish's safer
+choice, not Crafty's `break`.
+
+## Alpha-side vs. beta-side pruning: two mechanisms, not one
+
+Despite feeling like the same idea, EFP and reverse futility pruning (RFP)
+differ enough mechanically to warrant separate experiments, even though
+they are related by a satisfying piece of algebra.
+
+**The algebra.** In negamax, a parent's alpha is the negation of the
+child's beta (`alpha_parent = -beta_child`). EFP's test at the parent --
+skip move `m` if `iEstimatedMoveValue + margin <= iAlpha_parent` -- uses
+`iEstimatedMoveValue` as an approximation of `-StaticEval(child)` (the
+mover's-eye view of the position after `m`, without actually making it).
+Substituting:
+
+```
+-StaticEval(child) + margin <= iAlpha_parent
+ StaticEval(child) - margin >= -iAlpha_parent
+ StaticEval(child) - margin >= beta_child
+```
+
+...which is exactly Stockfish's RFP test, evaluated one ply later with a
+real number in place of an estimate. So EFP-at-the-parent-with-an-estimate
+and RFP-at-the-child-with-ground-truth are the same inequality, viewed from
+opposite sides of one ply. That's a useful mental model, but not a reason
+to implement only one of them:
+
+| | EFP (alpha-side) | RFP (beta-side) |
+|---|---|---|
+| Granularity | Per move, inside the move loop | Per node, before move generation |
+| Input | Estimated child eval (`iEval + iImprovement - iHangCp`) -- approximate, no Make() | Current node's own already-computed static eval -- exact, no estimation error |
+| Cost when it fires | Skips one move; loop continues | Skips the entire node, including move generation |
+| What it's betting on | "Even optimistically, this specific move can't reach alpha" | "This position is already so good that no single reply drags it back below beta" |
+| Typhoon status | 2 tiers, Heinz margins, `continue` | Does not exist |
+| Crafty | 6 tiers, flat small margins, `break` | Does not exist (razoring is alpha-side, not this) |
+| Stockfish | ~12-ply-equivalent (history-adjusted), `continue` | Exists, depth < 19, dynamic margin |
+
+Both are in scope. They prune different populations and at different
+points in the per-node sequence (RFP can save move-generation cost
+entirely; EFP cannot, since it needs the move to estimate against).
+
+## Why this is dangerous and needs a safety net
+
+Both mechanisms are forward pruning: skipping real search based on a
+static/approximate signal. Typhoon's EFP margins were chosen from Heinz's
+paper and calibrated after the fact against measured surprise rates
+(`CALIBRATE_QSEARCH_FUTILITY`, QSearch-side only). Crafty's much more
+aggressive schedule was validated only by whole-game cluster testing, which
+answers "does this help in aggregate" but not "how often, and at what
+cost, is a specific prune wrong." Any extension of EFP's tiers, and any new
+RFP mechanism, should ship with the same evidence-calibration discipline
+already used elsewhere in this codebase (QSearch futility calibration, the
+2026-08-29 move-ordering fail-high-rate-by-evidence-bucket methodology):
+measure the surprise rate at the proposed margin/tier/depth *before*
+trusting it live, not just end-to-end solve counts on a 90-position suite
+(too noisy for a single knob, per that session's own finding).
+
+## Eval variance and the shape of the tree
+
+Does the variance/distribution of `Eval()`'s output materially shape the
+tree built above it, independent of whether `Eval()` is accurate on
+average? Yes, and the mechanism is stronger than "noisy input, noisy
+output" -- minimax search doesn't just pass leaf noise through, it
+amplifies it.
+
+**Why.** If `Eval()` returns the true value plus independent noise at
+each leaf, backing that up through alternating min/max nodes doesn't
+average the noise away -- at each max node the search picks the most
+favorably-noisy branch, at each min node the most unfavorably-noisy one.
+The backed-up value is systematically biased relative to the true value,
+and the distortion grows with branching factor and with noise variance
+(the "minimax pathology" result -- Beal, Pearl, Nau). Searching deeper
+helps for two separable reasons that are usually bundled together as one:
+it resolves real tactics (moves bias toward truth), *and* it averages over
+more terminal positions (shrinks the noise-driven distortion). The second
+effect is exactly the mechanistic version of "a more accurate eval shapes
+a smaller, more correct tree" floated earlier about NNUE -- not just
+closer to the true value on average (bias), but plausibly smoother across
+similar positions too (variance), and the two have different
+consequences for search quality.
+
+**Where this bites every mechanism in this plan, concretely.** EFP's
+margin test, LMP's gap axis, the proposed RFP test, and `iImprovement`
+are all built directly on `iEval`. `iImprovement` specifically (`iEval -
+eval_two_plies_ago`) is a *difference* of two noisy reads -- if the
+per-call noise is roughly independent, the difference is noisier than
+either input, which argues for some skepticism before trusting it as a
+calibration axis (as proposed above for the new beta-side table) without
+checking its own signal-to-noise ratio first.
+
+More importantly, the joint FH% table's own "gap" axis is computed from
+`iEval` -- if `iEval` is noisy, a move's measured gap bucket is partly
+noise, not signal. That's a textbook attenuation-bias setup: a noisy
+covariate systematically shrinks the measured strength of a true
+relationship toward zero. Which reframes a question from earlier in this
+plan -- **Typhoon's margins may need to be wider than Stockfish's/
+Crafty's not because of a different risk-tolerance philosophy, but
+because the thing the margin is being compared against is measured less
+precisely.** That's a mechanistic, testable claim, not an editorial one.
+
+It also touches an assumption the "approximate the beta-side table from
+the existing one" idea leaned on and that got ruled out on bucketing
+grounds (see above) -- "move 1, by move ordering, is probably the best
+move." Move ordering itself depends partly on eval-derived heuristics; a
+noisier eval weakens that assumption independent of the bucketing problem
+that already killed the shortcut.
+
+**What to actually measure, using instrumentation that mostly already
+exists:**
+
+- **Eval-tier disagreement, position held fixed.** `eval.c`'s margin-
+ safety-swing tracking (`g_uSuperLazyMarginSwing*`, built for the lazy/
+ super-lazy calibration) already measures how much the eval disagrees
+ with itself across tiers, bucketed by army -- a clean noise measurement
+ since only the eval *tier* varies, not the position. Re-read this data
+ with the variance question in mind rather than building new collection.
+- **Sibling-eval spread at a node.** A full-move-list population analysis
+ (evaluate every legal move's resulting position, look at the spread) is
+ messier -- it conflates genuine positional differences between moves
+ with eval noise -- but it's informative in its own right: a node where
+ every move evals similarly is exactly the "tactics can equalize
+ anything" case flagged as the real risk in pruning deep, independent of
+ noise. Worth collecting, but don't conflate it with the tier-
+ disagreement measurement above when interpreting results.
+- **Correlate either signal against measured EFP/LMP/RFP surprise rate.**
+ If noisier positions (by either measure) show higher surprise rates at
+ a fixed margin, margins should be a function of (depth, gap, noise/
+ variance bucket), not just depth and gap -- a genuinely new axis
+ candidate for the tables in this plan, pending that correlation holding
+ up.
+
+**A genuinely new idea this surfaces, not yet anywhere in the tuning
+pipeline:** `eval_tune/`'s DNA tuner fits constants to minimize squared
+error against game outcomes -- a pure bias-reduction objective. Nothing in
+that objective rewards a parameterization with *lower score variance
+across similar positions* at equal average error, and two settings of the
+same constants could plausibly achieve the same training loss with very
+different volatility. If eval variance is costing tree quality the way
+this reasoning suggests, the fix isn't "tune harder" on the current
+objective, it's checking whether the tuner is unknowingly indifferent
+between a low-variance and a high-variance solution, and adding an
+explicit variance penalty to the objective if so. Lower priority than the
+RFP/EFP work above -- a tuning-pipeline change, not a search change -- but
+worth tracking as a follow-on, since it would improve every downstream
+mechanism in this plan rather than just one of them.
+
+## Plan
+
+1. **Baseline audit.** Confirm current EFP tier count/margins and absence
+ of RFP in the working tree; snapshot `head_reference/` at the pre-change
+ commit if not already current, both `sd 10` and `sn 5000000` logs for
+ all three curated suites.
+
+2. **Map the beta-side (inverted-gap) half of the 4D joint table.**
+ `g_uJointFHThousandths` has only ever been populated/queried for the
+ alpha-side case (gap-to-alpha positive, used by EFP/LMP pruning
+ decisions). The beta-side case (`iEval` already above `iBeta`) is
+ unmapped. See "The 4D FH% table and static nullmove" below for the
+ dimensional analysis -- the existing table's `lateness` and `hung
+ material` axes are not meaningful for this question, so this is a new,
+ smaller table, not a re-slice of the existing one.
+
+3. **Reverse futility pruning (RFP) experiment.** Add a Stockfish-style
+ static cutoff (`iEval - margin >= iBeta`, no nullmove verification,
+ evaluated once per node before move generation) at shallow-to-moderate
+ remaining depth. Margin sourced from the new 2D table where sample
+ confidence supports it, a Heinz/Stockfish-style formula as fallback
+ elsewhere. Add a `CALIBRATE_`-style diagnostic (search anyway, log
+ whether the cutoff would have held) before trusting it live, matching
+ the existing QSearch-futility calibration pattern.
+
+4. **EFP-vs-LMP substitution experiment** (supersedes a plain "extend EFP's
+ tiers" plan -- see "EFP vs LMP: same actuary, fewer dimensions" below for
+ the full reasoning). Rather than hand-extending EFP's own Heinz-derived
+ margin schedule deeper, test whether LMP's existing, already-4D-informed
+ schedule can be given equivalent license at the depths EFP currently
+ owns (1-2 ply) and simply carried further out, retiring EFP once that's
+ confirmed. If it can't hold strength even with matched permissiveness,
+ that's real evidence EFP's margin/value test sees something the table's
+ current axes don't, and extending EFP's own tiers (calibrated against
+ measured surprise rate, not hand-picked) becomes the right fallback.
+
+5. **Eval-variance side study.** See "Eval variance and the shape of the
+ tree" below for the full reasoning -- short version: minimax doesn't
+ just pass eval noise through, it amplifies it, and every mechanism in
+ this plan (EFP, LMP, RFP, `iImprovement`) reads `iEval` directly, so
+ this isn't a side curiosity, it's a shared input-quality question for
+ the whole plan.
+
+6. **Validation protocol.** One variable at a time against the
+ `head_reference/` baseline, fixed `sd` depth (not `st`, per existing
+ ECM-comparison guidance), all three curated suites per change (not
+ just the fastest one), fail-high/surprise rate as the primary signal
+ on individual knobs and curated-suite solve counts as the final gate.
+ Rebuild `head_reference/` when a change is kept.
+
+## EFP vs LMP: same actuary, fewer dimensions
+
+Working through this live surfaced a cleaner way to see the relationship,
+and it changes what "extend EFP" should even mean.
+
+**EFP is LMP running on 2 of its 4 axes, with a hand-picked margin standing
+in for what the table would otherwise estimate directly.** Both start from
+the same base eligibility gate (`CanWeReduceOrPruneMove`) and both end up
+consulting `g_uJointFHThousandths` for the same move. Where they differ:
+
+| | EFP | LMP (`ShouldLatePrune`) |
+|---|---|---|
+| Gap to alpha | Explicit, via a margin test (`iEstimatedMoveValue + margin <= iAlpha`) | Implicit, as one of the table's own 4 trained axes |
+| Hung material | Explicit, via `iHangCp` folded into the same margin test | Same `iHangCp`, same table axis -- identical input |
+| Remaining depth | Coarse 2-tier switch (`<=1.75 ply` / `<=2.75 ply`), margin hand-picked per tier from Heinz's paper | Smooth, trained schedule (`{200,200,150,75,50,25,12,6,3,1,0}` thousandths, indexed by remaining ply) |
+| Move lateness | Not consulted at all | One of the table's 4 trained axes |
+| Verdict source | A margin/value threshold, independently derived, then AND-ed with the table's strictest veto tier (`uPredictedFH < 300`) | The table's own calibrated prediction against a depth-indexed threshold, plus this move-identity's dynamic history |
+
+So EFP doesn't disagree with LMP's calibration -- on gap and hung material
+it uses the *identical* inputs, and both schedules get more conservative
+(prune less readily) as remaining depth grows, which matches the
+uncontroversial "more depth remaining = more chance of recovering" logic
+both Crafty and Heinz build on. Measured by hand (gdb breakpoints on real
+positions): EFP's working threshold (~0.3%, with 0.1% found to "kill" it)
+sits right next to LMP's own shallow-depth allowance (200/1000 = 0.2%) --
+not a coincidence, the same calibrated reality reached two different ways.
+The one thing EFP has that LMP doesn't is lateness-blindness: it prunes a
+move regardless of how early or late it was tried, something LMP's table
+lookup already accounts for.
+
+**Why the earlier "remove EFP, it backfired" result doesn't settle
+anything on its own.** That experiment removed a pruning mechanism without
+compensating the other one -- if LMP's shallow-depth threshold was left
+exactly as it was (tuned to co-exist alongside EFP owning the 1-2 ply
+window), removing EFP just leaves a gap neither mechanism covers, which
+is a different, less informative test than "margin-based pruning vs.
+table-based pruning, given equal license." It demonstrates removing a
+safety net without replacing it is bad, which isn't surprising, not that
+EFP's specific mechanism is what's load-bearing.
+
+**The fair test:** disable EFP, but first widen LMP's allowed-FH%
+threshold at remaining depth 1-2 ply to be at least as permissive as EFP
+was there, and let that same (already depth-and-lateness-aware) schedule
+extend outward toward the depths Crafty/Stockfish prune at, tightening
+further out the way the existing array already does. If strength holds,
+LMP genuinely subsumes EFP, and "go deeper" becomes "keep widening LMP's
+schedule outward," not "invent new EFP tiers." If it doesn't hold even
+with matched permissiveness, that's real evidence EFP's margin/value test
+catches something the table's current 4 axes can't see on their own --
+worth identifying what, specifically, before extending EFP's own tiers as
+the fallback plan (calibrated against measured surprise rate the way
+`CALIBRATE_QSEARCH_FUTILITY` already does, not hand-picked the way Heinz's
+original schedule was).
+
+The real, unresolved risk either way -- independent of which mechanism
+wins this test -- is the one check-evade-capture/tactical-volatility point
+raises: a full-ply search below a "hopeless-looking" move genuinely can
+even out the score the deeper it's allowed to run, and that volatility is
+exactly what's being traded away for node count the further from the
+horizon pruning reaches. Crafty's results say that trade pays off in
+aggregate even with real misses; whether Typhoon's calibration machinery
+(the table, or LMP's dynamic per-move history) lets the same trade be made
+more selectively -- pruning deep only where confidence is actually high,
+rather than uniformly the way Crafty's flat schedule does -- is the thing
+this whole plan is trying to find out.
+
+## The 4D FH% table and static nullmove
+
+### What the existing table answers, and why it doesn't directly apply
+
+`g_uJointFHThousandths[uShipD][uDumb][uGap][uLate]` (`data.c`) currently
+answers: *given remaining depth, hung-material estimate, gap-to-alpha, and
+move lateness, what's the (Wilson-bound, pessimistic) percent chance this
+specific move raises alpha?* It's built and consumed entirely on the
+alpha side (`search.c`'s `uRawGapB = GapCpBucket(iAlpha - iEval)`, used by
+`ShouldLatePrune` and to veto EFP).
+
+A static-nullmove/RFP decision is a different question: *given remaining
+depth and how far `iEval` already clears `iBeta`, what's the chance that a
+full search of this node would still come in at or above beta?* Two of the
+four existing axes don't transfer:
+
+- **Lateness is meaningless.** RFP is evaluated once per node, before any
+ move is even selected -- there is no "move number" to bucket by.
+- **Hung material (the `uDumb`/`iHangCp` axis) is move-specific** (it's an
+ estimate of what a *particular* move gives away). RFP has no candidate
+ move in hand yet when it fires, so this axis doesn't apply either. (It
+ may still be useful as a *node*-level signal -- see below -- but it would
+ need to be redefined as "is anything hanging in the current position,"
+ not "what does this move give away," which is a different computation.)
+
+So this needs a new, smaller table: **FH% predicted by (gap above beta,
+remaining depth)** -- the direct beta-side analogue of the existing
+alpha-side table, not a re-slice of it.
+
+### What `return` value RFP should use
+
+Three choices, in order of how much they trust the margin:
+
+- **`iBeta`** -- the conservative choice, and what Stockfish effectively
+ approximates (`(661 * beta + 363 * eval) / 1024`, a weighted blend
+ biased toward `beta` rather than the raw `eval`). Safe: never returns a
+ score less plausible than the bound being tested.
+ Stockfish's blend is worth copying outright rather than re-deriving --
+ it's already tuned.
+- **`iEval - margin`** (the actual tested quantity) -- more information-
+ preserving for any caller that inspects the returned score (e.g. a
+ parent's own improving/stand-pat logic), but injects the raw margin's
+ uncertainty into the score, not just the boolean pass/fail.
+- **A genuine average/expected value**, if the new table's confidence is
+ high enough to support one (i.e., calibrate not just "prune or don't"
+ but "what score should this cell predict" directly from the sampled
+ distribution). This is the most ambitious option and should only be
+ attempted after the simpler two have been validated -- it's a
+ significantly larger calibration effort (predicting a continuous score,
+ not a prune/don't-prune boolean) and shouldn't gate the first working
+ version of RFP.
+
+Start with Stockfish's blended-toward-beta return value; revisit once the
+new table has enough sample volume to ask whether a sharper value is
+justified.
+
+### Other signals worth considering for the new table
+
+- **`iImprovement`** (already computed and used by EFP's own estimate) --
+ whether static eval has been trending up across recent plies at this
+ node. Stockfish's own RFP explicitly folds in an `improving`/
+ `opponentWorsening` correction to the margin; this is likely the single
+ highest-value addition beyond the two base axes, and it's already
+ computed elsewhere in the search, so it's cheap to add as a bucket
+ dimension.
+- **Side-to-move's own king safety / piece activity**, as a coarse proxy
+ for "how much tactical headroom does the opponent have to claw back the
+ gap" -- more speculative, more expensive, lower priority than
+ `iImprovement`.
+- **Whether the position is a capture-heavy/tactical node** (recent-ply
+ volatility) vs. quiet -- RFP is riskiest exactly where the position is
+ about to get sharp; a coarse "was the last move a capture/check" bucket
+ may be cheap evidence for that without needing a full tactical-detector.
+- **Explicitly not** `iHangCp`/lateness, per above -- they don't have a
+ node-level equivalent that means the same thing they mean on the alpha
+ side.
+
+Recommended starting axis set for the new table: **(gap-above-beta bucket,
+remaining-depth bucket, improving bucket)** -- a 3D table, one dimension
+richer than the bare 2D minimum, reusing `iImprovement` since it's already
+on hand at every relevant call site. Hold the capture/check-volatility
+signal and king-safety proxy as "measure once collection infra exists, add
+only if the bucket shows a real split" rather than building them in up
+front.
+
+### Two more axes, found by re-examining what's already in the codebase
+
+**Opponent trend, not just own trend.** `iImprovement` (`search.c:728`,
+`iEval - ctx->sPlyInfo[ctx->uPly-2].iEval`) is the *mover's own* eval trend
+over the last full move, and it's already folded into EFP's per-move
+estimate as one scalar (`iEstimatedMoveValue = iEval + iImprovement -
+iHangCp`) -- which means the existing alpha-side joint table never sees it
+as an independent axis; a big gap with a flat trend and a smaller gap with
+a strongly-improving trend currently bucket identically. Worth adding
+`iImprovement` as a genuine 5th axis to the *existing* alpha-side table,
+not just the new one -- same collection machinery, one more dimension.
+Stockfish's RFP keeps this as **two** separate signals, not one:
+`improving` (mover's own trend, what Typhoon already computes) and
+`opponentWorsening` (the opponent's own trend, computed from the
+opponent's eval history, not a sign-flip of the mover's). Both are
+candidates for the new table's axis list -- don't conflate them into one
+"negate and reuse" axis.
+
+**Hung material can transfer, but needs a new consumer of a signal that
+already exists live -- correcting an earlier wrong turn in this
+discussion.** `_EvalLookForDanger` (`eval.c:6194`) really is `#if 0`'d out
+and dead, but it is *not* the only hung-piece detector in `Eval()`.
+`_EvalTrappedPieces` (`eval.c`, live, runs every full eval for both
+colors) already detects exactly "nowhere safe to move to, and currently
+attacked" -- the `bbStillSafe == 0` branch combined with
+`_WhoControlsSquareFast(pos, c) == xColor`. When it fires for the side
+*not* currently on move, it calls `RecordEnprisePieceAtPly(ctx,
+ctx->uPly - 1, c)`.
+
+Tracing where that's actually consumed today: `generate.c:5342` --
+`s += (mv.cFrom == cEnprise) * (PIECE_VALUE(mv.pMoved) / 4)`, read via
+`FindEnprisePiece`. That's a **flee bonus**: it rewards moving a piece
+*away from* the square where this check found it endangered. So the live
+detector is wired in the same direction as the disabled routine's
+docstring (self-protective -- "the mover is about to lose something"),
+not the "the opponent left me a free capture" direction RFP would want.
+It's a real, already-computed fact (both colors, every full eval) with
+only one consumer today (move-ordering's flee nudge) -- using it for RFP
+means adding a *second* consumer that reads the same recorded fact in the
+other direction (does the opponent, who just moved, have a trapped+
+attacked piece sitting there for the side now on move to take), not
+writing new detection code and not re-enabling anything disabled.
+
+This still matters for RFP the way originally argued: `Eval()` is purely
+static/positional and never searches captures, so a free piece sitting on
+the board isn't reflected in `iEval` at all. If the existing detector
+already flagged one for the side about to move, the true value of the
+position is higher than `iEval` says, and RFP should fire more
+confidently (accept a smaller gap), not less. The cost question from the
+original (disabled) routine -- full-eval-only, judged not worth paying
+unconditionally -- doesn't block this, since the detection is already
+being paid for and computed; the only new cost is reading it.
+
+Updated axis set to collect for the new table, reflecting both additions:
+**(gap-above-beta, remaining-depth, improving, opponent-has-hung-piece)**
+-- four axes. The last one reads `_EvalTrappedPieces`'s already-recorded
+`cEnprise`/`cTrapped` data from a new angle (see above) rather than
+computing anything new, but it's still only populated when a full eval
+actually ran at that node -- gate collection on eval tier, don't force a
+full eval just to populate the table during data collection.
+
+### Checked and ruled out: approximating the beta-side table from the existing one
+
+Tempting shortcut considered: skip collecting new data entirely and
+approximate the beta-side FH% by querying the *existing* alpha-side table
+at `(depth, hung=0, gap = iBeta - iEval, lateness=1)` -- i.e. treat the
+first (best-ordered) move's own "does it raise alpha" cell, with the sign
+of the gap flipped, as a proxy for "does the node's best move clear beta."
+The algebra behind it is sound (it's the same mirror-image relationship
+discussed above, evaluated at the same node instead of one ply over).
+
+It doesn't work, though, and the reason is checkable rather than a matter
+of judgment: `searchsup.c`'s `GapCpBucket` collapses every non-positive
+gap into a single bucket 0 --
+
+```c
+ULONG GapCpBucket(SCORE iGapCp)
+{
+ if (iGapCp <= 0) { return 0; }
+ ...
+}
+```
+
+-- by design, since from the alpha side, once a move already clears alpha
+there's nothing left to decide, so the table was never built to
+distinguish "cleared by 10cp" from "cleared by 900cp" in that regime. A
+beta-side lookup via `iBeta - iEval` (always <= 0 whenever RFP would even
+be considered) always lands in that same bucket regardless of magnitude --
+exactly the graded information an RFP margin needs, lost before it was
+ever shipped. It's not recoverable from the raw collection logs either:
+`search.c`'s own instrumentation (`uRawGapB = GapCpBucket(iAlpha - iEval)`,
+`search.c:1275`) applies the identical collapse at collection time, not
+just at baking/shipping time, so the magnitude was discarded at the source.
+
+Conclusion: a genuinely new collection run on the positive side of this
+boundary is required; it can't be shortcut from data already on hand. The
+one thing worth keeping from this idea: once the new table exists, check
+that it roughly agrees with the old table's bucket-0 values near the
+gap-is-zero boundary, as a cheap cross-validation between the two tables.
diff --git a/src/docs/MOBILITY_BITBOARD_REWRITE.md b/src/docs/MOBILITY_BITBOARD_REWRITE.md
new file mode 100644
index 0000000..51bb910
--- /dev/null
+++ b/src/docs/MOBILITY_BITBOARD_REWRITE.md
@@ -0,0 +1,150 @@
+# Replacing the loopy mobility chain-walks with single-probe magic bitboards
+
+## Motivation
+
+`_EvalBishop`/`_EvalRook`/`_EvalQueen` currently compute mobility (and the
+king-safety x-ray bitboards) via an unbounded `for (;;)` chain walk that
+repeatedly recomputes `_RookAttacksBB`/`_BishopAttacksBB` with newly-
+discovered blockers excluded, to handle batteries (friendly R/Q stacked
+behind each other) and x-ray-through-high-value-enemy-pieces. Crafty does
+this with one magic-bitboard probe and a popcount. Goal: match that for
+speed while keeping the signal that actually matters for eval quality.
+Approximate parity, not exact parity -- see the tradeoffs below, all
+explicitly agreed rather than accidental.
+
+## What's being thrown away, deliberately
+
+1. **Battery mobility/connectivity through more than one hop.** Today, a
+ rook stacked three deep behind two friendly rooks on an open file gets
+ mobility and connected-rook credit for every square and every blocker
+ in the stack. After this change, each rook only sees its own first
+ blocker. The squares/credit "lost" at the near rook are not actually
+ lost from eval overall -- the rook(s) further out credit those same
+ squares on their own pass. Confirmed via the king-safety-xray analysis:
+ the only case where continuing past a friendly blocker added
+ information nothing else already had was the enemy-GE case below.
+2. **Mobility credit for squares beyond an enemy queen/king (or, for
+ bishop, enemy rook) that the piece is directly attacking.** This was
+ never "real mobility" in the legal-move sense anyway -- a rook cannot
+ physically move past a piece it would capture in one ply. Dropping it
+ is a correctness fix that happens to fall out of the simplification,
+ not a loss. (Confirmed, no objection -- see open question 6, closed.)
+3. **The general multi-layer x-ray bitboard** (`bbMinorXrayAttacks`/
+ `bbRookXrayAttacks`/`bbQueenXrayAttacks`), replaced by a narrower,
+ cheaper mechanism (below) that preserves the one case that matters:
+ rook/bishop/queen pointed at the enemy king (or its ring) through
+ exactly one enemy queen/king (or, for bishop, rook) blocker.
+
+## What's being preserved
+
+- The existing "real mobility" masks (`bbUnsafeForRook`, `bbUnsafeForMinor`,
+ `bbUnsafeForQueen` -- squares where a cheaper enemy piece would recapture
+ for free) are untouched. These already encode "real mobility" exactly as
+ wanted; nothing new needed here.
+- The deferred low-mobility re-derivation in `_EvalTrappedPieces` is
+ untouched -- it only consumes a `bbMobility` snapshot and
+ `pos->bbKingAttacks[xColor]`, neither of which cares how the snapshot
+ was computed.
+- The king-ring x-ray signal for `_EvalKingSafety` (the skewer/pin-adjacent
+ "rook/bishop/queen threatens through the enemy queen/king" case) is
+ preserved, just computed more cheaply (below).
+- Blocking semantics for the x-ray check exactly match what the chain walk
+ does today: any pawn or minor piece fully blocks (no special-casing of
+ rammed vs. transient pawns -- that would have been *new* behavior, not
+ preserved behavior, and we're explicitly not adding it). Friendly R/Q and
+ the piece's enemy-GE set (queen/king for rook and queen; rook/queen/king
+ for bishop) are the only things that don't block.
+
+## Per-piece-type design
+
+### Bishop / Rook (mobility)
+
+```
+bbAttack = _{Bishop,Rook}AttacksBB(c, pos->bbOccupied);
+bbMobility = (bbAttack & ~bbOccupied & ~bbUnsafe)
+ | (bbAttack & bbEnemySame)
+ | (bbAttack & bbEnemyGE)
+ | (bbAttack & bbEnemyLE & ~bbUnsafe); // bishop's transient-pawn case folds in here
+uTotalMobility = FastCountBits(bbMobility);
+pos->bb{Minor,Rook}Attacks[uColor] |= bbAttack;
+```
+
+One magic probe, no loop. Same masks as today, just applied to the single
+first-blocker attack set instead of a chain-extended one.
+
+### Queen (mobility)
+
+Two probes (rook-direction + bishop-direction), same mask logic, no
+per-probe chain:
+
+```
+bbAttack = _RookAttacksBB(c, occ) | _BishopAttacksBB(c, occ);
+bbMobility = (bbAttack & ~occ & ~bbUnsafeForQueen)
+ | (bbAttack & bbEnemyGE)
+ | (bbAttack & bbEnemyLE & ~bbUnsafeForQueen);
+pos->bbQueenAttacks[uColor] |= bbAttack;
+```
+
+### Connected-rook bonus
+
+Direct-attack-only, no chain:
+
+```
+bbConnected = bbAttack & pos->bbPieces[uColor][ROOK];
+// fire EVAL_TERM per bit, horiz/vert exactly as today (RANK(c)==RANK(cBlocker))
+```
+
+### King-ring x-ray (replaces bbXXXXrayAttacks' chain-walk population)
+
+Computed inside each piece's own eval pass (not moved into
+`_EvalKingSafety` -- that function's consumer code at eval.c:5216-5248 is
+unchanged), gated so the common case (no enemy GE piece directly attacked)
+costs nothing beyond an AND against a bitboard already in hand:
+
+```
+bbSeenEnemyGE = bbAttack & bbEnemyGE; // already computed above for mobility -- free
+bbXray = 0;
+while (bbSeenEnemyGE) {
+ sq = pop a bit;
+ bbXray |= _{Bishop,Rook}AttacksBB(c, occ & ~BBSQUARE[sq]);
+}
+pos->bb{Minor,Rook,Queen}XrayAttacks[uColor] |= bbXray;
+```
+
+- Rook: `bbEnemyGE` = enemy queen | enemy king -- at most 2 extra probes,
+ almost always 0.
+- Bishop: `bbEnemyGE` = enemy rook | queen | king -- at most 3 extra probes,
+ almost always 0.
+- Queen: same as rook, applied per axis.
+- One probe per GE piece directly attacked is sufficient -- if removing
+ that single blocker reveals the king/ring, we're done; we never need a
+ second layer, since the only thing consumed downstream is ring-square
+ membership, not an arbitrarily deep battery.
+- No `g_VectorDelta`/ring-square enumeration needed anywhere -- the gate is
+ "was this enemy GE square already in my direct attack bitboard," which is
+ free, not "check every ring square against every enemy piece," which
+ would have been the expensive shape.
+
+## Order / plumbing
+
+No change to eval call order (pawns -> knights/bishops -> rooks -> queens
+-> king, per `Eval()`'s existing sequence) and no change to where results
+land on `POSITION` (`bbMinorAttacks`/`bbRookAttacks`/`bbQueenAttacks`/
+`bb{Minor,Rook,Queen}XrayAttacks`, all `[2]`-indexed by color, already
+there). Knight needs no change -- it was never loopy to begin with
+(single-hop `g_KnightAttacksBB` + mask).
+
+## Validation plan
+
+- `gmake TEST=1` + `debug_smoke_test.sh` first (asserts on mobility bounds
+ like `ASSERT(uTotalMobility <= 14)` still need to hold).
+- Then the three curated suites (`ecm_ringers`/`ecm_confident_quick`/
+ `ecm_hard_quick`) at fixed `sd`, compared against `head_reference/` --
+ expect small, explainable deltas (battery/overcount cases), not a cliff.
+ If solve counts move more than noise-level, suspect the x-ray gating
+ logic (an enemy GE piece not being re-detected) before suspecting the
+ mobility masks (those are a straight port).
+- `eval_tune/`'s DNA re-tune is not required before landing this (mobility
+ *tables* themselves are unchanged, only how the bitboard feeding them is
+ computed), but worth a sanity check with `evaldna dump` before/after on
+ a few batteries/pin positions by hand if anything looks off.
diff --git a/src/docs/MOVE_GUESSER.md b/src/docs/MOVE_GUESSER.md
new file mode 100644
index 0000000..35fd3ca
--- /dev/null
+++ b/src/docs/MOVE_GUESSER.md
@@ -0,0 +1,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).
diff --git a/src/docs/MOVE_ORDERING_IDEAS.md b/src/docs/MOVE_ORDERING_IDEAS.md
new file mode 100644
index 0000000..5c65934
--- /dev/null
+++ b/src/docs/MOVE_ORDERING_IDEAS.md
@@ -0,0 +1,226 @@
+# Move ordering experiment backlog
+
+Goal: a leaner tree (fewer nodes for the same search quality) via move-ordering
+changes, not eval or pruning changes. Methodology: per CLAUDE.md's "Dynamic
+move ordering experiments" section -- measure fixed-`sd` node count /
+first-move-cutoff-rate on the three curated suites (`ecm_ringers`,
+`ecm_confident_quick`, `ecm_hard_quick`) as the primary signal, cross-checked
+against a larger `cross_sectional_200k.ep_` sample at fixed `sn` as a tertiary
+aggregate sanity check. Avoid adding throwaway per-node instrumentation code
+to the tree; prefer compile-time diagnostic swaps (like the old
+zero-selection-budget trick) that are never committed, or just the suite-level
+before/after comparison.
+
+Status key: TODO / IN PROGRESS / DONE (kept/discarded) / DROPPED (not pursuing)
+
+## Queue
+
+2. **TODO -- Qsearch check generation.** Crafty's `quiesce.c` calls
+ `GenerateChecks()` (movgen.c) to extend qsearch's first ply with
+ non-capture checking moves (direct + discovered, via attack-set
+ bitboard tricks), not just captures. Confirmed (per CLAUDE.md) our
+ QSearch is capture-only. This is a search-shape change more than a pure
+ ordering change -- re-confirm current QSearch behavior in search.c
+ before building, since CLAUDE.md notes may be stale.
+
+5. **TODO -- Countermove fallback key (coarse, empty-slot-only).** Today
+ the countermove table is keyed by the *exact* previous move; if that
+ exact key's slots are empty/unevidenced, no countermove signal fires at
+ all for that node. Add a second, coarser table keyed by
+ `[piece-type][to-square]` of the previous move, consulted *only* when
+ the exact-match slots don't fire -- a genuine fallback, not a
+ competing signal. Different from the earlier "scaled continuation-
+ history" experiment (flat across a 256x range), which tested coarse
+ keying as an always-active competing signal, conflating key-coarseness
+ with "always on." This isolates key-coarseness alone, active only in
+ the exact-match's current blind spot.
+
+## Dropped
+
+6. **DROPPED -- Flee-to-safety revival.** `FLEE_BONUS`/the commented-out
+ `cEnprise`-match code in `movesup.c`'s `SelectBestWithHistory` (and
+ the matching `RETIRED` comment in `generate.c`) was dead -- retired
+ after the unconditional GOOD_MOVE-tier version it replaced was shown
+ (evidence-calibration, pre-dates this backlog) to perform at the
+ plain-leftover baseline for the vast majority of its population.
+
+ Reran with real evidence-bucket resolution (`MOVE_ORDERING_EVIDENCE_
+ COLLECTION`, reusing `CM_EVIDENCE_BUCKETS`/`_CMEvidenceBucket` --
+ same evidence scale as countermove, no butterfly-only workaround
+ needed since this legitimately reuses the ply-1-keyed continuation
+ table unlike move-pair/recapture): `evidence >= 10000` hit 56.32%/
+ 62.84%/68.23% FH (ecm_ringers/ecm_confident_quick/ecm_hard_quick)
+ vs. a ~1.35-1.54% unprivileged-leftover baseline -- comparable to
+ or exceeding countermove's own calibrated bar. A real signal the
+ prior session's cruder zero/nonzero split never isolated.
+
+ Implemented as a `GOOD_MOVE`-tier escape at that threshold, same
+ shape as `COUNTERMOVE_TIER_BONUS`. Result: **no measurable effect
+ either way** -- 0.00%/-0.05%/0.00% node delta at sd10, zero solve
+ diffs on any of the three suites, sn10M depth/cutoff flat. Not a
+ magnitude-tuning problem like move-pair/recapture had -- the
+ qualifying population (en-prise origin AND evidence >= 10000) was
+ just too rare (2,444-9,209 tries across an entire suite run, vs.
+ countermove's tens-to-hundreds-of-thousands) for any mechanism
+ acting on it to move the aggregate needle. `u64FleeTries/FH` and
+ the scoring/precompute code were fully reverted.
+
+4. **DROPPED -- Recapture bonus.** A capture landing on the same square
+ the opponent's last move landed on (`mv.cTo == mvLast.cTo`), nudged
+ within the winning/even-capture tier (`generate.c`'s `_ScoreAllMoves`,
+ `s >= 0` branch) rather than escaping it.
+
+ Built the comparison harness properly this time: `RECAPTURE_BUCKETS`
+ (2: SEE<0 losing, SEE>=0 even/winning) plus a parallel
+ `u64CaptureBaselineTries/FH` logging *every* capture (not just
+ recaptures) at the same SEE-sign buckets, so the recapture rate
+ could be read against an apples-to-apples baseline instead of in
+ isolation -- exactly the comparison move-pair's harness lacked at
+ first. Real signal: at SEE >= 0, recapture FH% ran 64-65% vs. a
+ 49-52% all-captures baseline, consistent across all three curated
+ suites, on samples of 300K-4.4M tries.
+
+ Two magnitudes tried as a same-tier nudge, neither produced a
+ tree-size win:
+ - `RECAPTURE_BONUS = 50` (first guess, scaled like FLEE_BONUS's
+ old magnitude): -0.74%/+5.04%/-0.45% nodes (mixed), but a
+ **confirmed real tactical loss** -- ECM.735 (see `../tests/ECM.md`'s
+ Legit table: "HEAD's own line is clean and solid"), on
+ `ecm_hard_quick` (17->14/90 solved). Root cause, found by
+ actually computing the surrounding arithmetic rather than
+ guessing: the branch's real MVV/LVA differentiator --
+ `PIECE_VALUE_OVER_100(captured) - PIECE_VALUE_OVER_100(mover)`,
+ pawn=1/knight,bishop=3/rook=5/queen=9 -- only spans ~112-128
+ (a ~16-unit band) across *every* capture combination. 50 is
+ over 3x that whole band -- not a tiebreak, a near-dominant
+ override capable of vaulting a recapture ahead of an
+ objectively higher-MVV/LVA non-recapture capture at the same
+ node, which is exactly what cost ECM.735.
+ - `RECAPTURE_BONUS = 4` (rescaled inside the ~16-unit band):
+ fixed the regression (ECM.735 recovered, 17/90 again; the
+ hard_quick loss/gain pair that remained, ECM.491/ECM.443, are
+ both independently classified Flaky in `ECM.md` -- net real
+ change zero) but still no node-count win: +2.23%/+7.95%/-0.81%,
+ worse on `ecm_confident_quick` than the broken 50 value was.
+ sn10M depth-reached flat to within noise at both magnitudes
+ (14.76 vs. flat-history-only's 14.83), same as every move-pair
+ variant.
+
+ Conclusion: same shape as move-pair -- a real, well-calibrated FH%
+ edge that MVV/LVA's existing ordering apparently already captures
+ most of, with no tried mechanism converting the residual signal
+ into a leaner tree. `RECAPTURE_BUCKETS`/`u64RecaptureTries/FH`/
+ `u64CaptureBaselineTries/FH` and the scoring-branch code were fully
+ reverted; `MOVE_ORDERING_EVIDENCE_COLLECTION` infrastructure kept.
+
+ **Methodology note for next time**: cross-referencing actual solve
+ losses/gains against `../tests/ECM.md`'s per-position Legit/Flaky
+ classifications (rather than reading raw suite-level solve-count
+ deltas) is what separated "real tactical cost" (ECM.735) from "suite
+ noise" (ECM.491/443/496) here -- worth doing by default for any
+ future candidate's solve-count changes, not just when something
+ looks surprising.
+
+3. **DROPPED -- Ply-2 move-pair table.** Crafty maintains a discrete
+ 2-slot table (`move_pair`, history.c) keyed on the *side's own* move
+ two plies back (`curmv[ply-2]`), tried in `next.c` right after the
+ countermove phase. Implemented as `mvMovePair` (same shape/indexing
+ as `mvCounter`), evidence-gated same as countermove.
+
+ First attempt (borrowed countermove's `g_HistoryCounters +
+ g_ContinuationHistory` evidence sum verbatim) measured a complete
+ no-op: identical node counts to flat-history-only across all three
+ curated suites at sd10, despite the table itself populating and
+ predicting fine (55-70% raw hit rate). Root cause: `g_ContinuationHistory`
+ is only ever written keyed by the move *one* ply back -- there's no
+ table anywhere keyed by two-plies-back, so the borrowed slot read
+ unrelated noise. Fixed to use butterfly history (`g_HistoryCounters`)
+ alone.
+
+ Built a dedicated evidence-bucket harness (`MOVE_ORDERING_EVIDENCE_
+ COLLECTION` build flag, independent of `PERF_COUNTERS` per later
+ request; `MP_EVIDENCE_BUCKETS`/`_MPEvidenceBucket`, denser floors
+ than countermove's own since butterfly-only evidence is capped at
+ `HISTORY_MAX`) and calibrated a real threshold from it: top band
+ (evidence >= 6000) hit 53.41%/64.43%/66.23% FH (ecm_ringers/
+ ecm_confident_quick/ecm_hard_quick) on large samples (26K/177K/271K
+ tries) -- comparable to or exceeding countermove's own calibrated
+ bar. A real, strong, well-sampled signal.
+
+ Three mechanisms tried to act on that calibrated signal, none
+ produced a tree-size win at sd10 across the three curated suites:
+ - GOOD_MOVE-tier escape (same pattern as countermove's own
+ promotion): +1.6%/+1.4%/+1.5% nodes -- small but consistent
+ regression across all three suites.
+ - Same-tier nudge at FLEE_BONUS's old magnitude (100000, a
+ dominant override far exceeding the leftover pool's actual
+ range): +19.8%/+4.9%/+2.1% nodes -- worse than the tier escape.
+ A dominant override apparently inherits the escape's downside
+ (jumping ahead of comparably-good moves) without its upside
+ (EFP/pruning exemption).
+ - Proportionate same-tier nudge (2000, scaled to compete with
+ rather than override PSQT+history+continuation): mixed,
+ net flat-to-negative (+20.5%/+2.0%/-0.11%) -- `ecm_hard_quick`
+ went essentially neutral, but `ecm_ringers` (smallest, noisiest
+ suite) got *worse* than every other variant, suggesting it
+ reacts badly to this kind of reordering regardless of magnitude.
+ sn10M depth-reached on the 30-position cross-sectional sample
+ (`/tmp/typhoon/smoke30.ep_`) stayed flat to within noise (14.72-14.83
+ vs. flat-history-only's 14.83) across every variant.
+
+ Conclusion: `SelectBestWithHistory`'s existing PSQT+history+
+ continuation scoring apparently already captures most of what this
+ signal offers; no mechanism tried converts the calibrated FH%
+ advantage into fewer nodes. `mvMovePair` and its evidence-bucket
+ harness were fully reverted (table, update function, nudge/tier
+ code, bucket counters/print block) -- re-derive from git history if
+ revisited with a different approach. The general
+ `MOVE_ORDERING_EVIDENCE_COLLECTION` infrastructure (flag, `MOI_INC`
+ macro, countermove's own bucket harness) was kept -- reusable for
+ calibrating the next candidate (recapture, flee-to-safety revival).
+
+## Done
+
+1. **DONE (kept) -- Flat (non-depth-scaled) history increment.** Replaced
+ `g_HistoryCounters`/`g_ContinuationHistory`'s old `(depth+1)^2`-scaled
+ growth with Crafty's flat/saturating update (`HISTORY_MAX`/
+ `HISTORY_UPDATE_SHIFT`, chess.h; `_Increment`/`_DecrementMoveHistoryCounter`
+ and continuation counterparts, dynamic.c) -- depth-independent on both
+ growth and decay. Measured against a fresh `fbdcb9d` HEAD baseline
+ (`head_reference/`, refreshed same session):
+ - sd10, curated suites: node count -18.9% (`ecm_confident_quick`),
+ -6.6% (`ecm_hard_quick`), +18.9% (`ecm_ringers`, smallest/noisiest --
+ first-move cutoff rate barely moved there, 91.918% -> 91.914%, so
+ likely a re-search artifact not a real regression); first-move
+ cutoff rate up +0.18pp / +0.16pp on the two larger suites.
+ - sn10M, 30-position random sample from `cross_sectional_1.ep_`
+ (seed 42, `/tmp/typhoon/smoke30.ep_`): avg deepest iteration
+ 14.52 -> 14.83 ply (+0.31), candidate reached equal-or-deeper depth
+ on 27/29 parsed positions, never shallower by more than 1 ply;
+ first-move cutoff rate 89.724% -> 89.967%.
+ Three independent signals (sd10 node count, sd10/sn10M cutoff rate,
+ sn10M depth-reached) all agree in direction. Kept, not yet committed.
+
+## Already covered / not pursuing
+
+- **History malus (decrement on moves tried before the fail-high move).**
+ Already implemented -- `UpdateDynamicMoveOrdering` (dynamic.c) calls
+ `_DecrementMoveHistoryCounter`/`_DecrementContinuationCounter` for every
+ quiet move considered before the one that beat alpha. Not a gap.
+- Crafty's `movgen.c` itself (pure move generation -- captures, checks,
+ evasions, noncaptures, pin detection) contains no ordering/scoring logic;
+ the real ordering machinery is `next.c` (phase FSM) + `history.c` (killer/
+ countermove/move-pair/history updates).
+
+## Protocol reminder
+
+- One variable at a time, against a fixed reference build (see
+ `head_reference/` convention) -- never "working tree a few hours ago."
+- `sd` (fixed depth), not `sn`/`st`, for "does this shrink the tree"
+ questions -- matches what all five ideas above are actually asking.
+- Read node-count and first-move-cutoff-rate off the three curated suites,
+ not solve-count alone (too noisy at 90-ish positions per CLAUDE.md).
+- `cross_sectional_200k.ep_` at fixed `sn` as a larger tertiary cross-check,
+ not the primary signal, and not on a 100-position sample (too small for
+ an aggregate depth-at-fixed-nodes metric) -- use at least a few thousand
+ positions if run.
diff --git a/src/docs/NNUE.md b/src/docs/NNUE.md
new file mode 100644
index 0000000..0ab46ec
--- /dev/null
+++ b/src/docs/NNUE.md
@@ -0,0 +1,1153 @@
+# Replacing Eval() with an NNUE evaluator
+
+Status: planning / discussion only. No code written yet. This document
+records the decisions made so far and the open items that still need
+resolving before implementation starts. See `MOVE_GUESSER.md` for the
+related-but-separate move-prediction ("policy") model idea, which is
+tracked independently and is not a dependency of this work.
+
+## Why
+
+Current `Eval()` is hand-tuned (see `eval_tune/`'s DNA tuning pipeline,
+622/879 ECM after a first tuning pass vs 606/879 baseline). NNUE-style
+learned evaluation is the next large step up in evaluation quality,
+at the cost of a meaningfully larger implementation + retraining project.
+
+## Architecture decisions made so far
+
+- **Feature set: HalfKAv2 (with the `_hm` horizontal-mirroring variant as
+ the likely final choice)**, not HalfKP and not a custom-invented
+ encoding. HalfKA includes the kings themselves as features (unlike
+ HalfKP, which excludes them), which captures king-vs-king spatial
+ relationships -- material for endgame/mating-net precision. `_hm`
+ roughly halves parameter count by exploiting left-right board symmetry.
+ Chose a known, documented format over inventing one specifically so
+ existing reference trainers/tooling (e.g. nnue-pytorch) and existing
+ write-ups are usable as a sanity check, rather than debugging both a
+ novel architecture and a novel implementation simultaneously.
+- **King bucketing: use HalfKAv2_hm's standard bucketing scheme as-is**,
+ not a custom bucket count. (Background: bucketing groups king squares
+ so sub-tables of weights are shared across squares in the same bucket
+ -- fewer buckets means less king-position nuance but faster training
+ convergence per bucket and a smaller table; more buckets is the
+ opposite tradeoff. Not worth re-deriving when a settled default
+ exists.)
+- **Net topology: start from a known, previously-validated small
+ architecture** (the kind of 256x2-32-32-1-ish shape used in early
+ nnue-pytorch default configs) rather than choosing dimensions from
+ scratch. Revisit sizing only after a first working net exists.
+- **Output convention: single scalar, side-to-move-relative**, matching
+ current `Eval()`'s convention. Achieved the standard way: two
+ king-relative accumulator halves (one per color's perspective) are
+ concatenated in an order determined by side-to-move (own-perspective
+ half first) before the hidden layers, so the net's one scalar output
+ is side-to-move-relative by construction. Explicitly rejected a
+ two-output (white-score, black-score) design -- that reintroduces a
+ combination-rule-design problem the standard trick already avoids,
+ and we already committed to following known designs over inventing
+ our own.
+- **SIMD target: AVX2 + FMA.** Confirmed on the actual build box (AMD
+ Ryzen 9 3900X, FreeBSD, clang 19.1.7): `clang -march=native -dM -E -`
+ reports `__AVX2__`, `__FMA__`, `__SSE4_1__`/`__SSE4_2__` defined, no
+ AVX512 (Zen 2 doesn't have it). Quantization/SIMD inference code
+ should target AVX2 as the baseline, not assume AVX512 availability.
+- **Quantization: int16 for the (sparse) input/accumulator layer, int8
+ for the dense hidden layers**, matching why Stockfish's own
+ implementation splits it this way -- the input layer's cost is
+ dominated by sparse incremental updates (only ~a handful of features
+ change per move), where int16's SIMD width cost is small, while the
+ dense later layers benefit more from int8's 2x SIMD lane packing.
+ Narrower quantization = faster but lossier; this split is the
+ standard compromise. Needs a quantization-aware scaling pass per
+ layer during export, not just truncate-and-ship.
+- **Lazy evaluation / fast first-pass filter: reuse a cheap linear
+ subcomponent of the SAME trained net (its PSQT/embedding-sum term,
+ no hidden-layer nonlinearity), not a second, separately-trained
+ smaller net.** Rationale: a separately-trained small net would need
+ its own calibration against alpha/beta margins, and that calibration
+ would almost certainly disagree with the full net's (nonlinear,
+ WDL-sigmoid-derived) output scale -- exactly the problem raised about
+ mixing material-cp lazy skips with a WDL-calibrated net output (see
+ below). A cheap *partial evaluation* of the one trained net lives in
+ the same output space as the full net by construction, so there is
+ no second calibration curve to reconcile. Only fall through to the
+ full forward pass (hidden layers) when the cheap linear term is
+ inconclusive relative to alpha/beta.
+ - Open experiment, explicitly not decided against: a super-lazy
+ *material-only* pre-filter ahead of even the cheap linear net
+ term, purely for search speed. Known risk: current `Eval()`'s
+ super-lazy/lazy/full three-tier system works because all three
+ tiers share units (centipawns, roughly linearly related to
+ material). A material-only prefilter ahead of an NNUE net does
+ NOT have that guarantee once the net's output has gone through
+ WDL-sigmoid calibration -- a position could look "way above beta"
+ in raw material and still not clearly fail high in the net's
+ (compressed, nonlinear) output space. Worth trying, worth
+ measuring carefully (fail-high-rate-at-the-margin style
+ instrumentation, not just aggregate solve counts -- see
+ CLAUDE.md's "Dynamic move ordering experiments" methodology),
+ explicitly allowed to fail and be reverted.
+- **Training data score space: train against win/draw/loss outcomes via
+ a sigmoid-squashed WDL-style target**, not raw centipawn regression,
+ extending the same sigmoid-squared-error approach `tune_eval_dna.py`
+ already uses. Rationale: raw centipawn MSE misweights the loss (a
+ +500-vs-+700 error matters far less to the actual game outcome than a
+ +20-vs-+80 error, but unweighted centipawn MSE penalizes them
+ similarly) -- WDL-style targets are naturally calibrated to what
+ actually matters (winning the game) and match the training signal
+ already available from game outcomes. Requires a calibrated
+ inverse-sigmoid conversion back to centipawns for use inside
+ alpha-beta search (search needs additive, mate-aware scores, not raw
+ probabilities) -- that conversion constant needs its own calibration
+ pass and is a likely source of subtle bugs if done carelessly.
+- **Exact scores (mate distance, EGTB/recognizer hits) are never run
+ through the WDL-sigmoid conversion, in either direction.** These are
+ game-theoretically exact, not noisy evidence to fit a sigmoid against.
+ The net itself never needs to learn "is this a mate" -- that detection
+ stays entirely in the search/TB-probe layer exactly as it does today;
+ the net is only ever consulted at ordinary, non-exact nodes. This is a
+ training-data-labeling concern (don't mix exact-value positions into
+ the sigmoid-fit loss, or into material-lazy-skip calibration), not an
+ architecture concern.
+ - **Resolved**: `<TT>`/`<EGTB>`/`<REC>` are sentinel pseudo-moves
+ (`HASHMOVE`, `EGTBMOVE`, `RECOGNMOVE` in `chess.h`) inserted into
+ the PV by `UpdatePV()` (called from `search.c` at the hash-hit,
+ recognizer, and EGTB-probe sites) to record provenance of the line
+ that follows. Distinction that matters for training labels:
+ **`<EGTB>`/`<REC>` are terminal and exact** -- the node's value
+ literally *is* that recognizer/tablebase result, so a position
+ whose PV ends in one of these tags should have its score excluded
+ from the sigmoid-fit WDL loss (treated as ground truth, not noisy
+ evidence). **`<TT>` is NOT an exactness marker** -- it means a
+ hash hit occurred and the remainder of the printed PV was
+ reconstructed by walking the hash table's stored continuation,
+ which can be a fail-high/fail-low *bound*, not necessarily an
+ exact score. A `<TT>` appearing anywhere in a PV should be treated
+ as an ordinary search score for labeling purposes, not specially
+ excluded.
+
+## Accumulator maintenance design
+
+- The NNUE input state lives as a **persistent "current" blob per
+ thread** (analogous to `POSITION`'s always-current board state), not a
+ recomputed-per-call structure, stored in `SEARCHER_THREAD_CONTEXT` so
+ each `MP=1` helper thread has its own copy.
+- **Normal (non-king) moves**: MakeMove incrementally adds the changed
+ feature deltas into the live accumulator; UnmakeMove incrementally
+ subtracts the same deltas back out. This is correct without any extra
+ bookkeeping because search's Make/Unmake calls are already properly
+ nested (a recursive call stack) -- UnmakeMove always undoes exactly the
+ delta the matching MakeMove applied.
+- **King moves**: every feature in HalfKA is king-relative, so a king
+ move invalidates the entire accumulator, not just a delta. Handling:
+ **a per-ply backup**, stored in the existing `PLY_INFO sPlyInfo[MAX_PLY_PER_SEARCH+1]`
+ array already present in `SEARCHER_THREAD_CONTEXT` (`chess.h`) --
+ add a field there rather than a new parallel array, following the
+ same pattern as `PLY_INFO`'s existing sig-stamped, conditionally-valid
+ fields (`u64FullEvalSig`, `iKingScore`).
+ - On a king MakeMove: memcpy live accumulator -> `sPlyInfo[uPly]`'s
+ backup slot, then recompute live from scratch.
+ - On that same king move's UnmakeMove: memcpy the backup slot back
+ into live (cheaper than recomputing from scratch -- a sequential
+ copy of a ~1-2KB blob vs. ~30 per-piece weight-table lookups/adds
+ across both perspectives).
+ - **Must be indexed by ply, not a single shared backup slot** -- king
+ moves can nest (either side's king can move more than once within
+ a single search line before any of those moves is unmade), and a
+ single shared slot would silently get overwritten by a second,
+ deeper king move before the first one's UnmakeMove runs, producing
+ a wrong-but-undetected restore with no assertion to catch it. Per-
+ ply indexing (bounded by `MAX_PLY_PER_SEARCH`, in practice far
+ fewer slots actually used) avoids this by construction.
+- **Null move**: moves no piece, so it is a true no-op for the
+ accumulator -- confirm this explicitly in the implementation rather
+ than assuming it falls out automatically.
+- **Audit required before implementation**: confirm nothing in the
+ current search bypasses paired MakeMove/UnmakeMove to mutate position
+ state directly (e.g. any bulk board-state copy used by aspiration
+ re-search, singular extension, or the `MP=1` split-search machinery --
+ see the recent `SearchOneMove`/`SPLIT_INFO` refactor). Any such path
+ would desync the accumulator silently and is exactly the kind of bug
+ that would not show up until much later.
+- **Performance follow-up, not required for correctness**: eager
+ accumulator maintenance on every MakeMove wastes work at nodes that
+ get pruned by a cheap lazy filter before ever consulting the net (see
+ the super-lazy-prefilter experiment above). A lazy/deferred
+ accumulator scheme (record "what moved" cheaply per ply, only replay
+ deltas forward from the nearest materialized ancestor when a node
+ actually needs a net evaluation) is the standard answer if profiling
+ shows this matters. Defer until there's a working eager version to
+ measure against.
+
+## Breaking external dependencies on Eval()'s internal signals
+
+`cTrapped`/`cEnprise` (and any other signal currently read outside
+`Eval()` by move ordering -- hung-piece-escape logic, `FLEE_BONUS`, the
+countermove-table evidence gating from the 2026-08-29 move-ordering
+session) must be decoupled from `Eval()`'s computation entirely before
+`Eval()` can be replaced, since NNUE has no equivalent intermediate
+signals to expose.
+
+- **Enumeration done, 2026-10-15** (grepped `cTrapped`/`pTrapped`/
+ `cEnprise`/`pEnprise` across every live `.c`/`.h` file -- `save/` and
+ `changes/` hold old snapshots, not live code, and were excluded; then
+ verified consumers by call site, not just by grep match, since one
+ memorable-sounding candidate turned out to be dead):
+ - **`FLEE_BONUS` (the 2026-08-29 session's leftover-move en-prise
+ nudge, `generate.c:4673`'s doc comment) is dead code, not a live
+ consumer** -- `chess.h:2949` says so explicitly ("separately dead
+ code in movesup.c's SelectBestWithHistory (commented out) --
+ retired after the GOOD_MOVE-tier version it replaced was shown...
+ to perform at the plain-leftover baseline"); confirmed in
+ `movesup.c`, only a commented-out declaration remains
+ (`movesup.c:1439`). Likewise, `search.c:184-194`'s null-move-
+ calibration-harness comment confirms en-prise/trapped state *was*
+ tried as an axis of the joint fail-high prediction table in an
+ earlier (2026-09-24) revision, found "not a safety signal at all"
+ for that purpose, and explicitly is **not** bucketed in the
+ current revision. Neither of these is a real dependency today.
+ - **Consumers that ARE live** (read-only, via `dynamic.c`'s accessor
+ functions -- none need to change regardless of who produces the
+ data):
+ 1. `generate.c:5342` (QSearch-specific move ordering, a different
+ function from the dead `FLEE_BONUS` one): nudges a move whose
+ origin square is the cached `cEnprise` hint, applied only on
+ top of a move SEE has already classified as winning/even --
+ SEE runs first, the hint is a secondary nudge.
+ 2. **`EstimateHangingMaterialCp`** (`searchsup.c:246-413`, called
+ from `searchsup.c:1826`/`1905`) -- feeds the "dumb move" axis
+ of the live joint fail-high prediction table (the LMR/reduction
+ soundness machinery; see CLAUDE.md's "LMR sd11 vs sn10M
+ protocol" and search.c:242's "live, in-progress collection run
+ as of this writing"). Structured as a cascade of checks, most
+ of them bitboard-based, falling through to a `cTrapped`
+ half-credit hint only as the last resort (`searchsup.c:403`) --
+ see the dedicated finding below, since 3 of its 5 checks turned
+ out to share `_EvalTrappedPieces`'s dependency, not just the
+ final fallback.
+ 3. `ValueOfMaterialInTroubleDespiteMove` (`dynamic.c:619`, called
+ once, from `search.c:2237`) -- a **binary QSearch stand-pat
+ gate** ("material in danger -> don't let this side stand pat"),
+ not an FH%-bucket. Reads `cEnprise` (two-slot) and `cTrapped`
+ (one-slot) together into one `iInTrouble` value, with no
+ bitboard check of its own -- trusts the cached hint directly,
+ validated only by an eval-signature match.
+ - **`EstimateHangingMaterialCp` finding, 2026-10-15**: its 5 checks
+ split 2 independent / 3 Eval()-entangled, and the entanglement is
+ bigger than just the `cTrapped` fallback:
+ 1. Pawn-attacks-destination -- independent (static attack tables +
+ raw pawn bitboard).
+ 2. Enemy-king-adjacency -- independent (static tables + `see.c`'s
+ on-demand `_WhoAttacksSquareBB`, the same on-demand
+ board-geometry lookup SEE itself uses -- not an eval-cached
+ bitboard).
+ 3. Minor-attacks-destination -- **depends on
+ `bbMinorAttacksSnapshot`**, which `search.c:606-609` populates
+ straight from `pos->bbMinorAttacks`, **gated on
+ `fParentAttackTablesAreFresh`** (`search.c:602-603`: true only
+ if the parent node's `Eval()` call was a full eval, not lazy).
+ 4. Rook-attacks-destination -- same dependency, `bbRookAttacks`.
+ 5. The `cTrapped` half-credit fallback (what prompted this
+ check) -- depends on `_EvalTrappedPieces`, same as `cTrapped`'s
+ producer below.
+ **So the real dependency here isn't just the `cTrapped` fallback
+ -- it's `bbMinorAttacks`/`bbRookAttacks` themselves** -- but
+ **this is not a blocker, and not a new condition an eval swap
+ introduces**: `fParentAttackTablesAreFresh` is already false
+ today on every node where the parent's `Eval()` call took a
+ lazy/super-lazy exit (today's normal three-tier behavior, not a
+ hypothetical) -- `EstimateHangingMaterialCp` already falls back to
+ just its 2 independent checks on those nodes, routinely, right
+ now. An NNUE swap just makes that already-live degraded path the
+ permanent one, not a newly-broken one. The actual effect, per the
+ function's own return-value contract (cp-loss-or-zero, consumed
+ only through `HangCpBucket`): losing checks 3-5 means some moves
+ that would have bucketed as "a little dumb" instead bucket as "not
+ dumb" (bucket 0) -- a **precision/recall shift in the joint
+ fail-high prediction table's "dumb move" axis, used for pruning/
+ reduction/node-type decisions, not a correctness bug.** Whether
+ this matters enough to rebuild is an empirical question, not an
+ a-priori blocker: measure what fraction of real search nodes
+ already hit the degraded (no-fresh-tables) path today, and how
+ much predictive power the joint table actually loses if that
+ fraction goes to 100%, before deciding whether building the
+ independent bitboard pass is worth it versus just accepting the
+ less-pessimistic FH% estimate this already-tolerated degraded mode
+ produces.
+ - **Producers -- two genuinely different cases, not one**, which
+ changes the decoupling cost estimate:
+ 1. **`cEnprise`'s cheap source is already fully decoupled from
+ `Eval()` today**: `searchsup.c:3032`'s null-move-refutation
+ path (`RecordEnprisePieceAtPly`, inside
+ `_VerifyNullMove`/nullmove handling) flags a piece en prise
+ purely from *the move that refuted a null-move search*
+ (`mv.pCaptured`) -- no eval attack-table dependency at all,
+ pure search/move-list logic. This half needs **no change** to
+ survive an eval swap.
+ 2. **`cTrapped`'s only source, and `cEnprise`'s *other* source,
+ are both inside `eval.c`'s `_EvalTrappedPieces`**
+ (`eval.c:6188`), and this one is genuinely entangled with
+ `Eval()`'s own term-by-term computation, not cleanly
+ separable by just moving a function: it reads
+ `pos->bbPawnAttacks`/`bbMinorAttacks`/`bbRookAttacks`/
+ `bbQueenAttacks`/`bbKingAttacks` (built incrementally by
+ earlier per-piece-type eval passes) and
+ `pos->cTrapped[uColor][]`/`pos->bbTrappedEscapes[uColor][]`
+ (a *nomination* list populated earlier still, during
+ low-mobility-piece detection, specifically deferred there
+ because full attack information isn't available until later
+ passes finish -- see `_EvalBishop`'s "carefully" comment).
+ `_WhoControlsSquareFast` (used both here and at the dead
+ `_EvalLookForDanger`, `#if 0`'d out) also wants those same
+ attack bitboards. None of this exists independently of
+ `Eval()` running its normal term-by-term pass today.
+ - **Revised estimate, 2026-10-15 -- not a blocker.** `cEnprise`'s
+ null-move-refutation source is free (already independent) with no
+ caveats. Everything routed through `_EvalTrappedPieces` --
+ `cTrapped`, `cEnprise`'s other source, and 3 of
+ `EstimateHangingMaterialCp`'s 5 checks -- degrades gracefully
+ rather than breaking: an eval swap just makes permanent a
+ low-information fallback path (`fParentAttackTablesAreFresh ==
+ FALSE`) that already fires routinely today on lazy/super-lazy
+ eval exits. Net effect is a less-pessimistic "dumb move" axis in
+ the joint fail-high prediction table -- a tuning/precision
+ question, not correctness. **Decision: do not build the
+ independent bitboard pass speculatively.** Ship the eval swap
+ first with these signals simply going permanently degraded, then
+ measure with the same joint-table instrumentation already in
+ `search.c` whether the prediction table's actual pruning/reduction
+ behavior suffers enough to be worth recovering. Revisit building a
+ slimmed `bbMinorAttacks`/`bbRookAttacks`-plus-trapped-piece pass
+ only if that measurement says so -- not before.
+- Preferred approach, revised: **no independent tactical-signals pass
+ is planned up front.** The earlier plan here (build a slimmed
+ bitboard pass before the eval swap, to protect `cTrapped`/`cEnprise`
+ consumers) is deferred per the decision above -- it's optional,
+ data-gated follow-up work, not a precondition for replacing
+ `Eval()`. What *does* still apply: the 2026-08-29 move-ordering
+ session's countermove-table evidence gating is unaffected by any of
+ this (doesn't touch `cTrapped`/`cEnprise` at all); `FLEE_BONUS`
+ itself was already dead code before this investigation started, so
+ there was never anything of its to protect. The null-move-
+ refutation `cEnprise` source needs no changes at all, regardless.
+
+## Feature-indexing parity (Python trainer vs. C inference)
+
+The single most likely way to silently wreck this project: the Python
+training code computes HalfKAv2 feature indices one way, the C inference
+code computes them a different way (square-numbering off-by-one,
+different king-bucket formula, different perspective ordering), and the
+net trains with a perfectly reasonable loss curve but performs randomly
+in the engine, because the trained weights are being read back out
+against the wrong feature slots.
+
+- Write one canonical feature-indexing spec (square numbering,
+ king-bucket formula, per-perspective feature ordering, HalfKAv2_hm's
+ mirroring rule) **once**, in this document or a linked one, before
+ either the Python encoder or the C encoder is written.
+- Before any real training run: build a small cross-checker that dumps
+ the feature vector for N known test positions from both the Python
+ encoder and a standalone C test harness and diffs them bit-for-bit.
+ Do this before training, not after a training run produces confusing
+ results.
+- Confirm the output sign/perspective convention (side-to-move-relative,
+ per the architecture decision above) the same way -- a sign flip only
+ manifests as "plays like it wants to lose," which is a much harder bug
+ to localize after the fact than a direct feature-vector diff.
+
+## Canonical HalfKAv2_hm feature-indexing spec (2026-10-15)
+
+Per the "known, documented format" architecture decision above, this
+is **not a reinvented scheme** -- it is Stockfish's own `HalfKAv2_hm`
+feature set, pinned to a specific historical implementation
+(`src/nnue/features/half_ka_v2_hm.{h,cpp}` at git tag `sf_16`, fetched
+directly from `official-stockfish/Stockfish` 2026-10-15, quoted
+verbatim below) rather than re-derived from memory or a secondary
+description. Current Stockfish `master` has moved on to a different,
+newer feature set entirely (`k32q2`/`pp_3wide`/`full_threats`) -- do
+not fetch "latest Stockfish" expecting to find `HalfKAv2_hm` there.
+
+**Square numbering (Stockfish side, the convention both the Python
+encoder and this spec's formulas use)**: `Square = file + 8*rank`,
+0-indexed, `SQ_A1 = 0` ... `SQ_H8 = 63` (`a1..h1` = 0-7, `a2..h2` =
+8-15, ..., `a8..h8` = 56-63).
+
+**typhoon's own square numbering is different and must be converted**:
+typhoon uses a 0x88 mailbox representation (`chess.h`): `A1 = 0x70`,
+`RANK(c) = 8 - (c >> 4)` (1-indexed rank), `FILE(c) = c & 0x0F`
+(0-indexed file). The C encoder must convert every typhoon `SQUARE`
+to the Stockfish-style 0-63 index before applying any formula below:
+`sq64 = FILE(c) + 8 * (RANK(c) - 1)`. Getting this one line wrong
+(e.g. an off-by-one on `RANK(c) - 1`, or forgetting typhoon's rank
+numbering runs opposite to its own nibble ordering) is exactly the
+class of silent bug this whole spec exists to prevent -- it must be
+the first thing the cross-checker below verifies, in isolation,
+before testing any king-bucket/orientation logic that builds on top
+of it.
+
+**Piece-square plane index** (`PieceSquareIndex`, verbatim from
+`half_ka_v2_hm.h`) -- `SQUARE_NB = 64`:
+```
+PS_NONE=0, PS_W_PAWN=0, PS_B_PAWN=64, PS_W_KNIGHT=128, PS_B_KNIGHT=192,
+PS_W_BISHOP=256, PS_B_BISHOP=320, PS_W_ROOK=384, PS_B_ROOK=448,
+PS_W_QUEEN=512, PS_B_QUEEN=576, PS_KING=640, PS_NB=704
+```
+Per-perspective piece->plane lookup (own/enemy colors swap when
+Perspective=BLACK; **both kings share the single `PS_KING` plane** --
+own-king and enemy-king features differ only by which square within
+that one 64-square block is set, not by which plane):
+```
+Perspective=WHITE: {pawn:PS_W_PAWN, knight:PS_W_KNIGHT, bishop:PS_W_BISHOP,
+ rook:PS_W_ROOK, queen:PS_W_QUEEN, king:PS_KING} for White(=own) pieces;
+ {pawn:PS_B_PAWN, ..., king:PS_KING} for Black(=enemy) pieces.
+Perspective=BLACK: own/enemy swapped (own=Black pieces use the PS_W_*
+ constants' *role*, i.e. the W/B labels in this table mean "us/them",
+ not literal color -- see the "convention: W-us, B-them" comment in
+ the source).
+```
+
+**King buckets** -- 32 buckets per perspective (not 64; this is
+where `_hm`'s horizontal mirroring halves the table vs. plain
+HalfKAv2). `KingBuckets[Perspective][[Square 0..63]]`, each entry
+`bucket_index * PS_NB` (so it's already a feature offset, not a bare
+bucket number) -- verbatim from the source, printed one visual board
+row per line, **rank 1 first** (i.e. row *i* of this literal = Squares
+`8*i .. 8*i+7`, matching Stockfish's own `rank*8+file` ordering, not a
+top-down board diagram):
+```
+KingBuckets[WHITE] (value = bucket*704):
+ rank1: 28 29 30 31 31 30 29 28
+ rank2: 24 25 26 27 27 26 25 24
+ rank3: 20 21 22 23 23 22 21 20
+ rank4: 16 17 18 19 19 18 17 16
+ rank5: 12 13 14 15 15 14 13 12
+ rank6: 8 9 10 11 11 10 9 8
+ rank7: 4 5 6 7 7 6 5 4
+ rank8: 0 1 2 3 3 2 1 0
+KingBuckets[BLACK] (value = bucket*704) -- mirror image of WHITE's table
+ (own king's bucket decreases as it advances up the board *from that
+ perspective*, same intent, opposite physical ranks):
+ rank1: 0 1 2 3 3 2 1 0
+ rank2: 4 5 6 7 7 6 5 4
+ rank3: 8 9 10 11 11 10 9 8
+ rank4: 12 13 14 15 15 14 13 12
+ rank5: 16 17 18 19 19 18 17 16
+ rank6: 20 21 22 23 23 22 21 20
+ rank7: 24 25 26 27 27 26 25 24
+ rank8: 28 29 30 31 31 30 29 28
+```
+Only 4 distinct bucket values per rank (not 8) because of the
+horizontal mirror: king on file a/h share a bucket, b/g share one,
+c/f share one, d/e share one -- the actual file-level distinction
+survives through the *orientation* step below, not the bucket.
+
+**Orientation / mirroring** (`OrientTBL`, verbatim) -- gives the
+XOR mask applied to every piece's square (including the king's own),
+keyed by the *perspective's own king's actual square*:
+```
+OrientTBL[WHITE][ksq]: SQ_H1 (=7) if ksq's file is a-d (king on the
+ queenside half)
+ SQ_A1 (=0) if ksq's file is e-h (kingside half)
+ (applies to ksq on ANY rank -- only ksq's FILE selects the branch)
+OrientTBL[BLACK][ksq]: SQ_H8 (=63) if ksq's file is a-d
+ SQ_A8 (=56) if ksq's file is e-h
+```
+`7 = 0b111` flips only the file bits (pure horizontal mirror);
+`56 = 0b111000` flips only the rank bits (vertical flip, i.e. "view
+from Black's side" with no horizontal mirror); `63 = 0b111111` flips
+both (Black's view, horizontally mirrored too). Net effect matches
+the header comment: **the perspective's own king always ends up on
+the e-h files** after orientation, regardless of which side it
+started on or which color is to move.
+
+**Feature index formula** (`make_index`, verbatim):
+```
+index(Perspective, s, piece_at_s, ksq) =
+ (s XOR OrientTBL[Perspective][ksq])
+ + PieceSquareIndex[Perspective][piece_at_s]
+ + KingBuckets[Perspective][ksq]
+```
+where `s` is every occupied square's Stockfish-style 0-63 index
+(including `ksq` itself, and including the *other* king's square),
+and `ksq` is fixed per perspective as that perspective's own king's
+*actual, unoriented* square (the bucket/orient lookups are keyed by
+the real square -- only the piece square `s` gets XORed).
+
+**Per-perspective ordering / total dimensions**: `Dimensions =
+SQUARE_NB * PS_NB / 2 = 64 * 704 / 2 = 22528` features per
+perspective (the `/2` is exactly the `_hm` mirror halving 64 king
+positions down to 32 buckets). Two independent 22528-wide sparse
+input vectors are produced per position, one per color's perspective
+(White's accumulator half and Black's accumulator half), concatenated
+own-perspective-first per the side-to-move-relative output convention
+already decided above. Up to 32 features are active at once per
+perspective (`MaxActiveDimensions`) -- one per piece on the board,
+including both kings (max 32 pieces total on a legal board).
+
+**Not yet decided, deliberately deferred**: whether typhoon's trained
+net keeps Stockfish's literal `HalfKAv2_hm(Friend)` hash/name
+convention or adopts its own -- irrelevant until a `.nnue`-compatible
+serialization format is chosen, which isn't needed for this project's
+own from-scratch C inference code. The spec above only needs to be
+byte-for-byte *internally* consistent between typhoon's Python encoder
+and typhoon's C encoder; it does not need to produce files
+interchangeable with real Stockfish `.nnue` binaries.
+
+**Bit-for-bit Python/C cross-checker built and passing, 2026-10-15**
+(closes milestone 1):
+- **C side**: `src/nnuefeatures.c`, a new `dump nnuefeatures` engine
+ command (wired into `command.c`'s existing `dump` dispatcher,
+ declared in `chess.h`, added to `GNUmakefile`'s `OBJS`).
+ Deliberately **not** wired into eval/search/the accumulator --
+ reference-math-only, operating on the already-trusted
+ `g_RootPosition`/`rgSquare` board state so no new FEN-parsing code
+ was needed. Implements the 0x88->64 conversion
+ (`CoorToSq64`), `KingBucket`, `OrientMask`, and the index formula as
+ small standalone static functions, each matching one piece of the
+ spec above 1:1. Prints `NNUE_FEATURES white <idx>...` /
+ `NNUE_FEATURES black <idx>...` for the current position.
+- **Python side**: `annotate_pgns/nnue_feature_spec.py`, an
+ independent from-scratch implementation of the identical formulas
+ using `python-chess`'s own native 0-63 square numbering (no 0x88
+ conversion needed on this side -- that's purely a typhoon-C-side
+ translation concern).
+- **Driver**: `annotate_pgns/cross_check_features.py` spawns the real
+ engine binary via `--batch --command "setboard <fen>; dump
+ nnuefeatures"`, diffs its output against the Python encoder,
+ index-set for index-set, over 9 hand-picked FENs chosen to catch a
+ perspective/orientation mixup specifically (not just the symmetric
+ starting position, which can't distinguish a White-perspective bug
+ from a Black-perspective bug since it's symmetric under exactly the
+ transform between perspectives): both kings queenside (a-d files),
+ both kingside (e-h), each combination of one king on each half (the
+ cases most likely to expose an orientation/bucket bug), a dense
+ midgame position exercising every piece type, a sparse king-and-pawn
+ endgame (3 active features, near the low end of the range), and a
+ multi-queen position. **All 9 positions pass on both
+ perspectives.** Also re-verified passing against the release binary
+ rebuilt by `precommit_check.sh` (which itself passed both gates),
+ not just a one-off debug build.
+- Run it again with `python3 cross_check_features.py` from
+ `annotate_pgns/` any time the C or Python side of this formula
+ changes -- cheap (a few seconds), should be treated as a required
+ gate before trusting any future feature-vector output from either
+ side.
+
+## Training data pipeline
+
+- **Score target**: sigmoid-squashed WDL-style, per the decision above.
+ Mixed with or validated against actual game outcome where available
+ (standard NNUE bootstrapping practice), with exact
+ mate/EGTB-recognizer positions excluded from the sigmoid-fit loss (see
+ above).
+- **Move-played-as-successor-inversion technique** (confirmed sound):
+ to get a score for the move actually played in a human game (no
+ guarantee the engine's own search would pick the same move), score the
+ resulting position after the move and negate:
+ `score_before_move_from_mover's_perspective = -eval_or_search(position_after_move)`.
+ This is just the standard negamax relationship -- well-posed regardless
+ of whose move it was, and does not require the engine to agree the
+ move was objectively best.
+- **Data sources and current state** (checked 2026-10-14):
+ - `../pgn/self_play_games.pgn`: 166,640 total games, but only
+ **26,347 have score annotations** (started "a couple of weeks
+ ago" per conversation) -- roughly 2.36M annotated `{ comment }`
+ positions total. The other ~140k pre-annotation games are also
+ pre-dating a recent change that scrambles opening positions more,
+ so they are likely heavily duplicate/near-duplicate and not worth
+ careful deduplication effort yet. Self-play is ongoing
+ concurrently with this planning, so this pool grows on its own.
+ - `../pgn/lichess_elite_*.pgn` (2013-2020, many files) and
+ `../pgn/twic_filtered.pgn` (642,268 games, >=2400 Elo / >40 ply,
+ 556MB) -- far larger and more diverse than the current self-play
+ pool, not sourced from this engine's own play, so free of the
+ "net just re-learns the current eval's blind spots" risk that
+ bootstrapping directly off self-play data would have.
+- **Recommended phasing** (standard NNUE bootstrapping order, not novel):
+ 1. **Phase 1 -- bootstrap from human games.** Train the first net
+ primarily on `twic_filtered.pgn`/`lichess_elite_*` positions.
+ Training on the current self-play pool first would mostly teach
+ the net to reproduce the current hand-tuned `Eval()`'s own blind
+ spots, since that's what generated those games -- not useful
+ signal for replacing it.
+ 2. **Phase 2 -- self-play with the new net.** Once a first net
+ exists, use it (not the old `Eval()`) to generate much larger,
+ more targeted self-play volume -- this is where self-play data
+ becomes valuable, since it now reflects the new net's own
+ tactical gaps rather than the old eval's.
+- **Search depth for annotation: `sd 10`, fixed depth (not `st` time-based),
+ decided 2026-10-14.** Measured on a 300-position sample drawn from
+ `twic_filtered.pgn` (`/tmp/typhoon/nnue_pilot/`): `sd 10` with a 32MB
+ hash runs at roughly 7 positions/sec single-threaded (~6/sec at the
+ default 256MB hash -- smaller hash is faster per-position because
+ every `setboard` triggers a full `ClearHashTable()` memset via
+ `SetBoardCommand`'s `PreGameReset(FALSE)` call, so a smaller table is
+ cheaper to clear). At 16 single-threaded shards (`--cpus 1 --hash 32m
+ --logfile -` each), 5M positions is projected at ~12.4 hours.
+ `--logfile -` was confirmed to already skip `BeginLogging`'s
+ backup-file dance entirely (`main.c`), no code change needed there.
+ The `script` command's periodic checkpoint-report dump (every 5
+ positions, `script.c`'s `CHECKPOINT_GO_INTERVAL`, kept for crash
+ resilience on long joint-table-collection runs) was measured and found
+ to cost nothing meaningful at this scale (43.9s vs 42.7s/300 positions
+ with it disabled) -- not worth adding a flag for.
+ - Depth-adequacy check: compared `sd 10` vs `sd 16` scores on the
+ same 21 positions (a subsample of the 300; the full 300-position
+ `sd 16` comparison was not completed -- it was running at ~14s/
+ position, ~100x slower than `sd 10`, confirming `sd 16`+ is not
+ feasible for bulk annotation at the volumes this project needs
+ regardless of any quality difference). Result: mean |delta| 19cp,
+ median 10cp; in win-probability terms (what actually matters for
+ WDL-sigmoid training labels) mean |delta| was ~2.3%, only 1 of 21
+ exceeded a 10% win-probability swing, and that one outlier was
+ already a clearly-winning position at both depths (didn't change
+ position *character*). Two apparent "sign flips" were both
+ near-equal/balanced positions where a small score wobble flips
+ which side nominally leads -- noise at the margin, not a missed
+ tactic. Small sample (n=21), not weighted toward tactically sharp
+ positions the way ECM is, but consistent with the expectation that
+ ordinary human-game middlegame positions (unlike curated tactical
+ suites) are not especially depth-sensitive for the net's purpose
+ -- the net's job is positional judgment on these; finding forced
+ tactics is the search's job at inference time, not the
+ training-label-generation search's job to get perfectly right.
+ Conclusion: proceed with `sd 10` for the bootstrap annotation pass.
+- **Annotation harness**: build a TWIC/lichess annotation tool reusing
+ `eval_tune/tune_eval_dna.py`'s `Engine`/subprocess pattern (xboard
+ protocol driver, persistent-context `eval` command, SysV-semaphore
+ pitfalls already solved there -- see that file's existing
+ `Engine`/`EnginePool` context-manager requirement). This is buildable
+ and runnable immediately, independent of all engine-internals work
+ below.
+- **Quiet-position filtering, applied at training-set assembly time (not
+ at annotation time)**: exclude positions where the side to move is in
+ check, or where the best/played move is an SEE-positive capture
+ (signals the position is mid-exchange, not settled). Two reasons: (1)
+ a position dominated by a forced tactical resolution tests "find the
+ tactic," which is quiescence search's job, not the net's positional
+ judgment -- training heavily on these wastes net capacity re-deriving
+ something SEE already gives for free; (2) a forcing capture sequence
+ sampled at plies N, N+1, N+2 produces several highly correlated
+ near-duplicate samples of one tactical event, not independent data
+ points, inflating apparent corpus diversity without adding real
+ signal. Keep annotating every ply regardless (the raw PV/score corpus
+ has standalone value for analysis); apply this filter only when
+ building the actual training set from that corpus. Note this does not
+ create a train/inference mismatch -- the net still gets invoked at
+ many non-quiet-looking nodes during `QSearch()` at inference time, but
+ quiescence search is specifically designed to walk down to a settled,
+ stand-pat-ish leaf before a position's evaluation is actually trusted,
+ so the distribution of positions where the score is relied upon is
+ itself mostly quiet-ish by construction.
+- **Held-out test set: resolved, already built.** `split_holdout.py`'s
+ stratified game-level split (result x length-quartile, 10% sampled
+ independently per stratum -- see Status below) is the holdout pool:
+ **26,995 train games / 2,298,648 positions** vs. **3,005 holdout
+ games / 255,245 positions**, split by *game* (not position, avoiding
+ cross-split leakage from one game's correlated positions) and never
+ touched when fitting the WDL-sigmoid K constant. This is a separate
+ pool from `eval_tune/tuned.best_holdout.dna` (that one is
+ DNA-tuning's own flat, position-level holdout, built for a different
+ pipeline) -- the two are not reused across projects; NNUE training/
+ validation/gating all draw from this `annotate_pgns/` pool instead.
+
+## Status (2026-10-14)
+
+- **Sharded annotation harness built**: `../annotate_pgns/annotate.py`.
+ Shards an input PGN across N single-threaded engine instances, scores
+ every position in every game at a fixed `sd <depth>` (depth is a
+ plain CLI arg, so re-running at `sd 12`/`sd 16` later needs no code
+ changes, just a different `--depth`/`--outdir`), and reassembles an
+ annotated PGN matching the self-play comment format (`{ +score/depth
+ PV }`). Validated end-to-end on an 8-game/4-shard smoke test before
+ trusting it at scale. `load_games()` skips `Result "*"` (unterminated)
+ games -- can't derive a WDL label from an unknown outcome. Short
+ agreed-draw games are not separately filtered: `twic_filtered.pgn`'s
+ existing `>40 ply` / `>=2400 Elo` filter (`eval_tune/filter_pgn.py`)
+ already excludes those by construction, confirmed empirically (a
+ 5000-game sample has a hard floor at 40-49 plies, nothing shorter).
+- **Stage A (pipeline-validation) run in progress.** Target: ~2M
+ annotated positions (~30,000 games from `twic_filtered.pgn`, the
+ first 30,000 in file order) at `sd 10`, 16 single-threaded shards
+ (`--cpus 1 --hash 32m --logfile -` each). Launched 2026-10-14 ~15:05.
+ - Output location: `../annotate_pgns/runs/sd10_twic_2M/annotated.pgn`
+ (written once all 16 shards finish and the driver reassembles
+ them). Intermediate per-shard files kept in the same directory:
+ `shard_NN.script` (generated engine command scripts),
+ `shard_NN.out` (raw engine stdout per shard). Driver progress log:
+ `../annotate_pgns/runs/sd10_twic_2M.driver.log`.
+ - Actual yield came in higher than the small smoke-test estimate:
+ 30,000 games produced 2,553,893 total positions to score (~85/game
+ average, vs. the ~67/game estimated from the 8-game smoke sample)
+ -- expect final usable count after quiet-filtering (applied later,
+ at training-set-assembly time, not during annotation) to land
+ somewhat under 2,553,893, comfortably still in/above the ~2M
+ target.
+ - Measured throughput came in slower than the single-shard pilot
+ extrapolation: ~75 positions/sec aggregate across all 16 shards
+ (vs. ~112/sec extrapolated from the single-shard pilot's 7/sec x
+ 16), most likely from 16-way contention on this shared box
+ (cache/memory bandwidth) rather than any bug -- progress checks
+ showed clean, steady, monotonic counts with no errors/aborts
+ across all 16 engine processes.
+ - As of 2026-10-14 16:47: 459,156 / 2,553,893 positions done (18.0%).
+ Revised ETA: ~7.7 more hours from that point (~9.4 hours total
+ from the 15:05 launch), finishing roughly 00:30 rather than the
+ original ~5-hour back-of-envelope guess.
+- **Stage A run finished 2026-10-14 ~00:40** (~9.6 hours, close to the
+ revised ETA): 30,000 games, 2,553,893 positions scored across all 16
+ shards with no crashes/aborts.
+- **Bug found and fixed in `annotate.py`'s output parser, discovered
+ only after the full run completed (would have been cheap to catch in
+ the smoke test -- lesson for next time: smoke-test specifically needs
+ a position or two that hits a forced mate/EGTB hit, not just ordinary
+ middlegame positions).** `TELLOTHERS_RE` only matched plain-float
+ scores; the engine's own `+MATE<N>`/`-MATE<N>` notation (and `<EGTB>`/
+ `<REC>` PV-terminal sentinel moves, which can carry an ordinary-
+ looking float score when real moves precede the sentinel, e.g.
+ `PV=Kf6 Bc3+ Kxg6 Kf8 Kxh5 <EGTB>`) didn't match, so those result
+ lines were silently dropped from the parsed list while staying in the
+ position index. Since `annotate_shard` zips the two lists
+ *positionally*, one dropped line didn't just lose its own
+ annotation -- it desynced every position after it in that shard,
+ attaching the wrong score/PV to the wrong FEN for the rest of the
+ shard's output. All 16 shards hit this (975-1,265 bad lines each,
+ 16,994 total, ~0.67% of all scored positions), confirmed via a full
+ scan of every `tellothers` line across all 16 `shard_NN.out` files
+ (2,553,893 total lines, 0 that failed to match the outer line format
+ at all -- the only two non-numeric score tokens that ever appear are
+ `+MATE<N>` (9,986 occurrences) and `-MATE<N>` (7,008), confirming
+ there's no other silent-drop source hiding in this run's data).
+- **Recovered without re-running the search.** `annotate_pgns/extract_positions.py`
+ mines `shard_NN.script` (the ordered list of `setboard <fen>` lines
+ already written for that shard) zipped positionally against
+ `shard_NN.out`'s `tellothers` results -- the engine processes `go` in
+ strict script order with no interleaving (see CLAUDE.md), and
+ `setboard`-count equals `tellothers`-count exactly once mate/EGTB
+ lines are captured as real results instead of dropped, so this
+ recovers a clean, correctly-aligned dataset straight from the
+ existing raw logs. PGN reconstruction (what `annotate.py` was doing)
+ turned out to be an unnecessary detour for a project that only ever
+ needed (FEN, score) pairs, not game trees -- it's also what
+ introduced the misalignment risk in the first place.
+ Output: `runs/sd10_twic_2M/positions.jsonl`, one JSON object per
+ position (`fen`, `depth`, `score`, `score_raw`, `pv`, `exact`,
+ `exact_reason`). Exactness is detected from the PV's *last move
+ token* being `<EGTB>`/`<REC>` (not the whole-PV string or the score
+ field -- a real line of moves can precede the sentinel, and the
+ score alongside it can look like an ordinary centipawn value even
+ though the position is game-theoretically exact) or the score being
+ `+-MATE<N>`. Result: 2,553,893 positions total, 48,407 flagged
+ `exact` (zero `<REC>` occurrences this run, all exact rows were mate
+ scores or `<EGTB>`-terminated PVs), **2,505,486 usable rows** for
+ sigmoid-fit training -- matches the original run's full target yield.
+ `annotate.py` itself still has the original bug (not yet patched --
+ low priority now that `positions.jsonl`, not its PGN output, is the
+ actual training artifact; would still need fixing before trusting any
+ of its PGN output for other purposes).
+- **Related engine-side bug found and fixed while investigating the
+ `+315.92`-style non-exact-looking EGTB scores above**: `probe.c`'s
+ `EGTB_WIN_SCORE`/`EGTB_LOSS_SCORE` used to be `NMATE - VALUE_QUEEN`
+ (deliberately kept below the `NMATE` threshold so it wouldn't be
+ mistaken for a literal forced mate elsewhere in the engine), which is
+ why EGTB hits printed as ordinary-looking floats instead of
+ `+-MATE<N>`. Changed to literal `+-NMATE` so `FormatScore` (`util.c`)
+ renders every EGTB win/loss as a flat `+-MATE100` uniformly, matching
+ the already-observed bare-`<EGTB>` case and making all EGTB-terminated
+ PVs parseable by the same plain `+-MATE\d+` regex going forward --
+ no separate PV-suffix check will be needed for *future* runs (this
+ run still needs it, since it predates the fix). Caveat checked before
+ trusting this: `ADJUST_MATE_IN_N` (the `#ifdef` in `hash.c`'s
+ `StoreExactScore` that would ply-adjust a score assuming it encodes
+ mate distance -- which a flat EGTB sentinel does not) is not defined
+ anywhere in this build (confirmed via grep across all headers and
+ `GNUmakefile`), so the hash-store path this change now exercises for
+ every EGTB hit is the safe `#else` branch (downgrades to a
+ `HASH_FLAG_LOWER`/`HASH_FLAG_UPPER` bound entry with a flat `+-NMATE`
+ value, no ply arithmetic) -- confirmed this exercises a **second,
+ previously-dormant bug** in that same `#else` branch: the `iValue <=
+ -NMATE` case was pairing `HASH_FLAG_UPPER` with `sNew.iValue =
+ +NMATE` (should be `-NMATE` -- compare the mirror-image win-case
+ branch just above it, which correctly pairs `HASH_FLAG_LOWER` with
+ `+NMATE`). Previously dormant because the old `NMATE - VALUE_QUEEN`
+ EGTB-loss score never satisfied `iValue <= -NMATE` on its own (only a
+ literal engine-found forced mate-against could reach this branch);
+ the `EGTB_LOSS_SCORE = -NMATE` change makes every EGTB-loss position
+ hit it. In a release build this was harmless (a `+NMATE` upper bound
+ is vacuously loose, never produces an incorrect cutoff, just wastes a
+ hash write); in a `DEBUG` build it would trip `search.c`'s
+ `VERIFY_HASH_HIT` macro's `ASSERT(iScore < +NMATE)` on later
+ retrieval under the `HASH_FLAG_UPPER` case. Fixed (`hash.c`:
+ `sNew.iValue = -NMATE`).
+- **Four more real engine bugs found and fixed in the same session**,
+ all via `precommit_check.sh`-verified fixes, committed together as
+ `ef2c2f6` ("Fix several latent search/move-generation bugs found via
+ NNUE annotation tooling"):
+ - **`san.c`**: a checking castle move (`O-O+`/`O-O-O+`) printed as
+ `+-O`/`+-O-O` -- the check-suffix pointer (`p = szTextMove;`) was
+ captured *before* `strcpy` filled the buffer with `"O-O"`, so
+ `*p++ = '+'` overwrote the leading `O` instead of appending after
+ it. Fixed by recomputing `p` from `strlen(szTextMove)` after the
+ `strcpy`.
+ - **`search.c`/`searchsup.c`**: all five PV sentinel-recording sites
+ (`MATEMOVE`, `DRAWMOVE` x2 -- stalemate and the separate
+ repetition/50-move site, `RECOGNMOVE`/`EGTBMOVE` x2 -- main
+ search and QSearch) were gated by the same strict
+ `(iAlpha < iScore) && (iScore < iBeta)` window check used for
+ ordinary heuristic PV moves. Traced live (temporary `Trace()`
+ instrumentation on a real mate-in-2 from the corpus, removed
+ after): once a forced line's value is already known (from an
+ earlier, shallower iterative-deepening pass, or the hash table),
+ later visits to it are typically probed with a PVS scout/null
+ window built *around* that known value, so the terminal node's
+ real score *ties* the window rather than strictly beating it --
+ failing this gate by equality, not by being out of range. But
+ these five values are exact/game-theoretic facts, not heuristic
+ bounds -- unlike an ordinary move, they can't be "wrong" for
+ having been reached outside the window. Fixed by recording them
+ unconditionally. This alone did not fully fix `<#>`/`<=>` display,
+ though -- see the next bullet.
+ - **`util.c` (`WalkPV`)**: traced further and found the *actual final
+ move* of a forced mate (one ply shallower than the terminal
+ sentinel) hits the identical tie, through the completely separate
+ *ordinary* move-acceptance code path (`iScore > iAlpha` at
+ `search.c`'s main move loop) -- so the recorded PV is often
+ missing its own concluding move, not just the sentinel after it.
+ Rather than touch that hot, per-node path (every interior node of
+ the entire search tree), `WalkPV` now reconstructs the missing
+ tail directly from the board when the recorded PV runs out one
+ ply short of (or exactly at) a forced mate/stalemate/draw: the
+ position at that point already reflects every printed move (each
+ was `MakeMove`'d during the walk), so it checks `IsDraw`, or
+ failing that, finds the one legal move the shrinking mate
+ distance guarantees must exist. Runs once per improved root
+ score, not in the search tree, so the extra legal-move
+ verification is cheap. Verified on mate-in-1 (`Rd8+ <#>`), a real
+ mate-in-6 (13-move chain, no gaps anywhere in the middle, not
+ just at the end), and a constructed 50-move-rule draw
+ (`Kc1 <=>`).
+ - **`gamelist.c`/`board.c`/`testsup.c`**: `SetRootPosition` used to
+ parse a `setboard` FEN straight into the global `g_RootPosition`,
+ so a *rejected* FEN (malformed syntax, or -- newly caught --
+ describing an illegal position) still left partially-parsed
+ garbage in `g_RootPosition`, because `GetRootPosition()` hands
+ out a stable pointer straight to it with no defensive copy
+ (removing one was a deliberate, confirmed-correct fix for a
+ *different*, unrelated bug -- see that function's own comment).
+ The next `go` searched that garbage regardless of the earlier
+ rejection, discovered independently two ways: an `InvalidGenerator`
+ dispatch panic (`generate.c`) and, separately, a debug-build
+ assert in `_ScoreAllMoves`'s MVV/LVA scoring (`PIECE_VALUE(KING)`
+ overflowing past `VALUE_KING` -- a pseudo-legal "capture the enemy
+ king" move, generated because nothing stops the generator from
+ producing one against an opponent king already left in check).
+ Root cause of *that*: a hand-built FEN leaving the side not on
+ move in check is illegal (unreachable through real play), and
+ nothing validated for it. Fixed: `SetRootPosition` now parses into
+ a local staging `POSITION` and only commits to `g_RootPosition` if
+ `FenToPosition` succeeds *and* `IsBoardLegal` (pawns never on
+ rank 1/8, side not on move never in check) *and*
+ `VerifyPositionConsistency` (internal struct consistency -- piece
+ lists, material counts, signatures) both pass. Both checks now
+ run unconditionally, in every build profile, not just
+ `TEST`/`DEBUG` -- `IsBoardLegal` had to be moved from `testsup.c`
+ (only linked under `TEST`/`EVAL_DUMP`) to `board.c` (always
+ linked) to make this possible; `VerifyPositionConsistency` is
+ DEBUG-gated at its many *other* (hot, per-move) call sites for
+ performance, but this one is cold (once per game/`setboard`, not
+ per node), so the cost is worth it here specifically.
+ **Important, deliberately NOT a bug**: a separate illegal-FEN
+ repro that looked like a second instance of the same class
+ (`8/8/8/4kp2/5P2/4K3/8/8 w - - 99 60`, locked pawn pair adjacent
+ to both kings) turned out to just be a badly hand-built test FEN
+ of my own -- White's own pawn on f4 already attacked the square
+ Black's king sat on while it was White's move, which is the same
+ "opponent in check" illegality, not a new generator bug. Confirmed
+ by reverting all of this session's changes (`git stash`) and
+ reproducing the identical crash on unmodified `master` -- notable
+ only as a reminder that a hand-built FEN needs to be checked for
+ basic legality before trusting a crash it causes.
+- **`extract_positions.py` exactness-tagging gap found and fixed** (see
+ the earlier bullet on the `<EGTB>`/`<REC>` MATE-regex bug): it also
+ missed `<=>` (`DRAWMOVE` -- a claimed repetition/50-move draw) and the
+ `"only"` sentinel (`root.c`'s single-legal-move-at-the-root shortcut,
+ which skips searching entirely and hardcodes `score=0`/`depth=0` --
+ not a real evaluation, not even indeterminate, just never searched).
+ A draw-by-repetition/50-move claim is a fact about the *game's move
+ history*, not the position -- the same position reached a different
+ way might not be a draw at all -- so it's excluded from training for
+ the same reason `<EGTB>`/`<REC>` are. Re-running extraction with both
+ fixed: exact-row count went from 48,407 to **93,415** (47,304 of the
+ increase is `<=>`), leaving 2,460,478 usable rows (down from
+ 2,505,486).
+- **Quiet filter built and run on the full corpus** (`quiet_filter.py`,
+ reusing `eval_tune/tune_eval_dna.py`'s pre-existing
+ `static_exchange_eval`/`has_hanging_material` rather than
+ reimplementing): excludes in-check positions and positions whose PV's
+ first move is an SEE-positive (>=50cp) capture. Full-corpus result:
+ 2,553,893 total rows -> 48,407 exact -> 112,405 excluded (in check) ->
+ 311,949 excluded (SEE-positive first move) -> **2,080,911 quiet
+ positions** (`runs/sd10_twic_2M/positions.quiet.jsonl`), ~81.5%
+ retention.
+- **Shared quiet-predicate module factored out** (`quiet_common.py`):
+ `position_is_quiet(board, move)` plus the SEE-threshold constant,
+ used by all three consumers below so the exclusion logic (in-check,
+ SEE >=50cp capture) lives in exactly one place. The two call sites
+ differ only in *which move* gets SEE-checked, and this is a real,
+ tracked distinction, not an implementation detail -- the engine's
+ own PV first move (the two post-hoc filters, below) vs. the move
+ actually played in the source game (the new pre-filter, below) are
+ two different training distributions; rows produced under the two
+ should not be silently mixed into one training run.
+- **Quiet filter now also applied to the game-shaped labeled files**
+ (`quiet_filter_games.py`), closing the gap this file previously
+ flagged as open -- re-applies the identical PV-based predicate
+ directly to each position already embedded in a game record, rather
+ than joining against `positions.quiet.jsonl` (which carries no
+ row/game identity, and a repeated FEN across different games/plies
+ would make a content-based join silently misattribute rows). A game
+ that loses every position to filtering is dropped entirely (none
+ were, in this corpus). Run on both labeled splits 2026-10-15:
+ **holdout**: 255,245 -> **215,134** positions (9,476 in-check +
+ 30,628 SEE-positive-capture + 7 indeterminate excluded), 3,005/3,005
+ games kept -- `games.holdout.labeled.quiet.jsonl`. **train**:
+ 2,298,648 -> **1,932,713** positions (88,278 in-check + 277,593
+ SEE-positive-capture + 64 indeterminate excluded), 26,995/26,995
+ games kept -- `games.train.labeled.quiet.jsonl`. Combined retention
+ across both splits is 2,147,847 / 2,553,893 = 84.1%, noticeably
+ higher than the flat-file filter's 81.5% -- **not noise, a real
+ behavioral difference**: `quiet_filter.py` (flat) drops exact rows
+ (mate/EGTB/`<=>`) from its output entirely, while
+ `quiet_filter_games.py` always keeps them (only the in-check/SEE
+ criterion applies to non-exact rows; exact rows are real,
+ game-theoretic ground truth, not something the quiet filter has any
+ basis to exclude -- NNUE.md already excludes them from the
+ sigmoid-fit *loss* elsewhere, which is a separate, correct exclusion
+ from excluding them from the dataset altogether). The game-shaped
+ filter's behavior is the one intended going forward; the flat file's
+ stricter exact-row drop was never a deliberate design choice, just
+ an artifact of how `quiet_filter.py` was first written.
+- **Quiet pre-filtering moved upstream into `annotate.py`**
+ (`--prefilter-quiet` flag; `quiet_common.position_is_quiet` applied
+ inside `positions_for_game` before a position is ever written into a
+ shard script), so a *future* annotation run never pays `sd N` search
+ cost on a position that would only be discarded afterward -- making
+ the post-hoc game-shaped filter above unnecessary for runs going
+ forward. **Deliberate semantic difference from the post-hoc
+ filters**: no PV exists before the search has run, so the pre-filter
+ SEE-checks the move *actually played* in the source game instead of
+ a PV's first move (see `quiet_common.py`'s module docstring).
+ `build_games.py` gained a matching `--prefilter-quiet` flag that
+ **must match** whatever the originating `annotate.py` run used -- it
+ recomputes per-game ply counts via the same `positions_for_game` call
+ to re-slice `positions.jsonl`, and a mismatched flag would silently
+ misalign every position after the first game that differs. Verified
+ end-to-end on a 4-game/2-shard/`sd 6` smoke test: pre-filtering wrote
+ 253 of the games' total plies into the shard scripts, all 253 came
+ back annotated with no count-mismatch warnings, and `build_games.py
+ --prefilter-quiet` reproduced the identical per-game slice
+ boundaries. The already-completed Stage A run below predates this
+ flag and was not pre-filtered -- this only changes the *next*
+ annotation run.
+- **Python SEE cross-checked against the engine's real `SEE()`**
+ (`check_see_against_engine.py`, using the bulk `seescores` command):
+ 2,000 sampled positions, 4,032 actual capture comparisons -- 31.0%
+ exact match, 92.7% sign match, 7.3% sign mismatch. Investigated one
+ mismatch in depth (`Bxa6` in a real position, engine said +100,
+ Python said -230) via temporary `Trace()` instrumentation in
+ `_SEEFull`/`SEE()`: **not an engine bug** -- the engine correctly
+ finds that after `Bxa6 Rxa6`, White's queen, freshly revealed by the
+ bishop's own departure (x-ray down the same diagonal), recaptures the
+ rook, so Black's actual best reply is to decline the recapture
+ entirely (net -100, not -230). Python's `static_exchange_eval`
+ computes all attacker lists once, off the pre-move board, so it never
+ sees a piece the mover's own departure uncovers -- exactly the
+ "ignores... discoveries" limitation its own docstring already
+ confessed. The 7.3% mismatch rate is overwhelmingly (likely entirely)
+ this same known, accepted limitation, not a new one -- confirms the
+ cross-check tool did its job, not that anything is broken.
+- **Game-container reconstruction built** (`build_games.py`): recovers
+ per-game grouping that `positions.jsonl` lost (flat list, no
+ `game_id`) by re-loading the same PGN with the same
+ `max_games`/`--shards` (pure parsing, no search, deterministic) and
+ slicing the already-extracted rows back into per-game chunks via each
+ game's known position count. Output shape -- `{game_id, result,
+ positions: [...]}`, one line per game -- deliberately matches the
+ shape a self-play PGN with per-move score/PV comments already has
+ (self-play annotation is a separate, ongoing effort outside this
+ project, run every time the engine plays a self-play match), so a
+ future loader for that source can emit the same schema and both mix
+ freely at training-set-assembly time. Verified exact: 30,000 games,
+ 2,553,893 positions, matching `positions.jsonl`'s count precisely.
+ Output: `runs/sd10_twic_2M/games.jsonl`.
+- **Stratified game-level train/holdout split built and run**
+ (`split_holdout.py`): splits by *game*, not position (every position
+ from one game lands on the same side -- avoids the leakage a flat
+ position-level shuffle has, which `eval_tune/tune_eval_dna.py`'s own
+ holdout split still does). Stratified on two axes so a 10% sample's
+ composition mirrors the full pool rather than risking (by chance) too
+ many decisive games, too few draws, or a skew toward short/long games:
+ result (White win/Black win/Draw) x game-length quartile (quartile
+ cutpoints derived from the actual corpus: 63/80/102 annotated plies,
+ not guessed). 10% sampled independently within each of the 12
+ resulting strata. Result: **26,995 train games / 2,298,648 positions**
+ (`games.train.jsonl`), **3,005 holdout games / 255,245 positions**
+ (`games.holdout.jsonl`).
+- **WDL-sigmoid target conversion built and run** (`fit_k_and_label.py`):
+ per NNUE.md's existing decision, reuses `tune_eval_dna.py`'s
+ `fit_k`/ternary-search-over-K approach, but K is fit directly against
+ the already-computed `sd 10` search scores (no engine round-trips
+ needed, unlike the original `fit_k`, which called live `Eval()`), and
+ **only on the train split** -- fitting K on holdout data would leak
+ information into what's supposed to be an untouched generalization
+ check; the same fitted K is then applied (never refit) to label
+ holdout positions too. **Caveat hit and fixed**: the first attempt
+ copied `tune_eval_dna.py`'s own search bracket (`[100, 2000]`,
+ calibrated for *centipawn*-scale `Eval()` scores) verbatim and got
+ `K=10.00` -- exactly its lower bound, a clear sign of a clamped,
+ not-actually-converged fit, not a real answer. Root cause: this
+ project's own scores are stored in *pawn* units (e.g. `+0.47`, not
+ `47`), a 100x unit mismatch. Refit with an appropriately-scaled
+ bracket (`[0.05, 20]`) landed well inside the range: **K = 1.3123**
+ (pawns), MSE improved from 0.1401 (clamped) to 0.1175 (real optimum).
+ Sanity-checks as plausible for >=2400-level play: a 1-pawn edge ->
+ `sigmoid(1/1.3123) ~= 0.68` win probability, a 2-pawn edge -> `~= 0.82`
+ -- modest material deficits are often held or converted at this
+ level, not auto-losses. Deliberately does **not** blend `p_search`
+ (the sigmoid-derived estimate) with `r_outcome` (that position's own
+ game's actual final result, side-to-move-relative) into a single
+ target yet -- both are stored separately per position so the blend
+ weight (lambda in `target = lambda*p_search + (1-lambda)*r_outcome`)
+ remains a free parameter to sweep later without re-deriving anything
+ from scratch. Verified correct on spot-checked positions (result
+ flips correctly to each position's own side-to-move perspective).
+ **Lambda decided for Phase A: lambda=1.0 (pure `p_search`, no
+ `r_outcome` blend), 2026-10-15.** Phase A's goal is pipeline/
+ framework validation on a small local-CPU training run, not the
+ best achievable net -- a free parameter with no data yet to tune it
+ against would just be guessed, so defer the sweep to Phase B/the
+ larger GPU-trained run, where there's enough data and enough training
+ iterations for a lambda sweep to produce a real signal rather than
+ noise. `r_outcome` stays stored per position regardless (already
+ computed, free to use later) so the sweep needs no re-derivation.
+ Output: `runs/sd10_twic_2M/games.train.labeled.jsonl` (26,995 games,
+ 2,298,648 positions, 84,302 exact rows left unlabeled),
+ `games.holdout.labeled.jsonl` (3,005 games, 255,245 positions, 9,113
+ exact rows left unlabeled).
+- **Not yet built**: the Python training script itself (the loss
+ function, quantization-aware export pass), the feature-indexing-spec
+ cross-checker, any `SEARCHER_THREAD_CONTEXT`/accumulator plumbing
+ (still correctly gated behind a dedicated branch, not started), and
+ the lambda sweep/selection for blending `p_search`/`r_outcome`. The
+ quiet-filter gap previously noted here (not yet applied to the
+ game-shaped/labeled files) is closed -- see `quiet_filter_games.py`
+ above -- and a future annotation run can skip the post-hoc step
+ entirely via `annotate.py --prefilter-quiet`.
+
+## Training compute
+
+- No reliable local GPU. Decided against Google Colab's notebook-session
+ model (session timeouts, no persistent SSH access) in favor of **hourly
+ GPU rental with real shell access** -- vast.ai, RunPod, or Lambda Cloud.
+ Rough order-of-magnitude expectation: a Stockfish-sized net (low
+ millions of parameters) over tens of millions of positions is on the
+ order of 1-2 hours/epoch on a single consumer-class rented GPU, full
+ run under a day; CPU-only training of the same workload is roughly
+ 20-50x slower (no tensor cores/wide SIMD at that scale), making a full
+ run impractical locally but a small-scale pipeline-smoke-test (a few
+ million positions, few epochs) plausible in hours-to-a-day on CPU if
+ needed for early iteration before committing to a paid GPU run.
+- **Decided 2026-10-15**: Phase A uses exactly that local-CPU
+ pipeline-smoke-test path -- PyTorch on CPU, against the ~2M-position
+ quiet-filtered train/holdout pool already built, purely to prove the
+ training script/data loader/export pass work end-to-end and to get
+ fast local iteration when something breaks. GPU rental is deferred
+ to the real, larger Phase B training run.
+
+## Integration into search
+
+- Lazy-eval tier restructuring: today's three-tier super-lazy/lazy/full
+ `Eval()` system does not map 1:1 onto NNUE, which doesn't have
+ comparable internal tiers the way the handcrafted eval's term-by-term
+ structure does. Tentative new tiering: (optional, experimental)
+ material-only super-lazy prefilter -> cheap linear PSQT-term-of-the-net
+ first pass -> full net forward pass. Decide how much of `eval.c`'s
+ existing tiering logic is kept vs. deleted once this is actually
+ implemented, not before.
+- Futility/EFP/LMR/NMP margins are all tuned against the current
+ `Eval()`'s score distribution and will need full recalibration against
+ the new net's score distribution -- expect this to be a comparably
+ sized project to the LMR/NMP tuning already referenced in CLAUDE.md,
+ not a quick follow-up step.
+
+## Validation and rollout
+
+- **Gate using `eval_tune/match_play.py`'s existing LOWER95 methodology**
+ (already used for DNA-tuning candidates) -- head-to-head match at
+ fixed search depth, gate on `LOWER95 >= 0.5`, not raw score, for the
+ same non-determinism reasons already documented for DNA tuning.
+- Snapshot a fresh `head_reference`-style baseline before any
+ search-affecting plumbing (accumulator maintenance, cTrapped/cEnprise
+ decoupling) lands, per the existing `head_reference/` protocol in
+ CLAUDE.md -- this is a broad enough change to want a clean before/after
+ reference point, same as any other search-affecting change.
+- Keep both evaluators coexisting behind a toggle for as long as
+ practical, so regressions can be isolated to "is it the net" vs. "is
+ it the recalibrated margins" vs. "is it the accumulator plumbing"
+ rather than landing all three at once.
+- Do not retire the handcrafted `Eval()` until NNUE wins convincingly and
+ consistently under the `match_play.py` gate.
+
+## Sequencing / milestones
+
+1. **Done 2026-10-15.** Canonical HalfKAv2_hm feature-indexing spec
+ written down + Python/C cross-checker built and passing on known
+ test positions.
+2. **Done 2026-10-15, concluded not a blocker.**
+ `cTrapped`/`cEnprise` consumers and producers enumerated (see
+ "Breaking external dependencies" above). `cEnprise`'s null-move-
+ refutation source needs no change. Everything else -- `cTrapped`'s
+ only source, `cEnprise`'s other source, and 3 of
+ `EstimateHangingMaterialCp`'s 5 checks -- is entangled in
+ `_EvalTrappedPieces`'s attack-bitboard logic, but degrades
+ gracefully (the same low-information fallback path already fires
+ routinely today on lazy/super-lazy eval exits) rather than breaking
+ -- a precision shift in the joint fail-high prediction table's
+ "dumb move" axis, not a correctness bug. **Decision: ship the eval
+ swap first, measure the actual pruning/reduction impact with
+ existing joint-table instrumentation, and only build an independent
+ bitboard pass if that measurement says it's worth it.** No
+ speculative tactical-signals pass planned up front.
+3. MakeMove/UnmakeMove accumulator plumbing (ply-indexed king-move
+ backup in `PLY_INFO`) built and proven correct/fast **with the old
+ `Eval()` still live** -- prove the plumbing before any trained net
+ exists to evaluate against it. **Do this on a separate git branch**,
+ not on `master`, given how much live search-path code it touches;
+ snapshot `head_reference` first.
+4. TWIC/lichess annotation harness built; `<TT>`/`<EGTB>`/`<REC>` tag
+ semantics investigated and resolved; first large annotated training
+ pool assembled (Phase 1 data, per above). **Unblocked now, independent
+ of steps 1-3** -- no engine-internals risk, can start immediately.
+5. First bootstrap net trained (rented GPU), quantization-aware export
+ pass built.
+6. C inference code (forward pass + quantized weight loader) written and
+ validated against the Python model's output on held-out positions.
+7. Wired into search behind a toggle; margins recalibrated.
+8. `match_play.py` gate; iterate; retire old `Eval()` only after a
+ convincing, repeated win.
+
+## Immediately actionable, unblocked work
+
+Not gated behind any engine-internals decision above:
+
+- Build the annotation harness and begin scoring `lichess_elite_*`/
+ `twic_filtered.pgn` games (Phase 1 training data).
+- Investigate `<TT>`/`<EGTB>`/`<REC>` tag semantics in the self-play PV
+ printer.
+- Finalize the written HalfKAv2_hm feature-indexing spec (design-only,
+ no code).
+
+Gated behind opening a dedicated branch (per discussion -- not yet
+started):
+
+- Any `SEARCHER_THREAD_CONTEXT`/`PLY_INFO`/MakeMove/UnmakeMove changes.
diff --git a/src/docs/NULLMOVE_SKIP_CALIBRATION.md b/src/docs/NULLMOVE_SKIP_CALIBRATION.md
new file mode 100644
index 0000000..50a050e
--- /dev/null
+++ b/src/docs/NULLMOVE_SKIP_CALIBRATION.md
@@ -0,0 +1,213 @@
+# Null-move-skip calibration plan (2026-10-12)
+
+## Motivation
+
+Two FH%-prediction tables now exist in `data.c`:
+
+- `g_uJointFHThousandths` ("alpha table") -- predicts a move's FH% given
+ [depth][hang][gap-to-alpha][late][improve]. Used today for LMP/EFP/LMR
+ decisions on individual leftover moves.
+- `g_uBetaFHThousandths` ("beta table") -- predicts a node's FH% given
+ [depth][gap-to-beta][ownImprove][enemyImprove], for nodes where
+ `iEval > iBeta`. Used today for the static-NM shortcut
+ (`search.c`'s `fTryBetaPruningHere` block, >95% -> return `iBeta`
+ without searching).
+
+Question: is there a *low*-confidence corner in either table reliable
+enough to justify **skipping the real null-move search attempt**
+entirely at a node (not pruning the node itself -- just not trying the
+null probe, falling through to the normal move loop: hash move,
+killers, etc.)? Unlike the static-NM shortcut, this is a pure
+efficiency play with no score-injection risk: if we're wrong, the real
+move loop still runs and finds the correct score. We just forgo the
+cheap shortcut on the rare occasion it would have worked.
+
+## Why the alpha table, not the beta table, for this
+
+At non-PV nodes, `iAlpha == iBeta - 1`, so `iEval <= iAlpha` and
+`iEval >= iBeta` together cover the entire range -- no gap. Checked
+both tables' actual baked data (`data.c`) for a usable low-FH corner:
+
+- **Alpha table**, sliced at hang=0 ("nothing hung"), late=0 ("even the
+ single best-looking move"): deep remaining depth (shipped buckets
+ 8-10) x large gap-to-alpha (buckets 8-10) x improve bucket 0 (own
+ eval declining) gives real, non-fallback predicted FH% as low as
+ 0.001-0.1% -- two-plus orders of magnitude below the ~0.4-0.9%
+ baseline leftover-move rate. A strong, well-observed signal.
+- **Beta table**: its 2026-10-11 inversion (storing gap-to-100% instead
+ of FH% directly, to keep resolution near 100% for the static-NM
+ shortcut) saturates every cell predicting below 34.466% FH to the
+ same stored value. No resolution exists below that floor in the
+ current bake -- there is no usable low corner to find here. Would
+ need a dedicated unsaturated rebake to pursue; out of scope for now.
+
+So: implement the skip using the alpha table only, for the
+`iEval <= iAlpha` side.
+
+## The EV algebra
+
+Trying null-move at a node has expected value (in nodes saved):
+
+```
+EV(try) = p * realLoopCost - nullProbeCost
+```
+
+where `p` = probability null-move fails high, `nullProbeCost` = the
+reduced-depth probe's node cost, `realLoopCost` = what the real move
+loop costs to reach its own cutoff (the cost skipped entirely when the
+probe succeeds). Skip trying null-move iff
+`p < nullProbeCost / realLoopCost`.
+
+- `nullProbeCost` is trivial to measure: a node-counter delta around
+ the probe's recursive `Search()` call (same as `TEST_NULL`'s existing
+ `uNullCalibProbeNodeCost`).
+- `realLoopCost` is conceptually simple (it's the cost the node always
+ pays via the real move loop when null isn't tried) but the
+ *counterfactual* isolation -- measuring it specifically for the
+ "null was never attempted" branch, without contamination from
+ production's own null-move logic firing afterward -- needs a
+ deliberate harness. Not yet built; see "Future work" below.
+
+### R-factor / branching-factor estimate
+
+Using `p* ~= b^(-R)` with `b = 2.5` (assumed average branching) and
+`R = 3 + floor(depth/6)` (actual `SelectNullmoveRFactor` formula):
+
+| remaining depth (plies) | R | p* = 2.5^-R |
+|---|---|---|
+| 0-5 | 3 | 6.4% |
+| 6-11 | 4 | 2.6% |
+| 12-17 | 5 | 1.0% |
+| 18-23 | 6 | 0.41% |
+| 24-29 | 7 | 0.16% |
+
+The threshold **tightens** (gets stricter) with depth, not looser --
+`realLoopCost` grows with full remaining depth while `nullProbeCost`
+only shrinks mildly via R's slow growth. The alpha-table corner found
+above (shipped depth 8-10, raw ~8-13 plies, landing in the R=4-5 rows,
+`p* ~= 1-2.6%`) showed predicted FH% of 0.001-0.15% there -- clearing
+the math-derived bar by one to two orders of magnitude. Good sign the
+corner is real, not just noise, even under a crude model.
+
+## Nested-null-move handling
+
+Raised explicitly: does a null-move search recursing into *another*
+null-move search deeper in its own subtree break any of this?
+
+- **Inside the probe's own subtree**: not a problem. `fAvoidNullmove`
+ is a single shared flag, not stacked per-ply -- it only blocks two
+ *consecutive* nulls, toggled tightly around the probe call and reset
+ immediately after. Nested null-moves several plies down inside the
+ probe are real and representative, exactly as they'd behave in
+ production. This does mean `nullProbeCost` is a high-variance
+ per-sample quantity (one lucky nested cutoff can collapse most of a
+ probe's subtree) -- argues for a large sample floor per bucket
+ (reuse the ~500-try convention `dual_table_run` already uses)
+ before trusting any bucket average, not a correctness fix.
+- **Inside the "skip branch" realLoopCost measurement**: this is where
+ it matters. If/when a skip-rule is compiled into `Search()`, nested
+ nodes inside that measured real-loop subtree will also apply the
+ same rule recursively -- calibration data collected this way depends
+ on whatever rule is *already* active at calibration time. Circular.
+ Resolution (same pattern already used for every other baked table
+ here): **Pass 1** calibrates with no skip-rule compiled in anywhere
+ (matches `TEST_NULL`'s existing "structural eligibility only, don't
+ consult the thing being evaluated" design). Ship from that. **Pass
+ 2**: re-measure/re-splice once live, since nested applications then
+ change descendant costs too (likely compounding favorably, since a
+ collapsing-eval signal is autocorrelated down a few plies -- a
+ hypothesis, not yet verified).
+
+## What's implemented now (2026-10-12)
+
+A single flat, compile-time sweep knob -- not yet the full
+depth-bucketed table:
+
+- `search.c:93`: `#define NULLMOVE_SKIP_FH_CEILING_THOUSANDTHS (0)`
+ -- `0` disables (default/shipped state).
+- `search.c`, inside `fTryBetaPruningHere`, opposite-direction check
+ from the existing static-NM block: when `iEval <= iAlpha`, look up
+ `_JointTablePredictedFHThousandths(uRawDepthB, 0, GapCpBucket(iAlpha
+ - iEval), 2, uRawImproveB)` (hang=0, late=2 -- the table's own domain
+ floor, since collection required `uLegalMoves > 1`, so there's no
+ literal "move 1" bucket; 2 is the nearest available proxy for "even
+ the single best-looking move"). `uRawDepthB`/`uRawImproveB` are
+ reused from the already-computed per-node locals (lines ~551, ~726),
+ not recomputed. If predicted FH% is below the ceiling, skip calling
+ `WeShouldTryNullmovePruning` for this node entirely, falling through
+ to the real move loop.
+- `chess.h`: new counter `u64NullMoveSkippedLowFH` (tree counters
+ struct only, matching `u64QuickNullMoveSuccess`'s precedent -- not
+ MP-merged, diagnostic-only).
+
+## Empirical sweep results
+
+### bench (6 fixed positions, fast but small)
+
+Baseline (ceiling=0): 2,714,949 nodes. Shallow dip at ceiling 10-30
+(~0.4-0.5% fewer nodes), rising above 100, blowing up by 3000-10000.
+
+### ecm_ringers (11 positions, `sd 10`) -- too small, non-monotonic
+
+| ceiling | total nodes | solved/11 |
+|---|---|---|
+| 0 | 4,293,355 | 3 |
+| 10 | 4,435,817 (+3.3%) | 3 |
+| 30 | 4,314,830 (+0.5%) | 3 |
+| 100 | 4,250,553 (-1.0%) | 3 |
+| 300 | 4,089,390 (-4.7%) | 3 |
+| 1000 | 4,700,197 (+9.5%) | **2** (lost ECM.783) |
+| 3000 | 3,904,085 (-9.1%) | 3 |
+
+Matches this project's own documented caveat: ringers is too small and
+chaotically order-sensitive to trust a single run's shape.
+
+### ecm_confident_quick + ecm_hard_quick (90+90 positions, `sd 10`)
+
+Ran as 8 parallel background processes (4 ceilings x 2 suites, 24-core
+box, ~idle at the time) using 4 separately-built binaries (ceiling is
+a compile-time `#define`, can't be swept at runtime without rebuilding).
+
+| ceiling | combined nodes (180 pos) | delta | combined solved (baseline 79) |
+|---|---|---|---|
+| 0 | 48,588,231 | -- | 79 |
+| 300 | 47,468,723 | -2.3% | 76 (-3) |
+| 1000 | 47,783,919 | -1.7% | 77 (-2) |
+| 3000 | 45,907,126 | **-5.5%** | **79 (net 0)** |
+
+Per-suite: `ecm_confident_quick` solved count degrades monotonically
+(62/60/61/59) as ceiling rises; `ecm_hard_quick` is flat/down through
+300-1000 (17/16/16) then jumps to **20** at 3000. The jump is what
+makes 300/1000 look worse than their neighbors on both sides --
+non-monotonic, not a stable curve.
+
+**Conclusion so far**: no clean single minimum for a flat ceiling --
+consistent with (not just predicted by) the depth-dependence argument
+above. Ceiling=3000 is the one genuinely encouraging point (real node
+savings, net-zero solve cost across 180 positions) but a flat knob
+can't properly track a threshold that should move with depth; finding
+a better flat number isn't the right next move.
+
+## Future work
+
+1. **Don't keep sweeping the flat knob.** Build the depth-bucketed
+ version instead: extend `TEST_NULL` to measure `nullProbeCost` and
+ `realLoopCost` per (depth, gap, improve) bucket (Pass 1, no live
+ skip-rule anywhere, per the nested-null discussion above), bake a
+ per-bucket `p*` the same way `dual_table_run`/`splice_dual.py`
+ already bakes the other tables, re-measure/re-splice once shipped
+ (Pass 2).
+2. If that proves too complex or doesn't pan out, fall back to a
+ coarser depth-only piecewise ceiling (a handful of depth bands,
+ each with its own flat threshold derived from this sweep's
+ per-depth behavior) rather than one global number.
+3. Before committing anything: full `ecm.ep_` run at the chosen
+ setting(s), `precommit_check.sh`, and the usual `head_reference`
+ comparison protocol.
+4. The beta-side (`iEval > iBeta`) mirror of this idea is still
+ blocked on a resolution-preserving rebake of `g_uBetaFHThousandths`
+ below its current 34.466% saturation floor -- not worth pursuing
+ until the alpha-side version proves out.
+
+Sweep artifacts (binaries, logs, stdout) are under
+`/tmp/typhoon/nullskip_sweep/`, scratch -- not kept.
diff --git a/src/docs/PROFILE.md b/src/docs/PROFILE.md
new file mode 100644
index 0000000..67106e0
--- /dev/null
+++ b/src/docs/PROFILE.md
@@ -0,0 +1,222 @@
+# Profiling ./typhoon
+
+Two environments, two different root situations. Short version: **on
+Linux, use `gmake GPROF=1` + gprof(1) -- fully root-free.** On FreeBSD,
+`pmcstat`'s hardware-counter sampling is also root-free *in practice* once
+`hwpmc.ko` is loaded, which is the one step that does need root (and only
+once per boot).
+
+## Linux (this box): gprof, no root required at all
+
+`perf` is blocked here -- `kernel.perf_event_paranoid` is `4` (stricter
+than the usual 0-3 range; Ubuntu-specific), so `perf record`/`perf stat`
+refuse to run for a non-root user with no CAP_PERFMON, and there's no
+non-root workaround short of asking someone to lower that sysctl or grant
+the capability. `valgrind`/`callgrind` and `uftrace` aren't installed and
+installing them needs `apt` (root). `gprof` (part of binutils) is already
+installed and needs no privilege of any kind -- it works by compiling the
+binary with `-pg`, which makes every function call `mcount()` to accumulate
+call-graph edge counts, plus a `SIGPROF`-interval sampler for self-time.
+Nothing about that touches hardware counters or kernel permissions.
+
+A `GPROF=1` gmake flag has been added to `GNUmakefile` for this (folds
+`-pg` into `$(PROFILE)`, so it lands in both the compile and link steps
+automatically):
+
+```sh
+gmake clean # -pg changes codegen for every TU; must rebuild from scratch
+gmake -j5 GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1 GPROF=1
+```
+
+Run the resulting `./typhoon` from whatever directory you want `gmon.out`
+written into (it's always named exactly that, in cwd, overwritten on each
+run) -- **use `--cpus 1`**, not the usual `MP=1` multi-threaded search, for
+this run specifically. See the threading caveat below for why.
+
+```sh
+mkdir -p /tmp/typhoon/profile && cd /tmp/typhoon/profile
+/home/scott/typhoon/src/typhoon --cpus 1 --hash 256m --batch --command \
+ "force; sd 12; script ../tests/ecm_confident_quick.ep_"
+```
+
+Then read it with gprof, pointing at the *same* binary that produced it
+(symbol addresses are binary-specific):
+
+```sh
+gprof -b /home/scott/typhoon/src/typhoon gmon.out > report.txt
+less report.txt
+```
+
+`report.txt` has two sections: a **flat profile** (self time, sorted
+descending -- "what's hot") first, then a **call graph** (who calls whom,
+cumulative time) after. The flat profile is usually the right place to
+start. Confirmed working end-to-end on this box: a `sd 14` search from the
+startpos produced a flat profile correctly topped by `Eval` (16.1% self
+time, 74M calls), then `Search`, `SelectBestWithHistory`,
+`_GenerateAllMovesBB`, `GenerateMoves`, etc. -- a sane-looking distribution.
+
+**Don't forget to rebuild the normal release binary afterward** (`gmake
+clean` + the standard `GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1` profile,
+no `GPROF=1`), same reasoning as `precommit_check.sh`: a `-pg`-instrumented
+`typhoon` sitting in the working directory as a side effect of profiling is
+easy to mistake for the real release build later.
+
+### Threading caveat: use `--cpus 1` for gprof runs
+
+glibc's gprof support predates pervasive multithreading and only reliably
+attributes `SIGPROF`-based self-time samples to the thread that set up
+profiling (typically the one running `main()`) -- worker threads spawned
+under `MP=1`'s multithreaded search can have their CPU time undercounted
+or misattributed in the flat profile. Call-graph edge counts (`mcount()`
+increments into global memory, not timer-based) aren't subject to this,
+but to keep the whole report trustworthy, profile with `--cpus 1` and
+treat multi-threaded gprof numbers as unreliable if you ever need to look
+at search scaling specifically (use the FreeBSD `pmcstat` path below for
+that, or `perf` if the paranoid sysctl ever gets relaxed on this box).
+
+### Other caveat: inlining
+
+`-pg` only instruments actual (non-inlined) function calls -- a function
+the optimizer inlined into its caller never gets its own `mcount()` call
+and shows up folded into the caller's self time, not as its own line. The
+flat profile above is still real, just coarser for small/hot helper
+functions than it would be with a sampling-based (hardware PMC) profiler.
+
+## FreeBSD (`wannabe.house`, this engine's native/original platform): pmcstat + gprof
+
+Verified against the live box 2026-10-05 (session had passwordless ssh to
+`[email protected]`, `~/typhoon` there, a separate enlistment from this
+one). **The commonly-assumed "needs root" turned out to be only partially
+true**: `hwpmc.ko` must be loaded (`kldload hwpmc`, root-only, but
+typically already loaded by someone else on a shared box -- check with
+`kldstat | grep hwpmc` before assuming you need to ask for it) but once
+it's loaded, **process-scoped sampling with `pmcstat -P` and the whole
+gprof-conversion pipeline below ran successfully as a plain, non-root user
+in this session** -- no `sudo` anywhere. The earlier assumption of
+needing root for every run likely came from either testing with `-S`
+(system-wide mode, which genuinely does require `PRIV_PMC_SYSTEM` /
+root) or from the `hwpmc.ko` load itself.
+
+This box is AuthenticAMD (`kern.hwpmc.cpuid: AuthenticAMD-23-71-0`), so
+Intel-style event names like `CPU_CLK_UNHALTED.THREAD` don't exist. List
+available raw events with `pmcstat -L`; the AMD equivalent of "unhalted
+cycles" is `ls_not_halted_cyc`. The portable alias `instructions` also
+works; the portable alias `cycles` does **not** work for sampling (`-P`/
+`-S`) because it's backed by the TSC, which can't deliver the
+overflow interrupt sampling needs -- use `ls_not_halted_cyc` (or another
+real hardware event) instead, not the `cycles` alias.
+
+### 0. Confirm hwpmc is loaded (one-time, needs root if not already)
+
+```sh
+kldstat | grep hwpmc # if this prints a line, skip straight to step 1
+kldload hwpmc # root-only, one-time (until next reboot)
+```
+
+### 1. Collect samples (process-scoped, not system-wide)
+
+Use capital `-P` (process-mode sampling, follows only the spawned child)
+-- NOT lowercase `-p` (counting mode only, incompatible with `-O`) and NOT
+`-S` (system-wide: captures every process on the box, requires root, and
+makes symbol resolution in step 2 slow/noisy).
+
+Append `,<count>` to the event name to set the sampling interval in
+cycles -- the default reload count samples far too often and produces
+multi-GB output files that take forever to convert. `4000000` (~every 4M
+cycles) is a reasonable starting point; increase for a coarser/faster
+profile, decrease for finer resolution on a short run.
+
+```sh
+mkdir -p /tmp/typhoon/profile
+pmcstat -P ls_not_halted_cyc,4000000 -O /tmp/typhoon/profile/pmc.out -- \
+ ./typhoon --cpus 1 --hash 256m --batch --command \
+ "force; sd 12; script ../tests/ecm_confident_quick.ep_"
+```
+
+No `sudo` needed for this step as long as step 0 is satisfied.
+
+### 2. Convert to a gmon.out-style file, per executable
+
+`pmcstat -R <file> -g` writes a `*.gmon` file per executable/library seen
+in the samples, auto-named, into a subdirectory named after the event
+(e.g. `ls_not_halted_cyc/`) under the current directory. (`-O` cannot be
+combined with `-R` -- the input file is the positional arg to `-R`
+itself.) A `kernel.gmon overflowed` warning is expected/harmless in
+process-mode -- ignore it, we only want the typhoon binary's own file.
+
+```sh
+pmcstat -R /tmp/typhoon/profile/pmc.out -g
+# -> creates ./ls_not_halted_cyc/typhoon.gmon (plus one per shared lib)
+```
+
+### 3. View the report with gprof
+
+FreeBSD's base gprof segfaults on the raw dump produced by `-G <file>`
+(a different, two-step raw-dump path) -- skip that entirely. It does
+**not** segfault on the proper gmon.out-format file produced by `-g`
+above:
+
+```sh
+gprof ./typhoon ls_not_halted_cyc/typhoon.gmon > /tmp/typhoon/profile/report.txt
+less /tmp/typhoon/profile/report.txt
+```
+
+Same two-section report as the Linux path: call graph, then flat profile
+(self time, sorted descending) -- the flat profile is usually the more
+useful starting point.
+
+### Gotchas recap
+
+- `-S` = system-wide sampling, requires root, resolves symbols for
+ unrelated processes too -- avoid. `-P` = process-scoped, no root needed
+ once `hwpmc.ko` is loaded -- what we want. `-p` (lowercase) = counting
+ mode, not sampling, incompatible with `-O`.
+- Always set an explicit `,<count>` sampling interval or the output file
+ balloons to multi-GB and conversion takes forever. Confirmed: an
+ ~11-second `sd 14` single-position search at the default interval
+ produced a 300MB+ sample file.
+- `-O` and `-R` are mutually exclusive; `-R` takes the input file
+ directly as its argument.
+- `-G` writes a raw dump gprof segfaults on; `-g` writes a proper
+ gmon.out-format file gprof handles fine. Use `-g`.
+- The workload needs to run long enough for `-P` sampling to actually
+ catch anything -- a single quick `sd 9` search (~1s) produced a fixed
+ ~3.8KB output file (just the exec/mapping record, zero PC samples)
+ regardless of the sampling interval used. A `script <suite>.ep_` run
+ over a whole suite (tens of seconds to minutes, e.g. `ecm_ringers.ep_`
+ at `sd 10`, ~25s) or a single deep `sd 14`+ search (confirmed: ~11s) is
+ long enough to get real samples.
+- **Not every event supports `-P` sampling** (some AMD PMC events are
+ counting-only). Branch-misprediction events (`ex_ret_brn_misp`,
+ `ex_ret_cond_misp`, etc.) work fine in counting mode (`pmcstat -p
+ ex_ret_brn_misp -- <cmd>`, prints a single total at exit) but produce
+ the same fixed ~3.8KB/zero-samples output as the "too-short workload"
+ case even against a long run -- a real per-event limitation, not a
+ workload-duration problem (confirmed by running the known-good
+ `ls_not_halted_cyc` event against an identical command/interval/
+ duration, which produced immediate real samples). Likely cause: not
+ every AMD PMC event supports overflow-interrupt (PMI) delivery, which
+ `-P`/`-S` sampling requires -- some are counting-only, and neither
+ `pmcstat -L` nor `pmccontrol -L` distinguish which is which. The only
+ way to find out is to try `-P` against a known-long-enough workload and
+ check the output file size. If a per-function mispredict breakdown is
+ ever actually needed, look into whether a PEBS-like interrupt-capable
+ event exists for this, before assuming it's simply not possible.
+
+## Which to reach for
+
+- **Linux, day-to-day**: `gmake GPROF=1` + gprof. No privilege
+ dependency, no sysadmin step, works today.
+- **FreeBSD (`wannabe.house`)**: `pmcstat -P` + gprof, after confirming
+ `hwpmc.ko` is loaded. Hardware-counter-based (actual cycles/
+ instructions, not call-count-overhead-inflated like `-pg`), so more
+ trustworthy for small/hot inlined-adjacent functions, and not subject
+ to the multithreading self-time caveat gprof's `SIGPROF` approach has
+ on Linux -- but still process-scoped, so still only reflects the one
+ profiled process, not the whole system.
+- **If Linux `perf` access ever gets relaxed** (someone lowers
+ `kernel.perf_event_paranoid` or grants `CAP_PERFMON`), it would be the
+ best of both: hardware counters *and* correct multithreaded attribution,
+ without `-pg`'s inlining blind spot. Worth revisiting if a future
+ profiling question specifically needs multi-threaded search behavior
+ under `MP=1`.
diff --git a/src/docs/TESTING_PLAN.md b/src/docs/TESTING_PLAN.md
new file mode 100644
index 0000000..7d2bfc2
--- /dev/null
+++ b/src/docs/TESTING_PLAN.md
@@ -0,0 +1,298 @@
+# Testing plan: closing coverage gaps in test*.c
+
+Written 2026-09-16 after an audit of every `src/test*.c` file and how
+(or whether) each is wired into `main.c`'s self-test sequence. Goal
+per Scott: not more test code for its own sake -- find the
+years-old bugs that asserts have been missing. Priorities: search,
+eval, generate (not exclusively). Perf testing is also explicitly
+in scope.
+
+## Dead/disconnected infrastructure (fix first -- near-zero cost)
+
+Several tests already exist but don't run. Recovering this coverage is
+strictly cheaper than writing anything new, so do this before anything
+else below.
+
+1. **SEE has zero active correctness testing.** `testsee.c`'s own
+ header comment admits it: `DebugSEE`/`DebugSEERecursive` (a
+ brute-force make/unmake exchange-walk oracle meant to check `SEE()`
+ against) is wrapped in `#ifdef TEST_BROKEN` -- a define nothing
+ sets. `testsee.c`'s only *called* functions (`TestGetAttacks`,
+ `TestIsAttackedBB`) test attack generation, not SEE. Given SEE is
+ what `GetMoveSee`/extensions/QSearch pruning all trust (and is the
+ exact code reworked in commit c0ac3d7), this is the single
+ highest-value fix here. Get `TEST_BROKEN` -> live (`TEST`), fix
+ whatever bitrot surfaces, and confirm it's called from `main.c`.
+2. **`TestEvalWithKnownPositions`** (`testeval.c`, ~20 hand-curated
+ "side X should be better here" positions from chess literature) is
+ defined but **never called from anywhere** -- not `main.c`, not
+ `command.c`. Wire it into the self-test sequence.
+3. **`TestRecognExhaustiveKNKP`** (`testrecogn.c`, exhaustive KNKP
+ endgame-recognizer enumeration) is defined and prototyped in
+ `chess.h` but referenced only in a comment -- never invoked. Only
+ the smaller table-driven `TestRecogn` runs. Wire it in.
+4. **The hash table has no correctness self-test at all.**
+ `testhash.c` contains `SanityCheckHashEntry` (an entry-shape
+ validator) and `AnalyzeFullHashTable` -- but the latter is a
+ post-search *diagnostic dump* called from `root.c`, not a test, and
+ it's never invoked from the `TEST` self-test sequence. See "New
+ test: hash correctness" below for what should replace this gap, not
+ just a wiring fix.
+5. **`TestEvalWithSymmetry`** only runs a *single* random symmetric
+ position per invocation, and only when a human types `eval` at the
+ prompt in an `EVAL_DUMP` build (`command.c:2203`) -- not part of the
+ automated suite, never loops. See "New test: eval symmetry fuzz"
+ below.
+
+## New tests, by area
+
+### Search (thinnest area relative to its bug surface)
+
+- `TestSearch` (`testsearch.c`) runs 20 random positions at fixed
+ **depth 2** only, checking solely "returned move is legal" and
+ "didn't blow the time limit."
+ - **DONE -- hash-consistency test** (`TestSearchHashConsistency`,
+ testsearch.c): searches a position to depth D twice in a row with
+ a warm hash table (scores must match), and separately confirms a
+ depth-D2 search seeded by a shallower depth-D1 warm-up agrees with
+ a from-scratch depth-D2 search. Wired into the `TEST=1` self-test
+ sequence (main.c).
+ - **DONE (partially) -- QSearch sanity test**
+ (`TestQSearchSanity`, testsearch.c): checks search termination
+ (no `INVALID_SCORE`/garbage) and a whole-search node-count
+ ceiling. Caveat found and documented in the function's own header
+ comment: the self-test suite builds with plain `gmake TEST=1`,
+ which doesn't define `PERF_COUNTERS` -- every `sCounters` field
+ (including `u64QNodeCount`, the qsearch-only counter this was
+ originally meant to check) is wrapped in `chess.h`'s `INC()` macro,
+ a no-op without that define. Confirmed live (`sd 3; go` on a
+ `TEST`-only binary reports "0 qnodes" regardless of real qsearch
+ activity). The test currently checks
+ `ctx->sCounters.tree.u64TotalNodeCount` instead (root.c's
+ unconditional per-node `++`, always live) -- a real but strictly
+ weaker signal, since it can't distinguish "qsearch exploded" from
+ "full-width search exploded." Building a `PERF_COUNTERS`-enabled
+ `TEST` profile (or adding an always-live qsearch-only counter) is
+ the natural follow-up to close this for real. The hand-auditable-
+ tree idea (`Trace()` every qsearch node on a small position) is
+ still worth doing but wasn't implemented this session.
+ - **NOT DONE -- determinism test** (same position/depth searched
+ twice, PV must match): explicitly descoped -- Scott's call is that
+ a stable-PV requirement makes the test break every time move
+ ordering changes for unrelated reasons, which is just noise, not a
+ real regression signal. A same-*score* (not same-PV) check is
+ already covered by the hash-consistency test above.
+ - **NOT DONE -- MP-vs-single-thread agreement test**: still open.
+ `split.c` has no dedicated correctness test.
+ - **DONE (as an EPD suite, not a C test) -- null-move/zugzwang
+ soundness**: `tests/zugzwang.epd` (5 positions, run via the new
+ `epd` command -- see below) checks actual best-move outcome
+ instead of an internal counter. An earlier attempt asserted
+ against `ctx->sCounters.extension.uZugzwang` (the verification-
+ search hit counter) using the 3 classic literature zugzwang
+ positions, but none of them structurally reach that code path on
+ this build: 2 have too little material for
+ `WeShouldTryNullmovePruning`'s own `> 2` non-pawn-count gate to
+ attempt null-move pruning at all, and the 3rd (despite landing in
+ the eligible window) gets resolved directly off this box's local
+ Syzygy tablebase before search ever touches null-move logic --
+ confirmed live via the `TABLEBASE MOVE` trace line. Removed in
+ favor of the EPD suite, which doesn't care about internal
+ mechanism, only the final answer. See `testsearch.c`'s comment
+ where `TestNullMoveZugzwang` used to be for the full writeup.
+
+**New infrastructure that came out of chasing the above (search.c
+touches none of this, but it's now how any future EPD-based search
+test should be added):**
+
+- **`epd <file>` engine command** (`EpdCommand`, script.c) parses and
+ runs a raw EPD suite file directly -- no perl preprocessing step
+ first. Backed by `ParseEpdRecord` (fen.c), real per-record EPD
+ parsing (4 position fields, optional FEN-style halfmove/fullmove
+ counters, then `;`-separated `bm`/`am`/`id` opcodes).
+- **Found and fixed while building this**: `tests/convert_epd.pl` (the
+ old perl-based EPD-to-script converter) had a real, silent-
+ corruption bug -- its `bm`/`am` regex used `[^b]+` as the filler
+ between the board and the opcode, which breaks on *every*
+ Black-to-move record (the side-to-move field is literally the
+ character `b`), and since `$fen`/`$id`/`$solution` were unscoped
+ globals rather than per-line locals, a failed match silently carried
+ the *previous* record's values forward instead of erroring.
+ Confirmed live: feeding it a 5-position batch with one Black-to-move
+ entry produced a byte-for-byte duplicate of the previous record for
+ that one line, no diagnostic at all. The script is left in place
+ (marked superseded in its own header) in case something still shells
+ out to it, but new suites should use `epd` instead.
+ Cross-checked the new parser against the old pipeline on
+ `ecm_ringers`: `script` (old `.ep_` format) and `epd` (new
+ `tests/ecm_ringers.epd`, produced by `ep_to_epd.pl`, see next item)
+ produced byte-identical node counts and solve stats.
+- **`tests/ep_to_epd.pl`**: one-off reverse converter, turns the
+ existing `setboard`/`solution`/`avoid`/`id`/`go` script-format
+ suites back into real EPD now that `epd` can read it directly. Run
+ against every genuine tactical suite in `tests/` (not `bench.ep_` or
+ `trial.ep_`, which use `sd`/`st`/`easy`/`quit` and aren't EPD
+ underneath) -- produced `.epd` siblings for `arasan`,
+ `ecm_confident_quick`, `ecm_confident_sample2`, `ecm_confident`,
+ `ecm_hard_quick`, `ecm_hard`, `ecm_late`, `ecm_quick`, `ecm_ringers`,
+ `ecm-gcp`, `ecm-hard`, `ecm`, `ecm016_only`, `LCT2`, `mes400`,
+ `newiq`, `quick`, `swiss`, `wac`. `ecm.epd` came out to 879 records,
+ matching the `606/879`/`622/879` solve-count baselines already
+ quoted elsewhere in CLAUDE.md.
+
+### Eval
+
+- Automate `TestEvalWithSymmetry` into a real fuzz loop (N random
+ symmetric positions, assert `Eval() == 0` for all, as part of the
+ standard self-test run) instead of the current single-shot
+ manual-only invocation.
+- **Adversarial/degenerate-position term-bounds test**: 0-pawn
+ positions, 9-queen positions, fully-open-board positions -- extend
+ `GenerateRandomLegalPosition` or hand-build cases, assert no eval
+ term produces an out-of-range score (reuse the existing
+ `EvalTrace`/`EVAL_DUMP` term-tracing machinery for this).
+- Audit for any **incrementally-maintained eval state** (pawn hash,
+ king-safety cache) and add a from-scratch-recompute-vs-incremental
+ consistency check if any exists -- classic "works until the hash
+ collides" bug class.
+- **Eval speed regression test**: no benchmark currently exists for
+ eval itself despite it being the named NPS bottleneck vs. Crafty
+ (see CLAUDE.md). Same interleaved-comparison shape as the existing
+ `TestGenerate*Speed` benchmarks, broken down by game phase.
+
+### Generate (best-covered area, but gaps remain)
+
+- Perft (`TestMoveGenerator`) only checks to **ply 4** even though
+ `uLeaves[]` already has reference values to ply 6/7 sitting unused --
+ extend to a `--perft-deep` slow path, and add perft-**divide**
+ (per-root-move leaf breakdown) to localize failures to a specific
+ move instead of just tripping a panic.
+- Add perft positions specifically chosen to stress **en passant,
+ underpromotion, and castling-through-check** individually (the
+ current 5 FENs give general coverage but weren't chosen for this).
+- `TestGenerateAllMovesSpeed` and the per-piece-type speed tests are
+ **pure microbenchmarks with no correctness assertion** -- they print
+ cycles and never diff the move *sets* produced by the mailbox vs.
+ bitboard generators being compared. Add a sorted-move-list-equality
+ assertion alongside each (both generators already run back-to-back
+ in the same loop, so this is nearly free) -- otherwise a bitboard
+ generator silently missing a move class just prints a suspiciously
+ fast number and passes.
+- `GetMoveSee`'s new non-pawn-capture SEE path (c0ac3d7) has no
+ dedicated test yet -- covered by the SEE oracle fix above once live.
+
+### Move/Position plumbing
+
+- Reasonably solid already (`TestMakeUnmakeMove`, `TestExposesCheck`,
+ `TestIsAttacked`). Add a **long-game fuzz test**: play N random games
+ of ~200 plies each (not just 1-ply-at-a-time exercises), asserting
+ `VerifyPositionConsistency` and `ComputeSig`-from-scratch agreement
+ with the incrementally-maintained signature at every ply. Confirm
+ `VerifyPositionConsistency` is actually compiled live in the
+ `TEST`/`DEBUG` build, not just present in source.
+
+### Hash (currently zero correctness coverage)
+
+New test needed, not just a wiring fix:
+- Store/probe roundtrip correctness.
+- Replacement-scheme behavior under forced collisions (fill table past
+ capacity with distinct signatures, confirm replacement policy
+ behaves as designed, not just "doesn't crash").
+- `ADJUST_MATE_IN_N` ply-distance adjustment on `HASH_FLAG_EXACT`
+ entries specifically -- the exact mechanism CLAUDE.md flags as
+ audited-by-hand but not test-covered after this session's `ASSERT`
+ removals in `QSearch()`.
+
+### Recognizer
+
+- Wire in `TestRecognExhaustiveKNKP` (see dead-infra list above).
+ Consider the same exhaustive-enumeration treatment for any other
+ small recognized endgame class in `recogn.c` (KPK, KRK) if one
+ exists without a test.
+
+## Perf testing
+
+Current coverage is narrow: five isolated per-piece-type
+mailbox-vs-bitboard microbenchmarks (`TestGenerate*Speed`), nothing for
+search, eval, or hash.
+
+- **Eval NPS/cycles-per-call benchmark**, same interleaved-comparison
+ shape as the existing generate benchmarks, by game phase -- eval is
+ the named bottleneck (CLAUDE.md's 1.5M vs 7.5M nps gap vs. Crafty)
+ and has no dedicated speed test today.
+- **Whole-search NPS benchmark**: `bench.c` already exists for this --
+ confirm whether it's part of the automated `TEST` self-test pass or
+ purely a manual `bench` command; if manual-only, wire a
+ fixed-node-budget version into the automated sequence so a
+ regression surfaces without someone remembering to run it by hand.
+- **Hash-probe-under-load benchmark**: cycles/probe at low occupancy
+ vs. near-full table -- replacement-scheme cost can regress silently
+ today with nothing to measure it.
+- **Stored baselines with pass/fail thresholds**, not print-only
+ output: today every perf test prints cycles/call and leaves
+ interpretation to a human reading the log. Store a last-known-good
+ baseline (same idea as `head_reference/` for solve-counts) and fail
+ loud if a call site regresses past some tolerance.
+
+## Suggested priority order
+
+1. Un-bitrot the three dead tests (SEE oracle,
+ `TestEvalWithKnownPositions`, `TestRecognExhaustiveKNKP`) --
+ near-zero cost, immediate coverage recovery.
+2. Hash-table correctness test (store/probe/replace/mate-adjust) --
+ currently the single biggest correctness blind spot.
+3. Search determinism + MP-vs-single-thread agreement test -- targets
+ the exact class of bug most likely to hide in threading/hash/
+ extension interactions that per-function unit tests can't see.
+4. Extend perft to deeper plies + divide output; add move-set-equality
+ assertions to the existing generate speed benchmarks (turns
+ dead-weight microbenchmarks into correctness tests for free).
+5. Eval: automate the symmetry fuzz test (loop N, not 1) and add
+ adversarial/degenerate-position term-bounds checks.
+6. Round out perf testing per the list above, with stored baselines
+ and pass/fail thresholds instead of print-only output.
+
+## "Fuzz" testing
+
+**Implemented: `fuzz_test.sh [binary] [depth] [batch_size] [cpus]`**
+(defaults: `./_typhoon`, `sd 6`, 200 positions/batch, `--cpus 2`). Loops
+forever: each iteration draws a fresh random batch of `setboard`/`go`
+records from the full 400,000-line / 200k-position
+`tests/cross_sectional_200k.ep_` pool, runs them through a `DEBUG=1`
+build under gdb, and scans the gdb output for a crash/assert signature
+before starting the next batch. Stops (exit 1) and prints the offending
+batch + engine log paths as soon as one is found; otherwise runs
+indefinitely (`^C` to stop). Logs land under `/usr/local/tmp/typhoon/fuzz/`
+(one sampled `.ep_` batch + one `.gdb.txt` + one engine `--logfile` per
+iteration).
+
+**Two build/invocation bugs found and fixed getting this working, neither
+a sandbox limitation despite first appearances:**
+
+1. **The DEBUG binary must be built with `MP=1` as well as `DEBUG=1`**
+ (`gmake clean; gmake DEBUG=1 MP=1`) -- `--cpus` is compiled in only
+ under `#ifdef MP` (`main.c:311`). A `DEBUG=1`-only build silently
+ treats `--cpus` and its numeric argument as two separate unrecognized
+ arguments (`main.c:400`'s "Error (unknown argument)" path) and falls
+ back to the 1-cpu default with no hard failure -- easy to miss, and it
+ also means `split.c`'s parallel-search code never actually links in,
+ defeating the point of passing `--cpus 2` at all.
+2. **`gdb -batch -ex "handle SIGTRAP nostop noprint pass" -ex run ...`
+ breaks thread control against this MP/pthread build specifically**,
+ producing `ptrace (PT_SUSPEND): Operation not permitted` followed by
+ `Child process unexpectedly missing: No child processes` -- looked
+ exactly like a sandboxed environment blocking ptrace outright (and was
+ initially misdiagnosed as that), but reproduced identically outside
+ any harness/sandbox, and disappeared entirely once that one `-ex` line
+ was dropped. `fuzz_test.sh` omits it; a plain `run`/`bt full`/
+ `info registers` gdb batch invocation debugs the MP binary fine,
+ including catching a genuine `int3` breakpoint from a failed `ASSERT`.
+
+Not yet done: no crash has been found by a run so far, so the
+crash-detection path (the `grep -qE` in `fuzz_test.sh`) is exercised by
+its own logic reading correctly, not yet by a real hit. Worth periodically
+running this for stretches (it's unbounded/`^C`-driven, unlike the other
+gates) rather than treating it as a one-time pass/fail check like
+`debug_smoke_test.sh`. See also: `./debug_smoke_test.sh` (fast, curated
+sample, single pass/fail) for the pre-commit-sized version of the same
+idea.
diff --git a/src/docs/UNIFY_SEARCH.md b/src/docs/UNIFY_SEARCH.md
new file mode 100644
index 0000000..f38970b
--- /dev/null
+++ b/src/docs/UNIFY_SEARCH.md
@@ -0,0 +1,406 @@
+# Unifying the per-move search kernel in `search.c` and `split.c`
+
+Status: **plan only, not started.** Do as the only change in a commit;
+test with `precommit_check.sh` plus the `head_reference` node-count gate
+below at each step. Line numbers below are current as of this revision
+(EFP's explicit per-move pruning block has been removed from `Search()`;
+only stale names remain — see "EFP is gone" below).
+
+## Motivation
+
+`Search()`'s main move loop (`search.c:909..1780`) and `HelpSearch()`
+(`split.c:1058..1283`) independently implement what is supposed to be the
+same per-move logic: make the move, compute its extension, decide whether
+to prune or reduce it, search it with a PVS window, re-search if a reduced
+move fails high, unmake it. They have drifted apart because every
+improvement to this logic has been written once, in `search.c`, and not
+reliably ported to `split.c`. Concretely, today:
+
+- `Search()` has LMP (late move pruning, via `ShouldLatePrune`/the joint
+ FH% table). `HelpSearch()` does not.
+- `Search()` gates reduction/pruning eligibility through the single
+ `CanWeReduceOrPruneMove()` (searchsup.c:783), which the routine's own
+ comment documents as 9 base checks plus 2 move-identity exemptions
+ (killer-adjacency, countermove match). `HelpSearch()` (split.c:1187-1193)
+ has a hand-rolled 7-condition gate that is missing: the SEE-negative-
+ capture carve-out, the mate-magnitude-alpha check, the `fThreat` check,
+ and both exemptions. **This is a bigger behavioral gap than "HelpSearch
+ lacks LMP"** — `HelpSearch()` is currently reducing/pruning-eligible on
+ moves `Search()` would exempt outright.
+- `Search()` passes real `bbMinorAttacksSnapshot`/`bbRookAttacksSnapshot`
+ attack-bitboard snapshots into `EstimateHangingMaterialCp`. `HelpSearch()`
+ passes zeroed snapshots (`split.c:1215-1216`), degrading its hang
+ estimate (and, by extension, `ShouldLatePrune`'s joint-table lookup once
+ step 4 below gives it one).
+- `split.c:1245` and `split.c:1268` write `ctx->sPlyInfo[ctx->uPly]
+ .iExtensionAmount` — but both writes happen **after** `MakeMove`, i.e.
+ after `ctx->uPly` has already advanced (confirmed: `search.c:465` binds
+ `pi = &ctx->sPlyInfo[ctx->uPly]` *before* the move loop's `MakeMove`
+ calls; `move.c:928` is where `MakeMove` increments `ctx->uPly`).
+ `split.c:1156`'s own `ComputeMoveExtension` call already compensates
+ with `uBegin[ctx->uPly - 1]` one line away, so the "-1 after MakeMove"
+ pattern is known and applied inconsistently, not unknown. **This is two
+ separate bugs, not one:**
+ 1. Wrong slot: writes the child's `iExtensionAmount`, not the mover's —
+ invisible to the null-move history-reduced-bailout check one ply
+ down (searchsup.c).
+ 2. Stale-on-skip: `search.c:1386`'s own comment documents that it used
+ to only write `pi->iExtensionAmount` when `iExtend < 0`, and that was
+ itself a bug (a later sibling move could read a stale reduction left
+ by an earlier one) — fixed in `Search()` by writing it
+ unconditionally every iteration. `split.c:1245`/`1268` still only
+ write inside `if (iLMR < 0)` / the re-search branch, i.e. still have
+ the stale-on-skip bug `Search()` already fixed once.
+ Both must be fixed together: unconditional write, to
+ `ctx->sPlyInfo[ctx->uPly - 1].iExtensionAmount`.
+
+## EFP is gone — update, don't carry forward
+
+A prior session removed EFP's explicit per-move futility-pruning block
+from `Search()`'s move loop entirely. Confirmed by search: `ShouldFutilityPrune`/
+`FutilityPrun*`/`StaticNullMove`/`ShouldStaticNull` have zero hits anywhere
+in `search.c`/`searchsup.c`/`split.c`. Only inert names remain:
+`EFP_FH_MIN_SAMPLES` (search.c:84, unused #define), `_PostReduceDepthBucketEfpAligned`
+(a depth-bucketing helper, name is now just vestigial), and
+`fAnyMoveEFPPruned` (search.c:1851, read in an `else` branch comment on
+the store-upper-bound path — confirm whether this flag can still ever be
+set anywhere before relying on it in the extraction; if nothing sets it
+any more it's dead and should be dropped in step 0 below, not carried into
+the shared kernel).
+
+This also **resolves the doc's previous closing question** ("unify EFP and
+LMP before this project") — there's no EFP left to unify; `ShouldLatePrune`'s
+joint FH% table is the sole pruning mechanism now. Drop that requirement.
+
+It also means the previously-planned "related gap" about `pi->uPVDistance`
+never written in `HelpSearch()` is **moot**: `uPVDistance` has zero hits
+anywhere in the codebase now (it was EFP-era instrumentation, removed
+along with EFP). Nothing to port.
+
+## What should NOT be extracted
+
+The two loops differ along three axes that are not just stylistic — they
+reflect genuinely different control/data flow, and trying to force them
+into one shape will make the result worse, not better.
+
+1. **Move acquisition.** `Search()` pulls from a staged iterator (hash
+ move, killers, generated moves, ..., tracked via index `x`).
+ `HelpSearch()` pulls from a shared move pool via `_GetNextParallelMove`,
+ which also hands back the *current* shared `iAlpha`/`iBestScore`
+ (another helper thread may have raised them since this thread's last
+ iteration). This is loop-header logic. **Stays in each caller.**
+2. **Result publication.** `Search()` keeps `iAlpha`/`mvBest` as plain
+ thread-local variables, calls `UpdatePV()`, and uses `goto end`/`goto
+ no_more_moves`. `HelpSearch()` publishes through the locked
+ `_UpdateSplitInfo(ctx, mv, iScore, u)` and checks `g_SplitInfo[u]
+ .fTerminate` after every move. Different synchronization models.
+ **Stays in each caller.**
+3. **The split-launch decision itself** (`search.c:1082-1173`,
+ `StartParallelSearch` and the `g_fCanSplit`/`g_uNumHelpersAvailable`
+ gating). Must stay outside the extracted kernel, or a helper thread
+ executing the kernel could attempt to launch a nested split, which the
+ current design does not expect. **Stays in `search.c` only.**
+
+## What SHOULD be extracted
+
+The inner per-move kernel, called once per move after it's been pulled off
+whichever move source the caller uses — current location
+`search.c:1175-1681` (SEE computation through `UnmakeMove` and the
+`iCumulativeExtend` restore), immediately before the publication tail that
+starts at `search.c:1684`:
+
+```
+SEE (pre-move, on pos)
+ -> MakeMove
+ -> ComputeMoveExtension
+ -> CanWeReduceOrPruneMove gate
+ -> ShouldLatePrune (LMP)
+ -> GetLMRReduction (LMR)
+ -> PVS search (null-window, then full-window re-search if it raises alpha)
+ -> re-search to full depth if a reduced move fails high
+ -> UnmakeMove
+```
+
+## The instrumentation problem (the design question the old draft missed)
+
+The span to extract is NOT a clean make/decide/search/unmake block with no
+side channels. The publication tail at `search.c:1684-1777` reads several
+locals the kernel's interior sets, and `JOINT_EXTRA_VERIFY`'s shadow-search
+block sits *between* the PVS re-search and `PRUNE_VERIFY`'s fail-high
+check, inside the make/unmake span — the instrumentation can't be
+boundary-excluded without duplicating the real move-handling logic around
+it. Locals that cross the proposed kernel/caller boundary in both
+directions:
+
+- Caller → kernel (already loop-invariant, computed once outside the
+ per-move loop; pass as params, don't recompute): `uRawDepthB`
+ (`search.c:993`), `uRawImproveB`, `fNodeSplitEligible`.
+- Kernel → caller (read by the publication tail at 1684+, must survive the
+ call): `fIsLeftoverMove`, `fMoveIsPruneReduceEligible`,
+ `uThisMoveHangBucket`/`GapBucket`/`NumBucket`/`DepthBucket` (all
+ `PERF_COUNTERS`/`FH_TABLE_COLLECTION`-gated), `fThisMoveIsCountermoveMatch`/
+ `uCMEvidenceBucket` (`MOVE_ORDERING_EVIDENCE_COLLECTION`-gated),
+ `fThisMoveJointExtraEligible`/`uThisMoveJointExtraTier`
+ (`PERF_COUNTERS`-gated), `fVerifyFlagged`/`uVerifyBand`/`uVerifyDepthB`/
+ `uVerifyAttB`/`uVerifyRateB` (`PRUNE_VERIFY`-gated), and the `TEST_NULL`
+ node-cost pair.
+
+**Decision needed before writing code, not discovered mid-extraction**:
+bundle all of this into one `OUT` struct (e.g. `MOVE_KERNEL_DIAGNOSTICS`,
+`#ifdef`-sectioned to match each field's existing gate) that the kernel
+fills in and the caller's publication tail reads — rather than a long
+flat parameter list. The user's note below ("put what you can in
+`PLY_INFO`") is the other half of this: some of these (the bucket/tier
+values) are genuinely per-move-at-this-ply state and belong as fields on
+`ctx->sPlyInfo[ctx->uPly - 1]` (indexed the same way `iExtensionAmount`
+already is) rather than threaded through call/return at all — this
+removes most of the diagnostics-struct bulk and gives `HelpSearch()`'s
+own ply slot the same fields for free. Resolve this split (struct vs.
+`PLY_INFO` fields vs. a mix) as part of scoping the extraction, before
+step 3.
+
+## Two sequencing traps that will break the "bit-identical node counts" gate
+
+- `pf->iCumulativeExtend += iExtend` (search.c:1387) happens *after* the
+ LMP-prune `continue` (search.c:1313-1315) — the prune path `UnmakeMove`s
+ but never increments/decrements `iCumulativeExtend` for that move at
+ all. A `MOVE_KERNEL_PRUNED` return must preserve that asymmetry exactly;
+ don't "clean up" by making prune and search paths symmetric around
+ `iCumulativeExtend`.
+- `WE_SHOULD_STOP_SEARCHING` (search.c:1684) is checked *after*
+ `UnmakeMove` and *after* the `iCumulativeExtend -=` restore. A
+ `MOVE_KERNEL_ABORTED` return must not skip either of those — the kernel
+ itself must still fully unwind (unmake + restore) before reporting
+ abort, not bail out of the middle of its own cleanup.
+
+## Proposed signature
+
+```c
+typedef enum _MOVE_KERNEL_RESULT
+{
+ MOVE_KERNEL_SEARCHED, // move was made, searched, and unmade; iScore valid
+ MOVE_KERNEL_PRUNED, // move was late-move-pruned; not made; iScore invalid
+ MOVE_KERNEL_ABORTED // search was stopped mid-move; iScore is a partial result
+} MOVE_KERNEL_RESULT;
+
+FORCEINLINE MOVE_KERNEL_RESULT
+SearchOneMove(SEARCHER_THREAD_CONTEXT *ctx,
+ MOVE mv,
+ ULONG uDepth,
+ SCORE iAlpha,
+ SCORE iBeta,
+ SCORE iEval,
+ ULONG uLegalMoves,
+ FLAG fIsLeftoverMove,
+ FLAG fFullWindow,
+ FLAG fThreat,
+ ULONG uRawDepthB,
+ ULONG uRawImproveB,
+ BITBOARD bbMinorAttacksSnapshot[2],
+ BITBOARD bbRookAttacksSnapshot[2],
+ OUT SCORE *piScore,
+ OUT INT *piExtend);
+ // Diagnostics (hang/gap/num/depth buckets, countermove-evidence
+ // bucket, joint-extra tier, prune-verify band) are NOT parameters --
+ // see "the instrumentation problem" above. They land in
+ // ctx->sPlyInfo[ctx->uPly - 1] (written by the kernel before it
+ // unmakes) wherever that's a clean fit, or an OUT diagnostics struct
+ // otherwise. Finalize the split while scoping step 3, not here.
+```
+
+Notes on why each non-obvious parameter is there:
+
+- **`fFullWindow` is explicit, not inferred.** `Search()`'s current code
+ infers "is this the first move at this node" from the sentinel
+ `iBestScore == -INFINITY_SCORE` (search.c:1396). That sentinel is only
+ true for `Search()`'s actual first move. `HelpSearch()` must never take
+ that branch — a helper thread's first move off the shared pool is *not*
+ the node's first move — and today this is only safe because
+ `_GetNextParallelMove` happens to never hand back `-INFINITY_SCORE`.
+ Once this logic is shared, that implicit contract becomes load-bearing
+ in a way that won't assert if violated. Making it an explicit
+ caller-supplied flag removes the trap.
+- **`uLegalMoves` is a parameter, not computed inside.** It feeds
+ `MoveNumBucket`/`ShouldLatePrune`'s floor and `CanWeReduceOrPruneMove`'s
+ own `uLegalMoves > 1` check. `search.c` has it as a running counter;
+ `split.c` must pass `g_SplitInfo[u].uAlreadyDone + uMoveNum + 1`
+ (matching what it already computes locally as `uSplitLegalMoves` at
+ split.c:1217 — confirm this matches the `> 5` floor `split.c:1189`
+ hardcodes today, which should become a call into the shared
+ `CanWeReduceOrPruneMove` instead of staying hand-rolled). Getting this
+ wrong silently changes LMP/LMR aggressiveness with no assert to catch
+ it.
+- **`fThreat` must be plumbed through `g_SplitInfo`.** `CanWeReduceOrPruneMove`
+ takes `fThreat` as a real gating input (searchsup.c:783) but
+ `chess.h:1612-1662`'s `SPLIT_INFO` struct has no such field today — add
+ one, populated by the originating thread at split-launch time (same
+ pattern as `iEval`/`iImprovement`), or `HelpSearch()` can't call the
+ real gate at all.
+- **Attack snapshots are real parameters, not internally zeroed.** This is
+ the fix for the degraded-hang-estimate gap above: `HelpSearch()` must
+ start passing real snapshots instead of the zeroed
+ `bbNoMinorAttacksSnapshot`/`bbNoRookAttacksSnapshot` it uses today
+ (split.c:1215-1216). Requires extending `g_SplitInfo[]` to carry
+ `bbMinorAttacksSnapshot`/`bbRookAttacksSnapshot` from the originating
+ thread (already computed in `Search()` before `StartParallelSearch`).
+ **Open risk, worth raising with the user explicitly (not just noting):**
+ once the kernel is shared, `FH_TABLE_COLLECTION`'s sampling
+ (search.c:1323-1365) fires from helper threads too. If step 4 (unify)
+ lands before step 5 (real snapshots), those samples get logged with
+ zeroed snapshots — training the joint FH% table on degraded inputs, not
+ just giving `HelpSearch()` a degraded *decision*. **Recommendation: land
+ step 5 together with step 4, not as a follow-up**, or gate
+ `FH_TABLE_COLLECTION`'s increments to the originating thread only
+ (`ctx->uPly` vs. some "is this a split helper" check) until step 5 lands.
+ Ask the user which they'd rather do before step 4.
+- **Return value is an enum, not control flow via `goto`.** The kernel
+ can't `goto end`/`goto no_more_moves`/`continue` — those targets don't
+ exist in the extracted function. Each caller's own loop dispatches on
+ the enum: `Search()` on `PRUNED` does `continue` (and must NOT touch
+ `iCumulativeExtend`, see the sequencing trap above); on `ABORTED` it
+ does its `goto end` with `iBestScore = iScore`; `HelpSearch()` on
+ `ABORTED` does `break`. The abort *condition* itself also differs
+ (`WE_SHOULD_STOP_SEARCHING` vs. `g_SplitInfo[u].fTerminate`) — stays a
+ caller-side check made around the kernel call, not a predicate pushed
+ into it.
+
+## Suggested sequencing
+
+0. **Housekeeping**: confirm whether `fAnyMoveEFPPruned` (search.c:1851)
+ can still be set anywhere post-EFP-removal; if dead, delete it rather
+ than carry it into the shared kernel's scope. Small, standalone.
+1. **Standalone fix**: `iExtensionAmount`'s two bugs in `split.c` (wrong
+ slot + stale-on-skip, see Motivation above) — write
+ `ctx->sPlyInfo[ctx->uPly - 1].iExtensionAmount = iExtend` unconditionally,
+ every iteration, matching `search.c:1386`'s already-fixed convention.
+ Commit on its own, verified via `precommit_check.sh`.
+2. **Extraction**: pull the kernel out of `search.c` into a new shared
+ header (`search_inline.h`, `FORCEINLINE` across translation units
+ requires a header), make `Search()` call it, with `HelpSearch()`
+ untouched (still running its own inline copy, including its own
+ gap-riddled gate). This isolates "did the extraction change
+ `Search()`'s behavior" from "did unifying the two callers change
+ `HelpSearch()`'s behavior." Resolve the instrumentation-placement
+ question (PLY_INFO fields vs. diagnostics struct) as part of this
+ step, before writing the header.
+3. **`g_SplitInfo` plumbing**: add `fThreat` and the two attack-snapshot
+ bitboards to `SPLIT_INFO` (chess.h), populated at split-launch time.
+ Do this *before* step 4, not after — see the `FH_TABLE_COLLECTION`
+ risk above.
+4. **Unification**: make `HelpSearch()` call the same kernel via the real
+ `CanWeReduceOrPruneMove()` gate (replacing its hand-rolled 7-condition
+ version) and `ShouldLatePrune`, removing its inline copy entirely. This
+ is the commit that actually changes `HelpSearch()`'s behavior — it
+ gains LMP, the full 9-check+2-exemption gate, and real attack
+ snapshots, all at once, since step 3 already landed the plumbing they
+ need.
+
+## Verification plan
+
+Standard gates at every step (`precommit_check.sh` — `TEST=1` self-test
+suite + `debug_smoke_test.sh`), plus:
+
+1. **Isolate the single-threaded path first (after step 2).** Force
+ `g_uNumHelpersAvailable = 0` (no split ever fires) and confirm **node
+ counts are bit-identical to HEAD** on the three curated suites
+ (`ecm_ringers`, `ecm_confident_quick`, `ecm_hard_quick`) at a fixed `sd`
+ depth, per the `head_reference` protocol in CLAUDE.md. This should be a
+ pure refactor at this stage — "no change whatsoever," not "no
+ significant change." Any node-count delta here is a real extraction
+ bug, not noise. Watch specifically for the two sequencing traps above
+ (`iCumulativeExtend` on the prune path, `WE_SHOULD_STOP_SEARCHING`
+ ordering) — those are exactly the kind of thing that produces a subtle
+ node-count drift instead of a crash.
+2. **After step 4, re-enable helpers and compare separately**, expecting a
+ real difference this time (`HelpSearch()` now has LMP, the full gate,
+ and real snapshots it didn't have before). Compare solve counts and
+ fail-high rates across the three curated suites per CLAUDE.md's
+ existing methodology (measure the pick, not just the game). Rebuild
+ `head_reference/` as the new baseline once this change is committed.
+3. Run the full `precommit_check.sh` gate before every commit in the
+ sequence above, not just at the end — this is a search/move-generation
+ change throughout.
+
+## Performance: `Search()` is the current profiler hotspot
+
+Confirmed with the user: `Search()` is the single hottest function in the
+program right now. That changes the risk profile of this refactor from
+"pure correctness/maintainability" to "must not cost NPS" — a few concrete
+implications for how to do each step:
+
+- **`FORCEINLINE` is load-bearing, not decorative, here.** The whole
+ point of extracting into a header function instead of a normal
+ `.c`-file one is that the compiler can fully inline `SearchOneMove` at
+ both call sites and optimize it in the context of each caller's
+ surrounding code (register allocation across the call, dead-branch
+ elimination of whichever `#ifdef` blocks aren't compiled in) exactly as
+ if the code were still written inline. If it isn't actually inlined in
+ the shipping `GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1` build, this
+ refactor becomes a real regression (a non-inlined call in the hottest
+ function in the program), not a neutral one. Don't just assert
+ `FORCEINLINE` and move on — check post-step-2 that `clang` actually
+ inlines it (`-Rpass=inline`/`-Rpass-missed=inline`, or inspect
+ `nm`/disassembly for a surviving `SearchOneMove` symbol) before trusting
+ the bit-identical-node-count gate alone; a node-count match doesn't
+ prove NPS didn't drop.
+- **Don't let the diagnostics-boundary decision regress the hot path.**
+ The "instrumentation problem" section above already flags that several
+ locals need to cross the kernel/caller boundary. For the fields that
+ move into `ctx->sPlyInfo[ctx->uPly - 1]`: writing them is already in
+ the hot path today (`iExtensionAmount` already lives there), so this is
+ likely free. For anything kept as an `OUT` diagnostics struct instead:
+ every field in that struct gets written on every move in the
+ `PERF_COUNTERS`/`FH_TABLE_COLLECTION`-eligible path even when the
+ calling code only reads a couple of them, and the struct itself is a
+ stack object passed by pointer — prefer the `PLY_INFO`-field approach
+ wherever a field is genuinely per-ply state (most of them), and reserve
+ an `OUT` struct only for values with no natural per-ply home. This is
+ exactly the tradeoff the "instrumentation problem" section says to
+ resolve before writing code — performance is now part of *why* that
+ decision matters, not just design cleanliness.
+- **Don't widen the non-`PERF_COUNTERS` build's hot path.** The
+ `SearchOneMove` signature above is sized for the worst case (every
+ diagnostic input). In a release build with none of those macros
+ defined, make sure the corresponding parameters either compile away
+ entirely (same `#ifdef`-sectioning convention the rest of this file
+ already uses) or are proven by the compiler to be dead weight — don't
+ let `uRawDepthB`/`uRawImproveB`/the snapshots turn into always-live
+ arguments on every call when the only consumers are instrumentation
+ that's compiled out. Verify by comparing generated code size/a quick
+ NPS run of a `PERF_COUNTERS`-undefined build before and after step 2,
+ not just the instrumented build used for the suite gates.
+- **Opportunistic, in-scope wins to look for while the code is open**
+ (don't go hunting outside this refactor's diff, but don't ignore an
+ obvious one either, since the whole per-move span is being rewritten
+ once regardless): any snapshot/bucket value currently recomputed
+ per-move that's actually loop-invariant per node (the doc's
+ `uRawDepthB`/`uRawImproveB` hoist is already one example carried over
+ from the current code — check for others while rewriting this block,
+ e.g. whether `GetMoveFailHighPctThousandths(mv, &uFHAttempts)` or the
+ joint-table lookup have any sub-computation that's the same across
+ moves at a node and is currently being redone); and whether the
+ diagnostics-struct-vs-PLY_INFO choice above can also drop a redundant
+ write (e.g. if a value is written to both a local and `pi->` today,
+ keeping only the `pi->` copy and reading it back removes a stack
+ write). Treat these as reviewed, measured changes (node-count +
+ NPS-on-`head_reference`-suite comparison), not assumed-safe
+ micro-optimizations — a performance claim needs the same before/after
+ evidence a correctness claim does here.
+- **Verification gate, added to the existing plan**: alongside the
+ bit-identical node-count check in step 2's isolation gate, also record
+ wall-clock NPS for the same `sd`-depth suite runs, before and after
+ each step, on this box (which the project's own CLAUDE.md already
+ warns is noisy under shared load — do a back-to-back A/B, not a single
+ run, per the Environment notes). A refactor that's node-count-identical
+ but measurably slower per node is a regression this plan must not ship
+ silently.
+
+## Open question for the user
+
+Whether to gate `FH_TABLE_COLLECTION`'s sampling to the originating
+thread only until step 3's snapshot plumbing lands, or accept a short
+window (within the same commit sequence, steps 3+4 landing together) where
+no helper-thread sample is ever collected with zeroed snapshots because
+the plumbing and the unification land atomically. Recommendation above is
+the latter (land 3 and 4 together) — flagging here so it's a decision, not
+an assumption.
diff --git a/src/dynamic.c b/src/dynamic.c
index a56cf29..07fc02e 100644
--- a/src/dynamic.c
+++ b/src/dynamic.c
@@ -86,7 +86,7 @@ volatile static ULONG g_uDynamicLock;
void
-InitLMRTable(void)
+InitializeLMRTable(void)
/**
Routine description:
diff --git a/src/eval.c b/src/eval.c
index a909953..f0036bd 100644
--- a/src/eval.c
+++ b/src/eval.c
@@ -548,7 +548,7 @@ static SCORE ROOK_ON_HALF_OPEN_WITH_FRIEND = 10;
// instead of rebuilding a local array every call. Not `static const`
// -- the three source constants are DNA-tunable globals (plain mutable
// SCORE, not compile-time constants), so this has to be a plain
-// mutable array, rebuilt by InitEval() whenever they change (startup,
+// mutable array, rebuilt by InitializeEval() whenever they change (startup,
// and after every `evaldna read`), not frozen at compile time.
static SCORE ROOK_FULL_HALF_OPEN_BONUS[2][2] = { { 16, 9 }, { 10, 0 } };
@@ -6584,7 +6584,7 @@ Return value:
void
-InitEval(void)
+InitializeEval(void)
{
ROOK_FULL_HALF_OPEN_BONUS[0][0] = ROOK_ON_FULL_OPEN;
ROOK_FULL_HALF_OPEN_BONUS[0][1] = ROOK_ON_HALF_OPEN_WITH_ENEMY;
diff --git a/src/main.c b/src/main.c
index 3c6352a..ad954ad 100644
--- a/src/main.c
+++ b/src/main.c
@@ -49,7 +49,7 @@ Return value:
char *p;
Trace("Typhoon %s (built on %s %s, commit %s):\n", VERSION, __DATE__, __TIME__, GIT_COMMIT);
- Trace(" Copyright (C) 2000-2007, Scott Gasch ([email protected])\n");
+ Trace(" Copyright (C) 2000-2026, Scott Gasch ([email protected])\n");
Trace(" " REVISION);
Trace(" " COMPILER_STRING);
Trace(" Make profile used: %s\n", PROFILE);
@@ -125,7 +125,7 @@ Return value:
FLAG
-BeginLogging(void)
+InitializeLogging(void)
/**
Routine description:
@@ -150,19 +150,24 @@ Return value:
return(FALSE);
}
- // If the logfile we want to write already exists, back it up.
- if (TRUE == SystemDoesFileExist(g_Options.szLogfile)) {
- VERIFY(BackupFile(g_Options.szLogfile));
- }
- ASSERT(FALSE == SystemDoesFileExist(g_Options.szLogfile));
-
- g_pfLogfile = fopen(g_Options.szLogfile, "wb+");
+ // If the logfile we want to write already exists, append to it.
+ FLAG fReusingLogfile = SystemDoesFileExist(g_Options.szLogfile);
+ g_pfLogfile = fopen(g_Options.szLogfile, "ab+");
if (NULL == g_pfLogfile) {
Trace("WARNING: Can't open logfile %s for write (%s); "
"continuing without logging.\n",
g_Options.szLogfile, strerror(errno));
return(FALSE);
}
+ if (TRUE == fReusingLogfile) {
+ time_t tNow = time(NULL);
+ CHAR szTimestamp[64];
+ strftime(szTimestamp, sizeof(szTimestamp), "%Y-%m-%d %H:%M:%S",
+ localtime(&tNow));
+ fprintf(g_pfLogfile,
+ "----- New engine run re-using this logfile (%s) -----\n",
+ szTimestamp);
+ }
return(TRUE);
}
@@ -491,21 +496,12 @@ Return value:
**/
{
- {
- // 2026-09-16 diagnostic: allow forcing a fixed srand seed via
- // TYPHOON_SEED for reproducing a specific run bit-for-bit
- // (there's no --seed command-line flag) -- e.g. re-running the
- // same debug_smoke_test.sh repro file to check whether a
- // failure is deterministic or a genuine SMP timing race.
- // TEMPORARY: revert once the split.c investigation is done,
- // this isn't meant to be a permanent CLI feature.
- const char *pszForcedSeed = getenv("TYPHOON_SEED");
- unsigned int uSeed = pszForcedSeed ?
- (unsigned int)strtoul(pszForcedSeed, NULL, 10) :
- (unsigned int)time(0);
- Trace("srand seed: %u\n", uSeed);
- srand(uSeed);
- }
+ const char *pszForcedSeed = getenv("TYPHOON_SEED");
+ unsigned int uSeed = pszForcedSeed ?
+ (UINT)strtoul(pszForcedSeed, NULL, 10) :
+ (UINT)time(0);
+ Trace("srand seed: %u\n", uSeed);
+ srand(uSeed);
InitializeOptions(argc, argv);
InitializeTreeDump();
InitializeEGTB();
@@ -520,17 +516,17 @@ Return value:
InitializeKnightAttackTables();
InitializeKingAttackTables();
InitializePawnAttackOriginTable();
- InitMagic();
+ InitializeBitboardMagic();
InitializeOpeningBook();
InitializeDynamicMoveOrdering();
- InitLMRTable();
+ InitializeLMRTable();
InitializeHashSystem();
- InitEval();
+ InitializeEval();
#ifdef MP
InitializeParallelSearch();
#endif
VERIFY(PreGameReset(TRUE));
- (void)BeginLogging();
+ InitializeLogging();
return TRUE;
}
@@ -825,10 +821,10 @@ Return value:
{
g_uInputThreadHandle = InitInputSystemWithDedicatedThread();
}
-
+
// Enter the main work loop code...
- MainProgramLoop();
-
+ MainProgramLoop();
+
// If we get here g_fExitProgram is set -- clean up.
Trace("MAIN THREAD: thread terminating.\n");
if (FALSE == MainProgramCleanup())
@@ -836,5 +832,5 @@ Return value:
Trace("main: Main program cleanup code failed, aborting.\n");
exit(-1);
}
- exit(0);
+ exit(0);
}
diff --git a/src/nnuefeatures.c b/src/nnuefeatures.c
new file mode 100644
index 0000000..c43e267
--- /dev/null
+++ b/src/nnuefeatures.c
@@ -0,0 +1,248 @@
+/**
+
+Copyright (c) Scott Gasch
+
+Module Name:
+
+ nnuefeatures.c
+
+Abstract:
+
+ Standalone reference implementation of the HalfKAv2_hm feature
+ index formula, used ONLY to cross-check against the Python
+ reference encoder (annotate_pgns/nnue_feature_spec.py) -- see
+ docs/NNUE.md's "Canonical HalfKAv2_hm feature-indexing spec"
+ section for the formulas this file implements, sourced verbatim
+ from Stockfish's src/nnue/features/half_ka_v2_hm.{h,cpp} (git tag
+ sf_16).
+
+ This is deliberately NOT wired into search/eval -- no accumulator,
+ no incremental maintenance, nothing performance-sensitive. It
+ exists purely so `dump nnuefeatures` can print the exact same
+ active-feature-index set the eventual real C inference code must
+ produce, so a Python/C cross-checker has a C-side ground truth to
+ diff against before any of that real code exists. The *real*
+ MakeMove/UnmakeMove accumulator plumbing (per NNUE.md's
+ sequencing, gated behind a dedicated branch, not started) will
+ reuse this same index math, not this file's dump-command
+ plumbing.
+
+Author:
+
+ Scott Gasch ([email protected]) 15 Oct 2026
+
+**/
+
+#include "chess.h"
+
+//
+// HalfKAv2_hm constants, verbatim from Stockfish's half_ka_v2_hm.h.
+// PS_NB counts 5 non-king piece types x 2 "us/them" roles x 64
+// squares, plus one shared 64-square king plane (own king and enemy
+// king features both land in this one plane, distinguished only by
+// which square is set).
+//
+#define NNUE_SQUARE_NB (64)
+#define NNUE_PS_W_PAWN (0 * NNUE_SQUARE_NB)
+#define NNUE_PS_B_PAWN (1 * NNUE_SQUARE_NB)
+#define NNUE_PS_W_KNIGHT (2 * NNUE_SQUARE_NB)
+#define NNUE_PS_B_KNIGHT (3 * NNUE_SQUARE_NB)
+#define NNUE_PS_W_BISHOP (4 * NNUE_SQUARE_NB)
+#define NNUE_PS_B_BISHOP (5 * NNUE_SQUARE_NB)
+#define NNUE_PS_W_ROOK (6 * NNUE_SQUARE_NB)
+#define NNUE_PS_B_ROOK (7 * NNUE_SQUARE_NB)
+#define NNUE_PS_W_QUEEN (8 * NNUE_SQUARE_NB)
+#define NNUE_PS_B_QUEEN (9 * NNUE_SQUARE_NB)
+#define NNUE_PS_KING (10 * NNUE_SQUARE_NB)
+#define NNUE_PS_NB (11 * NNUE_SQUARE_NB) // 704
+
+#define NNUE_DIMENSIONS ((NNUE_SQUARE_NB * NNUE_PS_NB) / 2) // 22528
+
+//
+// Convert a typhoon 0x88 COOR to Stockfish's flat 0-63 square index
+// (file + 8*(rank-1), rank/file both derived from the engine's own
+// RANK()/FILE() macros -- see docs/NNUE.md's spec for why this
+// one-line conversion is the single highest-risk spot in the whole
+// scheme).
+//
+static int
+CoorToSq64(COOR c)
+{
+ return (int)FILE(c) + 8 * ((int)RANK(c) - 1);
+}
+
+//
+// King bucket (0..31) for a king on Stockfish-square sq64, from
+// color uPerspective's (WHITE/BLACK) point of view. Verified against
+// Stockfish's literal KingBuckets[][] table -- see docs/NNUE.md.
+//
+static int
+KingBucket(ULONG uPerspective, int sq64)
+{
+ int file = sq64 & 7;
+ int rank0 = sq64 >> 3; // 0..7, rank-1
+ int fileQuartet = (file < 4) ? file : (7 - file); // 0..3, mirrored
+ int rankGroup = (uPerspective == WHITE) ? (7 - rank0) : rank0;
+ return rankGroup * 4 + fileQuartet;
+}
+
+//
+// Orientation XOR mask for perspective uPerspective whose own king
+// sits on Stockfish-square ksq64. Only ksq64's file selects the
+// branch (queenside half a-d vs. kingside half e-h); any rank.
+//
+static int
+OrientMask(ULONG uPerspective, int ksq64)
+{
+ int file = ksq64 & 7;
+ if (uPerspective == WHITE)
+ {
+ return (file < 4) ? 7 : 0; // SQ_H1 : SQ_A1
+ }
+ return (file < 4) ? 63 : 56; // SQ_H8 : SQ_A8
+}
+
+//
+// Piece-square plane offset for a piece of color uPieceColor and
+// type uPieceType (chess.h's PAWN..KING values), as seen from
+// perspective uPerspective. Both kings (own and enemy) share
+// NNUE_PS_KING.
+//
+static int
+PiecePlane(ULONG uPerspective, ULONG uPieceColor, ULONG uPieceType)
+{
+ FLAG fOwn;
+
+ if (uPieceType == KING)
+ {
+ return NNUE_PS_KING;
+ }
+ fOwn = (uPieceColor == uPerspective);
+ switch (uPieceType)
+ {
+ case PAWN: return fOwn ? NNUE_PS_W_PAWN : NNUE_PS_B_PAWN;
+ case KNIGHT: return fOwn ? NNUE_PS_W_KNIGHT : NNUE_PS_B_KNIGHT;
+ case BISHOP: return fOwn ? NNUE_PS_W_BISHOP : NNUE_PS_B_BISHOP;
+ case ROOK: return fOwn ? NNUE_PS_W_ROOK : NNUE_PS_B_ROOK;
+ case QUEEN: return fOwn ? NNUE_PS_W_QUEEN : NNUE_PS_B_QUEEN;
+ default:
+ ASSERT(FALSE);
+ return 0;
+ }
+}
+
+static int
+CompareInts(const void *a, const void *b)
+{
+ int x = *(const int *)a;
+ int y = *(const int *)b;
+ return (x > y) - (x < y);
+}
+
+//
+// Fill pIndices (caller-allocated, at least 32 ints) with the sorted
+// set of active HalfKAv2_hm feature indices for perspective
+// uPerspective at position pos. Returns the count (<=32).
+//
+static int
+ActiveFeatureIndices(POSITION *pos, ULONG uPerspective, int *pIndices)
+{
+ int f, r;
+ COOR cKing = ILLEGAL_COOR;
+ int ksq64;
+ int orientMask;
+ int kingBucketOffset;
+ int n = 0;
+
+ //
+ // Find this perspective's own king square.
+ //
+ for (f = 0; f < 8; f++)
+ {
+ for (r = 1; r <= 8; r++)
+ {
+ COOR c = FILE_RANK_TO_COOR(f, r);
+ PIECE p = pos->rgSquare[c].pPiece;
+ if (!IS_EMPTY(p) &&
+ (PIECE_TYPE(p) == KING) &&
+ (PIECE_COLOR(p) == uPerspective))
+ {
+ cKing = c;
+ }
+ }
+ }
+ ASSERT(cKing != ILLEGAL_COOR);
+ ksq64 = CoorToSq64(cKing);
+ orientMask = OrientMask(uPerspective, ksq64);
+ kingBucketOffset = KingBucket(uPerspective, ksq64) * NNUE_PS_NB;
+
+ for (f = 0; f < 8; f++)
+ {
+ for (r = 1; r <= 8; r++)
+ {
+ COOR c = FILE_RANK_TO_COOR(f, r);
+ PIECE p = pos->rgSquare[c].pPiece;
+ int sq64;
+ int idx;
+
+ if (IS_EMPTY(p))
+ {
+ continue;
+ }
+ sq64 = CoorToSq64(c);
+ idx = (sq64 ^ orientMask) +
+ PiecePlane(uPerspective, PIECE_COLOR(p), PIECE_TYPE(p)) +
+ kingBucketOffset;
+ ASSERT((idx >= 0) && (idx < NNUE_DIMENSIONS));
+ ASSERT(n < 32);
+ pIndices[n++] = idx;
+ }
+ }
+ qsort(pIndices, n, sizeof(int), CompareInts);
+ return n;
+}
+
+void
+DumpNnueFeatures(POSITION *pos)
+/**
+
+Routine description:
+
+ Prints the sorted set of active HalfKAv2_hm feature indices for
+ both perspectives at the current position, one line per
+ perspective, in a format trivial for a Python cross-checker to
+ parse:
+
+ NNUE_FEATURES white <idx> <idx> ...
+ NNUE_FEATURES black <idx> <idx> ...
+
+Parameters:
+
+ POSITION *pos : position to dump
+
+Return value:
+
+ void
+
+**/
+{
+ int rgIndices[32];
+ int n;
+ int i;
+
+ n = ActiveFeatureIndices(pos, WHITE, rgIndices);
+ Trace("NNUE_FEATURES white");
+ for (i = 0; i < n; i++)
+ {
+ Trace(" %d", rgIndices[i]);
+ }
+ Trace("\n");
+
+ n = ActiveFeatureIndices(pos, BLACK, rgIndices);
+ Trace("NNUE_FEATURES black");
+ for (i = 0; i < n; i++)
+ {
+ Trace(" %d", rgIndices[i]);
+ }
+ Trace("\n");
+}
diff --git a/tests/arasan.epd b/tests/arasan.epd
new file mode 100644
index 0000000..9902446
--- /dev/null
+++ b/tests/arasan.epd
@@ -0,0 +1,201 @@
+r1bq1r1k/p1pnbpp1/1p2p3/6p1/3PB3/5N2/PPPQ1PPP/2KR3R w - - bm g4; id "arasan2026.1"; c0 "J. Polgar-Berkes, Budapest Hunguest Hotels 2003";
+r1b2rk1/1p1nbppp/pq1p4/3B4/P2NP3/2N1p3/1PP3PP/R2Q1R1K w - - bm Rxf7; id "arasan2026.2"; c0 "Van der Wiel-Ribli, IBM Amsterdam 1980";
+r2r1k2/p3qpp1/1p1ppn1p/n5B1/P1PNbP2/2P3Q1/4B1PP/4RRK1 w - - bm Bg4; id "arasan2026.3"; c0 "Minic-Weiss, TCEC 2020";
+2rr3k/2qnbppp/p1n1p3/1p1pP3/3P1N2/1Q1BBP2/PP3P1P/1KR3R1 w - - bm Bxh7; id "arasan2026.4"; c0 "Eljanov-Van Wely, Tch-RUS Sochi 2008";
+3q1r1k/1b3ppp/p1n5/1p1pPB2/2rP4/P6N/1P2Q1PP/R4RK1 w - - bm Qh5; id "arasan2026.5"; c0 "Medusaz-Arasan, ICC 2016";
+r1b1k2r/1p1pppb1/p5pp/3P4/q2p1B2/3P1Q2/PPP2PPP/R3R1K1 w kq - bm Rxe7+; id "arasan2026.6"; c0 "Svidler-Carlsen, Longyearbyen 2006";
+R4bk1/2Bbp2p/2p2pp1/1rPp4/3P4/4P2P/4BPPK/1q1Q4 w - - bm Qa4; id "arasan2026.7"; c0 "Gurevich-Bareev, Cap D'Agde KO 2002";
+r1r3k1/p3bppp/2bp3Q/q2pP1P1/1p1BP3/8/PPP1B2P/2KR2R1 w - - bm e6; id "arasan2026.8"; c0 "Ostapenko-Kurkin, USSR 1970";
+b2rk1r1/p3q3/2p5/3nPR2/3P2pp/1R1B2P1/P1Q2P2/6K1 w - - bm Bc4; id "arasan2026.9"; c0 "Black Queen, Stockfish 081016 64-Mikachomika (Matrix C3 x64), playchess.com 2016";
+r2q3r/1p1bbQ2/4p1Bk/3pP3/1n1P1P1p/pP6/Pn4PP/R1B1R1K1 w - - bm g4; id "arasan2026.10"; c0 "Fier-Ryan, Sabadell op 2009";
+1r2brk1/4n1p1/4p2p/p2pP1qP/2pP1NP1/P1Q1BK2/2P4R/6R1 b - - bm Bg6; id "arasan2026.11"; c0 "David-Enders, Bundesliga 9900 1999";
+1rb2k1r/2q2pp1/p2b3p/2n3B1/2QN4/3B4/PpP3PP/1K2R2R w - - bm Bd8; id "arasan2026.12"; c0 "Volokitin-Mamedyarov, EU Club Cup Eilat ISR 2012";
+5rk1/1pp3p1/3ppr1p/pP2p2n/4P2q/P2PQ2P/2P1NPP1/R4RK1 b - - bm Rf3; id "arasan2026.13"; c0 "Arasan-Crafty, test game 2012";
+r4rk1/1b1n1pb1/3p2p1/1p1Pq1Bp/2p1P3/2P2RNP/1PBQ2P1/5R1K w - - bm Nf5; id "arasan2026.14"; c0 "Tal-Spassky, Tilburg 1980";
+2kr2r1/ppq1bp1p/4pn2/2p1n1pb/4P1P1/2P2N1P/PPBNQP2/R1B1R1K1 b - - bm Nfxg4; id "arasan2026.15"; c0 "Stockfish 4-Houdini 3, 2013 (Rybka forum)";
+6r1/pp3rpk/4p1Rp/3bBp1P/q1pP1Q2/2P5/P4PPK/4R3 b - - am Qxa2; id "arasan2026.16"; c0 "Mate-AI - ArasanX, lichess.org 2023";
+2r4k/2P3pp/6n1/1p1B4/1B1N1p2/P6b/5q2/1K1RR3 b - - bm h6; id "arasan2026.17"; c0 "caissa-x - ArasanX, lichess.org 2024";
+r1q2rk1/ppnbbpp1/n4P1p/4P3/3p4/2N1B1PP/PP4BK/R2Q1R2 w - - bm Bxh6; id "arasan2026.18"; c0 "Shirazi-Guichard, Malakoff op 2009";
+2r3k1/1b2qnp1/5p2/r1nP2p1/ppPBP2P/4Q3/3NB3/R4RK1 w - - bm Bh5; id "arasan2026.19"; c0 "Nikitosikbot - FireFishBOT, lichess.org 2023";
+3q1rk1/pr1b1p1p/1bp2p2/2ppP3/8/2P1BN2/PPQ3PP/R4RK1 w - - bm Bh6; id "arasan2026.20"; c0 "Shredder-Rybka, WCCC 2006";
+1r4k1/1pnq2pp/1p3p2/rP1ppP2/1R2P3/PQ1P1R1P/6PK/2B5 w - - bm d4; id "arasan2026.21"; c0 "Brainlearn 23.1 - Seer 2.5.0, offline tourney, 2023";
+8/6p1/P1b1pp2/2p1p3/1k4P1/3PP3/1PK5/5B2 w - - bm Bg2; id "arasan2026.22"; c0 "Vincent Lejeune #36";
+r5n1/p1p1q2k/4b2p/3pB3/3PP1pP/8/PPPQ2P1/5RK1 w - - bm Qf4; id "arasan2026.23"; c0 "Shulman-Marciano, Ubeda 1997";
+2b2rk1/r3q1pp/1nn1p3/3pP1NP/p1pP2Q1/2P1N3/1P1KBP2/R5R1 w - - bm Nxh7; id "arasan2026.24"; c0 "Vincent-Sebagh, 66th corr ch-FRA 2001";
+r4bk1/5pp1/2q2n2/2n1pPN1/2P5/4BP2/2B4Q/6RK w - - bm Kg2; id "arasan2026.25"; c0 "bot1e - ArasanX, lichess.org 2026";
+r3r1k1/p3bppp/q1b2n2/5Q2/1p1B4/1BNR4/PPP3PP/2K2R2 w - - bm Rg3; id "arasan2026.26"; c0 "Tukmakov-Minenkov, Soviet Union 1969";
+2bq1rk1/rpb2p2/2p1p1p1/p1N3Np/P2P1P1P/1Q2R1P1/1P3P2/3R2K1 w - - bm f5; id "arasan2026.27"; c0 "Soloman-MarioDeMonti, Tuesday 19 April Rapid Engine Tour 15+5, Infinity Chess 2016";
+8/8/2p5/2k3p1/7p/1p3K2/pN6/3R4 w - - bm Ke4; id "arasan2026.28"; c0 "RustBot2003 - ArasanX, lichess.org 2026 (analysis)";
+1q6/6k1/5Np1/1r4Pp/2p4P/2Nrb3/PP6/KR5Q b - - bm Bd4; id "arasan2026.29"; c0 "De Carbonnel-Berliner, 5th World CC Ch Final 1967";
+b2rk3/r4p2/p3p3/P3Q1Np/2Pp3P/8/6P1/6K1 w - - bm Qh8+; id "arasan2026.30"; c0 "Grischuk-Svidler, WCh Mexico City (6) 2007";
+2kr1b1r/1pp1ppp1/p7/q2P3n/2BB1pb1/2NQ4/P1P1N3/1R3RK1 w - - bm Rxb7; id "arasan2026.31"; c0 "Critter 1.6a 4CPU-Rybka 4.1 4CPU, Scandinavian Thematic 2013 (G. Banks)";
+r4k2/5Pp1/1n1b2p1/p2p2Pr/1ppP1NqP/8/PPQB1P2/2R1K2R w K - bm f3; id "arasan2026.32"; c0 "jest-bot - ArasanX, lichess.org 2023";
+br4k1/1qrnbppp/pp1ppn2/8/NPPBP3/PN3P2/5QPP/2RR1B1K w - - bm Nxb6; id "arasan2026.33"; c0 "Anand-Illescas Cordoba, Linares 1992";
+r2q1rk1/ppp2p2/3p1np1/4pNQ1/4P1pP/1PPP4/1P3P2/R3K1R1 w Q - bm Qh6; id "arasan2026.34"; c0 "Morella-Fernandez, Cuba 1998";
+1qb2rk1/3p1pp1/1p6/1pbBp3/r5p1/3QB3/PPP2P1P/2KR2R1 w - - bm b3; id "arasan2026.35"; c0 "Shirov-Zvjaginsev, Biel 1995";
+8/6pk/p3p3/2Q1P3/1p2P3/5P1P/5KP1/1q6 w - - id "arasan2026.36"; bm Qb6; c0 "Arasan-Granddroid, FICS 2020";
+1q3r1k/6b1/B6p/2P5/1P1p2pQ/P3r3/6P1/1R1R2K1 b - - bm g3; id "arasan2026.37"; c0 "Arasan-DeathBook2020, ICC 2020";
+r4rk1/p4ppp/qp2p3/b5B1/n1R5/5N2/PP2QPPP/1R4K1 w - - bm Bf6; id "arasan2026.38"; c0 "Alekhine-Sterk, Budapest 1921";
+r2q1rk1/4bppp/3pb3/2n1pP2/1p2P1PP/1P3Q2/1BP1N1B1/2KR3R b - - bm Ra2; id "arasan2026.39"; c0 "Gereben-Geller, Budapest 1952";
+6k1/p6p/1n3pb1/3p2p1/r2NP3/1R5P/3QNPPK/q7 b - - bm Nc4; id "arasan2026.40"; c0 "ZarkovX-Arasan, ICC 2020";
+2b1rk2/5p2/p1P5/2p2P2/2p5/7B/P7/2KR4 w - - bm f6; id "arasan2026.41"; c0 "study by Smyslov";
+rn1qr1k1/1p2bppp/p3p3/3pP3/P2P1B2/2RB1Q1P/1P3PP1/R5K1 w - - bm Bxh7+; id "arasan2026.42"; c0 "Polugaevsky-Torre, London 1984";
+1k4rr/p1pq2b1/1p6/1P1pp1p1/6n1/2PP2QN/PN1BP1B1/5RK1 b - - bm e4; id "arasan2026.43"; c0 "Houdini 5 Pro x64-pext - asmFishW_2016-12-17_bmi2, Stefan Pohl 2017";
+2r3k1/p1rnbppp/q3pn2/8/1pP5/1N2P1P1/PB1R1PBP/2RQ2K1 b - - am Rxc4; id "arasan2026.44"; c0 "rascal1 - ArasanX, lichess.org 2026";
+5k2/1p1b1p2/1r6/3Bn2p/p2qPN1P/3p1PK1/P2Q1RP1/8 b - - bm Qc5; id "arasan2026.45"; c0 "ArasanX - Mate-AI, lichess.org 2023";
+r1b2rk1/pp2bppp/3p4/q7/3BN1n1/1B3Q2/PPP3PP/R4RK1 w - - bm Qxf7+; id "arasan2026.46"; c0 "Velimirovic-Popovic, Yugoslavia 1986";
+2br3r/p1k5/1pp5/4pP2/2P1N1P1/1P2N1K1/P7/4R3 w - - bm Ng5; id "arasan2026.47"; c0 "Angledust-Arasan, FICS 2020";
+r2qr3/2p1b1pk/p5pp/1p2p3/nP2P1P1/1BP2RP1/P3QPK1/R1B5 w - - bm Bxh6; id "arasan2026.48"; c0 "Gurgenidze-Klovans, Soviet Union 1959";
+1rbq1rk1/p5bp/3p2p1/2pP4/1p1n1BP1/3P3P/PP2N1B1/1R1Q1RK1 b - - bm Bxg4; id "arasan2026.49"; c0 "Grefe-Kaplan, Berkeley 1968";
+k1b4r/1p3p2/pq2pNp1/5n1p/P3QP2/1P1R1BPP/2P5/1K6 b - - am Nxg3; id "arasan2026.50"; c0 "Deep Fritz 13 Q6600-Arasan 17.2 Q6600, SSDF 2014";
+7b/8/kq6/8/8/1N2R3/K2P4/8 w - - bm Nd4; id "arasan2026.51"; c0 "study by Kubbel";
+q3nrk1/4bppp/3p4/r3nPP1/4P2P/NpQ1B3/1P4B1/1K1R3R b - - bm Nc7; id "arasan2026.52"; c0 "Karjakin-Anand, Corus Wijk aan Zee 2006";
+2r5/8/6k1/P1p3p1/2R5/1P1q4/1K4Q1/8 w - - bm a6; id "arasan2026.53"; c0 "Stockfish 091114 - Houdini 4, TCEC Season 7, Stage 3 2014";
+8/3R1P2/1ppP1p2/3r4/8/K7/p4k2/8 w - - bm Kb2; id "arasan2026.54"; c0 "A. Herbstmann, 1954";
+2qrrbk1/1b3ppp/pn1Pp3/6P1/1Pp2B2/1nN2NQB/1P3P1P/3RR1K1 w - - bm g6; id "arasan2026.55"; c0 "Gulko-Popovic, Paris 1986";
+r1q1nrk1/3nb2p/3p2p1/p5P1/1pbNPB2/1N6/PPPQ4/2KR1B1R w - - bm Nf5; id "arasan2026.56"; c0 "Broniek-Santamaría Pérez, EU/C2014/sf-05 ICCF 2014";
+5rk1/pp3ppp/3q4/8/2Pp2b1/P5Pn/PBQPr1BP/4RR1K b - - bm Rxg2; id "arasan2026.57"; c0 "Komodo move (posted by Michael B)";
+3b3r/1q3pk1/4b2p/3pPppQ/R1pP1P1P/1rP1N1P1/6N1/2R3K1 w - - bm g4; id "arasan2026.58"; c0 "Stockfish 7 x64-Fritz 15 x64, Stockfish Gauntlet (G. Banks) 2016";
+r1b2rk1/pp1p2pR/8/1pb2p2/5N2/7Q/qPPB1PPP/6K1 w - - bm g3; id "arasan2026.59"; c0 "Rybka-Shredder, WCCCC Pamplona Openclass 2009";
+8/2k5/1p6/pP6/P7/2PN1B1p/4K3/1r6 b - - bm Ra1; id "arasan2026.60"; c0 "Fire-AllieStein, TCEC 2020";
+2k2b1r/p4p2/4pP2/2pbP3/1pp1NB1P/6P1/qP3PB1/3RR1K1 w - - bm Rxd5; id "arasan2026.61"; c0 "Lc0 0.22.0 11260-Laser 1.7, FGRL 16 core 2019";
+3r2k1/pb3Np1/4pq1p/2pp1n2/3P4/1PQ5/P4PPP/R2R2K1 b - - bm Nxd4; id "arasan2026.62"; c0 "Arasan-Crafty, ICC 2009";
+2kr1r2/ppq1b1p1/2n5/2PpPb1N/QP1B1pp1/2P5/P2N1P1P/R4RK1 b - - bm Rh8; id "arasan2026.63"; c0 "Arasan-Q9550 (Rybka), ICC 2009";
+3r2k1/2p1qppp/r7/1p1PP3/p3P3/nnR2N2/4BP1P/3Q2RK w - - bm d6; id "arasan2026.64"; c0 "TomomBot - Mr-Chess-Berserk, lichess.org 2024";
+r3r1k1/1b1n1ppp/1q2p1n1/1pp1P1PQ/4BBN1/P1pP2P1/P4P2/R3R1K1 w - - bm Nf6+; id "arasan2026.65"; c0 "Acha-Sorensen, Martinez Acosta mem-A corr Argentina 1995";
+3qb1k1/5rb1/r3p1Np/1n1pP2P/p1pB1PQ1/2P5/R1B4K/6R1 w - - bm Bc5; id "arasan2026.66"; c0 "Maximus-Akhtar, 90'+15, IC Freestyle Battle 2014";
+3q1k2/p4pb1/3Pp3/p3P3/r6p/2QB3P/3B1P2/6K1 w - - bm Bb5; id "arasan2026.67"; c0 "MingLi (Rybka 3 Dyanmic)-Ultrapower (Rybka), playchess.com 2009";
+r4r1k/ppqbn1pp/3b1p2/2pP3B/2P4N/7P/P2B1PP1/1R1QR1K1 w - - bm Rxe7; id "arasan2026.68"; c0 "Q9550(Rybka)-Arasan, ICC 2009";
+1r4k1/1q3pp1/r3b2p/p2N4/3R4/QP3P2/2P3PP/1K1R4 w - - bm Nf6+; id "arasan2026.69"; c0 "Topalov-Lutz, Sparkassen Gp 1 2002";
+r2q1r2/1bp1npk1/3p1p1p/p3p2Q/P3P2N/1BpPP3/1P1N2PP/5RK1 w - - bm Rf3; id "arasan2026.70"; c0 "Spark 0.3-Doch 09.980, Johan Havegheer 2009";
+5k2/2N2p2/1p1p1nr1/p2P4/P1P3Pb/1P2Q3/1q6/3B1RK1 b - - bm b5; id "arasan2026.71"; c0 "Ethereal 11.0-Stockfish 9, Andreas Strangmueller 2018";
+2r3r1/1p1qb2k/p5pp/2n1Bp2/2RP3P/1P2PNQ1/5P1K/3R4 w - - bm Ng5+; id "arasan2026.72"; c0 "Stockfish-Shredder, Pavel Hase 2010";
+r1b3k1/1p1nbrpp/2n1Np2/3pP2P/p4P2/P1P1BNR1/1q4P1/R2QKB2 w Q - bm Nxg7; id "arasan2026.73"; c0 "CupChessBot - ArasanX, lichess.org 2026";
+rr5k/1q2pPbp/3p2p1/PbpP4/1nB1nP1Q/1NB5/1P4PP/R4R1K w - - bm f5; id "arasan2026.74"; c0 "Chiku-Ratte - Rakotomaharo, Montreal, 2015";
+r4rk1/pp1qbppp/1n6/6R1/P1pP4/5Q1P/2B2PP1/2B2RK1 w - - bm Rxg7+; id "arasan2026.75"; c0 "Anand-Adams, Oakham 1990";
+1r4k1/p7/2P1n1pp/5p2/2QPqP2/PN2p3/5P1P/4RK2 b - - bm Rc8; id "arasan2026.76"; c0 "Komodo 10.4-Andscacs 0.89, Andreas Strangmüller 2017";
+1qrrbbk1/1p1nnppp/p3p3/4P3/2P5/1PN1N3/PB2Q1PP/1B2RR1K w - - bm Bxh7+; id "arasan2026.77"; c0 "Georgiev-Ionescu, UAE 1986";
+r1b2rk1/qp5p/p1n1ppp1/7N/4P1P1/2N1pP2/PPP5/2KR1QR1 w - - bm e5; id "arasan2026.78"; c0 "Van Kempen-Kariz, EU-chT fin 03 corr 1999";
+3r4/2q5/5pk1/p3n1p1/N3Pp1p/1PPr1P1P/2Q1R1P1/5R1K b - - bm g4; id "arasan2026.79"; c0 "Critter 0.60-Deep Fritz 12, Martin Thoresen 2010";
+1q2r1k1/3R1pb1/3R2p1/7p/p3N3/2P1BP1P/1P3PK1/8 b - - bm Rxe4; id "arasan2026.80"; c0 "Komodo 10.4-Deep Shredder 13, Andreas Strangmüller 2017";
+r1b1k2r/2q2pp1/p1p1pn2/2b4p/Pp2P3/3B3P/1PP1QPP1/RNB2RK1 b kq - bm Ng4; id "arasan2026.81"; c0 "Nataf-Volokitin, SCG-chT Budva 2004";
+2r1rb1k/ppq2pp1/4b2p/3pP2Q/5B2/2PB2R1/P4PPP/1R4K1 w - - bm Rxg7; id "arasan2026.82"; c0 "IvanHoe 57aU-x64.F-Deep Rybka 4, 2010";
+r2q1rk1/1p3pp1/p3b1n1/3pP1Q1/P1pP4/2N1P3/1P4BP/R4RK1 w - - bm Rf6; id "arasan2026.83"; c0 "wardog218-Arasan, ICC 2020";
+rnb1kb1r/pp1p1ppp/1q2p3/8/3NP1n1/2N1B3/PPP2PPP/R2QKB1R w KQkq - bm Qxg4; id "arasan2026.84"; c0 "Gaw Paw Sicilian: http://rybkaforum.net/cgi-bin/rybkaforum/topic_show.pl?tid=18253";
+r3kb1r/1b1n2p1/p3Nn1p/3Pp3/1p4PP/3QBP2/qPP5/2KR1B1R w kq - bm Qg6+; id "arasan2026.85"; c0 "Morozevich - Vachier-Lagrave, Biel 2009 (analysis after 17. .. Bb7)";
+1r1qrbk1/pb3p2/2p1pPpp/1p4B1/2pP2PQ/2P5/P4PBP/R3R1K1 w - - bm Bxh6; id "arasan2026.86"; c0 "Future Breeze(Naum 4.1)-Ultrapower(Rybka 3), playchess.com 2010 (analysis)";
+3q4/6kp/p1pp1pn1/Pr6/3PN1PP/8/5Q2/4R1K1 b - - bm h5; id "arasan2026.87"; c0 "Hutnik-Arasan, FICS 2020";
+1r4nr/1pbq1pk1/p1n4p/3pPpp1/3P1B2/P2B1N2/5QPP/1R3R1K w - - bm g4; id "arasan2026.88"; c0 "Jensen-Frica, WS/SIM/B/29 ICCF 2022";
+rn2r1k1/ppq1pp1p/2b2bp1/8/2BNPP1B/2P4P/P1Q3P1/1R3RK1 w - - bm Bxf7+; id "arasan2026.89"; c0 "Leitao-El Debs, Brazilian Chess Championship 2010";
+1kr5/1p3p2/q3p3/pRbpPp2/P1rNnP2/2P1B1Pp/1P2Q2P/R5K1 b - - bm Bxd4; id "arasan2026.90"; c0 "Argentum (Deep Rybka 4)-Obscure(Deep Rykba 4), playchess.com 2010";
+r3r2k/1pq2pp1/4b2p/3pP3/p1nB3P/P2B1RQ1/1PP3P1/3R3K w - - bm Rf6; id "arasan2026.91"; c0 "Houdini 1.5-Rybka 4, Pal Larkin 2011";
+2r3k1/pnpqrpb1/1p4pp/3P4/2P1NB1P/P3R1Q1/5PP1/4R1K1 b - - bm Nd6; id "arasan2026.92"; c0 "strain-on-veins - ArasanX, lichess.org 2025";
+1r3r2/q5k1/4p1n1/1bPpPp1p/pPpR1Pp1/P1B1Q3/2B3PP/3R2K1 w - - bm Rxd5; id "arasan2026.93"; c0 "Ivanchuk-Erdos, Tradewise Gilbraltar 2011";
+rq3rk1/1b1n1ppp/ppn1p3/3pP3/5B2/2NBP2P/PP2QPP1/2RR2K1 w - - bm Nxd5; id "arasan2026.94"; c0 "Kaidanov-Donaldson, USA 1992";
+7r/k4pp1/pn2p1pr/2ppP3/1q3P2/1PN2R1P/P1P2QP1/3R3K w - - bm a3; id "arasan2026.95"; c0 "Gull 1.1-Hannibal 1.0, 2011";
+1r3rk1/3bbppp/1qn2P2/p2pP1P1/3P4/2PB1N2/6K1/qNBQ1R2 w - - bm Bxh7+; id "arasan2026.96"; c0 "nick-Fritz 11, playchess.com 2011";
+1r1qrbk1/5ppp/2b1p2B/2npP3/1p4QP/pP1B1N2/P1P2PP1/1K1R3R w - - bm Bxh7+; id "arasan2026.97"; c0 "Vandermeulen-Napalkov, Sergey Korolev mem. email 2008";
+r5k1/pbpq1pp1/3b2rp/N3n3/1N6/2P3B1/PP1Q1PPP/R4RK1 b - - bm Rxg3; id "arasan2026.98"; c0 "Arasan-KingsBishopFour, freechess.org 2011";
+8/6p1/5nBp/2k1p2P/P1p1P3/2K3P1/8/8 b - - bm Ng8; id "arasan2026.99"; c0 "BulettenKarl1 - ArasanX, lichess.org 2024";
+r4r2/pp1b1ppk/2n1p3/3pPnB1/q1pP2QP/P1P4R/2PKNPP1/R7 w - - bm Qh5+; id "arasan2026.100"; c0 "Bobras-Can, 8th ch-Euro Dresden 2007";
+8/2k2Bp1/2n5/p1P4p/4pPn1/P3PqPb/1r1BQ2P/2R1K1R1 b - - bm Nce5; id "arasan2026.101"; c0 "Arasan-Rookie, freechess.org 2011";
+r1b1qr1k/2p3pp/4p3/1pb1PpN1/pn3N1P/8/PPP1QPP1/2KR3R w - - bm Rd8; id "arasan2026.102"; c0 "Rybka forum 2020";
+r3rnk1/pp2ppb1/1np3p1/3qP2p/3P1B2/4Q1N1/PP2BPP1/1K1R3R w - - bm Bh6; id "arasan2026.103"; c0 "Sergeev-Sande, EU/TC10/sf1 ICCF 2014";
+1r1q2k1/2r3bp/B2p1np1/3P1p2/R1P1pP2/4B2P/P5PK/3Q1R2 b - - bm Ng4+; id "arasan2026.104"; c0 "WHITE_RABBIT (Deep Rybka 4)-Blackborn, playchess.com 2011";
+2r1rnk1/1p2pp1p/p1np2p1/q4PP1/3NP2Q/4B2R/PPP4P/3R3K w - - bm b4; id "arasan2026.105"; c0 "Copie-Alvarez Rouchaud, Marcusi mem. corr 1996";
+2b2qk1/1r4pp/2p1p3/p2n1PPB/2p4P/2p5/P4Q2/4RRK1 w - - bm Qg3; id "arasan2026.106"; c0 "Lafarga Santorromaa-Kukk, ICCF Olympiad 17 Final 2009";
+1r1rkb2/2q2p2/p2p1P1B/P1pPp2Q/2P3b1/1P6/2B3PP/5R1K w - - bm Qxg4; id "arasan2026.107"; c0 "Walsh-Rivas Romero, Latin Am zt15 sf2 corr 2000";
+r4rk1/3b3p/p1pb4/1p1n2p1/2P2p2/1B1P2Pq/PP1NRP1P/R1BQ2K1 w - - bm Qf1; id "arasan2026.108"; c0 "Ivanchuk-Kamsky, Tilburg 1992";
+3r2k1/pb3qbp/1p1p3r/nN4p1/1BPPp3/4PpP1/P1Q2P1P/2RR1BK1 b - - bm Qh5; id "arasan2026.109"; c0 "Komodo 13.02 MCTS x64 1800X-Stockfish 8 x64 1800X, SSDF 2019";
+2nb2k1/1rqb1pp1/p2p1n1p/2pPp3/P1P1P3/2B1NN1P/2B2PP1/Q3R2K w - - bm Nxe5; id "arasan2026.110"; c0 "Spike 1.4-Booot 5.1.0, 29th Amateur Division 1, 2011";
+3r2k1/p1qn1p1p/4p1p1/2p1N3/8/2P3P1/PP2QP1P/4R1K1 w - - bm Nxf7; id "arasan2026.111"; c0 "Darkraider (Houdini 1.5a x64)-ABCDE, playchess.com 2011";
+r2q1rk1/pb1nbp1p/1pp1pp2/8/2BPN2P/5N2/PPP1QPP1/2KR3R w - - bm Nfg5; id "arasan2026.112"; c0 "Loop M1-P 4CPU-Deep Junior 10.1 4CPU, CEGT Quad 40/120 2007";
+4rr2/3bp1bk/p2q1np1/2pPp2p/2P4P/1R4N1/1P1BB1P1/1Q3RK1 w - - bm Bxh5; id "arasan2026.113"; c0 "Naum 4 x64 4CPU-Deep Fritz 11 4CPU, CEGT Quad 40/120 2009";
+6br/1KNp1n1r/2p2p2/P1ppRP2/1kP3pP/3PBB2/PN1P4/8 w - - bm Bxc5+; id "arasan2026.114"; c0 "Van Essen, Martin, Humor Tourney EBUR 2004, 2nd Prize";
+1rbq3r/pk6/2p5/n2n2N1/5b1P/3P2P1/PPP2P2/RNBQ1RK1 b - - bm Rxh4; id "arasan2026.115"; c0 "Kunzmann-J.L. Fernandez, cr. 2016";
+8/4nk2/1p3p2/1r1p2pp/1P1R1N1P/6P1/3KPP2/8 w - - bm Nd3; id "arasan2026.116"; c0 "Grandelius-Raznikov, EU Youth Chess Ch B18, Albena 2011";
+6k1/1bq1bpp1/p6p/2p1pP2/1rP1P1P1/2NQ4/2P4P/K2RR3 b - - bm Bd5; id "arasan2026.117"; c0 "Hard CCC 2008 #6 (Vincent Lejeune)";
+3rr1k1/1b3p1p/pN4p1/2pP4/4p1nq/PPN1P3/1BQ2PP1/2R1R1K1 b - - bm Rd6; id "arasan2026.118"; c0 "Arasan-exeComp, FICS 2022";
+4rrk1/1pp1n1pp/1bp1P2q/p4p2/P4P2/3R2N1/1PP2P1P/2BQRK2 w - - bm Nh5; id "arasan2026.119"; c0 "Ruy Lopez position (posted by Neel Basant)";
+3q4/4k3/1p1b1p1r/p2Q4/3B1p1p/7P/1P4P1/3R3K w - - bm b4; id "arasan2026.120"; c0 "Stockfish 021113-Naum 4.6, nTCEC - Stage 4 - Season 2 2013";
+4rb1k/6pp/NR3p2/P2Pp2q/6bP/4BnP1/5PK1/2R2Q2 b - - bm Qf5; id "arasan2026.121"; c0 "Arasan-exeComp, FICS 2018";
+r4r1k/1ppb2pp/p1nq1p2/P3p3/2B4N/2PPR2P/2P2PP1/R2Q2K1 w - - bm Ng6+; id "arasan2026.122"; c0 "BECELA9_KPEBETKA - ArasanX, lichess.org 2026";
+3r3r/k1p2pb1/B1b2q2/2RN3p/3P2p1/1Q2B1Pn/PP3PKP/5R2 w - - bm Rfc1; id "arasan2026.123"; c0 "Naum 4.2 MP-Deep Hiarcs 13.2, SSDF 2011";
+1r4k1/4pp1p/pp1pq1p1/r2R4/PbP1P3/1P1QBP1P/R5P1/7K w - - bm c5; id "arasan2026.124"; c0 "Almeida-Chronopoulos, WS/MN/B/57 ICCF 2022";
+2r3k1/1q3pp1/2n1b2p/4P3/3p1BP1/Q6P/1p3PB1/1R4K1 b - - bm Rb8; id "arasan2026.125"; c0 "Naum 4.2-Stockfish 2.2.1, Aser Huerga 2012";
+rn2kb1r/1b1n1p1p/p3p1p1/1p2q1B1/3N3Q/2N5/PPP3PP/2KR1B1R w kq - bm Nxe6; id "arasan2026.126"; c0 "Audino-Staroske, 1973 (anal. by Stockfish)";
+r7/ppp3kp/2bn4/4qp2/2B1pR2/2P1Q2P/P5P1/5RK1 w - - bm Rxf5; id "arasan2026.127"; c0 "Velimirovic-Romanishin, Odessa 1975";
+1r6/r6k/2np2p1/2pNp1qp/1pP1Pp1b/1P1P1P2/1B3P2/1Q1R1K1R b - - bm Bxf2; id "arasan2026.128"; c0 "Komodo 10.3 64-bit - asmFishW_2016-12-17_bmi2, Stefan Pohl 2017";
+1nr3k1/q4rpp/1p1p1n2/3Pp3/1PQ1P1b1/4B1P1/2R2NBP/2R3K1 w - - bm Qxc8+; id "arasan2026.129"; c0 "Runting-Barnsley, Purdy Jubilee-A email 2003";
+8/5rk1/p2p4/1p1P1b1p/1P1K2pP/2P3P1/4R3/5B2 w - - bm Rf2; id "arasan2026.130"; c0 "Andscacs 0.89-Houdini 5.01, Andreas Strangmüller 2017";
+5rk1/2p1R2p/r5q1/2pPR2p/5p2/1p5P/P4PbK/2BQ4 w - - bm d6; id "arasan2026.131"; c0 "Observer(Houdini 2.0c Pro)-Numerobis(Fritz 13), playchess.com 2011";
+r2q1r2/1b2bpkp/p3p1p1/2ppP1P1/7R/1PN1BQR1/1PP2P1P/4K3 w - - bm Qf6+; id "arasan2026.132"; c0 "Asauskas-Malisauskas, Vilnius 2004";
+r1r3k1/1ppn2bp/p1q1p1p1/3pP3/3PB1P1/PQ3NP1/3N4/2BK3R w - - bm Ng5; id "arasan2026.133"; c0 "Torres-Graudins, LAT-USA ICCF 2015 (analysis after 19. .. e6)";
+1rr1b1k1/1pq1bp2/p2p1np1/4p3/P2BP3/2NB2Q1/1PP3PP/4RR1K w - - bm Rxf6; id "arasan2026.134"; c0 "Glek-Lingnau, Dortmund op-A 1992";
+r1r3k1/1q3p1p/4p1pP/1bnpP1P1/pp1Q1P2/1P6/P1PR1N2/1K3B1R b - - bm axb3; id "arasan2026.135"; c0 "Great Ozzie(Komodo64 3)-Erdo(Critter 1.2), playchess.com 2011";
+r1b2rk1/pppnq3/4ppp1/6N1/3P3Q/2PB4/P1PK2PP/3R3R w - - bm Nxe6; id "arasan2026.136"; c0 "Leisebein-Woelfelschneider, BDG04-01 DVC 2001";
+3r1r1k/pp5p/4b1pb/6q1/3P4/4p1BP/PP2Q1PK/3RRB2 b - - bm Qxg3+; id "arasan2026.137"; c0 "Apicella-Lautier, Clichy 2001";
+r2r2k1/3bb1Pp/3pp1p1/p1q5/1p2PP2/P1N5/1PPQ4/1K1R1B1R w - - bm Nd5; id "arasan2026.138"; c0 "Timman-Sisniega, Taxco izt 1985 (analysis by Timman)";
+8/2R5/3p4/3P4/3k3P/2p3K1/1r4P1/8 w - - bm Kf3; id "arasan2026.139"; c0 "Flear-Legky, Le Touquet 1991 (anal. by Mueller)";
+r1bq2k1/1pp2ppp/3prn2/p3n3/2P5/PQN1PP2/1P1PB2P/R1B2R1K b - - bm Nfg4; id "arasan2026.140"; c0 "Arasan-zerowin, FICS 2017";
+r2qnrk1/pb5p/1p1bpp1P/3p2p1/6P1/2PBB3/PP1NQP2/1K1R3R w - - bm f4; id "arasan2026.141"; c0 "Notarious-Arasan, FICS 2023";
+5r2/1p4k1/pP1pP1pp/2rP2q1/4Qp2/3Bb3/P5PP/4RR1K w - - bm Rf3; id "arasan2026.142"; c0 "Hybl-Juarez, WC35/pr08 ICCF 2011";
+r2qr1k1/1b1pppbp/1p4p1/pP2P1B1/3N4/R7/1PP2PPP/3QR1K1 w - a6 bm Nf5; id "arasan2026.143"; c0 "Godes-Morozov, Mirotvorsky mem-A corr8185 1981";
+4k3/1R6/Pb3p2/1P1n4/5p2/8/4K3/8 w - - bm Kd3; id "arasan2026.144"; c0 "Ding Liren-Wang Yue, ch-CHN 2012 Xinghua 2012";
+r4nk1/2pq1ppp/3p4/p3pNPQ/4P3/2PP1RP1/Pr3PK1/7R w - - bm Ne3; id "arasan2026.145"; c0 "Houdini 4 MP-Chiron 2 MP, SCCT Gladiators 2014";
+r1q2rk1/1b2bppp/p1p1p3/4B3/PP6/3B3P/2P1QPP1/R2R2K1 w - - bm Bxh7+; id "arasan2026.146"; c0 "Konig-Cornforth, London 1952";
+r2qrb1k/1p1b2p1/p2ppn1p/8/3NP3/1BN5/PPP3QP/1K3RR1 w - - bm e5; id "arasan2026.147"; c0 "Spassky-Petrosian, World Chmp (19) 1969";
+r1q1r1k1/p1p2ppp/1p3nn1/1BP2b2/4p3/B1P1P2P/P1QN1PP1/R2R2K1 b - - bm Bxh3; id "arasan2026.148"; c0 "Deep Shredder 13-Lc0k 0.23.2 384x30 T-40-2036, FGRL 16-core 2020";
+4r1k1/6p1/bp2r2p/3QNp2/P2BnP2/4P2P/5qPK/3RR3 b - - bm Kh7; id "arasan2026.149"; c0 "Komodo 4-Houdini 2.0c, Harry Schnapp Tribute 2012 (G. Banks)";
+8/5rk1/p3Q1pp/1p1P1p1b/2p1Pq1P/P4P2/1PKN4/5R2 w - - bm d6; id "arasan2026.150"; c0 "asmFishW_2016-12-17_bmi2-Houdini 5 Pro x64-pext, Stefan Pohl 2017";
+r1bqkb1r/4pppp/p1p5/2ppP3/8/2P2N2/PPP2PPP/R1BQR1K1 w kq - bm e6; id "arasan2026.151"; c0 "Balashov-Sabjanov, Kstovo op 1994";
+3r1rk1/1b2bpp1/2n1p2p/qp1n2N1/4N3/P3P3/1BB1QPPP/2R2RK1 w - - bm Qh5; id "arasan2026.152"; c0 "Asadali-DeepElite, Tuesday 16 Feb Rapid Engine Tour 15+5, Infinity Chess 2016";
+3R4/pp2r1pk/q1p3bp/2P2r2/PP6/2Q3P1/6BP/5RK1 w - - bm Rxf5; id "arasan2026.153"; c0 "Zhak-Pavlov, ch-RUS sf ICCF 2010";
+r3k3/1p4p1/1Bb1Bp1p/P1p1bP1P/2Pp2P1/3P4/5K2/4R3 w - - bm g5; id "arasan2026.154"; c0 "Nakamura-Giri, FIDE Grand Prix, London 2012";
+1r1rb1k1/5ppp/4p3/1p1p3P/1q2P2Q/pN3P2/PPP4P/1K1R2R1 w - - bm Rxg7+; id "arasan2026.155"; c0 "Vescovi-Gschwendtner, Groningen op 1994 (Aemis Test Suite #30)";
+1r1q1rk1/4bp1p/n3p3/pbNpP1PB/5P2/1P2B1K1/1P1Q4/2RR4 w - - bm Ne4; id "arasan2026.156"; c0 "Rybka 4.1 SE42 x64-Komodo64 SSE Version 4, Clash of the Titans 2012";
+r1bq1rk1/pp2bppp/1n2p3/3pP3/8/2RBBN2/PP2QPPP/2R3K1 w - - bm Bxh7+; id "arasan2026.157"; c0 "Trent-Deslandes, Cap d'Agde CCAS op 2008";
+r2qkbnr/ppp1pppp/3p4/3Pn3/4P1b1/5N2/PPP2PPP/RNBQKB1R w KQkq - bm Nxe5; id "arasan2026.158"; c0 "Nimzo-Indian queen sac";
+r1b2rk1/1pq1nppp/pbn1p3/8/3N4/3BBN2/PPP1QPPP/3R1RK1 w - - bm Bxh7+; id "arasan2026.159"; c0 "Leu-Muck, DDR-ch H137 corr 1973";
+r7/8/4k3/1p1p4/1Rb3PP/2P5/1P3PK1/8 b - - bm d4; id "arasan2026.160"; c0 "ArasanX - Moment-That-Inspires, lichess.org 2024";
+br3bk1/3r1p2/3q2p1/3P2Np/2B4P/3QR1P1/3R1P1K/8 w - - bm Nxf7; id "arasan2026.161"; c0 "Stockfish 8-Deep Shredder 13, Andreas Strangmueller 2017";
+r3r2k/ppq3np/2p3p1/NPPp1bb1/P2Pnp2/3B1P2/2Q3PP/1RN1BRK1 b - - bm Ng3; id "arasan2026.162"; c0 "Zambrana-Krush, 2nd American Continental 2003";
+7k/5rp1/3q1p1p/2bNpQ1P/4P1P1/8/1R3PK1/8 w - - bm g5; id "arasan2026.163"; c0 "Blackborn (Houdini 2.0c Pro)-Eroe Inrico (Gull II beta 2), playchess.com 2012";
+4r3/4r3/1ppqpnk1/p3Rp1p/P2P1R1Q/2PB2P1/1P3P2/6K1 w - - bm Bxf5+; id "arasan2026.164"; c0 "Jernjoffen (Naum 4)-Redboy (Cyclone 3.4), playchess.com 2009";
+r3nrk1/1pqbbppp/p2pp3/2n1P3/5P2/2NBBNQ1/PPP3PP/R4RK1 w - - bm Bxh7+; id "arasan2026.165"; c0 "Wedberg-Ionescu, Berliner Sommer 1998";
+5q1k/5p1p/pp2pNpP/4P3/b6Q/5PK1/6P1/8 b - - bm Bb5; id "arasan2026.166"; c0 "DiverDan - Arasan, ICC 2023";
+8/2N5/1P2p3/5bPk/1q3b2/3Bp2P/2P5/6QK b - - bm Kh4; id "arasan2026.167"; c0 "Pinvoy (Houdini 1.5a)-Pawnattack (Houdini 2.0c Pro), playchess.com 2012";
+5r2/1r5k/1p1p1qpp/pP1Ppp2/N1P4R/4P1PP/1Q2B1K1/8 w - - bm c5; id "arasan2026.168"; c0 "Wasp 6.50 - Koivisto 8.0, offline tourney, 2023";
+5r2/3rkp2/2R2p2/p2Bb2Q/1p2P2P/4q1P1/Pp6/1K1R4 b - - bm b3; id "arasan2026.169"; c0 "Hotmorphy (Deep Shredder 9)-Mr Blue Sky (HIARCS 12), playchess.com 2012";
+r3r1k1/4qp1p/4p1p1/p2pP3/2pN1PP1/8/3Q3P/2R2R1K w - - bm f5; id "arasan2026.170"; c0 "Antifish-Komodo MC, CCC 8: Deep Dive 2019";
+r1b1k2r/pp3pbp/1nn3p1/q1pp4/5B2/PP2PN2/2QNBPPP/R4RK1 w q - bm b4; id "arasan2026.171"; c0 "Lc0 0.26.2 J92-130 (30x384)-Stockfish 200418 bmi2, Stefan Pohl 2020";
+4r1k1/1p4p1/p1qBp1Qp/b1pnP3/8/5NP1/1P3PKP/3R4 w - - bm Rxd5; id "arasan2026.172"; c0 "NVV5 (Houdini 2.0b Pro)-Deep Butterfly (Deep Fritz 13), playchess.com 2012";
+2r1k2r/pp1bb1pp/6n1/3Q1p2/1B1N4/P7/1q4PP/4RRK1 w k - bm Bxe7; id "arasan2026.173"; c0 "Hannibal-Arasan, Variant-ICS, Amsterdam, NL 2013";
+8/5p2/3k3p/1pP1pp2/1P6/6P1/1N1K1P1P/5b2 b - - bm Ke6; id "arasan2026.174"; c0 "Nikitosikbot - ArasanX, lichess.org 2023";
+1r2brk1/6p1/1q2p1Pp/pN1pPPb1/np1N4/5Q2/1PP1B3/1K1R3R w - - bm f6; id "arasan2026.175"; c0 "Aldrete Lobo-Maia, 9th Panamerican Team Chmp, ICCF 2007";
+2r1r1k1/q4pp1/p1n2P2/1p1p2Bp/P3P1P1/2p3P1/BpQ3K1/1R3R2 w - - bm Rh1; id "arasan2026.176"; c0 "DiverDan-Arasan, ICC 2022";
+r1b2rk1/1p4p1/p1n1p3/3p1pB1/NqP3n1/b2BP3/1PQN1P1P/1K4RR w - - bm Rxg4; id "arasan2026.177"; c0 "Van Oosterom-Plomp, Korning mem 1998";
+4rrk1/1p1n1ppp/2pp4/pP6/P1P1P3/q1NP2PP/3Q2K1/1R3R2 w - - bm d4; id "arasan2026.178"; c0 "EmptikVariantsBot - ReinforcementTrial, lichess.org 2023";
+r1b3r1/5p1k/p1n2P1p/P1qpp1P1/1p1p4/3P2Q1/BPPB2P1/R4RK1 w - - bm Kf2; id "arasan2026.179"; c0 "Efremov-Fischer, Danube/4 ICCF 2012";
+r2q1rk1/2p2ppp/pb1p1n2/n3p3/P2PP3/2P2NN1/R4PPP/2BQ1RK1 w - - bm Bg5; id "arasan2026.180"; c0 "Carlsen-Beliavsky, Corus-B Wijk aan Zee 2006";
+1r2rbk1/1p1n1p2/p3b1p1/q2NpNPp/4P2Q/1P5R/6BP/5R1K w - h6 bm Ng3; id "arasan2026.181"; c0 "Carlsen-Anand, Corus Wijk aan Zee 2008";
+r4rk1/1bqnppBp/pp1p1np1/8/P2pP3/2N1QN1P/1PP1BPP1/R4RK1 w - - bm Qh6; id "arasan2026.182"; c0 "Lugano (Cfish 291216 BMI2)-Pacificrabbit (Komodo 10.3 64-bit), playchess.com 2016";
+5b2/1b2qp1k/2pp1npp/1p6/1P2PP2/r1PQ2NP/2B3P1/3RB1K1 w - - bm e5; id "arasan2026.183"; c0 "Steve Wraith (Deep Rybka 4 X3)-Fulcrum (Deep Rybka 4 x64), playchess.com 2010";
+r1qr1bk1/2p2pp1/ppn1p2p/8/1PPPN1nP/P4NP1/2Q2PK1/2BRR3 w - - bm Neg5; id "arasan2026.184"; c0 "sedarpi-tazzzzz, www.chesspresso.net 2014";
+r1b2r1k/4qp1p/p2ppb1Q/4nP2/1p1NP3/2N5/PPP4P/2KR1BR1 w - - bm Nc6; id "arasan2026.185"; c0 "Kholmov-Bronstein, URS-ch32 Kiev 1964";
+5rk1/1p3n2/1q2pB2/1P1p1b1p/5Q1P/3p1NP1/5P2/2R3K1 w - - bm Ne5; id "arasan2026.186"; c0 "Houdini 5.01-Fritz 15, Andreas Strangmueller 2016";
+8/2k5/2PrR1p1/7p/5p1P/5P1K/6P1/8 w - - bm Rxd6; id "arasan2026.187"; c0 "Topalov-Short, Madrid Magistral 1997";
+8/4bBpp/3p4/P6P/2PN2p1/3k1b2/P7/6K1 w - - bm h6; id "arasan2026.188"; c0 "Timman, 1c Corus {m} 2008";
+5K1k/4n3/1bp5/5B1P/3P4/p4RR1/p2P2p1/N3q1N1 w - - bm Rg6; id "arasan2026.189"; c0 "Timman=J sp.p #19755 Schach 2022";
+5rn1/1p3p1k/r5pp/p1ppPPq1/6N1/1PPP3Q/1P5P/R4R1K w - - bm e6; id "arasan2026.190"; c0 "Felkel-Pavlicek, GER/CM01F (GER) ICCF 2014";
+8/k3qrpR/1p1p4/p2QpPp1/P1P1P1K1/1P6/8/8 w - - bm b4; id "arasan2026.191"; c0 "Keres-Euwe, Netherlands (m/6) 1940";
+3r1rk1/pbq1bp1p/1n1Rp1p1/2p1P1N1/4N2P/1P3Q2/PB3PP1/K6R w - - bm h5; id "arasan2026.192"; c0 "Houdini 5 Pro x64-pext - asmFishW_2016-12-17_bmi2, Stefan Pohl 2017";
+8/1p6/2p2kp1/R6p/4PP1P/P7/BPK1b3/r7 w - - bm e5+; id "arasan2026.193"; c0 "Mr-Chess-Berserk - ArasanX, lichess.org 2024";
+rn2r1k1/p4pn1/1p2p3/qPppP1Q1/3P4/2P2N2/2P2PPP/1R3RK1 w - - bm Nh4; id "arasan2026.194"; c0 "Oechslein-Oppermann, VWC9/pr 20 ICCF 2016";
+k6r/ppqb4/2n5/4p2r/P2p1P1P/B1pQ2P1/2P3B1/RR4K1 w - - bm a5; id "arasan2026.195"; c0 "Harimau-Arasan, ICC 2016";
+1r1q2k1/p4p1p/2Pp2p1/2p1P3/1r1n4/1P4P1/3R1PBP/3QR1K1 w - - bm e6; id "arasan2026.196"; c0 "ipfreely-Arasan, ICC 2016";
+1rr5/5R2/6k1/3B2P1/1p2P1n1/1PpK4/8/R7 w - - bm Ra6+; id "arasan2026.197"; c0 "Komodo 10.4 64-bit - Houdini 5 Pro x64-pext, Stefan Pohl 2017";
+b1r1r1k1/p2n1p2/1p5p/2qp1Rn1/2P3pN/6P1/P2N1P1P/Q3RBK1 b - - bm Qb4; id "arasan2026.198"; c0 "Andscacs 0.872-Houdini 5.01, match 2016";
+1q4rk/R1nbp3/1n1p3p/QP1P4/3pPp2/2N2P1P/1P1N3K/5B2 w - - bm Nb3; id "arasan2026.199"; c0 "Arasan-ipfreely, ICC 2016";
+r3k2r/pp4p1/2p1pnp1/3p1pn1/2PP4/1P2qPPP/P1Q3BK/3RBR2 b kq - bm Nxh3; id "arasan2026.200"; c0 "BOT ArasanX - BOT MateAI, lichess.org 2023";
+