summaryrefslogtreecommitdiff
path: root/src/eval_tune/compare_vs_crafty.py
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
committerScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
commitc8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (patch)
treeb6c2137c9ac577fa83b1e948fa0311d53dfe445d /src/eval_tune/compare_vs_crafty.py
parent3b5d3677bb14ae14728a1c4b221176bb83667d92 (diff)
Wire NNUE eval into search, build the training pipeline, and add a fast
move/eval-agreement benchmark against Stockfish (tests/lop) Implements the architecture decisions in src/docs/NNUE.md: HalfKAv2_hm feature set, int16/int8 quantization, per-ply accumulator backup for king moves, and a lazy-eval-ready integration into search.c/eval.c. Adds the full annotate_pgns/ training pipeline (extraction, K-fit/ labeling, quiet filtering, tensor building, training, export) plus an end-to-end orchestrator (run_nnue_pipeline.py) and validation tooling (sanity_check_net.py, compare_vs_stockfish_net.py). Adds tests/lop: a 10k-position list-of-positions benchmark comparing engine eval rank and move choice against pre-computed Stockfish/lc0 references, much cheaper than a full self-play match for day-to-day progress checks. First sd10-matched self-play result against classic HCE: LLR +2.28 (H1=+2.94), -72=119+169 over 360 games, confirming the net is "smarter per node" at matched depth. A real-time (--st 4) match is currently weaker (candidate loses ~3 plies of depth to a ~3.3-3.7x nps gap against an unrecalibrated search), consistent with NNUE.md's adoption-criterion section -- pruning/reduction constants still need retuning for the new eval's score scale before a fair real-time comparison is possible. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/eval_tune/compare_vs_crafty.py')
-rw-r--r--src/eval_tune/compare_vs_crafty.py597
1 files changed, 597 insertions, 0 deletions
diff --git a/src/eval_tune/compare_vs_crafty.py b/src/eval_tune/compare_vs_crafty.py
new file mode 100644
index 0000000..318ff87
--- /dev/null
+++ b/src/eval_tune/compare_vs_crafty.py
@@ -0,0 +1,597 @@
+#!/usr/bin/env python3
+"""
+Cross-engine static-eval audit: compare typhoon's `eval` command output
+against a *crowd* of other hand-crafted-eval engines (Crafty, Kiwi, Fruit,
+GNU Chess -- all pre-NNUE, additive-term evals, so genuinely comparable to
+typhoon's own, unlike Stockfish's NNUE), rank the positions where typhoon
+is the outlier against the crowd's median, and attribute the gap back to
+typhoon's own named EVAL_TERM lines (from a build with EVAL_DUMP=1).
+
+Methodology, and the gotchas that cost real time to find (2026-09-2x
+eval audit session):
+
+ - Build typhoon with EVAL_DUMP=1 (see CLAUDE.md's build instructions)
+ to get the per-term breakdown table, not just the "Static eval:"
+ total. The breakdown itself is a SEPARATE command, `dump eval`
+ (EvalTraceReport() in testeval.c) -- it reports on the terms
+ recorded by the *last* Eval() call, so the script sends `eval` then
+ `dump eval` back to back for every position, not `eval` alone.
+
+ - Run typhoon with --hash none. Not an optimization -- a correctness-
+ adjacent perf fix. SetBoardCommand -> PreGameReset(FALSE) ->
+ ClearHashTable() unconditionally, so every single `setboard` with a
+ real --hash size does a full memset of the whole hash table. Over
+ 2000 positions this was the entire difference between ~46s and
+ ~2.2s (21x) for byte-identical output. Always pass --hash none for
+ eval-only batch runs; there is no search happening, so there is
+ nothing to hash.
+
+ - Feed positions via `script <file>` (one `setboard <fen>` / `eval`
+ pair per line), not a single giant --command string. A few thousand
+ positions' worth of inline command text can exceed the shell's/
+ kernel's argv length limits; `script` reads from a file instead and
+ has no such ceiling.
+
+ - Only ONE process per engine for the whole batch -- every engine here
+ correctly returns different, real scores across repeated
+ setboard/eval calls in a single session; none needs restarting per
+ position. (Verified directly this session by feeding
+ /usr/local/bin/crafty three visibly different positions in one
+ stdin session and getting three different totals.)
+
+ - Use /usr/local/bin/crafty (whichever `crafty` resolves to on PATH),
+ NOT ~/programming/chess/crafty-25.2/crafty (formerly ~/crafty/crafty
+ -- that whole directory was moved to this path). The latter is a
+ different, older build (confirmed via md5) that a sibling script
+ here (compare_king_safety_crafty.py) found DOES silently reuse the
+ first position's cached score on a second setboard+score in one
+ session -- that bug is specific to that binary, not a general
+ Crafty limitation, and does not reproduce against
+ /usr/local/bin/crafty. If you ever see suspiciously identical
+ Crafty totals across different positions, check which crafty
+ binary you're actually calling before assuming a new engine bug.
+
+ - Crowd engines each have their own sign convention for the "eval"
+ command's printed number, confirmed empirically (setboard a position
+ with white down a whole knight and check the sign/magnitude), not
+ assumed from reading their source alone:
+ * crafty `score`, kiwi `eval` -- print the score from WHITE's
+ perspective already, no flip needed.
+ * fruit `eval` -- prints from the SIDE-TO-MOVE's perspective
+ (negamax convention: eval.cpp's `eval()` explicitly negates for
+ Black at the very end). Must flip sign when it's Black to move
+ to get a White-perspective number comparable to the others.
+ * gnuchess (gnuchessr) `score` -- ALWAYS prints "(white to
+ move)" and an always-White-relative number, regardless of the
+ loaded position's actual active color. Confirmed empirically:
+ loading the identical position via two xpos files differing
+ only in trailing "w"/"b" produced byte-identical output both
+ times. Root cause (read, not guessed): `xget` -> GetXGame()
+ (dspcom.c) sets the `computer` global from the file's color
+ field but never touches the separate `player` global that
+ ScorePosition()'s perspective and the printed "(%s to move)"
+ label actually key off -- `player` is a zero-initialized global
+ that only search.c's search loop ever updates, so outside of an
+ actual search it just stays at its BSS-zero value (White). Net
+ effect: harmless for this script (means no stm-relative flip is
+ ever needed for gnuchess) but it DOES mean gnuchess's score
+ never reflects tempo/mobility-from-whoever's-actually-on-move
+ nuance -- a known accuracy ceiling on this crowd member, not a
+ bug in this script.
+ Getting a sign convention wrong silently halves the apparent
+ disagreement rate (a sign-flipped engine agrees with the crowd
+ exactly when it should maximally disagree, and vice versa) rather
+ than crashing, so it does not announce itself -- always spot check
+ a lopsided material position per engine before trusting a new one's
+ numbers.
+
+ - Kiwi's own `kiwi` binary in ~/programming/chess/kiwi can be stale
+ relative to its own source (confirmed: mtimes showed the checked-in
+ binary predated winboard.cxx/engine.cxx, and it silently mis-parses
+ `eval` as an illegal `usermove` rather than erroring, because
+ command-table dispatch on prefix match falls through to
+ cmd_OpponentMoves on a lookup miss) -- there was already a correctly
+ rebuilt `kiwi.new` sitting alongside it in that same session, which
+ is what this script actually points at
+ (KIWI_BIN, below). If kiwi's numbers ever look wrong, rebuild with
+ `env OBJDIR=. LD=clang++ CC=clang++ gmake` from ~/programming/chess/kiwi
+ (its Makefile uses GNU-make-only `$(addprefix ...)`, so it must be
+ `gmake`, not BSD `make`; g++ doesn't exist on this box, hence the
+ clang++ overrides) and re-point KIWI_BIN at the fresh binary.
+
+ - gnuchess (gnuchessr, ~/programming/chess/gnuchess/src) has no
+ `setboard`/`position fen` command at all -- the only way to load an
+ arbitrary position is `xget`, an interactive prompt that reads a
+ FILE in gnuchess's old xboard.position.read format (just FEN's
+ piece-placement and active-color fields, one line, no
+ castling/ep/clock -- castling rights are simply lost, an inherent
+ approximation for this crowd member). `_build_gnuchess_script`
+ writes one such tiny file per position under
+ /tmp/typhoon/eval_audit/gnuchess/ rather than trying to reuse one
+ filename, since the position must be fully written before the
+ single stdin-scripted process reaches that `xget` line. Also: the
+ process needs one leading blank line to satisfy its startup
+ "Enter [moves] minutes[:sec]..." time-control prompt before the
+ "Your move is?" loop (where `xget`/`score`/`quit` are valid
+ commands) even begins reading commands -- a script missing that
+ blank line gets its first real command silently swallowed by the
+ time-control prompt instead. Also needs its OWN directory as cwd
+ (same gotcha class as Glaurung/kpk.bin in an earlier version of
+ this crowd): run from elsewhere it prints "NO LANGFILE (file
+ gnuchess.lang not found)" and exits immediately instead of falling
+ back gracefully.
+
+ - Compare typhoon against the crowd's MEDIAN, not vs. any single
+ other engine and not vs. the mean. Median is robust to any one
+ crowd engine being the actual outlier on a given position (e.g. a
+ known-narrow specialization one of them has and the others don't);
+ a mean lets one bad engine drag the "consensus" toward itself.
+ Historically (see the old Crafty-only version of this script)
+ averaging in an NNUE engine (Stockfish) made typhoon look like the
+ outlier for tracking a hand-crafted eval correctly (9 of 10 checked
+ outliers were actually typhoon+Crafty agreeing within ~0.3 pawns
+ with Stockfish alone ~12-15 pawns off) -- same failure mode extends
+ to any structurally-different evaluator diluting the crowd, so keep
+ the crowd restricted to same-family (additive, hand-tuned) evals.
+
+ - This box's kern.ipc.semmni (max SysV semaphore sets) is only 50.
+ Every typhoon process leaks its semaphore set on `kill -9` (a clean
+ `quit`/EOF releases it; a hard kill does not, regardless of the
+ unix.c pthread-mutex/condvar rewrite -- that fixed a different
+ leak). Repeatedly killing hung/timed-out typhoon processes during
+ debugging can silently exhaust the limit; once exhausted, EVERY new
+ typhoon process falls back to a ~1000x-slower 100ms polling loop
+ for input, which looks exactly like a hang or a new performance
+ regression. Check with `ipcs -s | grep $(whoami) | wc -l` before
+ concluding a slowdown is a code bug; clean up with `ipcrm -s <id>`
+ for each leaked entry (or `for id in $(ipcs -s | grep $(whoami) |
+ awk '{print $2}'); do ipcrm -s "$id"; done`).
+
+Usage:
+
+ python3 compare_vs_crafty.py <fen_source> [--limit N] [--top K]
+ [--engines crafty,kiwi,fruit,gnuchess]
+
+ <fen_source> is either a path to an .ep_-style file (lines of
+ "setboard <fen>" / "go", as in ../tests/*.ep_) or a plain file of one
+ FEN per line. Requires a typhoon binary built with EVAL_DUMP=1 at
+ ../typhoon (build it first: `gmake clean && gmake -j5 GENETIC=1
+ PERF_COUNTERS=1 MP=1 SIXTYFOUR=1 EVAL_DUMP=1` from src/), plus
+ whichever crowd engine binaries are selected (all on by default;
+ see ENGINE_SPECS for their expected paths).
+
+Output: prints the top-K disagreements (|typhoon(white) -
+crowd_median(white)|), each crowd engine's own white-perspective score
+for context, and typhoon's largest contributing EVAL_TERM line(s) from
+its own perspective -- a quick first read on which term is driving the
+gap, before digging in by hand.
+"""
+import argparse
+import re
+import statistics
+import subprocess
+import sys
+from pathlib import Path
+
+SRC_DIR = Path(__file__).resolve().parent.parent
+TYPHOON_BIN = SRC_DIR / "typhoon"
+SCRATCH = Path("/tmp/typhoon/eval_audit")
+
+CHESS_DIR = Path.home() / "programming" / "chess"
+
+# Every crowd engine speaks a different protocol/command for "set this
+# position, print static eval." `kind` selects how the position+eval
+# commands are framed; `parse_re` must capture exactly the numeric score
+# (possibly negative) as group 1; `scale` converts its native units to
+# pawns; `stm_relative` marks engines whose printed number is from the
+# SIDE TO MOVE's perspective (needs a sign flip on Black-to-move
+# positions) rather than already being White-relative.
+ENGINE_SPECS = {
+ "crafty": dict(
+ binary="crafty", cwd=None, kind="xboard",
+ startup="ponder off\n", pos_fmt="setboard {fen}\n", eval_cmd="score",
+ parse_re=r'^total\.+\s+([+-]?\d+\.\d+)', scale=1.0, stm_relative=False,
+ ),
+ "kiwi": dict(
+ binary=str(CHESS_DIR / "kiwi" / "kiwi.new"), cwd=None, kind="xboard",
+ startup="", pos_fmt="setboard {fen}\n", eval_cmd="eval",
+ parse_re=r'^Static eval\s*=\s*([+-]?\d+)', scale=0.01, stm_relative=False,
+ ),
+ "fruit": dict(
+ binary=str(CHESS_DIR / "fruit" / "fruit"), cwd=None, kind="uci",
+ startup="", pos_fmt="position fen {fen}\n", eval_cmd="eval",
+ parse_re=r'info string eval\s+([+-]?\d+)', scale=0.01, stm_relative=True,
+ ),
+ "gnuchess": dict(
+ binary=str(CHESS_DIR / "gnuchess" / "src" / "gnuchessr"),
+ cwd=str(CHESS_DIR / "gnuchess" / "src"),
+ kind="gnuchess", scale=0.01, stm_relative=False,
+ # Not anchored at line-start: gnuchess's "Your move is? " prompt
+ # precedes "Static score..." on the SAME line, unlike every
+ # other engine here which prints its own line cleanly.
+ parse_re=r'Static score \(\w+ to move\):\s*([+-]?\d+)',
+ ),
+}
+
+
+def extract_fens(path: Path) -> list[str]:
+ fens = []
+ for line in path.read_text().splitlines():
+ line = line.strip()
+ if line.startswith("setboard "):
+ fens.append(line[len("setboard "):].strip())
+ elif line and not line.startswith(("go", "#")) and " " in line and "/" in line:
+ # plain FEN file
+ fens.append(line)
+ return fens
+
+
+def run_typhoon_eval_dump(fens: list[str]) -> str:
+ SCRATCH.mkdir(parents=True, exist_ok=True)
+ script_path = SCRATCH / "typhoon_script.txt"
+ out_path = SCRATCH / "typhoon_out.txt"
+ with open(script_path, "w") as f:
+ for fen in fens:
+ # `eval` alone only prints the "Static eval:" total; the
+ # per-term "--- WHITE ... BLACK" breakdown table comes from
+ # the separate `dump eval` command (EvalTraceReport(),
+ # testeval.c), which reports on the terms from the *last*
+ # Eval() call -- so it must follow `eval` for the same
+ # position, not stand alone.
+ f.write(f"setboard {fen}\neval\ndump eval\n")
+ cmd = [str(TYPHOON_BIN), "--cpus", "1", "--hash", "none",
+ "--logfile", "-", "--batch", "--command",
+ f"force; book name /nonexistent.book.bin; script {script_path}"]
+ with open(out_path, "w") as out:
+ subprocess.run(cmd, stdout=out, stderr=subprocess.STDOUT, cwd=SRC_DIR,
+ timeout=max(60, len(fens) // 20))
+ return out_path.read_text()
+
+
+def run_crowd_engine(name: str, fens: list[str]) -> str:
+ spec = ENGINE_SPECS[name]
+ if spec["kind"] == "gnuchess":
+ script = _build_gnuchess_script(fens)
+ else:
+ script = spec["startup"]
+ for fen in fens:
+ script += spec["pos_fmt"].format(fen=fen) + spec["eval_cmd"] + "\n"
+ script += "quit\n"
+ p = subprocess.run([spec["binary"]], input=script, capture_output=True,
+ text=True, cwd=spec["cwd"], timeout=max(60, len(fens) // 20))
+ return p.stdout
+
+
+def _build_gnuchess_script(fens: list[str]) -> str:
+ """gnuchess (gnuchessr) has no `setboard`/`position fen` -- the only
+ way to load an arbitrary position is `xget`, which prompts for a
+ filename and reads a file in its own old xboard.position.read
+ format: just FEN's first two fields (piece placement + active
+ color), one per file, no castling/ep/clock info. Also: a leading
+ blank line is required first to accept the default time control
+ prompt before the "Your move is?" command loop (where `xget` is a
+ valid command) even starts accepting input."""
+ gc_dir = SCRATCH / "gnuchess"
+ gc_dir.mkdir(parents=True, exist_ok=True)
+ script = "\n"
+ for i, fen in enumerate(fens):
+ parts = fen.split()
+ pos_path = gc_dir / f"pos_{i}.xpos"
+ pos_path.write_text(f"{parts[0]} {parts[1]}\n")
+ script += f"xget\n{pos_path}\nscore\n"
+ script += "quit\n"
+ return script
+
+
+def parse_typhoon(text: str, n: int):
+ evals = [float(x) for x in
+ re.findall(r'^Static eval:\s*([+-]?\d+\.\d+)', text, re.M)]
+ assert len(evals) == n, f"expected {n} typhoon evals, got {len(evals)}"
+ return evals
+
+
+def parse_crowd_engine(name: str, text: str, stm: list[str], n: int):
+ """Returns n scores in pawns, from WHITE's perspective."""
+ spec = ENGINE_SPECS[name]
+ raw = [float(x) for x in re.findall(spec["parse_re"], text, re.M)]
+ assert len(raw) == n, (
+ f"expected {n} {name} evals, got {len(raw)} -- protocol/parse_re "
+ f"mismatch, or {name}'s binary crashed/changed output format")
+ white = []
+ for val, side in zip(raw, stm):
+ v = val * spec["scale"]
+ if spec["stm_relative"] and side == 'b':
+ v = -v
+ white.append(v)
+ return white
+
+
+LABEL_RE_CACHE = {}
+
+
+def term_labels_from_eval_c() -> list[str]:
+ """Pull every EVAL_TERM(...) call's label string directly out of
+ eval.c, so this stays in sync with the source without needing a
+ separately-maintained list."""
+ src = (SRC_DIR / "eval.c").read_text()
+ labels = set()
+ for m in re.finditer(r'EVAL_TERM\(', src):
+ start = m.end()
+ depth = 1
+ i = start
+ while depth > 0 and i < len(src):
+ if src[i] == '(':
+ depth += 1
+ elif src[i] == ')':
+ depth -= 1
+ i += 1
+ # Allow escaped quotes/backslashes inside the string literal
+ # (e.g. eval.c's own "in/out of \"action\"" label) -- a naive
+ # [^"]* stops at the first escaped quote and can pick up a
+ # spurious empty trailing match instead of the real label.
+ strs = re.findall(r'"((?:[^"\\]|\\.)*)"', src[start:i - 1])
+ if strs:
+ labels.add(strs[-1].replace('\\"', '"'))
+ return sorted(labels, key=len, reverse=True)
+
+
+def get_label_re():
+ if 're' not in LABEL_RE_CACHE:
+ labels = term_labels_from_eval_c()
+ LABEL_RE_CACHE['re'] = re.compile(
+ '(' + '|'.join(re.escape(l) for l in labels) + r')\s*(-|[a-h][1-8])?\s+(-?\d+)')
+ return LABEL_RE_CACHE['re']
+
+
+def parse_terms_for_block(block: str):
+ """block is the text between '--- WHITE' and 'Static eval:' for one
+ position. Returns {label: contribution_to_WHITE} (white/black columns
+ split at column 40, matching the dump's fixed layout). Always
+ White-relative regardless of side to move -- each column is that
+ color's own raw contribution, so White's column adds and Black's
+ subtracts from the White-relative total, independent of stm. (Do not
+ key the sign off stm: that gives the contribution to the side TO
+ MOVE instead, which flips sign on every Black-to-move position and
+ silently destroys any correlation against a White-relative residual.)
+ """
+ label_re = get_label_re()
+ contrib = {}
+ for line in block.splitlines():
+ for half, color in ((line[:40], 'w'), (line[40:], 'b')):
+ for m in label_re.finditer(half):
+ label, val = m.group(1), int(m.group(3))
+ sign = 1 if color == 'w' else -1
+ contrib[label] = contrib.get(label, 0) + sign * val
+ return contrib
+
+
+def split_typhoon_blocks(text: str) -> list[str]:
+ # Each block runs from its own "--- WHITE" header up to (but not
+ # including) the NEXT position's leading board-dump text -- trim at
+ # this block's own "Static eval:" line, or the raw split would
+ # include the following position's FEN/hash/board-art/"Material:"
+ # preamble as trailing garbage, which can spuriously match the term
+ # regex (large hex-signature/material digit runs sitting near label
+ # text by coincidence).
+ parts = re.split(r'(?=^--- WHITE)', text, flags=re.M)
+ blocks = []
+ for p in parts:
+ if not p.startswith('--- WHITE'):
+ continue
+ m = re.search(r'^Static eval:.*$', p, flags=re.M)
+ blocks.append(p[:m.end()] if m else p)
+ return blocks
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("fen_source")
+ ap.add_argument("--limit", type=int, default=2000,
+ help="max positions to sample (default 2000)")
+ ap.add_argument("--top", type=int, default=20,
+ help="how many biggest disagreements to print")
+ ap.add_argument("--engines", default=",".join(ENGINE_SPECS),
+ help=f"comma-separated crowd engines to use, from "
+ f"{{{','.join(ENGINE_SPECS)}}} (default: all)")
+ ap.add_argument("--sign-deadband", type=float, default=0.5,
+ help="pawns; both |typhoon| and |crowd median| (or "
+ "unanimous crowd value) must clear this to count "
+ "as a real sign disagreement, not ~0 noise "
+ "(default 0.5)")
+ ap.add_argument("--spread-floor", type=float, default=0.25,
+ help="pawns; floor on crowd stdev used when ranking "
+ "gap/spread so a near-unanimous crowd doesn't "
+ "produce a division-by-near-zero blowup "
+ "(default 0.25)")
+ args = ap.parse_args()
+
+ engines = [e.strip() for e in args.engines.split(",") if e.strip()]
+ for e in engines:
+ if e not in ENGINE_SPECS:
+ sys.exit(f"unknown engine {e!r} -- choose from {list(ENGINE_SPECS)}")
+ if not Path(ENGINE_SPECS[e]["binary"]).is_absolute() and \
+ ENGINE_SPECS[e]["cwd"] is None:
+ continue # resolved via PATH (e.g. crafty)
+ if not TYPHOON_BIN.exists():
+ sys.exit(f"missing {TYPHOON_BIN} -- build with EVAL_DUMP=1 first "
+ "(see this file's docstring)")
+
+ fens = extract_fens(Path(args.fen_source))[:args.limit]
+ stm = [f.split()[1] for f in fens]
+ print(f"{len(fens)} positions, crowd = {engines}", file=sys.stderr)
+
+ ty_text = run_typhoon_eval_dump(fens)
+ ty_evals = parse_typhoon(ty_text, len(fens))
+ ty_white = [e if s == 'w' else -e for e, s in zip(ty_evals, stm)]
+
+ crowd_white = {}
+ for name in engines:
+ text = run_crowd_engine(name, fens)
+ crowd_white[name] = parse_crowd_engine(name, text, stm, len(fens))
+
+ blocks = split_typhoon_blocks(ty_text)
+ assert len(blocks) == len(fens), (
+ f"{len(blocks)} eval-dump blocks vs {len(fens)} positions -- "
+ "did the typhoon binary actually have EVAL_DUMP=1?")
+ contribs = [parse_terms_for_block(b) for b in blocks]
+
+ n = len(fens)
+ per_pos = [] # (median, stdev, residual, is_extreme, extreme_dir)
+ for i in range(n):
+ crowd_vals = [crowd_white[name][i] for name in engines]
+ median = statistics.median(crowd_vals)
+ stdev = statistics.pstdev(crowd_vals) if len(crowd_vals) > 1 else 0.0
+ residual = ty_white[i] - median
+ all_vals = crowd_vals + [ty_white[i]]
+ is_max = ty_white[i] == max(all_vals) and all_vals.count(ty_white[i]) == 1
+ is_min = ty_white[i] == min(all_vals) and all_vals.count(ty_white[i]) == 1
+ per_pos.append((median, stdev, residual, is_max, is_min))
+
+ def print_position(i, extra=""):
+ median = per_pos[i][0]
+ crowd_str = " ".join(f"{name}={crowd_white[name][i]:+6.2f}"
+ for name in engines)
+ print(f"{extra}ty(w)={ty_white[i]:+6.2f} median(w)={median:+6.2f} "
+ f"{crowd_str} {fens[i]}")
+ top_terms = sorted(contribs[i].items(), key=lambda kv: -abs(kv[1]))[:4]
+ for label, val in top_terms:
+ print(f" {label:38s} {val:+5d}")
+
+ # --- 1. Raw magnitude disagreements (original behavior) -------------
+ diffs = sorted(range(n), key=lambda i: -abs(per_pos[i][2]))
+ print(f"\n=== Top {args.top} disagreements "
+ f"(|typhoon(white) - crowd_median(white)|) ===\n")
+ for i in diffs[:args.top]:
+ print_position(i, extra=f"diff={per_pos[i][2]:+7.2f} ")
+
+ # --- 2. Gap normalized by crowd spread -------------------------------
+ # A raw pawn gap conflates "typhoon is wrong" with "this position is
+ # just wild and the whole crowd disagrees with itself." Dividing by
+ # the crowd's own stdev (floored, so a near-unanimous crowd doesn't
+ # blow up the ratio) surfaces cases where typhoon breaks from an
+ # otherwise-tight consensus, even by a modest raw amount.
+ spread_ranked = sorted(
+ range(n), key=lambda i: -abs(per_pos[i][2]) / max(per_pos[i][1], args.spread_floor))
+ print(f"\n=== Top {args.top} disagreements normalized by crowd spread "
+ f"(|residual| / max(crowd_stdev, {args.spread_floor})) ===\n")
+ for i in spread_ranked[:args.top]:
+ ratio = abs(per_pos[i][2]) / max(per_pos[i][1], args.spread_floor)
+ print_position(i, extra=f"ratio={ratio:6.2f} stdev={per_pos[i][1]:5.2f} ")
+
+ # --- 3. Typhoon as the lone extreme value ----------------------------
+ extremes = [i for i in range(n) if per_pos[i][3] or per_pos[i][4]]
+ extremes.sort(key=lambda i: -abs(per_pos[i][2]))
+ print(f"\n=== Positions where typhoon is the strict min or max of "
+ f"typhoon+crowd ({len(extremes)} of {n}) ===\n")
+ for i in extremes[:args.top]:
+ direction = "HIGHEST" if per_pos[i][3] else "LOWEST"
+ print_position(i, extra=f"typhoon is {direction} ")
+
+ # --- 4. Sign disagreements -------------------------------------------
+ db = args.sign_deadband
+ vs_median, vs_unanimous = [], []
+ for i in range(n):
+ ty, median = ty_white[i], per_pos[i][0]
+ if abs(ty) >= db and abs(median) >= db and (ty > 0) != (median > 0):
+ vs_median.append(i)
+ crowd_vals = [crowd_white[name][i] for name in engines]
+ if all(abs(v) >= db for v in crowd_vals) and \
+ len({v > 0 for v in crowd_vals}) == 1:
+ crowd_sign_positive = crowd_vals[0] > 0
+ if abs(ty) >= db and (ty > 0) != crowd_sign_positive:
+ vs_unanimous.append(i)
+ print(f"\n=== Typhoon disagrees with crowd MEDIAN's sign "
+ f"(both sides >= {db} pawns) -- {len(vs_median)} positions ===\n")
+ for i in vs_median[:args.top]:
+ print_position(i)
+ print(f"\n=== Typhoon disagrees with a UNANIMOUS crowd's sign "
+ f"(every engine agrees, typhoon alone doesn't; >= {db} pawns) "
+ f"-- {len(vs_unanimous)} positions ===\n")
+ for i in vs_unanimous[:args.top]:
+ print_position(i)
+ if "gnuchess" in engines and len(engines) > 1:
+ others = [e for e in engines if e != "gnuchess"]
+ vs_unanimous_no_gc = []
+ for i in range(n):
+ vals = [crowd_white[name][i] for name in others]
+ if all(abs(v) >= db for v in vals) and len({v > 0 for v in vals}) == 1:
+ if abs(ty_white[i]) >= db and (ty_white[i] > 0) != (vals[0] > 0):
+ vs_unanimous_no_gc.append(i)
+ print(f"\n (gnuchess drops castling rights and ignores stm; "
+ f"same check EXCLUDING gnuchess: "
+ f"{len(vs_unanimous_no_gc)} positions)")
+
+ # --- 5. Per-term correlation with the White-relative residual --------
+ # For each EVAL_TERM label, correlate its own signed White-relative
+ # contribution against ty_white - crowd_median across the whole
+ # sample. A term whose contribution consistently tracks the residual
+ # is a candidate for being over- or under-weighted relative to the
+ # crowd's implicit consensus; a term with no correlation isn't
+ # driving the disagreement even if it's large in any one position.
+ residuals = [p[2] for p in per_pos]
+ all_labels = set()
+ for c in contribs:
+ all_labels.update(c.keys())
+ term_stats = []
+ for label in all_labels:
+ vals = [contribs[i].get(label, 0) for i in range(n)]
+ if len(set(vals)) < 2:
+ continue # constant (e.g. always zero) -- correlation undefined
+ try:
+ corr = statistics.correlation(vals, residuals)
+ except statistics.StatisticsError:
+ continue
+ mean_abs = sum(abs(v) for v in vals) / n
+ term_stats.append((label, corr, mean_abs, corr * mean_abs))
+ term_stats.sort(key=lambda t: -abs(t[3]))
+ print(f"\n=== EVAL_TERM correlation with (typhoon - crowd_median), "
+ f"whole sample (n={n}) ===")
+ print("(positive corr = term grows typhoon's score in the same "
+ "direction as its disagreement with the crowd -- a candidate "
+ "for being over-weighted; negative = under-weighted)\n")
+ for label, corr, mean_abs, score in term_stats[:20]:
+ print(f" {label:38s} corr={corr:+.3f} mean|contrib|={mean_abs:6.2f} "
+ f"score={score:+.3f}")
+
+ # --- 6. Global directional bias ---------------------------------------
+ mean_r = statistics.mean(residuals)
+ median_r = statistics.median(residuals)
+ w_res = [residuals[i] for i in range(n) if stm[i] == 'w']
+ b_res = [residuals[i] for i in range(n) if stm[i] == 'b']
+ print(f"\n=== Global bias: typhoon(white) - crowd_median(white) ===\n")
+ print(f" overall: mean={mean_r:+.3f} median={median_r:+.3f} n={n}")
+ if w_res:
+ print(f" white to move: mean={statistics.mean(w_res):+.3f} "
+ f"median={statistics.median(w_res):+.3f} n={len(w_res)}")
+ if b_res:
+ print(f" black to move: mean={statistics.mean(b_res):+.3f} "
+ f"median={statistics.median(b_res):+.3f} n={len(b_res)}")
+ print(" (a nonzero overall mean/median with the same sign on "
+ "both stm splits points at a constant-ish term -- tempo, draw "
+ "score, a global scale factor -- rather than a positional one)")
+
+ # --- 7. Residual by material phase ------------------------------------
+ # Cheap, self-contained phase proxy from the FEN alone (not typhoon's
+ # own internal phase bucketing) so this works even without EVAL_DUMP
+ # internals: sum of N=3/B=3/R=5/Q=9 for both sides, pawns/kings
+ # excluded. Eval bugs are usually phase-local, and a flat top-K list
+ # hides that.
+ piece_val = {'n': 3, 'b': 3, 'r': 5, 'q': 9}
+ phases = []
+ for fen in fens:
+ board = fen.split()[0]
+ phases.append(sum(piece_val.get(ch.lower(), 0) for ch in board if ch.isalpha()))
+ buckets = [(0, 12, "endgame (<=12)"), (13, 26, "middlegame (13-26)"),
+ (27, 999, "opening/near-full (>=27)")]
+ print(f"\n=== Residual by material phase (non-pawn point count, both sides) ===\n")
+ for lo, hi, name in buckets:
+ idxs = [i for i in range(n) if lo <= phases[i] <= hi]
+ if not idxs:
+ continue
+ r = [residuals[i] for i in idxs]
+ print(f" {name:28s} mean={statistics.mean(r):+.3f} "
+ f"median={statistics.median(r):+.3f} n={len(idxs)}")
+
+
+if __name__ == "__main__":
+ main()