summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-08-29 23:17:31 -0700
committerScott Gasch <[email protected]>2026-08-29 23:17:31 -0700
commitfa210fb9645afd27e2991b3ee8139be231d0b5ec (patch)
tree1ee7be4d51d3d2bc4ffa2c512db5270cea6b2af2
parentf2613dfabb5ef3a7a08a73e74c83467dfad9ddcc (diff)
Various utils.
-rwxr-xr-xsrc/eval_tune/bake_dna.py151
-rw-r--r--src/eval_tune/compare_ecm_depth.py46
-rw-r--r--src/eval_tune/compare_ecm_nodes.py79
-rw-r--r--src/eval_tune/compare_ecm_shareddepth.py74
-rwxr-xr-xsrc/eval_tune/cycle.sh137
-rwxr-xr-xsrc/eval_tune/dna_diff.py123
-rwxr-xr-xsrc/eval_tune/dna_trend.py154
-rwxr-xr-xsrc/eval_tune/run_ecm.sh41
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}"