summaryrefslogtreecommitdiff
path: root/src/eval_tune/fit_king_safety_ridge.py
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-09-18 09:43:17 -0700
committerScott Gasch <[email protected]>2026-09-18 09:43:17 -0700
commitf698c02de213d0ed8968fb7cca5f70df51fa0763 (patch)
tree080d6443fbe62d71de092f9acde61da387dff408 /src/eval_tune/fit_king_safety_ridge.py
parent4a7ec9ac91939957fb3d3e5564fc24a385b422ff (diff)
Redesign lazy/super-lazy eval margins with position-specific percentile
tables, and fix four real search/recognizer bugs found while validating it Main theme: replace the flat, heavily-padded LAZY_EVAL_BASE_MARGIN / SUPER_LAZY_MARGIN_BY_ARMY constants with per-material-bucket percentile tables (king safety by attacker army, residual by own army, separate tables for the lazy vs super-lazy baselines) derived from a 30k-position calibration pool, targeting a measured ~1-1.5% exceeded-margin rate instead of an unmeasured "add lots of headroom" floor. QSearch's FUTILITY_BASE_MARGIN_LAZY/SUPERLAZY were re-derived to match the new, now-meaningful (p50-based) iPositional this produces. Self-play confirms the working tree beats both HEAD, a more liberal (~5% miss target) variant, and a more conservative (near-zero miss) variant of the same margin mechanism. Along the way, an initial self-play regression (this margin work bundled with several changes in one pass, one of which broke things) led to a day of ZFS-snapshot bisection and then targeted DEBUG-build fuzzing against a stratified 200k-position corpus (tests/cross_sectional_200k.ep_, new), which is what actually found and fixed four distinct, real, pre-existing engine bugs -- none of them related to the margin redesign itself: - searchsup.c (CommonSearchInit): three early-return paths (timer abort, draw, too-deep bailout) left a node's own PV[] row stale instead of clearing it, letting an ancestor's UpdatePV splice a leftover move from a completely unrelated node into the displayed PV -- root cause of a WalkPV/MakeMove assert ("moving a piece to capture, but nothing was on the target square"). - dynamic.c (RecordEnprisePieceAtPly): the two-slot en-prise dedup compared (square, piece) instead of square alone, so a same-square re-record with a different piece (legitimate -- a fail-high capture's victim is restored by UnmakeMove and can differ across calls at the same ply) shifted a duplicate square into both slots, tripping ValueOfMaterialInTroubleDespiteMove's two-distinct-squares assert. - recogn.c (_RecognizeKBKP): the wrong-colored-bishop corner-fortress draw shortcut fired even when the weak side has its own pawn(s), which can hand the strong side real zugzwang resources the fortress logic doesn't account for -- produced a false hard draw bound where EGTB shows a near-forced loss. - eval.c (_EvalPasserRacesAgainstLoneKings): a rook pawn's race was credited as winning even when the strong side's own king sits exactly on its own pawn's queening square (distance 0 specifically -- adjacent is fine, the square is empty) -- a classic stalemate resource the race heuristic can't see. Only reachable through _RecognizeKPK's pawn-hash-cache-hit path, which is why testrecogn.c's TestRecognExhaustiveKPK reported "0 wrong" the whole time; extended it to cover both color assignments and the full board (no mirror-shortcut) and documented the remaining cache-hit coverage gap for a future session to close. Also: match_play.py gains a local_ebf metric (geometric-mean of depth-normalized node-growth ratios between consecutive completed iterations) alongside the existing cumulative nodes**(1/depth) ebf, which turned out to be misleading when comparing engines with different lazy-exit aggressiveness -- and probe.c/hash.c pick up two small, independently-found correctness fixes (TB root-probe promotion piece missing its color bit; ASSERT(g_pHashTable) crashing on the legitimate "--hash none" config) noticed along the way. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/eval_tune/fit_king_safety_ridge.py')
-rw-r--r--src/eval_tune/fit_king_safety_ridge.py202
1 files changed, 202 insertions, 0 deletions
diff --git a/src/eval_tune/fit_king_safety_ridge.py b/src/eval_tune/fit_king_safety_ridge.py
new file mode 100644
index 0000000..072c972
--- /dev/null
+++ b/src/eval_tune/fit_king_safety_ridge.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+"""Sanity-check alternative to Texel for the king-safety terms: fit Ridge
+and ElasticNet regression directly against the mate/control label (1=mated
+from this position, 0=survived-to-a-real-draw control), using the same
+matched_combined_small.csv the curated-Texel run consumed (see
+build_curated_texel_pool.py / eval_tune's 2026-09-15 king-safety session).
+
+Unlike Texel, this fits a real regularized linear model in one shot (no
+coordinate descent over a live engine), so it can't reproduce Texel's
+sign-flip pathology from correlated coordinate updates stepping on each
+other -- if Ridge/ElasticNet still flip signs on physically-obvious terms
+(e.g. an undefended ring square should never be a *bonus*), that is strong
+evidence the flips are a real property of the feature collinearity, not an
+artifact of Texel's optimizer.
+
+Features are built to mirror the actual _EvalKing ring-tier terms as
+closely as the KING_DEFECT_DUMP per-square dump allows: slots 0-2 and 5-7
+swap front/back meaning by color (see data.c's InitializeKingAttackTables
+iFrontDeltas/iBackDeltas -- white front = deltas -17/-16/-15, black front =
++15/+16/+17), slots 3-4 are always the side squares. Each row's dump was
+already collapsed to the mated/sampled color's own perspective (see
+extract_king_features.py), so slot->tier mapping only depends on that row's
+color column, not board color.
+"""
+import csv
+import sys
+import numpy as np
+from sklearn.linear_model import Ridge, ElasticNet
+from sklearn.model_selection import train_test_split
+from sklearn.preprocessing import StandardScaler
+
+IN_CSV = sys.argv[1] if len(sys.argv) > 1 else "/tmp/typhoon/matched_combined_small.csv"
+
+PIECE_TYPES = ("pawn", "minor", "rook", "queen")
+WHITE_FRONT_SLOTS = (0, 1, 2)
+WHITE_BACK_SLOTS = (5, 6, 7)
+SIDE_SLOTS = (3, 4)
+
+
+def tier_slots(color, tier):
+ if tier == "side":
+ return SIDE_SLOTS
+ is_front = (tier == "front")
+ use_white_front_slots = is_front == (color == "white")
+ return WHITE_FRONT_SLOTS if use_white_front_slots else WHITE_BACK_SLOTS
+
+
+def load_rows(path):
+ with open(path, newline="") as f:
+ return list(csv.DictReader(f))
+
+
+def build_features(rows_in):
+ feat_rows = []
+ y = []
+ col_names = None
+ for r in rows_in:
+ color = r["mated_color_or_sampled_color"]
+
+ def offboard(s):
+ return int(r.get(f"sq{s}_offboard", 0) or 0) != 0
+
+ feat = {}
+ for tier in ("front", "back", "side"):
+ slots = tier_slots(color, tier)
+ for p in PIECE_TYPES:
+ feat[f"{tier}_atk_{p}"] = sum(
+ int(r[f"sq{s}_atk_{p}"]) for s in slots if not offboard(s)
+ )
+ feat[f"{tier}_undef"] = sum(
+ int(r[f"sq{s}_undef"]) for s in slots if not offboard(s)
+ )
+ feat[f"{tier}_xray"] = sum(
+ int(r[f"sq{s}_xray"]) for s in slots if not offboard(s)
+ )
+ feat[f"{tier}_flight"] = sum(
+ int(r[f"sq{s}_flight"]) for s in slots if not offboard(s)
+ )
+ feat["queenproximity"] = int(r["queenproximity"])
+ feat["filestorm"] = int(r["filestorm"])
+ feat["filestormopenfile"] = int(r["filestormopenfile"])
+ feat["filestormpawn"] = int(r["filestormpawn"])
+ feat["castled"] = int(r["castled"])
+ feat["minorsathome"] = int(r["minorsathome"])
+ feat["misscastleshort"] = int(r["misscastleshort"])
+ feat["misscastlelong"] = int(r["misscastlelong"])
+ if col_names is None:
+ col_names = list(feat.keys())
+ feat_rows.append([feat[c] for c in col_names])
+ y.append(float(r["label"]))
+ return np.array(feat_rows, dtype=float), np.array(y, dtype=float), col_names
+
+
+def report(name, model, X_cols, coefs, intercept):
+ print(f"\n=== {name} ===")
+ print(f"intercept: {intercept:.4f}")
+ order = np.argsort(-np.abs(coefs))
+ for i in order:
+ if abs(coefs[i]) < 1e-6:
+ continue
+ print(f" {X_cols[i]:28s} {coefs[i]:+.4f}")
+
+
+SANITY_KEYS = ("front_undef", "back_undef", "side_undef",
+ "front_xray", "back_xray", "side_xray",
+ "queenproximity", "filestorm")
+
+
+def sign_flips(coefs, col_names):
+ n = 0
+ for key in SANITY_KEYS:
+ if key in col_names:
+ if coefs[col_names.index(key)] < -1e-9:
+ n += 1
+ return n
+
+
+def sweep(X_train, y_train, X_test, y_test, col_names):
+ print("\n=== Ridge alpha sweep ===")
+ print(f"{'alpha':>10} {'train MSE':>10} {'test MSE':>10} {'sign flips':>11}")
+ for alpha in (0.1, 1, 3, 10, 30, 100, 300, 1000):
+ m = Ridge(alpha=alpha).fit(X_train, y_train)
+ tr = np.mean((m.predict(X_train) - y_train) ** 2)
+ te = np.mean((m.predict(X_test) - y_test) ** 2)
+ flips = sign_flips(m.coef_, col_names)
+ print(f"{alpha:>10} {tr:>10.4f} {te:>10.4f} {flips:>11}")
+
+ print("\n=== ElasticNet alpha/l1_ratio sweep ===")
+ print(f"{'alpha':>8} {'l1_ratio':>9} {'train MSE':>10} {'test MSE':>10} "
+ f"{'nnz':>4} {'sign flips':>11}")
+ for alpha in (0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2):
+ for l1_ratio in (0.1, 0.3, 0.5, 0.7, 0.9, 1.0):
+ m = ElasticNet(alpha=alpha, l1_ratio=l1_ratio, max_iter=20000)
+ m.fit(X_train, y_train)
+ tr = np.mean((m.predict(X_train) - y_train) ** 2)
+ te = np.mean((m.predict(X_test) - y_test) ** 2)
+ nnz = int(np.sum(np.abs(m.coef_) > 1e-9))
+ flips = sign_flips(m.coef_, col_names)
+ print(f"{alpha:>8} {l1_ratio:>9} {tr:>10.4f} {te:>10.4f} "
+ f"{nnz:>4} {flips:>11}")
+
+
+def main():
+ rows_in = load_rows(IN_CSV)
+ print(f"Loaded {len(rows_in)} rows from {IN_CSV}")
+ X, y, col_names = build_features(rows_in)
+ print(f"Built {X.shape[1]} features")
+
+ X_train, X_test, y_train, y_test = train_test_split(
+ X, y, test_size=0.15, random_state=42
+ )
+ scaler = StandardScaler().fit(X_train)
+ X_train_s = scaler.transform(X_train)
+ X_test_s = scaler.transform(X_test)
+
+ if "--sweep" in sys.argv:
+ sweep(X_train_s, y_train, X_test_s, y_test, col_names)
+ return
+
+ ridge = Ridge(alpha=10.0)
+ ridge.fit(X_train_s, y_train)
+ ridge_train_err = np.mean((ridge.predict(X_train_s) - y_train) ** 2)
+ ridge_test_err = np.mean((ridge.predict(X_test_s) - y_test) ** 2)
+ print(f"\nRidge(alpha=10): train MSE={ridge_train_err:.4f} test MSE={ridge_test_err:.4f}")
+ report("Ridge coefficients (standardized X)", ridge, col_names, ridge.coef_, ridge.intercept_)
+
+ enet_alpha = float(sys.argv[sys.argv.index("--enet-alpha") + 1]) if "--enet-alpha" in sys.argv else 0.02
+ enet_l1_ratio = float(sys.argv[sys.argv.index("--enet-l1-ratio") + 1]) if "--enet-l1-ratio" in sys.argv else 0.5
+ enet = ElasticNet(alpha=enet_alpha, l1_ratio=enet_l1_ratio, max_iter=20000)
+ enet.fit(X_train_s, y_train)
+ enet_train_err = np.mean((enet.predict(X_train_s) - y_train) ** 2)
+ enet_test_err = np.mean((enet.predict(X_test_s) - y_test) ** 2)
+ print(f"\nElasticNet(alpha={enet_alpha}, l1_ratio={enet_l1_ratio}): train MSE={enet_train_err:.4f} test MSE={enet_test_err:.4f}")
+ report("ElasticNet coefficients (standardized X)", enet, col_names, enet.coef_, enet.intercept_)
+
+ # Sign sanity check: undefended-attack terms and any "flight" term for
+ # the front tier should be non-negative-danger (i.e. push toward
+ # label==1/mate, positive coefficient here since label=1 is mate) --
+ # a *negative* coefficient on front_undef/back_undef/side_undef would
+ # mean "more undefended attacked squares near the king make mate LESS
+ # likely", which is not physically sane.
+ print("\n=== Sign sanity check (positive = more mate-associated, as expected) ===")
+ for name, coefs in (("Ridge", ridge.coef_), ("ElasticNet", enet.coef_)):
+ cols = col_names
+ bad = []
+ for key in ("front_undef", "back_undef", "side_undef",
+ "front_xray", "back_xray", "side_xray",
+ "queenproximity", "filestorm"):
+ if key in cols:
+ c = coefs[cols.index(key)]
+ flag = "" if c >= 0 else " <-- SIGN FLIP (should be >= 0)"
+ print(f" [{name}] {key:16s} {c:+.4f}{flag}")
+ if c < 0:
+ bad.append(key)
+ if bad:
+ print(f" [{name}] {len(bad)} suspicious sign(s): {bad}")
+ else:
+ print(f" [{name}] all checked terms have the expected sign")
+
+
+if __name__ == "__main__":
+ main()