summaryrefslogtreecommitdiff
path: root/src/eval_tune/nnue_vs_head.sh
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
committerScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
commitc8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (patch)
treeb6c2137c9ac577fa83b1e948fa0311d53dfe445d /src/eval_tune/nnue_vs_head.sh
parent3b5d3677bb14ae14728a1c4b221176bb83667d92 (diff)
Wire NNUE eval into search, build the training pipeline, and add a fast
move/eval-agreement benchmark against Stockfish (tests/lop) Implements the architecture decisions in src/docs/NNUE.md: HalfKAv2_hm feature set, int16/int8 quantization, per-ply accumulator backup for king moves, and a lazy-eval-ready integration into search.c/eval.c. Adds the full annotate_pgns/ training pipeline (extraction, K-fit/ labeling, quiet filtering, tensor building, training, export) plus an end-to-end orchestrator (run_nnue_pipeline.py) and validation tooling (sanity_check_net.py, compare_vs_stockfish_net.py). Adds tests/lop: a 10k-position list-of-positions benchmark comparing engine eval rank and move choice against pre-computed Stockfish/lc0 references, much cheaper than a full self-play match for day-to-day progress checks. First sd10-matched self-play result against classic HCE: LLR +2.28 (H1=+2.94), -72=119+169 over 360 games, confirming the net is "smarter per node" at matched depth. A real-time (--st 4) match is currently weaker (candidate loses ~3 plies of depth to a ~3.3-3.7x nps gap against an unrecalibrated search), consistent with NNUE.md's adoption-criterion section -- pruning/reduction constants still need retuning for the new eval's score scale before a fair real-time comparison is possible. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/eval_tune/nnue_vs_head.sh')
-rwxr-xr-xsrc/eval_tune/nnue_vs_head.sh219
1 files changed, 219 insertions, 0 deletions
diff --git a/src/eval_tune/nnue_vs_head.sh b/src/eval_tune/nnue_vs_head.sh
new file mode 100755
index 0000000..25a4c4f
--- /dev/null
+++ b/src/eval_tune/nnue_vs_head.sh
@@ -0,0 +1,219 @@
+#!/usr/bin/env bash
+#
+# Gated self-play match against a baseline binary. Historically this
+# always compared the live working-tree build against head_reference/
+# -- generalized (2026-09-14) to take both binary paths as args so two
+# arbitrary builds (e.g. a file-storm-only worktree build vs. the
+# current working tree, to isolate one change at a time when several
+# have landed together -- see king_safety_data/HANDOFF.md's session
+# notes on not measuring two eval changes in one match) can be compared
+# without hand-rolling a match_play.py invocation each time.
+#
+# Usage: test_vs_head.sh [head_binary] [candidate_binary] [label]
+#
+# head_binary Baseline engine. Defaults to head_reference/typhoon
+# (the usual "is this better than last checkpoint"
+# question).
+# candidate_binary Candidate engine. Defaults to a fresh copy of the
+# live working-tree build (../typhoon) into
+# /tmp/typhoon/typhoon_match_play_candidate -- pass
+# an explicit path (already built) to skip that
+# copy/freeze step entirely, e.g. when comparing
+# two prebuilt worktree binaries.
+# label Distinguishes scratch dir/log/pgn-out so multiple
+# matches can run concurrently without colliding.
+# Defaults to "match".
+#
+# ...and any further arguments are passed straight through to
+# match_play.py, so a one-off flag no longer needs this file edited.
+#
+# Three env vars override the baked-in defaults (2026-10-03), so the
+# common knobs are reachable without touching the invocation below:
+#
+# TC_ARGS time control. Default: "--st 4"
+# SPRT_ARGS hypothesis test. Default: "--sprt --elo0 0 --elo1 5"
+# GAMES game cap. Default: 10000
+#
+# Why these became overridable: the baked-in "--sprt --elo0 0 --elo1 5"
+# cannot resolve the effect sizes this project's LMR work actually
+# produces. At the observed draw rate the standard error in Elo is about
+# 249/sqrt(n), so separating 0 from 5 Elo at even 2 sigma needs ~10,000
+# games (~53h here) -- and every LMR match run to date has instead
+# wandered around llr 0 for 2,000+ games and been abandoned, which is
+# exactly what a true effect sitting BETWEEN the two hypotheses looks
+# like. For a change you would only ship at >= 15 Elo, use
+# SPRT_ARGS="--sprt --elo0 0 --elo1 15": it rejects a +2 effect in hours
+# instead of never.
+#
+# Examples:
+# test_vs_head.sh # old default behavior
+# test_vs_head.sh /tmp/typhoon/filestorm_only /tmp/typhoon/candidate queenprox_backflight
+#
+# # Faster-rejecting bounds for a change only worth shipping at >=15 Elo:
+# SPRT_ARGS="--sprt --elo0 0 --elo1 15" test_vs_head.sh
+#
+# # TIME-ODDS CALIBRATION: same binary both sides, candidate gets 2x the
+# # time. Measures what ~1.6 extra plies (at ebf 3.6) is worth in Elo in
+# # THIS engine, which bounds what any depth-buying search change
+# # (LMR/EFP/LMP) can ever deliver. No SPRT -- this measures a quantity
+# # rather than testing a hypothesis, and ELO=+/- prints either way.
+# # ~600-1000 games is plenty: the expected effect is large, so +/-10 Elo
+# # of precision already separates "a ply is worth 25" from "worth 65".
+# SPRT_ARGS="" TC_ARGS="--st 4 --odds 2" GAMES=1000 \
+# test_vs_head.sh ../../head_reference/typhoon \
+# ../../head_reference/typhoon timeodds
+#
+# NNUE_WEIGHTS: path to a weights file (as produced by annotate_pgns/
+# export_nnue_weights.py). When set, the CANDIDATE binary is wrapped
+# in a thin exec shim that always injects "--nnueweights $NNUE_WEIGHTS"
+# ahead of whatever args match_play.py's Engine class passes it --
+# that's the only change needed, since Engine spawns the given path
+# directly via Popen (no shell), so a shebang script in its place works
+# unmodified. HEAD is untouched, so it keeps running the classic HCE --
+# this is how you get an NNUE-candidate-vs-HCE-reference match without
+# patching match_play.py's Engine (which currently passes identical
+# engine_args to both sides):
+#
+# NNUE_WEIGHTS=/path/to/net.nnue test_vs_head.sh
+#
+# NNUE_PROBE_WEIGHTS: path to a probe weights file (as produced by
+# annotate_pgns's probe-fitting script, "NPRB" format -- see docs/
+# NNUE.md's "Lazy-evaluation tiering plan"). Requires NNUE_WEIGHTS to
+# also be set (the probe gate only does anything once NNUE eval itself
+# is enabled) -- adds "--nnueprobeweights $NNUE_PROBE_WEIGHTS" to the
+# same wrapper, enabling the lazy-eval accumulator probe gate at
+# startup (main.c's --nnueprobeweights sets g_fUseNnueLazyProbe=TRUE
+# automatically, same convention as --nnueweights):
+#
+# NNUE_WEIGHTS=/path/to/net.nnue \
+# NNUE_PROBE_WEIGHTS=/path/to/probe.bin \
+# test_vs_head.sh
+
+set -e
+
+# Only consume LEADING args that don't look like a flag as the three
+# positionals (head/candidate/label) -- 2026-10-19 fix: a bare
+# ./nnue_vs_head.sh --sd 10 used to land "--sd" in HEAD and "10" in
+# CANDIDATE_ARG (since the old code unconditionally took "$1"/"$2"/"$3"
+# regardless of content), the exact same footgun class the comment
+# below already describes once biting the --shift-then-test-"$2"
+# version of this script. A flag-looking first arg now means "no
+# positionals given at all, everything is pass-through" instead.
+POSITIONALS=()
+while [ $# -gt 0 ] && [ "${#POSITIONALS[@]}" -lt 3 ]; do
+ case "$1" in
+ -*) break ;;
+ *) POSITIONALS+=("$1"); shift ;;
+ esac
+done
+
+HEAD="${POSITIONALS[0]:-../../head_reference/typhoon}"
+CANDIDATE_ARG="${POSITIONALS[1]:-}"
+LABEL="${POSITIONALS[2]:-match}"
+# Note the `-` (not `:-`) for the three env overrides below: an
+# explicitly EMPTY value must be honored, not replaced by the default.
+# SPRT_ARGS="" is the documented way to run without a hypothesis test
+# (e.g. the time-odds calibration above), and `:-` would silently put
+# --sprt back.
+TC_ARGS="${TC_ARGS---st 4}"
+SPRT_ARGS="${SPRT_ARGS---sprt --elo0 0 --elo1 5}"
+GAMES="${GAMES-10000}"
+
+# If the caller already passed --sd or --st through as an extra
+# argument (e.g. ./nnue_vs_head.sh --sd 10), don't ALSO inject the
+# baked-in TC_ARGS ("--st 4" by default) -- match_play.py's --sd/--st
+# are mutually exclusive, so the two would collide
+# ("error: argument --st: not allowed with argument --sd") even though
+# the caller only asked for one of them. Pass-through wins; TC_ARGS is
+# only the fallback when the caller didn't specify either.
+for arg in "$@"; do
+ case "$arg" in
+ --sd|--st) TC_ARGS="" ;;
+ esac
+done
+
+if [ -n "$CANDIDATE_ARG" ]; then
+ CANDIDATE="$CANDIDATE_ARG"
+else
+ cd ..
+ gmake clean
+ gmake release MP=1 PERF_COUNTERS=1
+ cd eval_tune
+ CANDIDATE=/tmp/typhoon/typhoon_match_play_candidate
+ cp -f ../typhoon_nnue ${CANDIDATE}
+ # typhoon_nnue's own TYPHOON_BIN/NNUE_WEIGHTS defaults are computed
+ # relative to ITS OWN location ("next to this script" -- see its
+ # header comment) -- correct when it's the real src/typhoon_nnue,
+ # but wrong once copied to /tmp/typhoon/ above: it would then look
+ # for the engine binary at /tmp/typhoon/typhoon and the weights
+ # file under /tmp/annotate_pgns/..., neither of which exist, and
+ # exit 1 immediately on every single spawn (2026-10-19: this is
+ # what produced hundreds of instant "BrokenPipeError" game aborts
+ # the first time this ran -- the copied wrapper was exiting before
+ # ever speaking the xboard protocol). match_play.py's Popen
+ # inherits this script's environment, so exporting the correct
+ # ABSOLUTE paths here -- computed from this script's own known-good
+ # location, not the relocated copy's -- fixes it without needing
+ # to touch typhoon_nnue itself. Only fill in NNUE_WEIGHTS if the
+ # caller didn't already set one explicitly.
+ export TYPHOON_BIN="$(cd .. && pwd)/typhoon"
+ : "${NNUE_WEIGHTS:=$(cd ../../annotate_pgns && pwd)/runs/sf98k/nnue_ckpt/nnue_weights.nnue}"
+ export NNUE_WEIGHTS
+fi
+
+if [ -n "${NNUE_WEIGHTS:-}" ]; then
+ NNUE_WRAPPER=/tmp/typhoon/typhoon_match_play_candidate_nnue
+ NNUE_FLAGS="--nnueweights \"$NNUE_WEIGHTS\""
+ if [ -n "${NNUE_PROBE_WEIGHTS:-}" ]; then
+ NNUE_FLAGS="$NNUE_FLAGS --nnueprobeweights \"$NNUE_PROBE_WEIGHTS\""
+ fi
+ # NNUE_LAZY_MARGIN / NNUE_SUPERLAZY_MARGIN: 2026-10-16 looser-
+ # margin experiment (see docs/NNUE.md and command.c's "nnue
+ # lazymargin"/"nnue superlazyon"). NNUE_LAZY_MARGIN overrides every
+ # material bucket's lazy-probe margin with one flat cp value
+ # instead of the calibrated p98 table; NNUE_SUPERLAZY_MARGIN turns
+ # on the even-cheaper material-only pre-filter (off by default)
+ # with the given flat cp margin. Both require NNUE_WEIGHTS; the
+ # lazy one further requires NNUE_PROBE_WEIGHTS to do anything.
+ if [ -n "${NNUE_LAZY_MARGIN:-}" ]; then
+ NNUE_FLAGS="$NNUE_FLAGS --nnuelazymargin $NNUE_LAZY_MARGIN"
+ fi
+ if [ -n "${NNUE_SUPERLAZY_MARGIN:-}" ]; then
+ NNUE_FLAGS="$NNUE_FLAGS --nnuesuperlazymargin $NNUE_SUPERLAZY_MARGIN"
+ fi
+ cat > "$NNUE_WRAPPER" <<WRAPPER
+#!/usr/bin/env bash
+exec "$CANDIDATE" $NNUE_FLAGS "\$@"
+WRAPPER
+ chmod +x "$NNUE_WRAPPER"
+ CANDIDATE="$NNUE_WRAPPER"
+fi
+
+# NNUE_CANDIDATE_NAME: PGN White/Black identity recorded for the
+# candidate engine, forwarded to match_play.py's --candidate-name
+# (2026-10-16, see that flag's own comment). Defaults to a name that
+# flags "don't trust this as training data" whenever NNUE is in play,
+# since self_play_games.pgn is reused later to train the NNUE net
+# itself and a differently-calibrated evaluator's scores shouldn't be
+# mixed in as labels without this being mechanically filterable.
+# Override explicitly if a given NNUE config's scores are actually
+# meant to be trusted.
+NAME_ARGS=""
+if [ -n "${NNUE_WEIGHTS:-}" ]; then
+ NNUE_CANDIDATE_NAME="${NNUE_CANDIDATE_NAME:-candidate_nnue}"
+ NAME_ARGS="--candidate-name $NNUE_CANDIDATE_NAME"
+fi
+
+python3 ./match_play.py \
+ ${HEAD} \
+ ${CANDIDATE} \
+ --pgn ../../pgn/twic_filtered.pgn ../../pgn/lichess*.pgn \
+ --games ${GAMES} \
+ --workers 18 \
+ --post \
+ ${TC_ARGS} \
+ ${SPRT_ARGS} \
+ ${NAME_ARGS} \
+ --scratch /usr/local/tmp/typhoon_match_${LABEL} \
+ --log ${LABEL}.log \
+ "$@"