summaryrefslogtreecommitdiff
path: root/src
AgeCommit message (Collapse)Author
6 daysAdd release/debug shortcut targets to GNUmakefileScott Gasch
Bundle the common build variable combos (MP+GENETIC, DEBUG+PERF_COUNTERS+GENETIC) with -j5 into single targets. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01XaWq66W3foZ2zpRmMerWW8
6 daysFix draw-score bug in hash-hit path; centralize as g_iDrawScore[2]Scott Gasch
Search()'s Dieter-Brusser hash-hit-leads-to-draw check only verified that a score of 0 would clear the same alpha/beta bound as the stored iScore -- it didn't establish that iScore itself was accurate. Since playing the hash move actually produces a draw, propagate the draw score upward instead of the stale score computed along a different, non-repeating path. While fixing this, centralized every other place that returned a literal 0 for a draw (search.c's stalemate leaf, searchsup.c's QSearch draw leaf, probe.c's EGTB draw case, which had a dead `// g_iDrawValue[...]` comment suggesting this was intended all along) into a single g_iDrawScore[2] global in draw.c, declared in chess.h. It's indexed by side to move rather than a scalar so a future contempt-factor tweak can bias the draw score per color without touching every call site again; both entries are currently 0, so behavior is unchanged except for the hash-hit bugfix above. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012e2SaEaQ27JJq1D3wCqryr
6 daysFix all build warnings across release/DEBUG/TEST profilesScott Gasch
Clean gmake GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1 build had 100 warnings; DEBUG=1 and TEST=1 builds had more once actually exercised. - OFFSET_OF/CONTAINING_STRUCT (chess.h) and PTR_TO_ALLOC_HASH (unix.c) truncated pointers through 32-bit ULONG before use in offset/hash arithmetic on this 64-bit build -- routed through size_t instead. - Diagnostic int<->void* round-trips (command.c, root.c, split.c, sig.c, data.c, unix.c, util.c) widened/narrowed via size_t to avoid implicit truncation. - ABS_DIFF on unsigned COOR now casts to int before abs(). - Dropped -fexpensive-optimizations (GCC-only, clang silently ignores it) from GNUmakefile. - Removed genuinely dead variables (book.c, gamelist.c, split.c, testgenerate.c, testhash.c). - Guarded DEBUG/PERF_COUNTERS/_X86_-only variables and the _CMEvidenceBucket helper under the #ifdef that actually reads them, since ASSERT/EVAL_TERM/KEEP_TRACK_OF_FIRST_MOVE_FHs compile away outside those builds. - Added missing prototypes for SlidePawn, SlidePawnWithoutSigs, SlidePieceWithoutSigs (move.c), previously undeclared in chess.h. - Removed dead _SystemIsRoot (unix.c). Verified via precommit_check.sh: TEST=1 self-test suite passes, DEBUG=1 smoke test (10 random ECM positions, sd 4) passes with no crashes/assertions, release build restored -- all three profiles now build with zero warnings. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014Cmv11sJZqVfanrPh6UnWE
9 daysUpdate run_tests.shScott Gasch
10 daysOops, a couple of issues with that last one.Scott Gasch
10 daysPlumb iImprovement into LMR and futiltiy and parallel search.Scott Gasch
Fix a bug (iEval) in HelpSearch This commit changes the heuristics for: nullmove pruning, LMR, and EFP slightly.
10 daysImproving for nullmove, eval speedup, remove old cruft, bugfix in split / MP.Scott Gasch
10 daysReplace bishop/knight/rook/queen mobility jump tables with inline switch ↵Scott Gasch
dispatch. The per-square mobility ray-walk called through a function pointer for every square visited (piece type varies square to square, so the CPU's indirect-branch predictor couldn't learn it), and nearly every handler reduced to a couple of constant increments and a stop/continue flag. Replaced each PMOBILITY_HELPER table with a small case-tag table and an inlined switch, dropping all now-dead handler functions and the PMOBILITY_HELPER typedef. Also hoisted the ray-invariant rank/file direction test (rook's "connected" bonus, queen's bishop/rook xray cases) out of the per-square switch to compute once per ray instead. Verified semantics-preserving: node counts, solve/fail sets, and PVs are byte-for-byte identical to head_reference across all three curated ECM suites (ringers/confident_quick/hard_quick) at sd 10. bench shows a consistent ~6-7% NPS improvement over two back-to-back runs. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012Q8wbZABsyG7PZQUd9WBAw
10 daysUpdate TODO (no code changes)Scott Gasch
10 daysAdd small static penalties for trapped pieces in _EvalTrappedPieces.Scott Gasch
Eval() already detects trapped/attacked pieces (for search hints via RecordEnprisePieceAtPly/RecordTrappedPiece) but never penalized them in the static score. Add two named, DNA-visible constants: a larger flag for the opponent-to-move/imminently-capturable case, a smaller one for the own-move/still-might-escape case -- flat "this is bad" nudges, not an attempt to price the material outcome, which search still owns. Verified flat on ecm_ringers/ecm_confident_quick vs head_reference at sd 10; ecm_hard_quick's lone flip (ECM.370) is a search-instability artifact of that specific position (its true evaluation was still moving through depth 14 in independent runs), not a real regression.
10 daysVarious utils.Scott Gasch
10 daysTrack match_play.py's real dependencies (tune_eval_dna.py, filter_pgn.py) ↵Scott Gasch
and Scott's overnight-SPRT shortcut (test_vs_head.sh); gitignore generated caches/PGN output. tune_eval_dna.py isn't Texel-tuning-specific tooling anymore -- it's a load-bearing dependency (match_play.py does `from tune_eval_dna import Engine`), so it needs to be tracked for match_play.py to run at all on a fresh checkout, independent of whatever happens to the rest of the auto-tuning pipeline. filter_pgn.py (builds twic_filtered.pgn, the pool match_play.py's --pgn points at) is similarly not tuning-specific. test_vs_head.sh is Scott's shortcut for the overnight SPRT run discussed this session (head_reference/typhoon vs. current build, --games 20000 --workers 20 --st 1 --sprt --elo0 0 --elo1 5). Left untracked, Texel-pipeline-specific and matching this session's move away from auto-tuning: bake_dna.py, dna_diff.py, dna_trend.py, cycle.sh, run_ecm.sh, compare_ecm_*.py, tuned.dna. .gitignore: eval_tune/__pycache__/ and eval_tune/opening_cache/ (pure regeneratable caches) and src/{match_games,self_play_games}.pgn (match_play.py's --pgn-out game logs, generated output not source) -- the dna/first.dna / dna/original_baseline.dna accidental-commit from earlier tonight was exactly this class of mistake, catching the obvious repeat cases now. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M9ZDiJhiUajUxh95mTXCFJ
10 daysRemove dna/first.dna and dna/original_baseline.dna, accidentally swept into ↵Scott Gasch
the previous commit. These were already staged in the index (not by anything in this commit's own git add) when the previous commit ran, and git commit without a pathspec commits the whole index, not just what was explicitly added that call -- should have checked git status right before committing. Old Texel-era baseline dumps (2026-08-24), unrelated to tonight's work and contrary to this session's decision to ditch Texel-based tuning for the hand-tuned baseline. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M9ZDiJhiUajUxh95mTXCFJ
10 daysAdd update_head_reference.sh: automate the head_reference checkpoint ritual, ↵Scott Gasch
so it actually happens instead of being forgotten. Refreshing head_reference/ (rebuild release binary, sd10+sn5m sweep across all three curated suites, copy logs, tag the commit, archive the binary) was, until now, a several-minute manual dance repeated by hand every time a commit became the new comparison baseline -- exactly the kind of multi-step ritual an AI assistant with no persistent memory across sessions will reliably forget to fully repeat. Scripted the mechanical parts: build, 6-way sweep, log copy, git tag (head- reference-<date>, the durable/versioned source of truth for "which commit was checkpoint N" -- see head_reference/README.md's new "Checkpoint history convention" section), and binary archive (a rebuild-avoidance cache alongside the tag, not a replacement for it). Does NOT write the README's prose sections (what changed, why, how to read the net score) -- that still needs a human/Claude actually looking at the diff and the numbers this script prints at the end, not a template trying to guess at them. Checks for a dirty working tree and warns rather than silently tagging something that doesn't correspond to a real commit -- the intended sequence is still git commit first, then this script, same as precommit_check.sh's gate runs before a commit rather than replacing it. precommit_check.sh: two lines printing the actual next-step commands ("git commit", "./update_head_reference.sh") after a passing check, so the two scripts' relationship is visible right where the first one succeeds. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M9ZDiJhiUajUxh95mTXCFJ
10 daysRewrite match_play.py: binary-vs-binary comparison, real SPRT, ↵Scott Gasch
EBF/NPS/first-move-beta stats, fix a completion-order bias in the live progress readout. match_play.py previously compared one fixed binary with two loaded evaldna files -- built for the Texel auto-tuning pipeline this project has since moved away from in favor of hand-tuned constants baked directly into eval.c. Converted to compare two separate compiled binaries instead (head_engine/candidate_engine positional args, no DNA loading at all) -- a real build is now required per side, but that's the right model: what's being compared is two source trees, not two parameter files loaded into an otherwise-identical process. Found and deleted eval_tune/binary_match_play.py, a pre-existing (never committed) sibling that already did binary-vs-binary comparison but predates and is now strictly superseded by this rewrite (no SPRT, no stats capture, and the same completion-order bias fixed below) -- keeping both would have left two overlapping tools to drift out of sync, the same duplication problem this session spent all night removing from eval.c itself. Added a real SPRT (Sequential Probability Ratio Test), the same formulation fishtest/cutechess-cli use: two Elo hypotheses (H0/H1) tested via the log-likelihood ratio of a normal approximation to the per-game trinomial (W/D/L) score, variance re-estimated from the running W/D/L mix as games accumulate. Verified against a Monte Carlo simulation before landing: correctly resolves H0 at true_elo=0 (~6-19k games) and H1 at game counts matching the theoretical fixed-N table almost exactly (30 Elo: ~1.4-2k, 20 Elo: ~2.3-4.5k, 10 Elo: ~5-9k, 5 Elo: ~10-25k). Required restructuring the game scheduler from "submit everything upfront, as_completed" to a bounded rolling window (at most --workers games in flight) so it can actually stop early once SPRT concludes instead of having thousands of already-launched futures it can't usefully cancel. Fixed a real, previously-unnoticed bug that explains a specific observed symptom (candidate consistently scoring high for the first ~500 games of a 1000-game run, then eroding toward 0.5 -- every run, same direction, which is what tipped this off as systematic rather than noise): the live "score so far" readout processed games in *completion* order (as_completed), not submission order, and decisive games plausibly finish faster than grindy draws/losses (a winning side wraps up before --max-plies; a losing/drawing side often runs long). That means candidate wins arrive disproportionately early and the live average was a biased mid-run estimator -- high at first, eroding as slower non-win games trickle in. The *final* score was never actually wrong (order-independent, sums the same regardless of arrival order), just the progress narrative watched live. Fixed by buffering out-of-order completions and only advancing the printed running score through games in their original submission order. Also fixed --sd defaulting to 8 even when --st was passed -- the argparse mutually-exclusive group only stops both flags being given together, it does nothing about one flag's default silently applying when only the other was specified. --st alone was being silently ignored in favor of sd=8 the whole time. Now --sd defaults to None and only falls back to 8 when neither --sd nor --st is given. Added EngineStats: per-engine (not per-color, since candidate/baseline swap sides every other game) speed and tree-shape capture -- NPS, EBF (nodes**(1/depth) per move, same formula script.c's suite runs use), and first-move-beta-cutoff-rate, all pulled from the same PostMoveSearchReport block every move already prints (confirmed this prints after EVERY move, not just script-run suite summaries, and confirmed the "move" line prints BEFORE the stats block against root.c, not after -- the read loop needed restructuring to keep reading past the move line rather than stopping on it). No separate benchmark pass needed; these come from the same games already being played for the strength comparison. Deliberately not built tonight, flagged as a possible follow-up: an opening-pool independence concern (sample_openings picks random byte offsets into the TWIC pool, which could pull duplicate/correlated lines if TWIC has many games following the same trendy opening in a season -- would make the standard-error math slightly overconfident, not wrong in direction). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M9ZDiJhiUajUxh95mTXCFJ
10 daysEval.c diet: kill exact-duplicate terms, turn down overlapping ones, fix a ↵Scott Gasch
real king-safety bug found along the way. A full-file pass over eval.c hunting for the "positional terms too loud" feedback from real chess programmers, following the concrete finding that Crafty prices most structural themes through one term where this codebase spread the same theme across several (passed pawns alone via 5-6 separate additive terms that can all fire for one pawn). Two categories of fix, applied per the rule "if it's counting the same thing twice, kill it; if it's a genuinely different angle on the same theme, turn it down rather than remove it": Pawns (pawn-hash cached, so free regardless of term count -- these are data/magnitude fixes, not perf fixes): - Removed ISOLATED_PAWN_PENALTY_BY_COUNT, a whole-position aggregate that re-priced the same uIsolated[] count already reflected by summing the per-pawn isolated term once per isolated pawn -- an exact duplicate, not a different angle. - CANDIDATE_PASSER_BY_RANK's "in endgame" bonus used to add the exact same value a second time (a literal clone of the term just added above it); now a /2 fractional modifier. - CONNECTED_PASSERS_BY_RANK / SUPPORTED_PASSER_BY_RANK / OUTSIDE_PASSER_BY_DISTANCE scaled to ~1/3 magnitude: each prices a genuinely distinct angle on "how good is this passer" (connected to a partner, pawn-defended, outside the opposing majority) and can stack for the same pawn, so turned down rather than removed. - ISOLATED_DOUBLED_PAWN turned down (-11 -> -5): a per-pawn kicker that stacks with the whole-position DOUBLED_PAWN_PENALTY_BY_COUNT aggregate for the isolated+doubled subset -- different angle (single-worst-case flag vs. whole-position severity), not a duplicate, but a real overlap worth trimming. Pieces (non-cached, real per-node cost, so these are also legibility/ perf fixes, not just magnitude): - Bishop: cut BISHOP_IN_CLOSED_POSITION outright -- it duplicated bishop mobility rather than adding a distinct angle (mobility already measures per-bishop diagonal blockage directly and more precisely than a coarse whole-board proxy). - Knight: killed a stale "don't block unmoved E2/D2 pawns" TODO (opening-book territory, not eval's job) and "a knight with an open file behind it is good" (dubious chess reasoning reusing an unrelated table -- the same lookup as the backward-pawn-blockade bonus, for a completely different concept). - Rook: killed ROOK_TRAPPING_EKING (a rook on the 7th/8th aligned with the enemy king is exactly the geometric pattern CountKingSafetyDefects' CHECK_VECTOR scan already folds into uPiecesPointingAtKing -- belongs in king safety, not a rook- specific bolt-on). Also removed pFriendRook, dead in the same block. - Queen: killed "pointing near enemy K" (QUEEN_ATTACKS_SQ_NEXT_TO_ KING) -- computed from the queen's own mobility ray-cast, direct- attacks only, duplicating what _EvalKing's real (non-lazy-estimate) danger computation already reads from the identical attack-table bits a few lines away. - cTrapped fixed from a single COOR per color to a small [2][4] list (_RecordTrappedCandidate): the old single-slot design let a later piece's zero-mobility candidacy silently overwrite an earlier one's on the same side, discarding a genuinely trapped-and- attacked piece. This fed into search too (RecordTrappedPiece's move-ordering hint), not just eval scoring. RecordTrappedPiece's own per-ply single slot is left alone per design (would double the cost on the branch that already computes it, this is the innermost eval loop) -- now reports the MOST VALUABLE of the candidates found, not just whichever was found last. King (the actual regression-and-recovery of this session): - Cutting the queen's "pointing near enemy K" term initially cost real solves (117->110 on the sd10 curated suites) despite being a correct duplication kill -- the general king-safety loop's per-square attacker accounting was piece-type-blind (a queen attacking a square near the king counted the same as a knight doing the same geometric thing), so removing the one place that priced queen-specific severity lost real fidelity, not just a duplicate. Fixed properly: added KING_QUEEN_PROXIMITY_DANGER, computed from bvAttacks[...].small.uQueen / .xray.uQueen bits the king-safety loop already reads for every one of its 11 squares -- free (no new attack-table work) and more accurate than the killed term (catches x-ray/latent queen threats it never did). Calibrated against the killed term's own empirical magnitude (uNearKing * 8, capped at 6) rather than guessed. Recovered to 118/191 (a new session-best), now with the fidelity gap actually closed instead of just removed. - KING_SUPPORTING_OWN_PASSER_BY_RANK split out from SUPPORTED_PASSER_BY_RANK, which _EvalKing's "kings in front of passers" endgame bonus was silently reusing -- pawn-support and king-escort are different concepts (fires when the KING stands next to its own passer, not when a pawn does); scaling the shared table down for its real purpose was silently also scaling the unrelated king-escort bonus. Seeded with the table's original (pre-scaling) hand-tuned magnitude. - Collapsed three copy-pasted file-scan blocks (c-1/c/c+1, identical logic repeated three times) into one loop -- confirmed behaviorally neutral by isolated sd10 suite testing before landing alongside the king-safety content changes. Net result across the three curated suites (sd10, vs. the hand-tuned+ bugfix baseline this built on): 117 -> 118, a new session best, with every intermediate checkpoint tested via EVAL_DUMP verification + precommit_check.sh + sd10 sweep before moving to the next change. Deliberately deferred, written down for a future session rather than attempted here: a holistic king-safety overhaul (the piece-type- tropism inconsistency across knight/bishop/queen/the general CountKingSafetyDefects scan goes deeper than tonight's scoped fixes), recalibrating EstimatePositionalScore's iKingSwingP90 lazy-eval margin table (the instrumentation that built it no longer exists in this tree, and today's changes have already shifted the true swing distribution it was calibrated against), and training a small king- danger classifier from TWIC checkmate games (snapshot king safety features at -10/-15 moves from real checkmates, not resignations) to calibrate whichever of the above happens first. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M9ZDiJhiUajUxh95mTXCFJ
11 daysAdd precommit check helper script.Scott Gasch
11 daysFix batch-mode exit code, killer-table backfill collision, impossible ↵Scott Gasch
QSearch mate-magnitude asserts, MATEMOVE PV display, --command truncation, and test.sh's stale egtbpath. Several small, independent correctness fixes bundled together since they were all exercised together through today's precommit_check.sh and curated-suite runs: - command.c: batch-mode's "Exhausted input" exit was exit(-1), which truncates to 255 (an 8-bit status) and is indistinguishable from a real crash's nonzero exit. Changed to exit(0) so debug_smoke_test.sh can reliably tell a clean batch run apart from a crash by exit status alone. - dynamic.c: _NewKillerMove's slot[1] backfill from mvNullmoveQuietRefutations[uPly] had no check that the backfilled move differed from the move just placed in slot[0]. When they coincided, both slots held the identical move, silently wasting a killer slot in release builds (ASSERT is a no-op there) and tripping _NewKillerMove's own IS_SAME_MOVE invariant in DEBUG builds. Fixed by skipping the backfill on collision. - search.c: removed two ASSERT(iBestScore > -NMATE) calls in QSearch that encoded an invariant that isn't actually guaranteed -- at an early full-width root iteration, or after aspiration-window widening following repeated fail-highs, an ancestor frame's iAlpha/iBeta can itself already be more extreme than -NMATE with no mate anywhere in the line, so a legitimate fail-low placeholder or fail-high score can land in mate-magnitude territory purely as a window artifact. hash.c's storage path already treats any value <= -NMATE as a sound upper bound regardless of origin, so this was a false invariant, not a caught bug. Also: minor whitespace cleanup, an added ASSERT documenting the futility-margin depth precondition it replaced a redundant runtime check for, and PV/leaf-count bookkeeping on the mate/draw-at-root leaf paths that was previously skipped. - util.c: MATEMOVE sentinel moves weren't handled in PV-to-string conversion, so a PV ending in a detected mate would either display garbage or hit the same-move assert. Added an explicit "<#>" marker. - test.sh: --egtbpath pointed at a nonexistent /egtb/three;/egtb/four; /egtb/five; corrected to /zscratch/egtb, this box's actual EGTB location. - main.c/input.c: --command's initial-command buffer (g_szInitialCommand) was a fixed 256-byte array; strncpy(..., SMALL_STRING_LEN_CHAR - 2) silently truncated any longer --command string, and -- worse -- when the source was long enough not to fit, strncpy doesn't null-terminate the destination, so the immediately-following strcat(..., "\r\n") could read/write past the buffer. Long move-replay command strings used during this session's debugging hit the truncation directly (a ~600 char move list silently cut off mid-token, desyncing the input queue). Changed g_szInitialCommand to a heap allocation sized to the actual input length instead of a fixed cap. - CLAUDE.md: documents the above (this file's own diff is prior session's writeup of these same fixes, committed now alongside the code). All exercised together via precommit_check.sh (self-test suite + DEBUG smoke test against random ecm.ep_ samples) and the sd10/sn5m curated suite sweep run for the eval.c hand-tuning commit just before this one. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M9ZDiJhiUajUxh95mTXCFJ
11 daysDitch Texel-tuned eval constants for the pre-tuning hand-tuned baseline; fix ↵Scott Gasch
a stale king-safety data bug found along the way. The Texel/coordinate-descent auto-tuning pass (started at 29d73f4) left several eval terms with non-monotonic or outright sign-flipped values that several ASSERTs had to be silently commented out to tolerate (e.g. BACKWARD_SHIELDED_BY_LOCATION scoring a structural pawn defect as a +12..+17 bonus on most squares, PASSER_BONUS_AS_MATERIAL_COMES_OFF staying flat until the defending side was down to almost nothing). Restored all 54 differing constant tables to their last hand-tuned values (commit df8facc, pre-dating 29d73f4) mechanically -- table names/shapes are identical between the two commits, only values differ, so this is a pure data restore with none of the surrounding code-structure changes since df8facc reverted. Also fixes a real bug found while investigating: pos->uPiecesPointingAtKing[] was only refreshed inside EstimatePositionalScore's lazy-eval-margin path (eval.c ~5648), but _EvalKing reads it unconditionally on every full eval. Whenever a node's cheap material+pawn score wasn't close enough to the alpha/beta window to trigger that lazy-margin branch, the full eval proceeded straight to _EvalKing using a stale uPiecesPointingAtKing value left over from a prior, unrelated node -- silent, intermittent noise in king-safety scoring on an unpredictable subset of evaluations. Introduced 2026-08-24/26 (29d73f4, 7857096), so it predates and was baked into the Texel tuning pass being reverted here. Fixed by computing it once in an else branch when the lazy-margin path isn't taken, so it's refreshed exactly once per full eval either way (this is the innermost eval loop, so avoided doubling the cost on the branch that already computes it). sd10/sn5m results across the three curated suites (vs. head_reference, the prior Texel-tuned HEAD): sd10: ringers 9/11 (was 10), confident 85/90 (was 88), hard 23/90 (was 17) -- total 117 vs 115 sn5m: ringers 11/11 (was 10), confident 87/90 (was 89), hard 14/90 (was 13) -- total 112 vs 112 Net win at sd10, wash at sn5m, in both cases with a large swing toward ecm_hard_quick -- consistent with hand-tuned values being more internally coherent (monotonic curves, no sign flips, no double-counted whole-position aggregates layered on top of already-summed per-item terms) even though they were never retuned against this specific suite or these specific opponents. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M9ZDiJhiUajUxh95mTXCFJ
11 daysDynamic move ordering overhaul: continuation-history, evidence-gatedScott Gasch
countermove promotion, retired hung-piece-escape and NumLeftoverMovesToSelect. Full session was built on a "measure the pick, not the game" methodology: aggregate solve counts on curated suites are too noisy to tune move-ordering knobs against, so most decisions here came from per-move fail-high/alpha-raise rates at much larger sample sizes (leftover FH% instrumentation, a zero- selection-budget diagnostic that isolates a single best-of-remaining pick, and evidence-bucket calibration), not solve-count deltas alone. See CLAUDE.md's "Dynamic move ordering experiments" section for the reusable methodology and generate.c's _ScoreAllMoves comment for the resulting ordering hierarchy. Changes: - Added g_ContinuationHistory: same growth/decay math as the existing g_HistoryCounters butterfly table, additionally keyed by the previous move, so its magnitude is self-calibrated rather than a hand-picked constant. Flat, sufficient response across a 256x scale sweep. - Countermove-table matches now get a real GOOD_MOVE-tier promotion (previously the table was write-only, tracked for stats but never read for ordering), but only when the match's own accumulated history+continuation evidence clears COUNTERMOVE_EVIDENCE_THRESHOLD (10,000) -- a raw match with no track record was shown to perform identically to an ordinary leftover (~0.6-0.85% FH), so promoting on match alone would have repeated hung-piece-escape's mistake below. - Retired hung-piece-escape's unconditional GOOD_MOVE-tier promotion. Evidence-calibration showed the overwhelming majority of triggers (a zero-evidence population 250-1000x larger than countermove's) performed at the plain-leftover baseline -- the promotion was mostly free tier- escape treatment for moves that hadn't earned it. Replaced with FLEE_BONUS, a flat same-tier nudge inside SelectBestWithHistory (never escapes GOOD_MOVE/leftover classification, unlike a generation-time promotion) at the magnitude found to plateau a same-tier-nudge sweep. - Retired NumLeftoverMovesToSelect (the depth-indexed budget on how many leftover moves got a full selection scan before falling back to unsorted order). search.c's main move loop now always fully selects -- the leftover pool was shown to contain real, findable signal a bailout budget was discarding for a node-count savings that didn't hold up net- net once measured by solve counts and fail-high rates rather than raw node counts (noisy on small suites independent of this change). - Collapsed leftover-move instrumentation from sorted/raw pairs down to a single set now that "raw" (unsorted fallback) is structurally impossible; kept the countermove evidence-bucket calibration counters (ongoing check that COUNTERMOVE_EVIDENCE_THRESHOLD stays well- calibrated); removed the contested-node A/B harness and hung-piece evidence calibration now that the decisions they were built to inform are made. Net effect on the three curated suites (sd 10): solve counts wash (tied, +1, -1 across ringers/confident/hard), leftover fail-high rate improved consistently on all three (the intended, directly-measured target of this work). Not yet validated beyond sd 10 -- an sn-based run or eval_tune/match_play.py head-to-head gate is the natural next check before leaning on this as a proven strength gain rather than a directionally- sound, sd-10-clean change. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014XePz6Sk4qQsTaP2jVJWJu
11 daysBake the short git commit hash (plus -dirty suffix) into the binary and ↵Scott Gasch
trace it at startup alongside the build timestamp. A --logfile trace could previously only be tied back to a build timestamp, not the exact source state -- distinguishing same-day rebuilds during A/B testing required diffing binaries. GIT_COMMIT is injected via GNUmakefile (git rev-parse --short HEAD, kept out of the PROFILE variable itself since PROFILE gets separately stringified whole for the "Make profile used" trace line, and this value's embedded quotes broke that outer string literal when first tried folded in there). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EortUUkDVpsfrbqshBJYJg
11 daysSwitch to Crafty-style killer ordering; fix mvNullmoveRefutations ↵Scott Gasch
type-mixing bug and add a quiet-refutation killer backfill. Killer tiers now try both of this ply's own killers before either ply-2-back one, matching Crafty's ordering. Two earlier attempts at this same swap were reverted for regressing; this pass lands on top of NumLeftoverMovesToSelect (more SelectBestWithHistory budget to reach these lower-tier slots) and a real bug fix below, and beats interleaved order head-to-head on solves, node count, and first-move beta cutoff across the three curated suites. The bug: mvNullmoveRefutations's empty-killer-slot backfill could only ever contain a capturing move (TryNullmovePruning only wrote it inside the capture-refutation branch), but IS_SAME_MOVE's mask includes the pCaptured bits, so that backfilled value could never match a real quiet candidate -- the backfill was silently dead code. Fixed by recording genuinely quiet null-move refutations into a new, separate mvNullmoveQuietRefutations array (kept separate so it can't clobber the capture history mvNullmoveRefutations still needs for the Botvinnik-Markoff same-piece-two-squares extension check) and backfilling the regular killer table from that instead. The check-evasion killer table intentionally does *not* get this backfill: a null-move refutation can never legitimately be an escaping-check move (null moves can't deliver check), so backfilling there risks IS_SAME_MOVE cross-context false positives instead of the old guaranteed-inert no-op. Measured at sd10 across ecm_ringers/ecm_confident_quick/ecm_hard_quick against head_reference (commit d11e973): 115/191 solves (vs. 116 baseline), 924.36M total nodes (vs. 933.23M), first-move beta cutoff within 0.1-0.9 points of baseline on all three suites -- and clearly better than the same fix under interleaved order (113/191 solves, 963.10M nodes), which loses to head_reference on every metric. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EortUUkDVpsfrbqshBJYJg
12 daysDocument the three-curated-suites testing methodology and theScott Gasch
head_reference protocol. Realized while asking "where is this documented" that it wasn't -- scattered implicitly across lmr_testing/RESULTS.md and head_reference/README.md but never stated as a methodology anywhere durable. Also records the sd-vs-sn guidance (pick based on what the change is expected to affect) and flags the specific gap that cost real time this session: assuming a working tree's disabled-mechanism state matched HEAD's actual committed behavior, when it didn't.
12 daysFix stale comment in split.c referencing removed SEARCH_SORT_LIMIT.Scott Gasch
Comment-only; split.c never called the macro itself (it always fully sorts its remaining moves regardless, unaffected by the rename), just referenced the name in a doc comment explaining why it opts out of the leftover-selection budget entirely.
12 daysReplace SEARCH_SORT_LIMIT's ply-indexed leftover-selection budget withScott Gasch
NumLeftoverMovesToSelect, indexed by remaining depth; make EFP's leftover-only scope explicit. SEARCH_SORT_LIMIT[ply] was a poor proxy for what actually matters here -- how large the remaining subtree below this node is. Distance from root only correlates with that when total search depth is roughly fixed; it says nothing once extensions/reductions/iterative-deepening are in play. NumLeftoverMovesToSelect(ctx, uDepth) uses remaining depth instead, only ever consulted once every high-performer move (winning/ even capture, killer, killer-mate -- anything >= GOOD_MOVE) has already been exhausted; this never limits how many of *those* get selected, only how much further care to spend on the ordinary/leftover tail. Table values carried over verbatim from the old one as an untuned starting point, just reindexed. Also adds an explicit (TRUE == fInLeftovers) gate to EFP's per-move checklist (landed last commit) -- every high-performer move was already excluded as a side effect of the capture/check/killer exemptions, but this makes "EFP only ever touches leftovers" a real, direct condition rather than an emergent property of unrelated checks. Verified against HEAD (commit bb07fbd) at sd10: ecm_ringers: 9/11 -> 10/11 (+1 solve), ~flat nodes (-0.04%) ecm_confident_quick: 88/90 -> 88/90 (even), +0.43% nodes ecm_hard_quick: 15/90 -> 18/90 (+3 solves), +4.4% nodes Net +4 solves across 269 positions for a negligible node-count cost.
12 daysReplace EFP (what this file erroneously called EFP for years) withScott Gasch
Heinz's actual two-tier schedule, a hardened per-move checklist, and a TT-soundness fix. Proto-LMR (live, unrelated) untouched. The old condition used a single flat VALUE_ROOK margin across uDepth <= TWO_PLY -- that's neither of the two numbers Heinz's book actually specifies for that range. Split into his real two tiers, each gated on the same common conditions (PV-node guard added; HEAD's version had none, unlike GetLMRReduction which has always required non-PV) but different depth bands and margins: - frontier ("selective futility"): VALUE_KNIGHT, one ply above the QSearch jump (bands are relative to THREE_QUARTERS_PLY, the actual cutoff, not ONE_PLY, since the check-extension rework lowered it). - pre-frontier ("extended futility pruning" proper): VALUE_ROOK, the next ply out. Heinz's third tier ("limited razoring", pre-pre-frontier, VALUE_QUEEN) is a per-node depth reduction, not a per-move prune -- a different technique, deliberately not implemented here (spiritual predecessor to LMR, revisit then). Also drops the old ValueOfMaterialInTroubleDespite- Move requirement (an en-prise/trapped-piece safety net) -- intent is to fire on ordinary quiet positions too, not just ones with an already- flagged piece in danger. Per-move checklist, replacing an ASSERT that captures/checks couldn't reach here (untrue in a non-DEBUG build, so no actual protection) with real exemptions: explicit !IS_CAPTURE_OR_PROMOTION / !IS_CHECKING_MOVE, a killer-adjacency exemption (ply-1/ply-3, borrowed from GetLMRReduction), a well-evidenced fail-high-history exemption (GetMoveFailHighPercentage, >=5 samples before trusting it), an en-prise-escape exemption, and suppression at any node where this node's own null-move probe raised fThreat. TT-soundness fix (Heinz's own book, quoted directly): a node whose result depends on alpha/beta via forward pruning can't be stored as an exact score or a sound upper bound -- a skipped move might have been the best one, so the true value could be higher than computed in either case. Tracks fAnyMoveEFPPruned; downgrades an alpha-raise to StoreLowerBound instead of StoreExactScore when set, and skips hash storage entirely on a fail-low with pruning (no sound bound available in either direction). Verified against head_reference (HEAD, commit 7e762b2) at sd10: ecm_ringers: 10/11 -> 9/11 (-1 solve), -8.96% nodes ecm_confident_quick: 87/90 -> 88/90 (+1 solve), -16.49% nodes ecm_hard_quick: 17/90 -> 15/90 (-2 solve), -15.94% nodes Net -2 solves across 269 positions for 9-16% fewer nodes per suite -- similar shape to Heinz's own reported trade-off in the book (8 lost solutions for -16.70% fewer nodes). Old flat-margin candidates built earlier today (kept in git stash, not this commit) only achieved 0.3-1% node reduction vs. a no-EFP baseline; dropping the stale material-in-trouble gate is what actually recovered real pruning power, not the checklist alone.
12 daysRewire GetMoveFailHighPercentage: index by (cFrom, cTo, pMoved) insteadScott Gasch
of (cFrom, cTo, color), and expose sample size. MOVE_TO_INDEX (used elsewhere for the counter-move table) folds any two moves with the same from/to/color into one fail-high bucket -- e.g. a king shuffle and a queen sac to the same square/color shared a slot. New MOVE_TO_FH_INDEX uses the low 20 bits of mv.uMove (cFrom+cTo+pMoved), which already encodes color in pMoved's low bit, so this is strictly more granular for free. Table grown 0x20000 -> 0x100000 entries to match. Also adds an optional ULONG *puAttempts out-param so future callers can weight by sample confidence instead of trusting a percentage computed from as few as one observation. GetLMRReduction (proto-LMR, live at HEAD) is the only real consumer right now; verified against head_reference at sd10 across ecm_ringers/ecm_confident_quick/ecm_hard_quick: net +2 solves (88 vs 87 on confident_quick, 18 vs 17 on hard_quick), node counts flat within noise (-1.5%/+1.4%/+0.5%).
12 daysDefault GNUmakefile to a 64-bit (SIXTYFOUR=1) build.Scott Gasch
32-bit clang isn't available in this environment, so every build this session has needed the flag passed explicitly anyway; make it the default via ?= (still overridable) rather than requiring it on every invocation. Build-only change, verified byte-identical search behavior (ringers/sd10: 10/11 solved, 46,341,121 nodes, matching head_reference exactly).
12 daysReplace SEARCH_SORT_LIMIT's pure move-count cutoff with a quality-basedScott Gasch
one: always fully select "high performer" moves regardless of count, only apply the per-ply budget to leftover ordinary moves. The old gate (uLegalMoves < SEARCH_SORT_LIMIT(ply)) stopped selecting carefully after a fixed count, counting the hash move too -- so at ply 6+ (limit 5), a position with a hash move already used one of only 5 total slots before the cutoff hit. It had no way to tell "a handful of mediocre quiet moves" from "a hash move plus three winning captures and two killers" -- in the latter case, a real high-performer beyond the 4th/5th slot would get treated identically to a random leftover quiet move, even though generate.c had already tagged it as excellent. Checked what three real engines do here: Crafty always fully sorts the hash move, then MVV/LVA-ordered captures, then up to 4 killers -- its own cheap fallback (a move-count cutoff, gated by remaining depth) only ever applies to what's left after all of that, i.e. plain untested quiet moves. Stockfish uses a value threshold, not a position/count threshold, so a good move is never orphaned by where it happens to sit in the list, only by its own assessed quality. Berserk never gates at all -- full selection sort unconditionally, every node. New design: keep fully selecting for as long as every move found so far is >= GOOD_MOVE (a generate.c ordering-encoding constant that already sits, by construction, below every killer tier and SORT_THESE_FIRST's winning/even-capture range, and above ordinary quiet moves and losing captures -- a real quality floor already baked into the existing encoding, not a new one). The first selection that reveals a move below that floor marks the transition to "the rest of the team"; from there, SEARCH_SORT_LIMIT's existing table is reused (as an explicitly untuned starting point -- its old numbers were calibrated, if at all, against a different question: total selection budget from move 1, not a leftover-only budget) to decide how many more full selections are worth the cost before taking the remainder in place. On an IID-rescored ply, GOOD_MOVE is meaningless (iValue is a real eval-axis score there, not generate.c's encoding), so that ply type keeps its existing unconditional full-select behavior unchanged. Measured (ecm_ringers.ep_/ecm_confident_quick.ep_/ecm_hard_quick.ep_, sn=5M) against the prior baseline (10/88/9): 11/87/12, net +3 solves. EBF: unchanged on ringers, worse on confident_quick (the one suite that also lost a solve -- consistent single-suite regression, not a systemic pattern), better on hard_quick (paired with its solve gain). Not yet a fully validated result -- SEARCH_SORT_LIMIT's numbers (17/12/9/7/6/5) now need their own recalibration pass under this new "leftover budget" meaning, since whatever they were tuned against before doesn't apply to this role. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
12 daysFix RescoreMovesViaSearch's fail-high handling: stop destroying theScott Gasch
winning move's own score, and stop discarding generate.c's ordering information for moves it never got to search. Two bugs, found while reading this function to understand it: 1. On fail-high, `goto end` jumped past the for loop's own x++, so x at the `end:` label still pointed at the move that just failed high -- whose iValue had just been correctly set to its real score two lines earlier. The clearing loop then started at that same x, immediately overwriting the winning move's own just-computed score with -INFINITY: exactly backwards, marking the one move IID found good enough to fail high on as worst-possible, while the inferior moves it beat kept their real scores and would be preferred instead. 2. Even with that fixed, every move after the winner was still set to -INFINITY -- total, deliberate amnesia about generate.c's original ordering estimate for moves we simply didn't get to (a fail-high means we stop early on purpose, to avoid burning nodes confirming what we've already decided to play). If the winner's fail-high doesn't hold up at full depth, the caller falls back to a list where every remaining move is a tied -INFINITY -- worse than never having run IID at all for that tail, and inconsistent with -INFINITY's use elsewhere in this function for genuinely-known-illegal moves. Restructured to defer committing to mvf[].iValue until it's known whether every move got an honest, fully-searched score (scores go into a local scratch array during the loop instead of directly into the move stack). On full completion, commit all of them and set fMovesRescoredByIID as before. On fail-high, commit nothing -- leave every move's original generate.c ordering value untouched, and bump just the winning move into killer-tier territory (same trick generate.c uses for a real killer move) so the normal, non-rescored selection path still tries it first. fMovesRescoredByIID stays FALSE in this case, since the ply's iValue is back to being generate.c's ordering encoding, not real scores. Measured (ecm_ringers.ep_/ecm_confident_quick.ep_/ecm_hard_quick.ep_, sn=5M): 10/88/9, recovering the confident_quick point lost by the previous IID-trust commit (was 10/87/9) with no cost elsewhere. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
12 daysRemove RescoreMovesViaSearch's dead same-ply recursive pre-call.Scott Gasch
Before searching its own move list, RescoreMovesViaSearch called itself recursively at an even shallower depth, at the *same* ctx->uPly, on the theory that the extra rescore's side effects (hash/killer/history table population) would help the real loop's own -Search() calls find cutoffs faster. But the recursive call's own iValue writes were always fully overwritten by this same call's loop immediately after it (same ply, same move-stack range), so the only way it could possibly help was via those side effects. Measured directly: disabling it produced a bit-identical result across all three test suites (ecm_ringers.ep_, ecm_confident_quick.ep_, ecm_hard_quick.ep_ at sn=5M) -- no change whatsoever, not even a single position. It was pure wasted search effort. Removed. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
12 daysFix IID's killer-blind gate and its ordering-flag/history contaminationScott Gasch
of iValue; harden against a latent ComputeMoveExtension bug. DO_IID's "is the top move crappy" gate compared raw iValue against SORT_THESE_FIRST only, missing that ordinary killer moves (FIRST_KILLER through FOURTH_KILLER) sit below that threshold too -- a killer that already proved itself elsewhere in the tree was being treated as "crappy" and triggering an unnecessary shallow rescore. Fixed by also excluding killer-flagged moves from the gate. RescoreMovesViaSearch corrupted the winning move's real search score by OR-ing in SORT_THESE_FIRST to force it to sort first (`mvf[uBest].iValue |= SORT_THESE_FIRST`) -- unnecessary (SelectBest{With,No}History already find the true max by plain magnitude comparison, no flag needed) and actively dangerous: a later ComputeMoveScore() call on that same move, if it's a capture, would see the corrupted value, mistake it for generate.c's biased-capture-ordering format, and subtract the wrong bias entirely. Removed the OR; added an explicit PLY_INFO.fMovesRescoredByIID flag so ComputeMoveScore and the main search-loop's move-selection call can both recognize "this ply's iValue holds a real eval-axis score" without relying on bit-pattern inference. Consequently, ComputeMoveScore now trusts an IID-rescored move's score outright instead of running it through the capture-bias-subtraction or quiet-move-collapse-to-0 logic (both of which assume generate.c's ordering encoding, which a rescored ply no longer holds). Separately hardened it against quiet killer-mate moves, which can reach SORT_THESE_FIRST via a different, capture-unrelated path and were incorrectly getting the capture bias subtracted from them; they now correctly collapse to 0 like other quiet moves. Two follow-on ideas -- blending history into the real IID score (scaled or capped) and a exact-tie-only history tiebreak -- were implemented, measured, and rejected: blending invents a new, leak-prone move-scoring axis for no measured benefit, and the tiebreak-only compromise still cost solves relative to just trusting the real score outright. Main search's move-selection call now branches once per selection (not once per candidate move) between SelectBestNoHistory (IID-rescored plies) and SelectBestWithHistory (everyone else), keeping the overwhelmingly common non-rescored path at zero added cost. Net measured effect (ecm_ringers.ep_/ecm_confident_quick.ep_/ ecm_hard_quick.ep_, sn=5M): 10/90/9, down from a pre-existing 11/88/10 on ringers and hard specifically -- see lmr_testing/RESULTS.md for the full sweep of rejected alternatives and why the regression was accepted as the cost of removing a latent, leak-prone bug class rather than chasing the exact prior numbers. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
12 daysFix _ShouldWeConsiderThisMove's SEE-purity bug and ComputeMoveScore'sScott Gasch
killer-mate edge case; fix PV-display cycle hang. _ShouldWeConsiderThisMove (QSearch's move-consider gate) read the raw, move-ordering-biased mvf[].iValue directly instead of going through ComputeMoveScore, so it inherited the same +120-ish flat bias (plus small MVV-LVA nudges) on winning/even captures that ComputeMoveScore was already fixed to strip out. Fixed via the same MOVE_SCORE_ORDERING_BIAS subtraction, now factored into a shared chess.h macro. Restoring the old effective leniency required an explicit QSEARCH_CONSIDER_MARGIN (120, A/B'd against 0/60/120 on ecm_ringers/confident_quick/hard_quick) rather than assuming the bug's magnitude was itself a meaningful margin -- net effect vs the pre-fix baseline is -2 solves on hard_quick, accepted as the cost of correctness (see lmr_testing/RESULTS.md for the full sweep). ComputeMoveScore separately mishandled quiet killer-mate moves: they can reach SORT_THESE_FIRST via generate.c's killer-mate bonus (unrelated to the capture-bias path), so the bias-subtraction was wrongly applied to a move that never had that bias. Gated the subtraction on IS_CAPTURE_OR_PROMOTION(mv); quiet moves (including killer-mate ones) now correctly collapse to 0, per the function's contract of estimating a move's value on the 100=1-pawn axis. Measured as a no-op on all three suites -- rare in practice, but a real correctness fix. Left a comment documenting two candidate refinements for scoring quiet moves as non-uniform future work, deliberately not implemented (each needs its own isolated test). FinishPVTailFromHash (cosmetic PV-display hash-walk, used only for printing) had no cycle detection, so a drawish/repeating position could spin until the output buffer filled instead of terminating naturally. Added visited-position-signature tracking and a <REP> marker. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
13 daysFix QSearch stand-pat gating bug and ComputeMoveScore's SEE contamination; ↵Scott Gasch
tune singular-reply-to-check margin. QSearch stand-pat: the "deny stand pat when material is genuinely in trouble" check was gated on fCouldStandPat (has this side had a chance to stand pat earlier in this qsearch line). That's wrong -- whether an ancestor node had a moment of safety says nothing about whether *this* node's material danger is real; a hanging piece doesn't stop hanging because the position was quiet three plies ago. fCouldStandPat's legitimate uses (search.c:950, 1190/1193) are about deciding whether a *whole line* looks forcing enough to justify extra qsearch depth/ breadth, a different question from per-node stand-pat correctness. Removed the gate; the material-in-trouble check now always denies stand-pat, regardless of history. ComputeMoveScore: for winning/even captures and promotions, the value extracted from the move-ordering sort key (generate.c's _ScoreAllMoves) included a flat +120 ordering bias plus small MVV-LVA tie-break nudges (PIECE_VALUE_OVER_100 terms) baked in on top of the real SEE/ material-diff value. Harmless for its original sorting purpose (every capture gets the same treatment), but this function's callers (futility pruning, the singular-reply-to-check extension) use the result as an eval-axis quantity compared against material-scale margins -- the contamination doesn't belong there. Subtracted the ordering-only bias back out to recover pure SEE/material-diff, same axis as the raw PIECE_VALUE() fallback used when no move-stack index is available. Quiet-move and losing-capture handling were already correct (both collapse to a clean, uncontaminated value). Also bumped the singular-reply-to-check margin (225 -> 400): confirmed via direct A/B on the quick suites that this is a real, independent improvement on top of the SEE fix, not just compensating for it -- reverting to 225 measurably regressed both ecm_confident_quick.ep_ (89->88/90) and ecm_hard_quick.ep_ (10->6/90) versus keeping 400. Verified against pristine baseline (no LMR in this binary) on the three-suite protocol (ecm_ringers.ep_, ecm_confident_quick.ep_, ecm_hard_quick.ep_, sn=5M, book disabled): 11/11 ringers (matches baseline exactly), 89/90 confident (vs baseline's 90/90), 10/90 hard (vs baseline's 4/90) -- a real net improvement over baseline with zero LMR involved, considerably stronger than any state reached earlier in this session's LMR-only experimentation. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
13 daysRemove ctx->uPositional and the EVAL_HASH subsystem; fix GetRoughEvalScore.Scott Gasch
Finishes work left half-done in 7857096 ("Replace ctx->uPositional with a data-calibrated Eval() return value"): that commit added Eval()'s new piPositional out-param but never migrated GetRoughEvalScore onto it, so GetRoughEvalScore's mid/deep-tree fallback kept reading the old ctx->uPositional field -- a per-thread EWMA written only on full-eval calls and never touched by the (far more common) lazy-eval path, so it carried a stale value from whatever unrelated position last triggered a full eval, potentially many nodes/plies away. Combined with EVAL_HASH being long since disabled (its probe branch already dead), every GetRoughEvalScore call past ply 4 was effectively "material + garbage." Fixed by having GetRoughEvalScore just call Eval() directly -- its own lazy-exit machinery already is the cheap, calibrated estimate this function exists to provide, so there's no separate estimator to maintain. Removed ctx->uPositional entirely (struct field, its EWMA update in eval.c, both root.c init sites, split.c's cross-split propagation, testeval.c's reset) along with the entire EVAL_HASH subsystem (struct, table, Probe/StoreEvalHash, main.c's now-dead reporting branch, the GNUmakefile flag) -- confirmed unused elsewhere and explicitly being cut for good, not coming back in this form. Also fixed GetRoughEvalScore's prototype being wrongly declared inside #ifdef EVAL_HASH in chess.h even though the function itself is defined and called unconditionally -- this was the source of the recurring "call to undeclared function 'GetRoughEvalScore'" implicit-declaration warning seen throughout this session's builds. Separately, fixed QSearch to match its own documented intent: the en-prise/trapped-piece "don't let this side stand pat" check now only fires if the side hasn't already been allowed to stand pat earlier in this qsearch line (matching the comment above it, which already said this but the code never implemented it). Verified against baseline/typhoon_baseline (pristine, pre-session) on ecm_ringers.ep_ (4), ecm_hard_quick.ep_ (50-sample), and ecm_confident_quick.ep_ (40) at sn=5M, --cpus 1, book disabled: pristine baseline solves 3/50 on the hard sample; this commit solves 6/50, with the stand-pat fix and GetRoughEvalScore fix each contributing +1 independently confirmed. No regressions on the other two suites (4/4 and 40/40 unchanged throughout). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
13 daysFix integer divide-by-zero in script.c's post-suite histogram printer.Scott Gasch
uMax (the largest histogram bucket count) can be 0 when a script run's positions are all unsolved -- ASSERT(uMax > 0) doesn't stop this in a non-DEBUG build, and the following loop unconditionally divides by uMax, crashing with SIGFPE. Hit repeatedly today running single-position diagnostic scripts (an isolated unsolved position naturally has an all-zero histogram) while investigating an LMR regression on ECM.213. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YGSMkwjqiCk4XhbfN7ugD2
13 daysBaseline: uPositional data-calibrated fix, enprise/trapped hints, ↵Scott Gasch
EBF/beta-cutoff/counter-move stats, script.c FPE fix. No LMR, no counter-move-driven move ordering (both explored separately, kept out for now -- counter-move measured worse, ~655->647 solved on ecm879 @ sn=4M with a leaner tree beforehand). Futility pruning restored. Verified: 647/879 solved, EBF 4.609 @ sn=4M; 684/879 solved, EBF 3.995 @ 20s/move, 1cpu, 256m hash (typhoon_baseline.log). The counter-move table is still written and its stats still tracked (dynamic.c) for diagnostic purposes, but generate.c no longer reads it for move ordering, so it has no effect on search behavior in this commit. lmr_testing/ holds the in-flight graded-LMR + counter-move code (not applied here) with notes on what was already tried and measured, so a future session can resume without re-deriving it.
14 daysReplace ctx->uPositional with a data-calibrated Eval() return value.Scott Gasch
uPositional was a per-thread EWMA of abs(material - true score) used to size lazy-eval and futility margins. It was history-derived (reflecting whatever recent, unrelated positions looked like) rather than derived from the position actually being margined, and its update/consumption was tangled with EVAL_HASH (now disabled). Eval() now takes an optional SCORE *piPositional out-param and fills it in on every return path: exact (abs(material-delta)) on a full eval, or an estimate from a new EstimatePositionalScore() on a lazy exit. EstimatePositionalScore()'s two terms (king-safety-defect-bucketed, and a flat residual for mobility/passers/everything else) are calibrated from ~1.6M measured full-eval samples (p90 of the actual swing), not guessed -- an initial guessed version measurably regressed ECM solve rate (630 vs a 650 baseline at sn=4M); the recalibrated version is back at parity (649/879). search.c's qsearch futility now reads the value Eval() just computed instead of the stale/shared ctx field. Also removes QSearchInDangerNoStandPat and SideCanStandPat, dead since the danger-hash check that fed them was already commented out (e08387a) -- they depended on the same enprise/ trapped-piece data this conversation is about to move off of g_PositionHash entirely. Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26Don't [re-]consult the SEE when generator already just did so.Scott Gasch
2026-08-26Make EVAL_HASH optional.Scott Gasch
2026-08-26Segfault fix.Scott Gasch
2026-08-26Disable qsearch danger-detection (SideCanStandPat) as an experimentScott Gasch
Profiling (pmcstat, sampled) showed SideCanStandPat as the single hottest leaf function in the engine, ahead of Eval itself -- not from expensive logic (it's a 3-line signature/lock/lookup), but from sheer call volume (~95% of the tree is qsearch) combined with a likely-cold 16MB global hash table probe and an uncontended-but-nonzero lock/unlock pair paid on every call even single-threaded. Disabling it entirely: real bench nps was flat (~1.53M vs ~1.57M, within noise -- the earlier "1.2M->1.75M" bench reading was itself an uncontrolled, noisy single comparison on this shared box, not a real effect). But ECM (sn 4M) came back at 655/879, the best result of the whole session (vs 650 baseline) -- suggests the danger-detection heuristic may have been net-negative for search quality on balance, forcing exhaustive no-stand-pat search in some positions where standing pat was actually fine. Speed claim didn't hold up; the tactical-quality result is a genuine, unexpected positive worth investigating further. Not yet validated in self-play -- pending a clean match run. Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-25Tame check-extension compounding; verified neutral in self-play (0.4955)Scott Gasch
Fixes a real pathology: checks along a long unbroken forcing line could extend for free (net zero cost against the qsearch boundary), letting tree size blow up multiple orders of magnitude on positions like a near-all-check forced mate (ECM.089: 4.5B nodes / 31min at depth 12 before this change). - Main-search check extension: gate on SEE soundness (a losing sacrifice check gets a small consolation QUARTER_PLY instead of the full bonus a sound check gets), and flatten the sound-check bonus to a flat THREE_QUARTERS_PLY instead of a near-free ONE_PLY. - Lower the qsearch entry threshold to match (THREE_QUARTERS_PLY instead of ONE_PLY) so a lone check still buys one extra full-width ply as before; root.c trims QUARTER_PLY off the per-iteration depth budget so this doesn't add a blanket 1/4 ply to every search. - Qsearch's own check-widening (QSearchFromCheckNoStandPat) now relies on fCouldStandPat history plus a g_uIterateDepth/4 ceiling instead of an unconditional per-check grant, and QPLIES_OF_NON_CAPTURE_CHECKS moved from 1 to 2 to cover both "enter qsearch already in check" and "opponent's reply is the first real check" cases with one baseline window instead of ad hoc attacker-color tracking. Net effect on ECM.089 (sn 4M canary): ~7.5x fewer nodes and ~4x less time at depth 12 versus the original, unbounded behavior. Costs solve count on the full ECM suite (879 pos, sn 4M): 650 baseline -> 636 here -- expected and accepted, since ECM is unusually check-extension-heavy tactics and not representative of real games. Self-play vs baseline (1000 games, st 1) came back at B_SCORE=0.4955, ELO=-3.1+/-21.5 -- statistically neutral, confirming the fix costs nothing in real play. Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-25Add LMR with PV-adjacency guard (v7), verified against saved binaryScott Gasch
Late Move Reductions using a depth x movecount table (Ethereal-style formula), gated off PV nodes and the ply directly below a PV node (PLY_INFO.fIsPVNode), with magnitude-aware re-search on fail-high. Verified node-for-node identical to the previously tested-good v7 binary on a canary position (sd 10) after reconstructing from a ZFS snapshot of search.c/root.c/split.c taken just before that binary was built. Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-24Started doing texel eval tuning.Scott Gasch
2026-08-23Integrate new syzygy egtb code, lose the old Nalimov code.Scott Gasch
2026-08-23Clean X64 build and ported GetAttacks to x64.Scott Gasch
2026-08-22Bug fixes.Scott Gasch
2026-08-18Stop hanging on exit!Scott Gasch
2018-10-03Update codebase to remove clang warnings (and a couple of legit errorsScott Gasch
it found)