summaryrefslogtreecommitdiff
path: root/src/eval_tune/match_play.py
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-13 08:59:29 -0700
committerScott Gasch <[email protected]>2026-10-13 08:59:29 -0700
commitf03bcac9ce3c6542310010bfbef2b126bb6ae861 (patch)
treefb30befe5cdd08d126d38009a5a62ca3ef40e9f9 /src/eval_tune/match_play.py
parent03545c4600df81b1a47dbc63fc606c065e857d8b (diff)
Minor changes; eval code was play tested as ~even.
Diffstat (limited to 'src/eval_tune/match_play.py')
-rwxr-xr-xsrc/eval_tune/match_play.py177
1 files changed, 127 insertions, 50 deletions
diff --git a/src/eval_tune/match_play.py b/src/eval_tune/match_play.py
index 4eecfd6..af7f011 100755
--- a/src/eval_tune/match_play.py
+++ b/src/eval_tune/match_play.py
@@ -64,12 +64,19 @@ _MOVE_RE = re.compile(r"^move (\S+)")
# and against real engine output. A per-move stats block trails every
# move, not just script-run suite summaries -- see EBF/first-move-beta
# discussion in session history.
+# Matches both the old report format ("Searched for 0.3 seconds, saw 123
+# nodes (45 qnodes) ( 969 nps).") and the new, more compact one ("Searched
+# for 0.3s, saw 123 nodes (45 qnodes) (969knps)."): "seconds"/"s", and an
+# optional "k" multiplier before "nps" (new format reports already-scaled
+# thousands-of-nodes-per-second).
_SEARCHED_RE = re.compile(
- r"^Searched for\s+([\d.]+) seconds, saw (\d+) nodes "
- r"\((\d+) qnodes\) \(\s*([\d.]+) nps\)\.")
+ r"^Searched for\s+([\d.]+)\s*(?:seconds|s), saw (\d+) nodes "
+ r"\((\d+) qnodes\) \(\s*([\d.]+)\s*(k?)nps\)\.")
_DEPTH_RE = re.compile(r"^tellothers d(\d+),")
+# Old: "First move beta cutoff rate was 93.681 percent."
+# New: "First move beta cutoff rate was 93.681%."
_FIRST_MOVE_BETA_RE = re.compile(
- r"^First move beta cutoff rate was\s+([\d.]+) percent\.")
+ r"^First move beta cutoff rate was\s+([\d.]+)\s*(?:percent|%)\.")
# 2026-09-18: per-iteration line UtilPrintPV (util.c) prints once per
# completed depth during a move's search (both under-xboard "%2u %6d
# %5u %-12llu %s" and human "%2u[+-]? %5s %-9s %-11llu %s" formats --
@@ -150,23 +157,77 @@ def _dbg(tag, msg):
print(f"[{tag}] {msg}", file=sys.stderr, flush=True)
-def sample_openings(pgn_path, n, min_ply=8, max_ply=20, seed=0,
+def _sample_one_opening(fb, file_size, rng, min_ply, max_ply):
+ """One attempt at pulling a random opening position out of an
+ already-open pgn pool file: seek to a random byte offset, find the
+ next game's header block, parse just that one game, and walk a
+ random number of its plies. Returns a FEN, or None if this attempt
+ didn't land on a usable game (truncated tail, too-short game, etc.)
+ -- callers retry elsewhere on None rather than treating it as fatal."""
+ offset = rng.randint(0, max(file_size - 1, 0))
+ fb.seek(offset)
+ fb.readline() # discard partial line at the seek point
+ # scan forward to the start of the next game's header block
+ start = fb.tell()
+ line = fb.readline()
+ while line and not line.startswith(b"[Event "):
+ start = fb.tell()
+ line = fb.readline()
+ if not line:
+ return None # landed past the last game in the file; retry elsewhere
+ # read forward to the START of the game AFTER this one (or EOF)
+ fb.seek(start)
+ fb.readline() # consume this game's own "[Event " line
+ end = fb.tell()
+ line = fb.readline()
+ while line and not line.startswith(b"[Event "):
+ end = fb.tell()
+ line = fb.readline()
+ fb.seek(start)
+ raw = fb.read(end - start).decode("utf-8", errors="replace")
+ game = chess.pgn.read_game(io.StringIO(raw))
+ if game is None:
+ return None
+ moves = list(game.mainline_moves())
+ if len(moves) < min_ply:
+ return None
+ board = game.board()
+ ply = rng.randint(min_ply, min(max_ply, len(moves)))
+ for mv in moves[:ply]:
+ board.push(mv)
+ return board.fen()
+
+
+def sample_openings(pgn_paths, n, min_ply=8, max_ply=20, seed=0,
cache_dir="opening_cache"):
- """Pick n random (roughly) opening positions out of a pgn pool by
- seeking to random byte offsets and parsing just the one game found
- there, rather than sequentially chess.pgn.read_game()-ing from the
- start -- on a several-hundred-MB pool, replaying every game up to
+ """Pick n random (roughly) opening positions out of one or more pgn
+ pools by seeking to random byte offsets and parsing just the one game
+ found there, rather than sequentially chess.pgn.read_game()-ing from
+ the start -- on a several-hundred-MB pool, replaying every game up to
the sample point (even while mostly skipping them) means real I/O
and SAN-parsing cost that scales with how far into the file the
first few accepted samples happen to land, which was observed
taking minutes for as few as 2 requested openings. Cached like
tune_eval_dna.py's position_cache, keyed on pgn identity + params,
- since repeated cycle.sh runs against the same pool shouldn't pay
- this more than once."""
- st = Path(pgn_path).stat()
+ since repeated cycle.sh runs against the same pool(s) shouldn't pay
+ this more than once.
+
+ `pgn_paths` may name several pools at once (e.g. the TWIC game
+ archive alongside a curated opening-book PGN) to widen the set of
+ starting positions beyond whatever one file happens to contain --
+ every match otherwise draws its "variety" from a single source,
+ which just means every match looks like that source's opening
+ repertoire. Each attempt picks a source file weighted by byte size
+ (a bigger pool has proportionally more distinct games to offer), so
+ pools aren't implicitly up- or down-weighted just by being listed
+ first or being much smaller than the others."""
+ if isinstance(pgn_paths, (str, Path)):
+ pgn_paths = [pgn_paths]
+ stats = [Path(p).stat() for p in pgn_paths]
+ key_parts = [f"{p}|{st.st_mtime}|{st.st_size}"
+ for p, st in zip(pgn_paths, stats)]
key = hashlib.sha256(
- f"{pgn_path}|{st.st_mtime}|{st.st_size}|{n}|{min_ply}|{max_ply}|{seed}"
- .encode()
+ ("||".join(key_parts) + f"||{n}|{min_ply}|{max_ply}|{seed}").encode()
).hexdigest()[:16]
cache_path = Path(cache_dir) / f"openings_{key}.txt"
if cache_path.exists():
@@ -176,49 +237,36 @@ def sample_openings(pgn_path, n, min_ply=8, max_ply=20, seed=0,
return openings
rng = random.Random(seed)
- file_size = st.st_size
+ file_sizes = [st.st_size for st in stats]
+ total_size = sum(file_sizes) or 1
+ # Precompute cumulative weights once for weighted source selection
+ # per attempt, rather than re-summing on every pick.
+ cum_weights = []
+ running = 0
+ for sz in file_sizes:
+ running += sz
+ cum_weights.append(running)
+
openings = []
attempts = 0
max_attempts = n * 50 + 200 # generous; each attempt is one seek+one game parse
- with open(pgn_path, "rb") as fb:
+ file_handles = [open(p, "rb") for p in pgn_paths]
+ try:
while len(openings) < n and attempts < max_attempts:
attempts += 1
- offset = rng.randint(0, max(file_size - 1, 0))
- fb.seek(offset)
- fb.readline() # discard partial line at the seek point
- # scan forward to the start of the next game's header block
- start = fb.tell()
- line = fb.readline()
- while line and not line.startswith(b"[Event "):
- start = fb.tell()
- line = fb.readline()
- if not line:
- continue # landed past the last game in the file; retry elsewhere
- # read forward to the START of the game AFTER this one (or EOF)
- fb.seek(start)
- fb.readline() # consume this game's own "[Event " line
- end = fb.tell()
- line = fb.readline()
- while line and not line.startswith(b"[Event "):
- end = fb.tell()
- line = fb.readline()
- fb.seek(start)
- raw = fb.read(end - start).decode("utf-8", errors="replace")
- game = chess.pgn.read_game(io.StringIO(raw))
- if game is None:
- continue
- moves = list(game.mainline_moves())
- if len(moves) < min_ply:
- continue
- board = game.board()
- ply = rng.randint(min_ply, min(max_ply, len(moves)))
- for mv in moves[:ply]:
- board.push(mv)
- openings.append(board.fen())
+ pick = rng.uniform(0, total_size)
+ idx = next(i for i, cw in enumerate(cum_weights) if pick <= cw)
+ fen = _sample_one_opening(file_handles[idx], file_sizes[idx],
+ rng, min_ply, max_ply)
+ if fen is not None:
+ openings.append(fen)
+ finally:
+ for fb in file_handles:
+ fb.close()
if len(openings) < n:
print(f"warning: only found {len(openings)} openings "
- f"(wanted {n}) in {pgn_path}", file=sys.stderr)
+ f"(wanted {n}) across {pgn_paths}", file=sys.stderr)
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text("\n".join(openings) + "\n" if openings else "")
@@ -763,7 +811,16 @@ def main():
ap = argparse.ArgumentParser()
ap.add_argument("head_engine", help="path to the baseline/head typhoon binary")
ap.add_argument("candidate_engine", help="path to the candidate typhoon binary")
- ap.add_argument("--pgn", required=True)
+ ap.add_argument("--pgn", required=True, nargs="+",
+ help="one or more pgn pools to sample starting "
+ "positions from (e.g. --pgn ../../pgn/"
+ "twic_filtered.pgn ../../pgn/self_play_games.pgn). "
+ "Each candidate opening's source file is picked "
+ "weighted by that file's byte size, so listing "
+ "several pools widens the set of starting "
+ "positions instead of implicitly favoring "
+ "whichever is listed first or is largest/"
+ "smallest.")
ap.add_argument("--games", type=int, default=500,
help="total games; rounded down to an even number "
"since each opening is played as a pair "
@@ -837,7 +894,27 @@ def main():
ap.add_argument("--log", default="match_history.log")
ap.add_argument("--pgn-out", default="../self_play_games.pgn",
help="every played game is appended here as PGN")
+ ap.add_argument("--opening-seed", type=int, default=None, metavar="N",
+ help="seed for opening-position sampling. Default: "
+ "a fresh random seed every run, so successive "
+ "invocations draw a DIFFERENT set of starting "
+ "positions from --pgn rather than silently "
+ "replaying whatever sample a previous run "
+ "already cached under opening_cache/ for these "
+ "exact (pgn, n, min_ply, max_ply) params -- that "
+ "caching exists to make a single run's sampling "
+ "cheap to redo (e.g. a crashed/resumed run), not "
+ "to pin one fixed opening set across unrelated "
+ "runs. Pass an explicit value to reproduce a "
+ "specific run's exact opening set.")
args = ap.parse_args()
+ if args.opening_seed is None:
+ # SystemRandom, not random.Random/time-based: back-to-back runs
+ # (e.g. a shell loop launching several matches within the same
+ # second) must not collide on the same seed and silently resample
+ # the same "random" set.
+ args.opening_seed = random.SystemRandom().randrange(2**31)
+ print(f"OPENING_SEED={args.opening_seed}", file=sys.stderr)
# --- resolve the per-side time controls once, loudly ---------------
# Deliberately strict: a time-odds match that silently degrades into
@@ -893,7 +970,7 @@ def main():
candidate_engine = str(Path(args.candidate_engine).resolve())
n_pairs = args.games // 2
- openings = sample_openings(args.pgn, n_pairs)
+ openings = sample_openings(args.pgn, n_pairs, seed=args.opening_seed)
n_games = len(openings) * 2
jobs = []