summaryrefslogtreecommitdiff
path: root/src/eval_tune
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-04 08:17:52 -0700
committerScott Gasch <[email protected]>2026-10-04 08:17:52 -0700
commitbbdf1bda8f410df6aa9be0a38e71b7db04982198 (patch)
treee8315247f700ee94875cd6d2eb5dd81aef71395d /src/eval_tune
parent9b28080f8b3dca4588cb2aaaf748451f45e3c626 (diff)
Dynamic move-ordering/LMR session: honest FH tracking, decoupled admission/magnitude, bugfixes, tooling
Lands the 2026-09-28..10-03 move-ordering investigation (full history in lmr_testing/*.md). Summary of what ships: LMR/move-ordering redesign (searchsup.c, dynamic.c, data.c, chess.h): - GetLMRReduction's admission decision (do we reduce at all) is now decoupled from magnitude (how much): LMR_STATIC_ADMIT_THOUSANDTHS=500 gates admission via the 4D joint fail-high table (data.c) plus a countermove-match veto; magnitude is a flat Crafty/Ethereal-shaped log(depth)*log(moves) table (g_iLMRQuietReduction), which measured as good or better than the old per-tier joint-table payout while being structurally immune to the per-bucket miscalibration that sank the tiered approach. g_FailHighs honesty fix (real 0/0 init, not a fake prior) underlies the dynamic-danger signal. - Retired NumLeftoverMovesToSelect's depth-indexed selection budget -- evidence showed the leftover pool has real findable signal a bailout was discarding. - ShouldLatePrune (overt late-move pruning) implemented but disabled at its call site -- measured net-negative at the tested threshold; kept for reference and as a base for a future hang-bucket-9-targeted retry. - PRUNE_VERIFY, PRUNE_LIVE, DISABLE_LMR: opt-in diagnostic build flags (GNUmakefile), off in the standard profile. Bug fixes found and fixed while exercising this work: - JOINT_EXTRA_TIER_COUNT 4->6 (chess.h): fixed an out-of-bounds counter write reachable at remaining depth >= 14 (unreachable at sd10, routine in real search/self-play). - Node-type instrumentation (search.c, root.c, searchsup.c): fixed four compounding defects that produced impossible percentages (e.g. 106.2% of PV nodes) -- wrong denominator, qsearch misclassified as main search, untyped early-exit nodes, and fail-low tests using the in-place-raised alpha instead of the initial one. - FH-rate unit unification (search.c, searchsup.c, dynamic.c, chess.h): GetMoveFailHigh* returned per-mille while g_uJointFHThousandths is thousandths-of-a-percent, two different units sharing a "_THOUSANDTHS" suffix. Unified to one scale, renamed to "_PCT_THOUSANDTHS". RateBucket's band edges fixed to match. - QSearch option B (search.c): waives the opponent-stand-pat requirement at NODE_PV only (sound there since the exact score propagates; not sound at null-window nodes). iExtensionAmount unit fix (was 0/2 into a ONE_PLY-scaled field). pi->iEval now published when Eval() runs. - eval_tune/match_play.py local_ebf: was reporting 0.000 in every match since 2026-09-18 (regex only matched the trailing-stats parser, not the live move-scan loop; engines were also spawned with "nopost" so root.c never emitted the lines at all). True per-ply EBF is ~2.0, not the ~3.9 every prior match report implied. - eval_tune/test_vs_head.sh: positional-arg shift ordering bug caused every no-extra-args run to silently rebuild ./typhoon with a non-standard profile and, separately, could feed a flag's value in as the candidate binary path. - PERF_COUNTERS cleanup (eval.c, search.c): moved 3 leaked rdtsc sites to the existing EVAL_TIME guard, deleted two concluded one-off diagnostics (u64HeadStyle*, u64FHJointHist/RateBucket/AttemptsBucket) and their report plumbing, and re-gated a per-move hang-material estimate behind FH_TABLE_COLLECTION instead of running it unconditionally. Infrastructure: - match_play.py time-odds support (--odds/--st-head/--st-candidate), validated against the engine seat rather than color. - Wilson-upper-bound re-bake of the joint table (data.c): cells store a confidence-aware bound instead of a raw point estimate, so a consumer can threshold on the value alone; monotone, so every consumer became strictly more conservative. Verified behavior-neutral for LMR. Known-open items (not resolved here, see lmr_testing/GET_LMR_REDUCTION.md section 10 and 2026-10-03_UNCOMMITTED_TRIAGE.md): the self-play gate on this candidate is underpowered (+5 Elo, 95% CI roughly [-6, +17]); the curated-suite solve-count deltas used earlier in the investigation turned out to be noise-dominated cancellation when diffed per position, not the clean signal they first appeared to be. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/eval_tune')
-rwxr-xr-xsrc/eval_tune/match_play.py224
-rwxr-xr-xsrc/eval_tune/test_vs_head.sh73
2 files changed, 258 insertions, 39 deletions
diff --git a/src/eval_tune/match_play.py b/src/eval_tune/match_play.py
index d8a8e7b..0985801 100755
--- a/src/eval_tune/match_play.py
+++ b/src/eval_tune/match_play.py
@@ -48,6 +48,7 @@ import select
import sys
import time
from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
+from datetime import date
from pathlib import Path
import chess
@@ -220,11 +221,26 @@ def sample_openings(pgn_path, n, min_ply=8, max_ply=20, seed=0,
def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
- depth=None, time_control_sec=None, max_plies=200):
+ depth=None, white_time_sec=None, black_time_sec=None,
+ max_plies=200, post=False):
"""Returns (result, moves, white_stats, black_stats) where result is
1.0/0.5/0.0 from White's perspective, moves is the list of UCI moves
actually played (for PGN reconstruction), and white_stats/black_stats
- are EngineStats instances covering that side's moves in this game."""
+ are EngineStats instances covering that side's moves in this game.
+
+ 2026-10-03: the time control is now PER SIDE (white_time_sec /
+ black_time_sec) rather than one value shared by both engines, so a
+ time-odds match is expressible -- notably HEAD vs HEAD at 2:1, which
+ measures what one extra ply of depth is actually worth in Elo *in
+ this engine*, the calibration every depth-buying search change
+ (LMR/EFP/LMP) is implicitly betting on. Pass the same value twice for
+ an ordinary even match. `depth` (--sd) stays shared: fixed-depth odds
+ would be a different experiment and there's no caller for it.
+
+ NOTE for callers: these two parameters are keyed by SEAT (white/
+ black), but in a time-odds match the odds must follow the ENGINE,
+ which swaps seats every other game by design. main()'s run() does
+ that mapping via candidate_is_white -- see the warning there."""
moves = []
white_stats = EngineStats()
black_stats = EngineStats()
@@ -250,15 +266,41 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
with Engine(white_engine_path, f"{scratch_dir}/w", engine_args) as we, \
Engine(black_engine_path, f"{scratch_dir}/b", engine_args) as be:
# Disable book and pondering. Set time control. Resign lost positions.
- tc_cmd = f"sd {depth}" if depth is not None else f"st {time_control_sec}"
- for command in ( "book name /none",
- "set ThinkOnOpponentsTime false",
- "set ResignThreshold -1000",
- tc_cmd):
+ # 2026-10-03: Engine.__init__ (tune_eval_dna.py:120) sends
+ # "nopost" unconditionally, and root.c:248 gates the
+ # per-iteration PV lines on g_Options.fShouldPost -- so those
+ # lines are never emitted and local_ebf (the TRUE per-ply
+ # branching ratio, as opposed to the cumulative
+ # nodes**(1/depth) `ebf` that conflates aspiration/root-PV
+ # re-search cost with real tree growth) could never be computed.
+ # It reported 0.000 for every engine in every match since it was
+ # added. "post" re-enables those lines. Opt-in via --post rather
+ # than default-on: it adds roughly one stdout line per iteration
+ # per move, which every engine process must then be read through,
+ # and the default behavior of a harness in active use should not
+ # change silently underneath a running experiment.
+ startup = ["book name /none",
+ "set ThinkOnOpponentsTime false",
+ "set ResignThreshold -1000"]
+ if post:
+ startup.append("post")
+ for command in startup:
_dbg("W", f"send: {command}")
we._send(command)
_dbg("B", f"send: {command}")
be._send(command)
+ # Time control is the one setting that can legitimately differ
+ # between the two engines (time odds), so it is sent separately
+ # rather than as part of the shared command list above.
+ if depth is not None:
+ white_tc = black_tc = f"sd {depth}"
+ else:
+ white_tc = f"st {white_time_sec}"
+ black_tc = f"st {black_time_sec}"
+ _dbg("W", f"send: {white_tc}")
+ we._send(white_tc)
+ _dbg("B", f"send: {black_tc}")
+ be._send(black_tc)
board = chess.Board(fen)
for ply_idx in range(max_plies):
@@ -273,12 +315,49 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
mover._send(f"setboard {board.fen()}")
mover._send("go")
move_uci = None
+ # 2026-10-03 BUGFIX: the per-iteration PV lines _ITER_RE
+ # matches are printed DURING the search, i.e. BEFORE the
+ # "move" line -- so they arrive in THIS loop, not in the
+ # trailing stats block below. They used to be parsed only
+ # down there, which meant this loop read and discarded every
+ # one of them and iter_ratio_count was always 0, so
+ # avg_local_ebf silently reported 0.000 for every engine in
+ # every match since it was added (2026-09-18). The whole
+ # point of local_ebf is that it is a TRUE per-ply branching
+ # ratio, unlike the cumulative nodes**(1/depth) `ebf` which
+ # conflates aspiration-window/root-PV-change re-search cost
+ # with real tree growth -- so the one metric that could
+ # settle "is our branching factor actually ~3.9 or ~2.0"
+ # was dead on arrival. Accumulate here as well.
+ last_iter_depth = last_iter_nodes = None
+ iter_log_ratio_sum = 0.0
+ iter_ratio_count = 0
+
+ def _note_iter(line):
+ nonlocal last_iter_depth, last_iter_nodes
+ nonlocal iter_log_ratio_sum, iter_ratio_count
+ im = _ITER_RE.match(line)
+ if not im:
+ return
+ iter_depth = int(im.group(1))
+ iter_nodes = int(im.group(2))
+ if (last_iter_depth is not None and
+ last_iter_nodes is not None and
+ iter_nodes > last_iter_nodes and
+ iter_depth > last_iter_depth):
+ iter_log_ratio_sum += (math.log(iter_nodes / last_iter_nodes)
+ / (iter_depth - last_iter_depth))
+ iter_ratio_count += 1
+ last_iter_depth = iter_depth
+ last_iter_nodes = iter_nodes
+
for _ in range(400):
line = mover.stdout.readline()
if not line:
_dbg(tag, "EOF while scanning for move line")
break
_dbg(tag, f"read(move-scan): {line!r}")
+ _note_iter(line)
if line.startswith("tellics resign"):
# Engine resigned instead of moving -- root.c prints
# this and returns without ever printing "move ...",
@@ -310,9 +389,8 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
# together at the end (arithmetic mean of ratios would let
# one huge outlier iteration dominate; geometric mean is
# the correct way to average ratios/growth-factors).
- last_iter_depth = last_iter_nodes = None
- iter_log_ratio_sum = 0.0
- iter_ratio_count = 0
+ # (state hoisted above the move-scan loop -- see the
+ # 2026-10-03 bugfix comment there)
for _ in range(_STATS_TRAILING_LINES):
line = _readline_or_none(mover.stdout, _STATS_IDLE_TIMEOUT_SEC)
if line is None:
@@ -339,22 +417,7 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
if fm:
ply_fmb = float(fm.group(1))
continue
- im = _ITER_RE.match(line)
- if im:
- iter_depth = int(im.group(1))
- iter_nodes = int(im.group(2))
- if (last_iter_depth is not None and
- last_iter_nodes is not None and
- iter_depth != last_iter_depth and
- iter_nodes > last_iter_nodes and
- iter_depth > last_iter_depth):
- ratio = iter_nodes / last_iter_nodes
- depth_delta = iter_depth - last_iter_depth
- iter_log_ratio_sum += math.log(ratio) / depth_delta
- iter_ratio_count += 1
- last_iter_depth = iter_depth
- last_iter_nodes = iter_nodes
- continue
+ _note_iter(line)
if ply_nodes is not None and ply_time is not None:
ply_local_ebf = (math.exp(iter_log_ratio_sum / iter_ratio_count)
if iter_ratio_count else None)
@@ -391,10 +454,11 @@ def play_one_game(white_engine_path, black_engine_path, scratch_dir, fen,
def build_pgn_game(start_fen, moves, result_white, white_name, black_name):
board = chess.Board(start_fen)
game = chess.pgn.Game()
- game.headers["Event"] = "typhoon DNA gate match"
+ game.headers["Event"] = "typhoon self-play match"
game.headers["White"] = white_name
game.headers["Black"] = black_name
game.headers["FEN"] = start_fen
+ game.headers["Date"] = f"{date.today()}"
game.setup(board)
result_str = {1.0: "1-0", 0.0: "0-1", 0.5: "1/2-1/2"}[result_white]
game.headers["Result"] = result_str
@@ -672,14 +736,85 @@ def main():
"the two are actually mutually exclusive now.")
tc.add_argument("--st", type=float, default=None,
help="seconds per move per side instead of fixed depth")
+ # Time odds. See play_one_game's docstring for why this exists: the
+ # headline use is HEAD vs HEAD at 2:1 to price one extra ply of
+ # depth in Elo for THIS engine, which bounds what any depth-buying
+ # search change can ever be worth.
+ ap.add_argument("--odds", type=float, default=None, metavar="MULT",
+ help="give the CANDIDATE engine MULT times the "
+ "head engine's seconds per move (e.g. --st 4 "
+ "--odds 2 => head 4s, candidate 8s). Requires "
+ "--st; incompatible with --sd. Use --st-head/"
+ "--st-candidate instead for full control.")
+ ap.add_argument("--st-head", type=float, default=None, metavar="SEC",
+ help="explicit seconds per move for the head engine "
+ "(overrides --st for that side)")
+ ap.add_argument("--st-candidate", type=float, default=None, metavar="SEC",
+ help="explicit seconds per move for the candidate "
+ "engine (overrides --st for that side)")
+ ap.add_argument("--post", action="store_true",
+ help="send 'post' so engines emit per-iteration PV "
+ "lines, which is what local_ebf (the true "
+ "per-ply branching ratio) is computed from. "
+ "Without this, local_ebf reads 0.000 -- the "
+ "engines never print those lines because "
+ "Engine.__init__ sends 'nopost'.")
ap.add_argument("--max-plies", type=int, default=200)
ap.add_argument("--scratch", default="/usr/local/tmp/typhoon_match")
ap.add_argument("--log", default="match_history.log")
- ap.add_argument("--pgn-out", default="match_games.pgn",
+ ap.add_argument("--pgn-out", default="../self_play_games.pgn",
help="every played game is appended here as PGN")
args = ap.parse_args()
- if args.sd is None and args.st is None:
+
+ # --- resolve the per-side time controls once, loudly ---------------
+ # Deliberately strict: a time-odds match that silently degrades into
+ # an even one is the dangerous failure here, because it still scores
+ # ~0.500 and looks perfectly healthy (same family of silent confound
+ # as the still-live opening book and the shared logfile this script's
+ # header documents). So every inconsistent combination is a hard
+ # error rather than a best-effort interpretation.
+ odds_requested = (args.odds is not None or
+ args.st_head is not None or
+ args.st_candidate is not None)
+ if odds_requested and args.sd is not None:
+ ap.error("time odds (--odds/--st-head/--st-candidate) needs a "
+ "time control, not --sd; fixed-depth odds would be a "
+ "different experiment entirely.")
+ if args.sd is None and args.st is None and not odds_requested:
args.sd = 8
+ if args.odds is not None:
+ if args.st is None:
+ ap.error("--odds multiplies --st, so --st is required with it")
+ if args.st_candidate is not None:
+ ap.error("--odds and --st-candidate both set the candidate's "
+ "time; pass one or the other")
+ if args.odds <= 0:
+ ap.error("--odds must be positive")
+ head_time = args.st_head if args.st_head is not None else args.st
+ cand_time = args.st_candidate if args.st_candidate is not None else args.st
+ if args.odds is not None:
+ # round: 4 * 1.5 is 6.000000000000001 in binary float, which ends
+ # up verbatim in the "st" command and in the result line's TC=
+ # stamp. Harmless to the engine, ugly in a log that gets diffed.
+ cand_time = round(args.st * args.odds, 4)
+ if args.sd is None:
+ if head_time is None or cand_time is None:
+ ap.error("need --st (or both --st-head and --st-candidate) "
+ "when not using --sd")
+ if odds_requested and head_time == cand_time:
+ ap.error(f"time odds requested but both sides resolved to "
+ f"{head_time}s -- that is an even match. Check "
+ f"--odds/--st-head/--st-candidate.")
+ print(f"TIME_CONTROL: head={head_time}s/move "
+ f"candidate={cand_time}s/move"
+ + (" *** TIME ODDS ***" if head_time != cand_time else ""))
+ else:
+ print(f"TIME_CONTROL: both sides sd {args.sd}")
+ # Stash on args so print_final_summary (a separate function, already
+ # taking args) can stamp the time control into the result line
+ # without widening its signature at both call sites.
+ args.head_time = head_time
+ args.cand_time = cand_time
head_engine = str(Path(args.head_engine).resolve())
candidate_engine = str(Path(args.candidate_engine).resolve())
@@ -706,10 +841,23 @@ def main():
def run(job):
i, fen, candidate_is_white, white_engine, black_engine = job
game_start = time.monotonic()
+ # The odds follow the ENGINE, not the seat. Every opening is
+ # played twice with colours swapped (see the jobs[] loop above),
+ # so keying the time control on White/Black instead would hand
+ # the advantage to each engine in exactly half the games -- a
+ # balanced match that measures nothing while scoring a healthy-
+ # looking ~0.500. Hence this mapping off candidate_is_white, and
+ # the per-game assertion below.
+ white_time = cand_time if candidate_is_white else head_time
+ black_time = head_time if candidate_is_white else cand_time
+ if args.sd is None and head_time != cand_time:
+ assert ((white_time == cand_time) == candidate_is_white), \
+ "time-odds seat mapping inverted"
result_white, moves, white_stats, black_stats = play_one_game(
white_engine, black_engine, f"{args.scratch}/g{i}", fen,
- depth=args.sd, time_control_sec=args.st,
- max_plies=args.max_plies,
+ depth=args.sd,
+ white_time_sec=white_time, black_time_sec=black_time,
+ max_plies=args.max_plies, post=args.post,
)
game_elapsed = time.monotonic() - game_start
candidate_result = result_white if candidate_is_white else (1.0 - result_white)
@@ -873,9 +1021,21 @@ def print_final_summary(done, candidate_points, wins, draws, losses,
sprt_str = (f" SPRT={outcome} LLR={sprt.llr():+.3f} "
f"BOUNDS=[{sprt.la:.3f},{sprt.lb:.3f}] "
f"ELO0={args.elo0:.1f} ELO1={args.elo1:.1f}")
+ # Stamp the time control into the result line itself: a time-odds
+ # score is NOT comparable with an even-match score, and a bare
+ # CANDIDATE_SCORE in a log file gives no way to tell them apart
+ # later.
+ if args.sd is not None:
+ tc_str = f" TC=sd{args.sd}"
+ elif args.head_time != args.cand_time:
+ tc_str = (f" TC=ODDS(head={args.head_time}s,"
+ f"cand={args.cand_time}s)")
+ else:
+ tc_str = f" TC=st{args.head_time}"
print(f"CANDIDATE_SCORE={score:.4f} GAMES={n_games} "
f"WINS={wins} DRAWS={draws} LOSSES={losses} "
- f"ELO={elo:+.1f}+/-{err:.1f} LOWER95={lower95:.4f}{sprt_str}")
+ f"ELO={elo:+.1f}+/-{err:.1f} LOWER95={lower95:.4f}"
+ f"{tc_str}{sprt_str}")
print(f"CANDIDATE_STATS: {candidate_stats.summary()}")
print(f"HEAD_STATS: {head_stats.summary()}")
diff --git a/src/eval_tune/test_vs_head.sh b/src/eval_tune/test_vs_head.sh
index d6d9fba..592ecf1 100755
--- a/src/eval_tune/test_vs_head.sh
+++ b/src/eval_tune/test_vs_head.sh
@@ -24,17 +24,75 @@
# 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
set -e
HEAD="${1:-../../head_reference/typhoon}"
+CANDIDATE_ARG="${2:-}"
LABEL="${3:-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}"
+
+# Drop the (up to) three positional args so "$@" holds only extra
+# pass-through flags for match_play.py. This MUST come after the three
+# positionals have been captured into HEAD/CANDIDATE_ARG/LABEL above:
+# an earlier version of this shifted first and then tested "$2" to
+# decide whether to rebuild, which (a) made every no-extra-args run
+# take the rebuild branch and silently clobber ../typhoon with a
+# different make profile, and (b) made the first pass-through FLAG VALUE
+# land in "$2" and get used as the candidate BINARY PATH -- producing a
+# baffling "PermissionError: ... Permission denied: <scratch dir>" from
+# Popen trying to execute a directory.
+for _ in 1 2 3; do
+ if [ $# -gt 0 ]; then shift; fi
+done
-if [ -n "$2" ]; then
- CANDIDATE="$2"
+if [ -n "$CANDIDATE_ARG" ]; then
+ CANDIDATE="$CANDIDATE_ARG"
else
cd ..
gmake clean
@@ -48,10 +106,11 @@ python3 ./match_play.py \
${HEAD} \
${CANDIDATE} \
--pgn ../../pgn/twic_filtered.pgn \
- --games 10000 \
- --workers 12 \
- --st 4 \
- --sprt --elo0 0 --elo1 5 \
+ --games ${GAMES} \
+ --workers 18 \
+ ${TC_ARGS} \
+ ${SPRT_ARGS} \
--scratch /usr/local/tmp/typhoon_match_${LABEL} \
--log ${LABEL}.log \
- --pgn-out ../${LABEL}_games.pgn
+ --pgn-out ../${LABEL}_games.pgn \
+ "$@"