From c3653fb09240e2a9973b597fe1534c5ed8b7ce4d Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 11 Oct 2026 20:49:59 -0500 Subject: [PATCH 1/6] Store static evaluation in TT entries --- include/ironphoenix/tt.hpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/include/ironphoenix/tt.hpp b/include/ironphoenix/tt.hpp index 5086355..8aba32f 100644 --- a/include/ironphoenix/tt.hpp +++ b/include/ironphoenix/tt.hpp @@ -8,6 +8,8 @@ namespace ironphoenix { +inline constexpr int TT_EVAL_NONE = -32768; + enum class TTBound : std::uint8_t { None = 0, Upper = 1, @@ -19,6 +21,7 @@ struct TTProbe { bool hit = false; Move move{}; int score = 0; + int eval = TT_EVAL_NONE; int depth = -1; TTBound bound = TTBound::None; bool pvNode = false; @@ -36,6 +39,7 @@ class TranspositionTable { void store(Key key, int depth, int score, + int staticEval, TTBound bound, Move bestMove, bool pvNode, @@ -46,9 +50,10 @@ class TranspositionTable { private: struct Entry { - Key key = 0; + std::uint32_t key = 0; std::uint32_t move = 0; std::int16_t score = 0; + std::int16_t eval = TT_EVAL_NONE; std::int8_t depth = -1; std::uint8_t flags = 0; }; From 71f25a9bf3f7ca17d9ecbd75c5ef80d872f99744 Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 11 Oct 2026 20:50:15 -0500 Subject: [PATCH 2/6] Cache static eval alongside TT score --- src/tt.cpp | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/tt.cpp b/src/tt.cpp index 89fa445..54a90dc 100644 --- a/src/tt.cpp +++ b/src/tt.cpp @@ -15,6 +15,10 @@ constexpr unsigned GENERATION_SHIFT = 3u; constexpr std::uint8_t GENERATION_MASK = 0xF8u; constexpr std::uint8_t GENERATION_MOD = 32u; +IRONPHOENIX_FORCE_INLINE std::uint32_t keySignature(Key key) noexcept { + return static_cast(key >> 32u); +} + } TranspositionTable::TranspositionTable(std::size_t megabytes) { @@ -87,12 +91,14 @@ bool TranspositionTable::probe(Key key, int ply, TTProbe& out) const noexcept { if (table_.empty()) return false; + const std::uint32_t signature = keySignature(key); const Cluster& cluster = table_[static_cast(key) & mask_]; for (const Entry& entry : cluster.entry) { - if (entry.key == key && entry.key != 0) { + if (entry.depth >= 0 && entry.key == signature) { out.hit = true; out.move = Move(entry.move); out.score = scoreFromTT(entry.score, ply); + out.eval = entry.eval; out.depth = entry.depth; out.bound = boundOf(entry); out.pvNode = pvOf(entry); @@ -105,6 +111,7 @@ bool TranspositionTable::probe(Key key, int ply, TTProbe& out) const noexcept { void TranspositionTable::store(Key key, int depth, int score, + int staticEval, TTBound bound, Move bestMove, bool pvNode, @@ -112,15 +119,16 @@ void TranspositionTable::store(Key key, if (table_.empty()) return; + const std::uint32_t signature = keySignature(key); Cluster& cluster = table_[static_cast(key) & mask_]; Entry* replace = &cluster.entry[0]; for (Entry& entry : cluster.entry) { - if (entry.key == key) { + if (entry.depth >= 0 && entry.key == signature) { replace = &entry; break; } - if (entry.key == 0) { + if (entry.depth < 0) { replace = &entry; break; } @@ -135,13 +143,20 @@ void TranspositionTable::store(Key key, replace = &entry; } - if (!bestMove && replace->key == key) + const bool sameKey = replace->depth >= 0 && replace->key == signature; + + if (!bestMove && sameKey) bestMove = Move(replace->move); + if (staticEval == TT_EVAL_NONE && sameKey) + staticEval = replace->eval; + const int ttScore = scoreToTT(score, ply); - replace->key = key; + replace->key = signature; replace->move = bestMove.v; replace->score = static_cast(std::clamp(ttScore, -32767, 32767)); + replace->eval = static_cast( + staticEval == TT_EVAL_NONE ? TT_EVAL_NONE : std::clamp(staticEval, -32767, 32767)); replace->depth = static_cast(std::clamp(depth, -1, 127)); replace->flags = static_cast(static_cast(bound) | (pvNode ? PV_MASK : 0u) @@ -157,7 +172,7 @@ int TranspositionTable::hashfull() const noexcept { std::size_t total = samples * 4; for (std::size_t i = 0; i < samples; ++i) { for (const Entry& entry : table_[i].entry) { - if (entry.key != 0 && generationOf(entry) == generation_) + if (entry.depth >= 0 && generationOf(entry) == generation_) ++used; } } From 9b4ec4dd3e44b100776ef4a5452fa8722a2a672e Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 11 Oct 2026 20:51:27 -0500 Subject: [PATCH 3/6] Reuse TT static eval and improve pruning eval --- src/search.cpp | 53 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/src/search.cpp b/src/search.cpp index 4d790ad..daa0f0f 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -35,6 +35,25 @@ namespace ironphoenix { return static_cast(pieces); } + IRONPHOENIX_FORCE_INLINE int ttImprovedEval(int staticEval, bool ttHit, const TTProbe& ttProbe) noexcept { + if (staticEval == TT_EVAL_NONE || !ttHit) + return staticEval; + + // Search scores in the mate range are distance-dependent and should + // never replace a static evaluation used by pruning heuristics. + if (ttProbe.score <= -MATE_THRESHOLD || ttProbe.score >= MATE_THRESHOLD) + return staticEval; + + if (ttProbe.bound == TTBound::Exact) + return ttProbe.score; + if (ttProbe.bound == TTBound::Lower && ttProbe.score > staticEval) + return ttProbe.score; + if (ttProbe.bound == TTBound::Upper && ttProbe.score < staticEval) + return ttProbe.score; + + return staticEval; + } + } SearchEngine::SearchEngine() = default; @@ -292,14 +311,18 @@ namespace ironphoenix { return ttProbe.score; } + int staticEval = TT_EVAL_NONE; int bestScore = -INF; Move bestMove{}; if (!inCheckNode) { - bestScore = evaluate(pos); + staticEval = ttHit && ttProbe.eval != TT_EVAL_NONE + ? ttProbe.eval + : evaluate(pos); + bestScore = staticEval; if (bestScore >= beta) { - tt_.store(pos.key(), 0, bestScore, TTBound::Lower, Move{}, pvNode, ply); + tt_.store(pos.key(), 0, bestScore, staticEval, TTBound::Lower, Move{}, pvNode, ply); return bestScore; } @@ -376,7 +399,7 @@ namespace ironphoenix { pvLength_[ply] = std::max(ply + 1, pvLength_[ply + 1]); if (alpha >= beta) { - tt_.store(pos.key(), 0, bestScore, TTBound::Lower, bestMove, pvNode, ply); + tt_.store(pos.key(), 0, bestScore, staticEval, TTBound::Lower, bestMove, pvNode, ply); return bestScore; } } @@ -387,12 +410,14 @@ namespace ironphoenix { if (inCheckNode && legalMoves == 0) { const int mateScore = -MATE_SCORE + ply; - tt_.store(pos.key(), 0, mateScore, TTBound::Exact, Move{}, pvNode, ply); + tt_.store(pos.key(), 0, mateScore, TT_EVAL_NONE, TTBound::Exact, Move{}, pvNode, ply); return mateScore; } - if (bestScore == -INF) - bestScore = evaluate(pos); + if (bestScore == -INF) { + staticEval = evaluate(pos); + bestScore = staticEval; + } TTBound bound = TTBound::Exact; if (bestScore <= originalAlpha) @@ -400,7 +425,7 @@ namespace ironphoenix { else if (bestScore >= beta) bound = TTBound::Lower; - tt_.store(pos.key(), 0, bestScore, bound, bestMove, pvNode, ply); + tt_.store(pos.key(), 0, bestScore, staticEval, bound, bestMove, pvNode, ply); return bestScore; } @@ -444,6 +469,11 @@ namespace ironphoenix { const Color us = pos.sideToMove(); const bool mateWindow = !(alpha > -MATE_THRESHOLD && beta < MATE_THRESHOLD); + // Reuse the raw static evaluation saved in the TT when available. Keep + // this separate from the searched TT score: the latter is only allowed + // to improve pruning decisions when its bound supports that direction. + int staticEval = ttHit ? ttProbe.eval : TT_EVAL_NONE; + // Null Move Pruning (NMP): if the current player can effectively pass // and the opposing team still cannot push the score below beta, normal // moves are very likely to cause the same fail-high. Keep this away @@ -457,8 +487,11 @@ namespace ironphoenix { && !mateWindow && hasNonPawnMaterial(pos, us)) { - const int staticEval = evaluate(pos); - if (staticEval >= beta) { + if (staticEval == TT_EVAL_NONE) + staticEval = evaluate(pos); + + const int eval = ttImprovedEval(staticEval, ttHit, ttProbe); + if (eval >= beta) { StateInfo nullState; pos.makeNullMove(nullState); searchStack_[ply + 1] = HistoryContext{}; @@ -655,7 +688,7 @@ namespace ironphoenix { else if (bestScore >= beta) bound = TTBound::Lower; - tt_.store(pos.key(), depth, bestScore, bound, bestMove, pvNode, ply); + tt_.store(pos.key(), depth, bestScore, staticEval, bound, bestMove, pvNode, ply); return bestScore; } From 64f081027c656747d620de8f9066c890ed3ddced Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 11 Oct 2026 20:55:35 -0500 Subject: [PATCH 4/6] Fix TT eval helper build and shadowed mate window --- src/search.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/search.cpp b/src/search.cpp index daa0f0f..9fe5d80 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -21,6 +21,7 @@ namespace ironphoenix { constexpr int NULL_MOVE_MIN_DEPTH = 4; constexpr int NULL_MOVE_BASE_REDUCTION = 2; constexpr int NULL_MOVE_DEPTH_DIVISOR = 4; + constexpr int TT_MATE_THRESHOLD = 29'000; IRONPHOENIX_FORCE_INLINE Bitboard teamOccupancy(const Position& pos, Color c) noexcept { const Color partner = static_cast(static_cast(c) ^ 2u); @@ -41,7 +42,7 @@ namespace ironphoenix { // Search scores in the mate range are distance-dependent and should // never replace a static evaluation used by pruning heuristics. - if (ttProbe.score <= -MATE_THRESHOLD || ttProbe.score >= MATE_THRESHOLD) + if (ttProbe.score <= -TT_MATE_THRESHOLD || ttProbe.score >= TT_MATE_THRESHOLD) return staticEval; if (ttProbe.bound == TTBound::Exact) @@ -547,7 +548,7 @@ namespace ironphoenix { bool legal = true; const bool quiet = !move.isCapture() && !move.isPromotion(); - const int mateWindow = !(alpha > -MATE_THRESHOLD && beta < MATE_THRESHOLD) + const int moveMateWindow = !(alpha > -MATE_THRESHOLD && beta < MATE_THRESHOLD) && !terminalKingCapture; // Late Move Pruning (LMP): at non-PV nodes, stop spending search @@ -560,7 +561,7 @@ namespace ironphoenix { && quiet && move != ttMove && !givesCheckMove - && !mateWindow + && !moveMateWindow && lmrHistoryScore < LMP_HISTORY_THRESHOLD) { continue; } @@ -573,7 +574,7 @@ namespace ironphoenix { && !move.isPromotion() && move != ttMove && !givesCheckMove - && !mateWindow) { + && !moveMateWindow) { const int seeThreshold = -tuning.seePruneMarginPerDepth * depth; if (!seeGE(pos, move, seeThreshold, SeeMode::Legal)) continue; From cdc5ad1bd57b42768861e88d79ebf18502d7e53d Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 12 Oct 2026 00:33:25 -0500 Subject: [PATCH 5/6] Add conservative futility pruning --- src/search.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/search.cpp b/src/search.cpp index 4a2af09..1589066 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -20,6 +20,8 @@ namespace ironphoenix { constexpr int NULL_MOVE_MIN_DEPTH = 2; constexpr int NULL_MOVE_DIVISOR = 190; + constexpr int FUTILITY_MAX_DEPTH = 2; + constexpr int FUTILITY_MARGIN_PER_DEPTH = 200; constexpr int TT_MATE_THRESHOLD = 29'000; IRONPHOENIX_FORCE_INLINE Bitboard teamOccupancy(const Position& pos, Color c) noexcept { @@ -35,6 +37,10 @@ namespace ironphoenix { return static_cast(pieces); } + IRONPHOENIX_FORCE_INLINE int futilityMargin(int depth) noexcept { + return FUTILITY_MARGIN_PER_DEPTH * depth; + } + IRONPHOENIX_FORCE_INLINE int ttImprovedEval(int staticEval, bool ttHit, const TTProbe& ttProbe) noexcept { if (staticEval == TT_EVAL_NONE || !ttHit) return staticEval; @@ -570,6 +576,22 @@ namespace ironphoenix { continue; } + // Futility Pruning: at shallow non-PV nodes, skip quiet moves when + // even the TT-improved node evaluation plus a depth-scaled margin + // cannot raise alpha. Keep the first legal move searchable so the + // node cannot be turned into a false mate or stalemate. + if (!pvNode + && !inCheckNode + && depth <= FUTILITY_MAX_DEPTH + && legalMoves > 0 + && quiet + && move != ttMove + && !givesCheckMove + && !moveMateWindow + && eval + futilityMargin(depth) <= alpha) { + continue; + } + // Static Exchange Evaluation (SEE) Pruning: at non-PV nodes, if a // capture move is likely to lose material, skip it. This is only done // at shallow depths, since SEE is not always accurate in complex positions. From da044b8a7657c89f2669cdba9ba7bdb23f118974 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 12 Oct 2026 00:34:32 -0500 Subject: [PATCH 6/6] Preserve futility pruning comment wording