diff options
| author | Scott Gasch <[email protected]> | 2026-10-15 13:11:22 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-15 13:11:22 -0700 |
| commit | 993b6eeb2a9ac7b659c473b6883fa53af1ad3a0b (patch) | |
| tree | c5388824dec15a7691ce9952a6eb4af66e837127 | |
| parent | 53b380c658f828c4c0e096be2763e6b012c23abd (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]>
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 @@ -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: @@ -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; @@ -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"; + |
