diff options
| author | Scott Gasch <[email protected]> | 2026-10-13 08:59:29 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-13 08:59:29 -0700 |
| commit | f03bcac9ce3c6542310010bfbef2b126bb6ae861 (patch) | |
| tree | fb30befe5cdd08d126d38009a5a62ca3ef40e9f9 /src/eval_tune/match_play.py | |
| parent | 03545c4600df81b1a47dbc63fc606c065e857d8b (diff) | |
Minor changes; eval code was play tested as ~even.
Diffstat (limited to 'src/eval_tune/match_play.py')
| -rwxr-xr-x | src/eval_tune/match_play.py | 177 |
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 = [] |
