diff options
| author | Scott Gasch <[email protected]> | 2026-08-29 23:17:31 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-08-29 23:17:31 -0700 |
| commit | fa210fb9645afd27e2991b3ee8139be231d0b5ec (patch) | |
| tree | 1ee7be4d51d3d2bc4ffa2c512db5270cea6b2af2 /src | |
| parent | f2613dfabb5ef3a7a08a73e74c83467dfad9ddcc (diff) | |
Various utils.
Diffstat (limited to 'src')
| -rwxr-xr-x | src/eval_tune/bake_dna.py | 151 | ||||
| -rw-r--r-- | src/eval_tune/compare_ecm_depth.py | 46 | ||||
| -rw-r--r-- | src/eval_tune/compare_ecm_nodes.py | 79 | ||||
| -rw-r--r-- | src/eval_tune/compare_ecm_shareddepth.py | 74 | ||||
| -rwxr-xr-x | src/eval_tune/cycle.sh | 137 | ||||
| -rwxr-xr-x | src/eval_tune/dna_diff.py | 123 | ||||
| -rwxr-xr-x | src/eval_tune/dna_trend.py | 154 | ||||
| -rwxr-xr-x | src/eval_tune/run_ecm.sh | 41 |
8 files changed, 805 insertions, 0 deletions
diff --git a/src/eval_tune/bake_dna.py b/src/eval_tune/bake_dna.py new file mode 100755 index 0000000..9d03644 --- /dev/null +++ b/src/eval_tune/bake_dna.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +Bake a tuned .dna file into eval.c as the new hardcoded baseline. + +The .dna format (see ExportEvalDNA/ImportEvalDNA in eval.c) is +POSITIONAL: one line per entry in g_EvalDNA[], in that exact order, +each line a flat comma-separated list matching that entry's declared +size (DNA_VAR=1, DNA_ARRAY(x)=ARRAY_LENGTH(x), +DNA_MATRIX(x)=ARRAY_LENGTH(x)*ARRAY_LENGTH(x[0])). There are no names +in the file -- this script recovers the name/order from the +g_EvalDNA[] initializer in eval.c itself, so it only works against +the exact eval.c revision the .dna was tuned against. If arrays were +added/reordered/resized since, per-entry count checks below will +catch a misalignment and abort rather than silently writing values +into the wrong array. + +This does NOT touch formatting/whitespace/line-breaks in eval.c's +board-shaped grids -- it walks each array's existing initializer, +replaces only the numeric literals in place (in textual order), and +leaves everything else (comments, 8x8 layout) untouched, so `git +diff` on a bake-in only shows the numbers that changed. +""" +import argparse +import re +import sys + +DNA_ENTRY_RE = re.compile(r'DNA_(VAR|ARRAY|MATRIX)\((\w+)\)') +INT_RE = re.compile(r'-?\d+') +# Declarations seen for DNA-tracked values are `static SCORE x = ...`, +# `static ULONG x[...] = ...`, or (RACER_WINS_RACE) `SCORE x = ...` +# with no `static` at all -- match any of these. +DECL_RE_TMPL = r'(?:static\s+)?(?:SCORE|ULONG)\s+{name}\s*(\[[^=;]*\])?\s*=\s*' + + +def find_g_eval_dna_order(src): + m = re.search(r'static DNA_BASE_SIZE g_EvalDNA\[\]\s*=\s*\{(.*?)\};', + src, re.S) + if not m: + sys.exit("could not find g_EvalDNA[] initializer in eval.c") + return [(kind, name) for kind, name in DNA_ENTRY_RE.findall(m.group(1))] + + +def mask_comments(text): + """Blank out //... and /*...*/ comment bodies, preserving length/newlines + so character offsets into the masked text stay valid in the original.""" + out = list(text) + i = 0 + n = len(text) + while i < n: + if text[i:i+2] == "//": + j = text.find("\n", i) + j = n if j == -1 else j + for k in range(i, j): + out[k] = " " + i = j + elif text[i:i+2] == "/*": + j = text.find("*/", i + 2) + j = n if j == -1 else j + 2 + for k in range(i, j): + if out[k] != "\n": + out[k] = " " + i = j + else: + i += 1 + return "".join(out) + + +def find_declaration_span(src, name): + """Return (value_start, value_end, is_scalar) for the `... name ... = ...;` declaration.""" + m = re.search(DECL_RE_TMPL.format(name=re.escape(name)), src) + if not m: + sys.exit(f"could not find declaration of {name} in eval.c") + value_start = m.end() + if src[value_start:].lstrip().startswith("{"): + # array/matrix: find the matching closing brace by depth counting + i = src.index("{", value_start) + depth = 0 + j = i + while True: + if src[j] == "{": + depth += 1 + elif src[j] == "}": + depth -= 1 + if depth == 0: + break + j += 1 + semi = src.index(";", j) + return i, semi, False + else: + semi = src.index(";", value_start) + return value_start, semi, True + + +def replace_ints(text, new_values): + """Replace only the numeric literals that are live code (not inside + comments) with new_values, in order. Returns (new_text, matched, remaining).""" + masked = mask_comments(text) + matches = list(INT_RE.finditer(masked)) + if len(matches) != len(new_values): + return text, len(matches), len(new_values) - len(matches) + out = [] + pos = 0 + for m, val in zip(matches, new_values): + out.append(text[pos:m.start()]) + out.append(str(val)) + pos = m.end() + out.append(text[pos:]) + return "".join(out), len(matches), 0 + + +def bake(eval_c_path, dna_path, out_path): + src = open(eval_c_path).read() + order = find_g_eval_dna_order(src) + dna_lines = [l for l in open(dna_path).read().splitlines() if l.strip()] + + if len(dna_lines) != len(order): + sys.exit(f"DNA file has {len(dna_lines)} lines but g_EvalDNA[] has " + f"{len(order)} entries -- eval.c and the .dna file don't " + f"match (different revision?). Aborting.") + + # Apply edits back-to-front by source position so earlier offsets + # stay valid as we splice. + edits = [] + for (kind, name), line in zip(order, dna_lines): + values = [int(v) for v in line.split(",") if v.strip() != ""] + start, end, is_scalar = find_declaration_span(src, name) + segment = src[start:end] + new_segment, matched, remaining = replace_ints(segment, values) + if remaining != 0 or matched != len(values): + sys.exit(f"{name}: declaration has {matched} numeric literals " + f"but .dna line has {len(values)} values -- aborting " + f"(likely eval.c/.dna mismatch).") + edits.append((start, end, new_segment, name)) + + edits.sort(key=lambda e: e[0], reverse=True) + for start, end, new_segment, name in edits: + src = src[:start] + new_segment + src[end:] + + with open(out_path, "w") as f: + f.write(src) + print(f"baked {len(order)} entries from {dna_path} into {out_path}") + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("dna_file") + ap.add_argument("--eval-c", default="../eval.c") + ap.add_argument("--out", default=None, + help="defaults to overwriting --eval-c in place") + args = ap.parse_args() + bake(args.eval_c, args.dna_file, args.out or args.eval_c) diff --git a/src/eval_tune/compare_ecm_depth.py b/src/eval_tune/compare_ecm_depth.py new file mode 100644 index 0000000..51329a7 --- /dev/null +++ b/src/eval_tune/compare_ecm_depth.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Pair up per-problem achieved-depth (final iterated depth reached before +the node-count budget ran out) from two sn=N ECM stdout logs (same input +file, same order) and report win/loss/tie counts plus median depth delta. +""" +import re +import statistics +import sys + +TELL_RE = re.compile(r"tellothers d(\d+),") + + +def parse(path): + """Return list of achieved-depth ints, one per completed problem, in order.""" + depths = [] + with open(path) as f: + for line in f: + m = TELL_RE.search(line) + if m: + depths.append(int(m.group(1))) + return depths + + +def main(): + base_path, cand_path = sys.argv[1], sys.argv[2] + base = parse(base_path) + cand = parse(cand_path) + n = min(len(base), len(cand)) + base, cand = base[:n], cand[:n] + + deltas = [c - b for b, c in zip(base, cand)] + wins = sum(1 for d in deltas if d > 0) + losses = sum(1 for d in deltas if d < 0) + ties = sum(1 for d in deltas if d == 0) + + print(f"Paired problems: {n}") + print(f" candidate deeper: {wins}") + print(f" candidate shallower: {losses}") + print(f" tied depth: {ties}") + print(f" median depth delta (candidate - baseline): {statistics.median(deltas):+.1f}") + print(f" mean depth delta: {statistics.mean(deltas):+.3f}") + print(f" sum of depth delta: {sum(deltas):+d}") + + +if __name__ == "__main__": + main() diff --git a/src/eval_tune/compare_ecm_nodes.py b/src/eval_tune/compare_ecm_nodes.py new file mode 100644 index 0000000..73408ef --- /dev/null +++ b/src/eval_tune/compare_ecm_nodes.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Pair up per-problem node counts from two ECM sd=N stdout logs (same +input file, same order) and report a median/geomean ratio plus win/loss +counts -- robust to the handful of huge forced-mate outliers that would +otherwise dominate a raw sum. Mate-hunt problems (last iterated depth +line containing MATE) are reported separately since their node count is +chaotic w.r.t. move-ordering and not representative of typical +branching-factor efficiency. +""" +import re +import statistics +import sys + +SEARCHED_RE = re.compile(r"Searched for\s+[\d.]+ seconds, saw (\d+) nodes") +DEPTH_LINE_RE = re.compile(r"^\s*\d+[+]?\s+(\S+)\s+[\d:.]+\s+(\d+)\s") + + +def parse(path): + """Return list of (node_count, is_mate) per completed problem, in order.""" + results = [] + last_score = None + with open(path) as f: + for line in f: + m = DEPTH_LINE_RE.match(line) + if m: + last_score = m.group(1) + continue + m = SEARCHED_RE.search(line) + if m: + is_mate = bool(last_score and "MATE" in last_score) + results.append((int(m.group(1)), is_mate)) + last_score = None + return results + + +def main(): + base_path, cand_path = sys.argv[1], sys.argv[2] + base = parse(base_path) + cand = parse(cand_path) + n = min(len(base), len(cand)) + base, cand = base[:n], cand[:n] + + normal_ratios = [] + mate_ratios = [] + wins = losses = ties = 0 + mate_wins = mate_losses = mate_ties = 0 + + for (bn, bmate), (cn, cmate) in zip(base, cand): + ratio = cn / bn if bn else 1.0 + is_mate = bmate or cmate + bucket = mate_ratios if is_mate else normal_ratios + bucket.append(ratio) + if is_mate: + if cn < bn: mate_wins += 1 + elif cn > bn: mate_losses += 1 + else: mate_ties += 1 + else: + if cn < bn: wins += 1 + elif cn > bn: losses += 1 + else: ties += 1 + + print(f"Paired problems: {n}") + print() + print(f"=== Non-mate problems (n={len(normal_ratios)}) ===") + if normal_ratios: + print(f" median candidate/baseline node ratio: {statistics.median(normal_ratios):.4f}") + print(f" geomean ratio: {statistics.geometric_mean(normal_ratios):.4f}") + print(f" wins (candidate fewer nodes): {wins}") + print(f" losses (candidate more nodes): {losses}") + print(f" ties: {ties}") + print() + print(f"=== Mate-hunt problems (n={len(mate_ratios)}) -- reported separately, chaotic ===") + if mate_ratios: + print(f" median ratio: {statistics.median(mate_ratios):.4f}") + print(f" wins: {mate_wins} losses: {mate_losses} ties: {mate_ties}") + + +if __name__ == "__main__": + main() diff --git a/src/eval_tune/compare_ecm_shareddepth.py b/src/eval_tune/compare_ecm_shareddepth.py new file mode 100644 index 0000000..2194f02 --- /dev/null +++ b/src/eval_tune/compare_ecm_shareddepth.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Pair up per-problem node counts at the DEEPEST depth BOTH binaries +completed within their sn=N node budget (not the final/achieved depth, +which is too coarse -- see compare_ecm_depth.py). Continuous signal, +still fully bounded since we only ever compare depths both sides +actually finished. +""" +import re +import statistics +import sys + +DEPTH_LINE_RE = re.compile(r"^\s*(\d+)[+]?\s+(\S+)\s+[\d:.]+\s+(\d+)\s") +ROOT_POS_RE = re.compile(r"^The root position is:") + + +def parse(path): + """Return list of {depth: node_count} dicts, one per problem, in order.""" + problems = [] + cur = {} + with open(path) as f: + for line in f: + if ROOT_POS_RE.match(line): + if cur: + problems.append(cur) + cur = {} + continue + m = DEPTH_LINE_RE.match(line) + if m: + depth = int(m.group(1)) + nodes = int(m.group(3)) + cur[depth] = nodes # later (deeper/re-searched) lines overwrite + if cur: + problems.append(cur) + return problems + + +def main(): + base_path, cand_path = sys.argv[1], sys.argv[2] + base = parse(base_path) + cand = parse(cand_path) + n = min(len(base), len(cand)) + + ratios = [] + wins = losses = ties = 0 + skipped = 0 + + for i in range(n): + b, c = base[i], cand[i] + shared_depths = set(b) & set(c) + if not shared_depths: + skipped += 1 + continue + d = max(shared_depths) + bn, cn = b[d], c[d] + if bn == 0: + skipped += 1 + continue + ratio = cn / bn + ratios.append(ratio) + if cn < bn: wins += 1 + elif cn > bn: losses += 1 + else: ties += 1 + + print(f"Paired problems: {n} (usable: {len(ratios)}, skipped: {skipped})") + if ratios: + print(f" median candidate/baseline ratio at shared depth: {statistics.median(ratios):.4f}") + print(f" geomean ratio: {statistics.geometric_mean(ratios):.4f}") + print(f" wins (candidate fewer nodes): {wins}") + print(f" losses (candidate more nodes): {losses}") + print(f" ties: {ties}") + + +if __name__ == "__main__": + main() diff --git a/src/eval_tune/cycle.sh b/src/eval_tune/cycle.sh new file mode 100755 index 0000000..0a64ccb --- /dev/null +++ b/src/eval_tune/cycle.sh @@ -0,0 +1,137 @@ +#!/bin/sh +# One full wash/rinse/repeat cycle of the eval-tuning pipeline: +# +# 1. Dump the currently-compiled-in DNA as this cycle's baseline. +# 2. Tune a candidate DNA against the TWIC position pool. +# 3. Gate: play baseline vs. candidate head-to-head (match_play.py). +# 4. If candidate scores >= 0.5, bake it into eval.c as the new +# baseline, rebuild, and leave eval.c MODIFIED BUT UNCOMMITTED +# for manual review (per project preference -- this script never +# runs `git commit`). If it loses, eval.c is untouched and the +# losing candidate.dna is kept only for the record. +# +# Run this again after you've reviewed/committed a win, or right away +# after a loss -- tune_eval_dna.py resamples a fresh random batch from +# the pool each time regardless. +# +# Usage: cycle.sh [pgn_pool] [n_games] [workers] [max_positions] [max_passes] \ +# [sd_depth] [batch_size] [holdout_frac] +# The 4th/5th args are optional tune_eval_dna.py budget overrides -- +# leave them unset for a real cycle (full historical budget); pass +# small values (e.g. 2000 1) for a quick dry run of the whole +# pipeline. sd_depth (default 10) is the gate match's fixed search +# depth -- lower it (e.g. 4-5) for a fast pipeline-wiring smoke test; +# sd 10 games can run long on a loaded box (observed: single game +# still going after 15+ min in one dry run) so don't use the default +# for anything time-boxed. batch_size (default: unset, meaning +# tune_eval_dna.py's own default of 1/10th the training pool) and +# holdout_frac (default: unset, meaning tune_eval_dna.py's own 0.1) +# are further tune_eval_dna.py overrides -- see its --help/docstring. + +set -e +cd "$(dirname "$0")/.." # repo src/ root +SRC="$(pwd)" +EVAL_TUNE="$SRC/eval_tune" + +PGN="${1:-/usr/home/scott/typhoon/pgn/twic_filtered.pgn}" +N_GAMES="${2:-500}" +WORKERS="${3:-4}" +MAX_POSITIONS="${4:-}" +MAX_PASSES="${5:-}" +SD_DEPTH="${6:-10}" +BATCH_SIZE="${7:-}" +HOLDOUT_FRAC="${8:-}" + +ts=$(date -u +%Y%m%dT%H%M%SZ) +cycle_dir="$EVAL_TUNE/cycles/$ts" +mkdir -p "$cycle_dir" +echo "=== cycle $ts ===" +echo "pgn pool: $PGN" +echo "cycle dir: $cycle_dir" + +echo "--- building current baseline ---" +gmake -j5 GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1 >"$cycle_dir/build_baseline.log" 2>&1 + +echo "--- dumping baseline DNA ---" +./typhoon --batch --command "evaldna write $cycle_dir/baseline.dna" >/dev/null 2>&1 || true +if [ ! -s "$cycle_dir/baseline.dna" ]; then + echo "ERROR: evaldna write did not produce $cycle_dir/baseline.dna" >&2 + exit 1 +fi +cp eval.c "$cycle_dir/eval.c.baseline" + +echo "--- tuning candidate DNA (this can take hours) ---" +python3 "$EVAL_TUNE/tune_eval_dna.py" "$SRC/typhoon" "$PGN" $MAX_POSITIONS $MAX_PASSES \ + $BATCH_SIZE $HOLDOUT_FRAC \ + > "$cycle_dir/tune.log" 2>&1 || { + echo "tune_eval_dna.py failed -- see $cycle_dir/tune.log" >&2 + exit 1 + } +# tune_eval_dna.py's __main__ currently hardcodes out_path="tuned.dna" +# in its own cwd; capture wherever it actually landed. +if [ -f "$EVAL_TUNE/tuned.dna" ]; then + mv "$EVAL_TUNE/tuned.dna" "$cycle_dir/candidate.dna" +elif [ -f "tuned.dna" ]; then + mv "tuned.dna" "$cycle_dir/candidate.dna" +else + echo "couldn't find tuned.dna output -- see $cycle_dir/tune.log" >&2 + exit 1 +fi + +echo "--- gate: baseline vs candidate, $N_GAMES games @ sd $SD_DEPTH ---" +match_out="$cycle_dir/match_result.txt" +python3 "$EVAL_TUNE/match_play.py" "$SRC/typhoon" \ + "$cycle_dir/baseline.dna" "$cycle_dir/candidate.dna" \ + --pgn "$PGN" --games "$N_GAMES" --workers "$WORKERS" --sd "$SD_DEPTH" \ + --log "$cycle_dir/match_games.log" --pgn-out "$cycle_dir/match_games.pgn" \ + 2> "$cycle_dir/match_stderr.log" | tee "$match_out" + +score=$(grep -o 'CANDIDATE_SCORE=[0-9.]*' "$match_out" | cut -d= -f2) +lower95=$(grep -o 'LOWER95=[0-9.-]*' "$match_out" | cut -d= -f2) +if [ -z "$score" ] || [ -z "$lower95" ]; then + echo "couldn't parse CANDIDATE_SCORE/LOWER95 from match_play.py output" >&2 + exit 1 +fi + +# Gate on the 95% confidence LOWER bound, not the raw point estimate: +# search is non-deterministic (MP=1 multithreaded), so a bare score +# >= 0.5 is not evidence the candidate is actually better -- only a +# lower bound that still clears break-even is. +pass=$(awk -v s="$lower95" 'BEGIN { print (s >= 0.5) ? "1" : "0" }') +summary="$cycle_dir/summary.txt" +{ + echo "cycle: $ts" + echo "pgn pool: $PGN" + echo "games: $N_GAMES @ sd $SD_DEPTH" + cat "$match_out" + echo +} > "$summary" + +if [ "$pass" = "1" ]; then + echo "=== candidate lower95 $lower95 (score $score) >= 0.5: baking in as new baseline ===" + python3 "$EVAL_TUNE/bake_dna.py" "$cycle_dir/candidate.dna" \ + --eval-c "$SRC/eval.c" --out "$SRC/eval.c" + + echo "--- rebuilding with new baseline ---" + gmake clean >"$cycle_dir/build_candidate.log" 2>&1 + gmake -j5 GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1 >>"$cycle_dir/build_candidate.log" 2>&1 + + python3 "$EVAL_TUNE/dna_diff.py" "$cycle_dir/eval.c.baseline" "$cycle_dir/candidate.dna" \ + > "$cycle_dir/dna_diff.txt" 2>&1 || true + + { + echo "RESULT: KEPT -- eval.c modified, NOT committed." + echo "Review: git diff eval.c" + echo "Then commit yourself, e.g.:" + echo " git add eval.c" + echo " git commit -m 'Eval DNA tune cycle $ts: score $score vs prior baseline (pgn=$PGN, $N_GAMES games)'" + } >> "$summary" +else + echo "=== candidate lower95 $lower95 (score $score) < 0.5: discarding, eval.c untouched ===" + { + echo "RESULT: DISCARDED -- candidate underperformed baseline." + echo "candidate.dna and match games kept in $cycle_dir for the record." + } >> "$summary" +fi + +cat "$summary" diff --git a/src/eval_tune/dna_diff.py b/src/eval_tune/dna_diff.py new file mode 100755 index 0000000..ecdd6bb --- /dev/null +++ b/src/eval_tune/dna_diff.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Human-readable diff between a baseline DNA dump and a tuned .dna +file: names each of g_EvalDNA's 56 arrays (order taken directly from +eval.c's g_EvalDNA[] table) and, for the 128-cell board-shaped +location tables, renders an actual 8x8 grid diff instead of a wall of +raw numbers.""" +import sys + +# Exact order from eval.c:604-659 (g_EvalDNA[] initializer). +DNA_NAMES = [ + "TRADE_PIECES", "DONT_TRADE_PAWNS", "REDUCED_MATERIAL_DOWN_SCALER", + "REDUCED_MATERIAL_UP_SCALER", "PASSER_MATERIAL_UP_SCALER", + "PAWN_CENTRALITY_BONUS", "BACKWARD_SHIELDED_BY_LOCATION", + "BACKWARD_EXPOSED_BY_LOCATION", "DOUBLED_PAWN_PENALTY_BY_COUNT", + "ISOLATED_PAWN_PENALTY_BY_COUNT", "ISOLATED_PAWN_BY_PAWNFILE", + "ISOLATED_EXPOSED_PAWN", "ISOLATED_DOUBLED_PAWN", "PASSER_BY_RANK", + "CANDIDATE_PASSER_BY_RANK", "CONNECTED_PASSERS_BY_RANK", + "SUPPORTED_PASSER_BY_RANK", "OUTSIDE_PASSER_BY_DISTANCE", + "PASSER_BONUS_AS_MATERIAL_COMES_OFF", "RACER_WINS_RACE", + "UNDEVELOPED_MINORS_IN_OPENING", "BISHOP_OVER_KNIGHT_IN_ENDGAME", + "BISHOP_PAIR", "STATIONARY_PAWN_ON_BISHOP_COLOR", + "TRANSIENT_PAWN_ON_BISHOP_COLOR", "BISHOP_MOBILITY_BY_SQUARES", + "BISHOP_MAX_MOBILITY_IN_A_ROW_BONUS", + "BISHOP_UNASSAILABLE_BY_DIST_FROM_EKING", "BISHOP_IN_CLOSED_POSITION", + "KNIGHT_CENTRALITY_BONUS", "KNIGHT_KING_TROPISM_BONUS", + "KNIGHT_UNASSAILABLE_BY_DIST_FROM_EKING", + "KNIGHT_ON_INTERESTING_SQUARE_BY_RANK", "KNIGHT_MOBILITY_BY_COUNT", + "KNIGHT_WITH_N_PAWNS_SUPPORTING", "KNIGHT_IN_CLOSED_POSITION", + "ROOK_ON_FULL_OPEN_BY_DIST_FROM_EKING", + "ROOK_ON_HALF_OPEN_WITH_ENEMY_BY_DIST_FROM_EKING", + "ROOK_ON_HALF_OPEN_WITH_FRIEND_BY_DIST_FROM_EKING", + "ROOK_BEHIND_PASSER_BY_PASSER_RANK", "ROOK_LEADS_PASSER_BY_PASSER_RANK", + "KING_TRAPPING_ROOK", "ROOK_TRAPPING_EKING", + "ROOK_VALUE_AS_PAWNS_COME_OFF", "ROOK_CONNECTED_VERT", + "ROOK_CONNECTED_HORIZ", "ROOK_MOBILITY_BY_SQUARES", + "ROOK_MAX_MOBILITY_IN_A_ROW_BONUS", "QUEEN_MOBILITY_BY_SQUARES", + "QUEEN_OUT_EARLY", "QUEEN_KING_TROPISM", + "QUEEN_ATTACKS_SQ_NEXT_TO_KING", "KING_INITIAL_COUNTER_BY_LOCATION", + "KING_TO_CENTER", "KING_SAFETY_BY_COUNTER", + "KING_MISSING_ONE_CASTLE_OPTION", +] + +# Arrays laid out as a 128-cell "0x88-style" board: 8 files + 8 padding +# zeros per rank, 8 ranks (see eval.c's literal formatting -- each row +# of the C initializer is one rank, padded to 16 slots). Rendered +# top-to-bottom as rank 8 -> rank 1 like the board is shown elsewhere. +BOARD128_NAMES = { + "PAWN_CENTRALITY_BONUS", "BACKWARD_SHIELDED_BY_LOCATION", + "BACKWARD_EXPOSED_BY_LOCATION", "STATIONARY_PAWN_ON_BISHOP_COLOR", + "TRANSIENT_PAWN_ON_BISHOP_COLOR", "KNIGHT_CENTRALITY_BONUS", + "KING_TO_CENTER", +} +# KING_INITIAL_COUNTER_BY_LOCATION is [2][128] -- one 128-board per color. +BOARD128_PAIR_NAMES = {"KING_INITIAL_COUNTER_BY_LOCATION"} + +FILES = "ABCDEFGH" + + +def read_dna_file(path): + rows = [] + with open(path) as f: + for line in f: + line = line.strip() + if not line: + continue + rows.append([int(x) for x in line.split(",")]) + return rows + + +def diff_board128(old_row, new_row): + lines = [] + for rank8_from_top in range(8): + old_cells = old_row[rank8_from_top * 16: rank8_from_top * 16 + 8] + new_cells = new_row[rank8_from_top * 16: rank8_from_top * 16 + 8] + rank_label = 8 - rank8_from_top + cell_strs = [] + for o, n in zip(old_cells, new_cells): + if o == n: + cell_strs.append(f"{n:4d}") + else: + cell_strs.append(f"{o:+d}->{n:+d}") + lines.append(f" {rank_label} " + " ".join(f"{s:>9s}" for s in cell_strs)) + lines.append(" " + " ".join(f"{f}" for f in FILES)) + return lines + + +def report(baseline_rows, tuned_rows, names=DNA_NAMES, only_changed=True): + assert len(baseline_rows) == len(tuned_rows) == len(names), ( + f"row count mismatch: baseline={len(baseline_rows)} " + f"tuned={len(tuned_rows)} names={len(names)}" + ) + any_change = False + for name, old_row, new_row in zip(names, baseline_rows, tuned_rows): + if old_row == new_row: + if not only_changed: + print(f"{name}: unchanged") + continue + any_change = True + print(f"\n=== {name} ===") + if name in BOARD128_PAIR_NAMES: + half = len(old_row) // 2 + for color, lo, hi in (("BLACK", 0, half), ("WHITE", half, len(old_row))): + if old_row[lo:hi] != new_row[lo:hi]: + print(f" -- {color} --") + for line in diff_board128(old_row[lo:hi], new_row[lo:hi]): + print(" ", line) + elif name in BOARD128_NAMES and len(old_row) == 128: + for line in diff_board128(old_row, new_row): + print(" ", line) + else: + diffs = [ + (i, o, n) for i, (o, n) in enumerate(zip(old_row, new_row)) if o != n + ] + print(f" old: {old_row}") + print(f" new: {new_row}") + print(f" changed cells: {diffs}") + if not any_change: + print("No differences -- tuned DNA is identical to baseline.") + + +if __name__ == "__main__": + baseline_path, tuned_path = sys.argv[1], sys.argv[2] + report(read_dna_file(baseline_path), read_dna_file(tuned_path)) diff --git a/src/eval_tune/dna_trend.py b/src/eval_tune/dna_trend.py new file mode 100755 index 0000000..15ecca8 --- /dev/null +++ b/src/eval_tune/dna_trend.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +Track per-parameter direction across a sequence of .dna files (e.g. +one KEPT candidate.dna per cycle.sh cycle, in chronological order) to +tell apart two very different things that both show up as "the DNA +changed again": + + - a parameter trending: consecutive deltas keep the same sign, i.e. + the tuner keeps pushing it the same direction cycle over cycle -- + this looks like real signal. + - a parameter flip-flopping: consecutive deltas alternate sign -- + the tuner is chasing sampling noise in the position batch, not + converging on anything. + +For each raw DNA cell we compute: + net = final_value - first_value + churn = sum(|delta| for each consecutive step) + consistency = net / churn (in [-1, 1]; 0 churn -> consistency 1 if + net is also 0, else undefined/skipped) + +consistency near +-1 means every step moved the same direction (pure +trend); consistency near 0 with nonzero churn means it moved a lot but +ended up roughly where it started (pure flip-flop). + +Usage: + python3 dna_trend.py cycle1.dna cycle2.dna cycle3.dna ... + python3 dna_trend.py --cycles-dir eval_tune/cycles # auto-discover, + chronological by directory timestamp, only cycles with a KEPT + candidate.dna per their summary.txt + +Prints, per named array, a one-line summary, then the individual cells +with the strongest trend and the worst flip-flop for a closer look. +""" +import argparse +import sys +from pathlib import Path + +from dna_diff import DNA_NAMES, read_dna_file + + +def load_sequence(paths): + rows_by_file = [read_dna_file(p) for p in paths] + n_cells_per_file = [sum(len(row) for row in rows) for rows in rows_by_file] + if len(set(n_cells_per_file)) != 1: + sys.exit(f"cell-count mismatch across files: {dict(zip(paths, n_cells_per_file))} " + f"-- these .dna files don't all match the same eval.c revision.") + # Flatten each file to one list of (name, index_within_array, value) + flat_sequences = [] + for rows in rows_by_file: + flat = [] + for name, row in zip(DNA_NAMES, rows): + for i, v in enumerate(row): + flat.append((name, i, v)) + flat_sequences.append(flat) + return flat_sequences + + +def discover_kept_cycles(cycles_dir): + paths = [] + for d in sorted(Path(cycles_dir).iterdir()): + summary = d / "summary.txt" + candidate = d / "candidate.dna" + if summary.exists() and candidate.exists(): + text = summary.read_text() + if "RESULT: KEPT" in text: + paths.append(candidate) + return paths + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("dna_files", nargs="*") + ap.add_argument("--cycles-dir", default=None, + help="auto-discover KEPT candidate.dna files under this " + "cycle.sh cycles/ directory, chronologically") + ap.add_argument("--top", type=int, default=15, + help="how many strongest-trend / worst-flip-flop cells to list") + args = ap.parse_args() + + if args.cycles_dir: + paths = discover_kept_cycles(args.cycles_dir) + else: + paths = [Path(p) for p in args.dna_files] + + if len(paths) < 2: + sys.exit("need at least 2 .dna files (in chronological order) to " + "compute a trend -- got " + f"{len(paths)}: {[str(p) for p in paths]}") + + print(f"Sequence ({len(paths)} points, chronological):") + for p in paths: + print(f" {p}") + print() + + sequences = load_sequence(paths) + n_cells = len(sequences[0]) + + results = [] # (name, index, net, churn, consistency, first, last) + for cell_idx in range(n_cells): + name, arr_idx, _ = sequences[0][cell_idx] + values = [seq[cell_idx][2] for seq in sequences] + deltas = [b - a for a, b in zip(values, values[1:])] + net = values[-1] - values[0] + churn = sum(abs(d) for d in deltas) + if churn == 0: + continue # never moved -- not interesting either way + consistency = net / churn + results.append((name, arr_idx, net, churn, consistency, values[0], values[-1])) + + if not results: + print("No cell changed at all across this sequence.") + return + + # Per-array rollup: mean |consistency| weighted by churn, plus counts. + by_array = {} + for name, arr_idx, net, churn, consistency, first, last in results: + d = by_array.setdefault(name, {"churn": 0, "weighted": 0.0, "n": 0, + "trending": 0, "flipping": 0}) + d["churn"] += churn + d["weighted"] += abs(consistency) * churn + d["n"] += 1 + if abs(consistency) >= 0.6: + d["trending"] += 1 + elif abs(consistency) <= 0.25: + d["flipping"] += 1 + + print(f"{'ARRAY':45s} {'cells':>6s} {'trend':>6s} {'flip':>5s} {'churn-wtd consistency':>22s}") + for name in DNA_NAMES: + if name not in by_array: + continue + d = by_array[name] + wavg = d["weighted"] / d["churn"] if d["churn"] else 0.0 + print(f"{name:45s} {d['n']:6d} {d['trending']:6d} {d['flipping']:5d} {wavg:22.2f}") + + results.sort(key=lambda r: -abs(r[4]) * r[3]) # weight by churn too + trending = [r for r in results if r[4] >= 0.6][:args.top] + flipping = sorted([r for r in results if abs(r[4]) <= 0.25], + key=lambda r: -r[3])[:args.top] + + print(f"\nTop {len(trending)} most consistently-trending cells " + f"(same direction every step):") + for name, idx, net, churn, cons, first, last in trending: + print(f" {name}[{idx}]: {first} -> {last} " + f"(net {net:+d}, churn {churn}, consistency {cons:+.2f})") + + print(f"\nTop {len(flipping)} worst flip-flopping cells " + f"(moved a lot, net ~0 -- likely noise):") + for name, idx, net, churn, cons, first, last in flipping: + print(f" {name}[{idx}]: {first} -> {last} " + f"(net {net:+d}, churn {churn}, consistency {cons:+.2f})") + + +if __name__ == "__main__": + main() diff --git a/src/eval_tune/run_ecm.sh b/src/eval_tune/run_ecm.sh new file mode 100755 index 0000000..a6c1284 --- /dev/null +++ b/src/eval_tune/run_ecm.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Run the ECM tactical suite at a fixed search depth (not fixed time -- +# see CLAUDE.md: st introduces machine-load noise that sd avoids) and +# print just the "correct solutions" tally so callers can parse it. +# +# Usage: run_ecm.sh <label> [depth] +# Writes eval_tune/ecm_logs/<label>.log (full Trace() output) and +# appends one line to eval_tune/ecm_history.log. + +set -e +cd "$(dirname "$0")/.." # repo src/ root + +label="$1" +depth="${2:-11}" +if [ -z "$label" ]; then + echo "Usage: $0 <label> [depth]" >&2 + exit 1 +fi + +mkdir -p eval_tune/ecm_logs +logfile="eval_tune/ecm_logs/${label}.log" + +opts='--cpus 1 --hash 256m --egtbpath /zscratch/egtb' +# --batch exits non-zero on normal EOF ("Exhausted input in batch +# mode") -- that's expected, not a failure, so don't let set -e treat +# it as one; the real success/failure check is the grep below. +./typhoon ${opts} --logfile "$logfile" --batch \ + --command "sd ${depth}; script ../tests/ecm.ep_" || true + +correct=$(grep -o 'correct solutions : [0-9]*' "$logfile" | tail -1 | grep -o '[0-9]*$') +total=$(grep -o 'total problems : [0-9]*' "$logfile" | tail -1 | grep -o '[0-9]*$') + +if [ -z "$correct" ] || [ -z "$total" ]; then + echo "ERROR: couldn't parse solved count from $logfile" >&2 + exit 1 +fi + +echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) label=$label depth=$depth solved=${correct}/${total}" \ + >> eval_tune/ecm_history.log + +echo "${correct} ${total}" |
