From 4b39f6bab6cf34c8b8f5629af2271b859c3b040b Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 18 Oct 2026 10:05:04 -0500 Subject: [PATCH 1/7] Add conservative Teams-aware reverse futility pruning --- CMakeLists.txt | 4 + docs/reverse-futility-pruning.md | 40 +++++++++ include/ironphoenix/search.hpp | 2 + src/search.cpp | 69 ++++++++++++++- tests/reverse_futility.cpp | 143 +++++++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 docs/reverse-futility-pruning.md create mode 100644 tests/reverse_futility.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f117c3f..0156fdf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -181,6 +181,10 @@ if(IRONPHOENIX_BUILD_TESTS) target_compile_features(ironphoenix_search_stack PRIVATE cxx_std_20) add_test(NAME ironphoenix_search_stack COMMAND ironphoenix_search_stack) + add_executable(ironphoenix_reverse_futility tests/reverse_futility.cpp) + target_link_libraries(ironphoenix_reverse_futility PRIVATE IronPhoenix::Core) + add_test(NAME ironphoenix_reverse_futility COMMAND ironphoenix_reverse_futility) + set_target_properties(ironphoenix_smoke ironphoenix_property ironphoenix_search_stack PROPERTIES FOLDER "Tests" ) diff --git a/docs/reverse-futility-pruning.md b/docs/reverse-futility-pruning.md new file mode 100644 index 0000000..60b7515 --- /dev/null +++ b/docs/reverse-futility-pruning.md @@ -0,0 +1,40 @@ +# Reverse futility pruning in Teams search + +At shallow non-PV nodes, RFP skips the subtree when the side-to-move team's +static evaluation minus a safety margin is already at least beta. This can +save nodes in clearly favorable positions; it is a selective heuristic, not +a proof of a minimax bound or of increased playing strength. + +The initial settings are depth 1–4 (plies, not full four-seat cycles) and +200 evaluation units per remaining ply. The returned fail-high estimate is +`staticEval - 200 * depth`, rather than the unadjusted static surplus. +No exact TT entry or best move is manufactured by the cutoff. + +RFP runs after mate-distance/TT cutoffs and before NMP and ordinary move +search. Evaluation is already relative to the moving team's perspective: +Red/Yellow versus Blue/Green. The regular four-seat rotation alternates teams, +so the existing child negation is retained. The raw evaluation is recorded on +the per-ply search stack and reused by NMP if RFP declines to cut. + +Guards exclude root/PV nodes, synthetic null-move child nodes, mate windows and +mate-range static evaluations, own/partner check, and movers without non-pawn +material. A candidate cutoff additionally requires four living players and +four present, unchecked kings. Checking enemy kings is conservative but also +protects immediate king-capture wins. Pawns one step from promotion in any +of the four orientations disable RFP, even when blocked. Existing geometry +helpers retain the standard rank 11/file k/rank 4/file d promotion lines. +Pseudo-legal terminal captures and promotions are explicitly excluded, and +two legal witnesses are required to preserve stalemate and forced moves. +Every witness make/undo restores the board and hash. + +These guards cost work on candidate cutoffs and do not eliminate all tactical +or zugzwang errors. They leave NMP's existing policy and other pruning intact. +Use CTest for deterministic guard/score/state regressions, then compare to +the same main revision with identical compiler, network, hash, threads and +opening suite. Run paired seat/team-swapped fixed-node and fixed-time games, +inspect tactical losses and node/time/depth changes, and use a predeclared +SPRT before claiming an Elo gain or tuning the depth/margin more aggressively. + +The old experiment is PR #28 at branch tip +`dcefe70131df6c83a69910e2edec39086e3efa7f`. Its RFP idea and initial constants +are retained, while its unrelated merge history is excluded from this branch. diff --git a/include/ironphoenix/search.hpp b/include/ironphoenix/search.hpp index 3cc0044..6a87714 100644 --- a/include/ironphoenix/search.hpp +++ b/include/ironphoenix/search.hpp @@ -65,6 +65,8 @@ namespace ironphoenix { void newGame() noexcept; private: + friend struct ReverseFutilityTestAccess; + [[nodiscard]] bool reverseFutilitySafe(Position& pos) const; struct TimeBudget final { std::int64_t minimumMs = 0; std::int64_t softMs = 0; diff --git a/src/search.cpp b/src/search.cpp index 4aa240c..ab01457 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -26,6 +26,8 @@ namespace ironphoenix { constexpr int NULL_MOVE_MIN_DEPTH = 2; constexpr int NULL_MOVE_DIVISOR = 190; + constexpr int REVERSE_FUTILITY_MAX_DEPTH = 4; + constexpr int REVERSE_FUTILITY_MARGIN_PER_DEPTH = 200; constexpr std::size_t CHECK_BIT_WORDS = (MAX_MOVES + 63u) / 64u; // Non-PV nodes normally cut off quickly. Selecting only the strongest @@ -650,6 +652,49 @@ namespace ironphoenix { return bestScore; } + bool SearchEngine::reverseFutilitySafe(Position& pos) const { + // This heuristic assumes the full alternating Teams turn cycle. Do not + // infer anything about positions with missing/eliminated players. + if (pos.aliveMask() != 0xF) + return false; + for (unsigned c = 0; c < COLOR_NB; ++c) { + const Color color = static_cast(c); + // Check either allied king (including the partner who cannot move + // yet), and enemy kings so immediate terminal captures are searched. + if (pos.kingSquare(color) == SQ_NONE || pos.inCheck(color)) + return false; + auto pawns = pos.pieces(color, PAWN); + while (pawns) { + const Square from = popLsb(pawns); + const Square to = Geometry::step(from, Geometry::pawnForward(color)); + // Deliberately conservative even if blocked: promotion by any + // seat during the next cycle makes a static cutoff unreliable. + if (to != SQ_NONE && Geometry::isPromotionSquare(color, to)) + return false; + } + } + + // A static surplus must not turn stalemate into a win or hide a forced + // move. Stop after two legal witnesses rather than building a full list. + MoveList moves; + generatePseudoLegalMoves(pos, moves); + const Color us = pos.sideToMove(); + int legal = 0; + for (Move move : moves) { + if (isTerminalKingCapture(pos, move) || move.isPromotion()) + return false; + } + for (Move move : moves) { + StateInfo state; + pos.makeMove(move, state); + const bool valid = !pos.inCheck(us); + pos.undoMove(move, state); + if (valid && ++legal == 2) + return true; + } + return false; + } + int SearchEngine::negamax(Position& pos, int depth, int alpha, int beta, int ply, bool pvNode, bool allowNull) { if (ply >= MAX_PLY - 1) return evaluate(pos); @@ -700,8 +745,26 @@ namespace ironphoenix { const Color us = pos.sideToMove(); const bool mateWindow = !(alpha > -MATE_THRESHOLD && beta < MATE_THRESHOLD); - // Null Move Pruning (NMP): keep expensive partner-check detection and - // static evaluation completely off nodes where NMP cannot run anyway. + // RFP estimates a fail-high in the side-to-move TEAM's perspective, + // exactly as evaluate() does. Never negate for the partner. Keep this + // out of null-move child nodes, root/PV nodes, checks and mate windows. + if (allowNull && !pvNode && ply > 0 + && depth <= REVERSE_FUTILITY_MAX_DEPTH + && !inCheckNode && !mateWindow + && hasNonPawnMaterial(pos, us)) { + const Color partner = static_cast(static_cast(us) ^ 2u); + if (!pos.inCheck(partner)) { + const int staticEval = evaluate(pos); + recordStackEvaluation(ply, staticEval); + const int estimate = staticEval - REVERSE_FUTILITY_MARGIN_PER_DEPTH * depth; + if (staticEval > -MATE_THRESHOLD && staticEval < MATE_THRESHOLD + && estimate >= beta && reverseFutilitySafe(pos)) + return estimate; + } + } + + // Null Move Pruning (NMP): reuse the stack's raw evaluation when RFP + // already evaluated this node. The existing NMP policy is unchanged. if (allowNull && !pvNode && ply > 0 @@ -712,7 +775,7 @@ namespace ironphoenix { const Color partner = static_cast(static_cast(us) ^ 2u); if (!pos.inCheck(partner)) { - const int staticEval = evaluate(pos); + const int staticEval = ss.staticEvalValid ? ss.staticEval : evaluate(pos); recordStackEvaluation(ply, staticEval); if (staticEval >= beta) { diff --git a/tests/reverse_futility.cpp b/tests/reverse_futility.cpp new file mode 100644 index 0000000..dc807d9 --- /dev/null +++ b/tests/reverse_futility.cpp @@ -0,0 +1,143 @@ +#include "ironphoenix/search.hpp" +#include "ironphoenix/eval.hpp" +#include "ironphoenix/movegen.hpp" +#include +#include + +using namespace ironphoenix; + +namespace ironphoenix { +struct ReverseFutilityTestAccess { + static bool safe(SearchEngine& engine, Position& pos) { + return engine.reverseFutilitySafe(pos); + } + static int search(SearchEngine& engine, Position& pos, int depth, int beta, + int ply = 1, bool pv = false, bool allowNull = true) { + engine.tt_.clear(); + engine.searchStack_.fill(SearchStackEntry{}); + engine.stopRequested_.store(false); + engine.nodes_ = 0; + engine.limits_.nodes = 2000; + return engine.negamax(pos, depth, beta - 1, beta, ply, pv, allowNull); + } + static std::uint64_t nodes(const SearchEngine& engine) { return engine.nodes_; } + static const SearchStackEntry& stack(const SearchEngine& engine) { return engine.searchStack_[1]; } +}; +} + +namespace { +int failures = 0; +void expect(bool ok, const char* message) { + if (!ok) { std::cerr << "FAIL: " << message << '\n'; ++failures; } +} +Square sq(char file, int rank) { return Geometry::square(file - 'a', rank - 1); } +Position quiet(Color side) { + Position pos; + pos.clear(); + pos.placePiece(sq('g', 1), makePiece(RED, KING)); + pos.placePiece(sq('a', 7), makePiece(BLUE, KING)); + pos.placePiece(sq('g', 14), makePiece(YELLOW, KING)); + pos.placePiece(sq('n', 7), makePiece(GREEN, KING)); + pos.placePiece(sq('e', 5), makePiece(side, KNIGHT)); + pos.setSideToMove(side); + pos.finalizeSetup(); + return pos; +} +} + +int main() { + Geometry::init(); + auto engine = std::make_unique(); + using Access = ReverseFutilityTestAccess; + for (unsigned c = 0; c < COLOR_NB; ++c) { + Position pos = quiet(static_cast(c)); + const Key key = pos.key(); + expect(Access::safe(*engine, pos), "quiet position eligible for every seat"); + expect(pos.key() == key && pos.verify(), "safety probe restores board and hash"); + const int eval = Eval::evaluate(pos); + for (int depth = 1; depth <= 4; ++depth) { + const int beta = eval - 200 * depth; + expect(Access::search(*engine, pos, depth, beta) == beta, "margin boundary returns adjusted score"); + expect(Access::nodes(*engine) == 1, "RFP cuts before child search"); + expect(Access::stack(*engine).staticEvalValid && Access::stack(*engine).staticEval == eval, + "raw team evaluation recorded on search stack"); + } + Access::search(*engine, pos, 1, eval - 199); + expect(Access::nodes(*engine) > 1, "insufficient margin searches children"); + Access::search(*engine, pos, 1, eval - 200, 1, true); + expect(Access::nodes(*engine) > 1, "PV node excluded"); + Access::search(*engine, pos, 1, eval - 200, 0); + expect(Access::nodes(*engine) > 1, "root excluded"); + Access::search(*engine, pos, 1, eval - 200, 1, false, false); + expect(Access::nodes(*engine) > 1, "null subtree excluded"); + Access::search(*engine, pos, 1, -29000); + expect(Access::nodes(*engine) > 1, "mate window excluded"); + Access::search(*engine, pos, 5, eval - 1000); + expect(Access::nodes(*engine) > 1, "depth beyond four excluded"); + expect(pos.key() == key && pos.verify(), "search restores board and hash"); + } + { + auto pos = quiet(RED); + pos.placePiece(sq('d', 14), makePiece(BLUE, ROOK)); + pos.finalizeSetup(); + expect(!pos.inCheck() && pos.inCheck(YELLOW), "partner-only check fixture"); + expect(!Access::safe(*engine, pos), "partner check excluded"); + const int eval = Eval::evaluate(pos); + Access::search(*engine, pos, 1, eval - 200); + expect(Access::nodes(*engine) > 1, "partner check cannot RFP"); + } + { + auto pos = quiet(RED); + pos.placePiece(sq('d', 7), makePiece(RED, ROOK)); + pos.finalizeSetup(); + expect(!Access::safe(*engine, pos), "terminal king capture excluded"); + expect(Access::search(*engine, pos, 1, -20000) == 29998, + "immediate enemy king capture keeps mate distance"); + } + const Color colors[] = { RED, BLUE, YELLOW, GREEN }; + const Square promotionSources[] = { sq('g', 10), sq('j', 7), sq('g', 5), sq('e', 7) }; + for (unsigned c = 0; c < COLOR_NB; ++c) { + auto pos = quiet(RED); + pos.placePiece(promotionSources[c], makePiece(colors[c], PAWN)); + pos.finalizeSetup(); + expect(!Access::safe(*engine, pos), "promotion threat from any seat excluded"); + } + { + auto pos = quiet(RED); + pos.erasePiece(sq('e', 5)); + pos.placePiece(sq('g', 2), makePiece(RED, ROOK)); + for (auto square : { sq('f', 1), sq('h', 1), sq('f', 2), sq('h', 2), sq('g', 3) }) + pos.placePiece(square, makePiece(YELLOW, PAWN)); + pos.finalizeSetup(); + MoveList legal; + generateLegalMoves(pos, legal); + expect(!pos.inCheck() && legal.size == 0, "stalemate fixture"); + expect(!Access::safe(*engine, pos), "stalemate excluded"); + expect(Access::search(*engine, pos, 1, -20000) == 0, "stalemate retains draw score"); + pos.erasePiece(sq('h', 1)); + pos.finalizeSetup(); + generateLegalMoves(pos, legal); + expect(legal.size == 1, "forced-move fixture"); + expect(!Access::safe(*engine, pos), "only legal move excluded"); + } + { + auto pos = quiet(RED); + pos.placePiece(sq('d', 1), makePiece(BLUE, ROOK)); + pos.finalizeSetup(); + expect(pos.inCheck(), "own check fixture"); + expect(!Access::safe(*engine, pos), "own check excluded"); + Access::search(*engine, pos, 1, Eval::evaluate(pos) - 200); + expect(Access::nodes(*engine) > 1, "own check cannot RFP"); + } + { + auto pos = quiet(RED); + pos.setAliveMask(7); + expect(!Access::safe(*engine, pos), "eliminated player excluded"); + pos.setAliveMask(15); + pos.erasePiece(sq('g', 14)); + pos.finalizeSetup(); + expect(!Access::safe(*engine, pos), "missing king excluded"); + } + if (!failures) std::cout << "Reverse futility regression tests: PASS\n"; + return failures ? 1 : 0; +} From f29f2e79d169cc76b39708947edc3fc081fb0c2f Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 18 Oct 2026 10:58:19 -0500 Subject: [PATCH 2/7] Tune team-safe reverse futility pruning aggressively --- docs/reverse-futility-pruning.md | 19 +++++++++--- src/search.cpp | 20 ++++++++++-- tests/reverse_futility.cpp | 52 ++++++++++++++++++++++++-------- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/docs/reverse-futility-pruning.md b/docs/reverse-futility-pruning.md index 60b7515..6c3faa7 100644 --- a/docs/reverse-futility-pruning.md +++ b/docs/reverse-futility-pruning.md @@ -5,10 +5,14 @@ static evaluation minus a safety margin is already at least beta. This can save nodes in clearly favorable positions; it is a selective heuristic, not a proof of a minimax bound or of increased playing strength. -The initial settings are depth 1–4 (plies, not full four-seat cycles) and -200 evaluation units per remaining ply. The returned fail-high estimate is -`staticEval - 200 * depth`, rather than the unadjusted static surplus. -No exact TT entry or best move is manufactured by the cutoff. +The aggressive experimental settings are **depth 1–6** (plies, not full +four-seat cycles) and **130 evaluation units per remaining ply**. When a +same-team ancestor provides a valid improving trend, the margin shrinks by +`min(80, improvingRate / 3)` centipawns, never falling below 90 cp. +No improvement comparison means no discount. The returned fail-high estimate +is `staticEval - max(90, 130 * depth - improvingDiscount)`, rather than +the unadjusted static surplus. No exact TT entry or best move is manufactured +by the cutoff. RFP runs after mate-distance/TT cutoffs and before NMP and ordinary move search. Evaluation is already relative to the moving team's perspective: @@ -29,11 +33,16 @@ Every witness make/undo restores the board and hash. These guards cost work on candidate cutoffs and do not eliminate all tactical or zugzwang errors. They leave NMP's existing policy and other pruning intact. +The increased max depth and smaller margin intentionally trade tactical safety +for more speculative cutoffs; no Elo gain is assumed. The improving discount +only applies to team-relative, previously recorded evaluations; missing history +does not imply improvement. + Use CTest for deterministic guard/score/state regressions, then compare to the same main revision with identical compiler, network, hash, threads and opening suite. Run paired seat/team-swapped fixed-node and fixed-time games, inspect tactical losses and node/time/depth changes, and use a predeclared -SPRT before claiming an Elo gain or tuning the depth/margin more aggressively. +SPRT before claiming an Elo gain. The old experiment is PR #28 at branch tip `dcefe70131df6c83a69910e2edec39086e3efa7f`. Its RFP idea and initial constants diff --git a/src/search.cpp b/src/search.cpp index ab01457..9781fb8 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -26,8 +26,12 @@ namespace ironphoenix { constexpr int NULL_MOVE_MIN_DEPTH = 2; constexpr int NULL_MOVE_DIVISOR = 190; - constexpr int REVERSE_FUTILITY_MAX_DEPTH = 4; - constexpr int REVERSE_FUTILITY_MARGIN_PER_DEPTH = 200; + // More aggressive team-aware RFP experiment. Keep the established + // tactical / team-safety guards; adjust only depth and margins. + constexpr int REVERSE_FUTILITY_MAX_DEPTH = 6; + constexpr int REVERSE_FUTILITY_MARGIN_PER_DEPTH = 130; + constexpr int REVERSE_FUTILITY_MIN_MARGIN = 90; + constexpr int REVERSE_FUTILITY_MAX_IMPROVING_DISCOUNT = 80; constexpr std::size_t CHECK_BIT_WORDS = (MAX_MOVES + 63u) / 64u; // Non-PV nodes normally cut off quickly. Selecting only the strongest @@ -756,7 +760,17 @@ namespace ironphoenix { if (!pos.inCheck(partner)) { const int staticEval = evaluate(pos); recordStackEvaluation(ply, staticEval); - const int estimate = staticEval - REVERSE_FUTILITY_MARGIN_PER_DEPTH * depth; + // Search-stack evaluations are already team-relative. If a + // comparable same-team ancestor shows the position improving, + // use a smaller margin; otherwise keep the base margin. + // The bounded discount never turns this into a zero-margin + // static-evaluation cutoff, even at depth one. + const int improvingDiscount = ss.improving + ? std::min(REVERSE_FUTILITY_MAX_IMPROVING_DISCOUNT, ss.improvingRate / 3) + : 0; + const int margin = std::max(REVERSE_FUTILITY_MIN_MARGIN, + REVERSE_FUTILITY_MARGIN_PER_DEPTH * depth - improvingDiscount); + const int estimate = staticEval - margin; if (staticEval > -MATE_THRESHOLD && staticEval < MATE_THRESHOLD && estimate >= beta && reverseFutilitySafe(pos)) return estimate; diff --git a/tests/reverse_futility.cpp b/tests/reverse_futility.cpp index dc807d9..68fa72d 100644 --- a/tests/reverse_futility.cpp +++ b/tests/reverse_futility.cpp @@ -3,6 +3,7 @@ #include "ironphoenix/movegen.hpp" #include #include +#include using namespace ironphoenix; @@ -12,9 +13,15 @@ struct ReverseFutilityTestAccess { return engine.reverseFutilitySafe(pos); } static int search(SearchEngine& engine, Position& pos, int depth, int beta, - int ply = 1, bool pv = false, bool allowNull = true) { + int ply = 1, bool pv = false, bool allowNull = true, + int ancestorEval = std::numeric_limits::max()) { engine.tt_.clear(); engine.searchStack_.fill(SearchStackEntry{}); + if (ply >= 2 && ancestorEval != std::numeric_limits::max()) { + auto& ancestor = engine.searchStack_[ply - 2]; + ancestor.beginNode(pos.sideToMove(), depth + 2, false); + ancestor.recordStaticEval(ancestorEval); + } engine.stopRequested_.store(false); engine.nodes_ = 0; engine.limits_.nodes = 2000; @@ -55,25 +62,46 @@ int main() { expect(Access::safe(*engine, pos), "quiet position eligible for every seat"); expect(pos.key() == key && pos.verify(), "safety probe restores board and hash"); const int eval = Eval::evaluate(pos); - for (int depth = 1; depth <= 4; ++depth) { - const int beta = eval - 200 * depth; - expect(Access::search(*engine, pos, depth, beta) == beta, "margin boundary returns adjusted score"); + for (int depth = 1; depth <= 6; ++depth) { + const int beta = eval - 130 * depth; + expect(Access::search(*engine, pos, depth, beta) == beta, "aggressive margin boundary returns adjusted score"); expect(Access::nodes(*engine) == 1, "RFP cuts before child search"); expect(Access::stack(*engine).staticEvalValid && Access::stack(*engine).staticEval == eval, "raw team evaluation recorded on search stack"); } - Access::search(*engine, pos, 1, eval - 199); + Access::search(*engine, pos, 1, eval - 129); expect(Access::nodes(*engine) > 1, "insufficient margin searches children"); - Access::search(*engine, pos, 1, eval - 200, 1, true); + Access::search(*engine, pos, 1, eval - 130, 1, true); expect(Access::nodes(*engine) > 1, "PV node excluded"); - Access::search(*engine, pos, 1, eval - 200, 0); + Access::search(*engine, pos, 1, eval - 130, 0); expect(Access::nodes(*engine) > 1, "root excluded"); - Access::search(*engine, pos, 1, eval - 200, 1, false, false); + Access::search(*engine, pos, 1, eval - 130, 1, false, false); expect(Access::nodes(*engine) > 1, "null subtree excluded"); Access::search(*engine, pos, 1, -29000); expect(Access::nodes(*engine) > 1, "mate window excluded"); - Access::search(*engine, pos, 5, eval - 1000); - expect(Access::nodes(*engine) > 1, "depth beyond four excluded"); + Access::search(*engine, pos, 7, eval - 1000); + expect(Access::nodes(*engine) > 1, "depth beyond six excluded"); + // At ply 3, +180 cp against a same-team ancestor discounts + // depth-3's 390 cp base margin by 60 cp (to 330 cp). + const int improvingBeta = eval - 330; + expect(Access::search(*engine, pos, 3, improvingBeta, 3, false, true, eval - 180) + == improvingBeta, "improving team can prune at reduced margin"); + expect(Access::nodes(*engine) == 1, "improving team RFP searches no children"); + expect(Access::stack(*engine).improving + && Access::stack(*engine).improvingRate == 180, + "improving rate populated from same-team ancestor"); + + // When the trend is flat or negative, preserve the base margin. + Access::search(*engine, pos, 3, improvingBeta, 3, false, true, eval + 100); + expect(Access::nodes(*engine) > 1, "worsening trend receives no discount"); + expect(!Access::stack(*engine).improving, "worsening trend is not improving"); + + // Cap the discount at 80 cp, leaving a nonzero margin. + const int cappedBeta = eval - 310; + expect(Access::search(*engine, pos, 3, cappedBeta, 3, false, true, eval - 1000) + == cappedBeta, "improving discount is bounded"); + expect(Access::nodes(*engine) == 1, "bounded improvement still enables RFP"); + expect(pos.key() == key && pos.verify(), "search restores board and hash"); } { @@ -83,7 +111,7 @@ int main() { expect(!pos.inCheck() && pos.inCheck(YELLOW), "partner-only check fixture"); expect(!Access::safe(*engine, pos), "partner check excluded"); const int eval = Eval::evaluate(pos); - Access::search(*engine, pos, 1, eval - 200); + Access::search(*engine, pos, 1, eval - 130); expect(Access::nodes(*engine) > 1, "partner check cannot RFP"); } { @@ -126,7 +154,7 @@ int main() { pos.finalizeSetup(); expect(pos.inCheck(), "own check fixture"); expect(!Access::safe(*engine, pos), "own check excluded"); - Access::search(*engine, pos, 1, Eval::evaluate(pos) - 200); + Access::search(*engine, pos, 1, Eval::evaluate(pos) - 130); expect(Access::nodes(*engine) > 1, "own check cannot RFP"); } { From 1b4144f7b6091c53780e7cc2eaca796c07e9e0fc Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 18 Oct 2026 10:59:30 -0500 Subject: [PATCH 3/7] Fix ply-3 search-stack assertion in aggressive RFP regression test --- tests/reverse_futility.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/reverse_futility.cpp b/tests/reverse_futility.cpp index 68fa72d..8b574dd 100644 --- a/tests/reverse_futility.cpp +++ b/tests/reverse_futility.cpp @@ -28,7 +28,7 @@ struct ReverseFutilityTestAccess { return engine.negamax(pos, depth, beta - 1, beta, ply, pv, allowNull); } static std::uint64_t nodes(const SearchEngine& engine) { return engine.nodes_; } - static const SearchStackEntry& stack(const SearchEngine& engine) { return engine.searchStack_[1]; } + static const SearchStackEntry& stack(const SearchEngine& engine, int ply = 1) { return engine.searchStack_[ply]; } }; } @@ -87,14 +87,14 @@ int main() { expect(Access::search(*engine, pos, 3, improvingBeta, 3, false, true, eval - 180) == improvingBeta, "improving team can prune at reduced margin"); expect(Access::nodes(*engine) == 1, "improving team RFP searches no children"); - expect(Access::stack(*engine).improving - && Access::stack(*engine).improvingRate == 180, + expect(Access::stack(*engine, 3).improving + && Access::stack(*engine, 3).improvingRate == 180, "improving rate populated from same-team ancestor"); // When the trend is flat or negative, preserve the base margin. Access::search(*engine, pos, 3, improvingBeta, 3, false, true, eval + 100); expect(Access::nodes(*engine) > 1, "worsening trend receives no discount"); - expect(!Access::stack(*engine).improving, "worsening trend is not improving"); + expect(!Access::stack(*engine, 3).improving, "worsening trend is not improving"); // Cap the discount at 80 cp, leaving a nonzero margin. const int cappedBeta = eval - 310; From 3dec655d3f17d0c5877cc4112939f4042a490dd6 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 19 Oct 2026 00:33:01 -0500 Subject: [PATCH 4/7] Improve RFP search NPS by deferring check tests and sharing static-prune guards --- docs/reverse-futility-pruning.md | 12 ++++ src/search.cpp | 118 ++++++++++++------------------- 2 files changed, 56 insertions(+), 74 deletions(-) diff --git a/docs/reverse-futility-pruning.md b/docs/reverse-futility-pruning.md index 6c3faa7..8bb6a80 100644 --- a/docs/reverse-futility-pruning.md +++ b/docs/reverse-futility-pruning.md @@ -47,3 +47,15 @@ SPRT before claiming an Elo gain. The old experiment is PR #28 at branch tip `dcefe70131df6c83a69910e2edec39086e3efa7f`. Its RFP idea and initial constants are retained, while its unrelated merge history is excluded from this branch. + +## Search hot-path optimization (NPS experiment) + +The `reverse-futility-pruning-teams` branch also defers expensive +`givesCheck()` calls at non-PV nodes until a move would otherwise be +pruned by LMP, HMP, or SEE. PV nodes still receive full move ordering but +no longer build check metadata used only by non-PV pruning. RFP and null-move +pruning share a single partner-check/non-pawn-material eligibility test and +reuse the recorded static evaluation. No pruning margins, move protections, +or cutoffs were relaxed in this optimization. It targets time per node, not +just reducing the number of nodes searched; measure NPS in an identical +fixed-position/fixed-node search before asserting a speedup. diff --git a/src/search.cpp b/src/search.cpp index 9781fb8..7efa1b1 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -57,10 +57,6 @@ namespace ironphoenix { return static_cast(pieces); } - IRONPHOENIX_FORCE_INLINE bool cachedGivesCheck(const std::uint64_t* bits, std::size_t index) noexcept { - return ((bits[index >> 6u] >> (index & 63u)) & 1ull) != 0; - } - IRONPHOENIX_FORCE_INLINE int captureOrderingScore(Piece movingPiece, Move move) noexcept { // Full four-player SEE is expensive enough that using it on every // generated capture purely for ordering costs substantial NPS. @@ -749,17 +745,19 @@ namespace ironphoenix { const Color us = pos.sideToMove(); const bool mateWindow = !(alpha > -MATE_THRESHOLD && beta < MATE_THRESHOLD); + // RFP and NMP share all of these guards. In 4PC the partner-check + // query can be expensive: compute it once for both heuristics. + const bool canStaticPrune = allowNull && !pvNode && ply > 0 + && !inCheckNode && !mateWindow + && hasNonPawnMaterial(pos, us) + && !pos.inCheck(static_cast(static_cast(us) ^ 2u)); + // RFP estimates a fail-high in the side-to-move TEAM's perspective, // exactly as evaluate() does. Never negate for the partner. Keep this // out of null-move child nodes, root/PV nodes, checks and mate windows. - if (allowNull && !pvNode && ply > 0 - && depth <= REVERSE_FUTILITY_MAX_DEPTH - && !inCheckNode && !mateWindow - && hasNonPawnMaterial(pos, us)) { - const Color partner = static_cast(static_cast(us) ^ 2u); - if (!pos.inCheck(partner)) { - const int staticEval = evaluate(pos); - recordStackEvaluation(ply, staticEval); + if (canStaticPrune && depth <= REVERSE_FUTILITY_MAX_DEPTH) { + const int staticEval = evaluate(pos); + recordStackEvaluation(ply, staticEval); // Search-stack evaluations are already team-relative. If a // comparable same-team ancestor shows the position improving, // use a smaller margin; otherwise keep the base margin. @@ -774,22 +772,13 @@ namespace ironphoenix { if (staticEval > -MATE_THRESHOLD && staticEval < MATE_THRESHOLD && estimate >= beta && reverseFutilitySafe(pos)) return estimate; - } } // Null Move Pruning (NMP): reuse the stack's raw evaluation when RFP // already evaluated this node. The existing NMP policy is unchanged. - if (allowNull - && !pvNode - && ply > 0 - && depth >= NULL_MOVE_MIN_DEPTH - && !inCheckNode - && !mateWindow - && hasNonPawnMaterial(pos, us)) { - - const Color partner = static_cast(static_cast(us) ^ 2u); - if (!pos.inCheck(partner)) { - const int staticEval = ss.staticEvalValid ? ss.staticEval : evaluate(pos); + if (canStaticPrune && depth >= NULL_MOVE_MIN_DEPTH) { + const int staticEval = ss.staticEvalValid ? ss.staticEval : evaluate(pos); + if (!ss.staticEvalValid) recordStackEvaluation(ply, staticEval); if (staticEval >= beta) { @@ -818,26 +807,22 @@ namespace ironphoenix { // Never manufacture a mate score from a synthetic pass. if (nullScore >= beta && nullScore < MATE_THRESHOLD) return nullScore; - } - } } MoveList moves; generatePseudoLegalMoves(pos, moves); std::array orderedScores; - std::array givesCheckBits; if (pvNode) { - // PV nodes are comparatively rare and benefit from complete ordering. - // Keep their existing full sort and cached check metadata. + // PV nodes benefit from full sorting but never use the check + // bitmap or pruning scores. Checking every capture/promotion + // here adds work without changing ordering. orderMoves(pos, moves.begin(), moves.end(), ttMove, - ply, - orderedScores.data(), - givesCheckBits.data()); + ply); } else { // At cut nodes avoid sorting the entire move list and avoid eager @@ -882,11 +867,8 @@ namespace ironphoenix { ss.currentMove = move; const Piece movingPiece = pos.pieceAt(move.from()); const bool terminalKingCapture = move.isCapture() && isTerminalKingCapture(pos, move); - const bool givesCheckMove = pvNode - ? cachedGivesCheck(givesCheckBits.data(), moveIndex) - : (!terminalKingCapture && pos.givesCheck(move)); const bool quiet = !move.isCapture() && !move.isPromotion(); - const int lmrHistoryScore = quiet ? orderedScores[moveIndex] : 0; + const int lmrHistoryScore = (!pvNode && quiet) ? orderedScores[moveIndex] : 0; int score = -INF; bool legal = true; @@ -894,49 +876,37 @@ namespace ironphoenix { const bool moveMateWindow = !(alpha > -MATE_THRESHOLD && beta < MATE_THRESHOLD) && !terminalKingCapture; - // Late Move Pruning (LMP): at non-PV nodes, stop spending search - // effort on late quiet moves unless history says the move is - // promising. Checks, TT moves, promotions/captures, and mate-window - // searches are never pruned here. This intentionally has no depth cap. - if (!pvNode - && !inCheckNode + // Most 4PC moves are *not* candidates for LMP/HMP/SEE pruning. + // Only call the expensive givesCheck() routine if a move would + // otherwise be pruned. This retains the existing checks exemption + // and avoids needless attack-map queries on every searched move. + const bool canPruneMove = !pvNode && !inCheckNode + && move != ttMove && !moveMateWindow; + const bool lmpCandidate = canPruneMove && quiet && legalMoves >= 3 + depth * depth - && quiet - && move != ttMove - && !givesCheckMove - && !moveMateWindow - && lmrHistoryScore < LMP_HISTORY_THRESHOLD) { - continue; - } - - // History Move Pruning (HMP): prune quiet moves with strongly negative - // accumulated history before they consume a search. Keep it shallow, - // protect the first legal move, and never prune tactical/special moves. - if (!pvNode - && !inCheckNode + && lmrHistoryScore < LMP_HISTORY_THRESHOLD; + const bool hmpCandidate = canPruneMove && quiet && depth <= HISTORY_MOVE_PRUNE_MAX_DEPTH && legalMoves > 0 - && quiet - && move != ttMove - && !givesCheckMove - && !moveMateWindow - && lmrHistoryScore < -HISTORY_MOVE_PRUNE_MARGIN_PER_DEPTH * depth) { + && lmrHistoryScore < -HISTORY_MOVE_PRUNE_MARGIN_PER_DEPTH * depth; + const bool seeCandidate = canPruneMove && legalMoves > 0 + && depth <= tuning.seePruneMaxDepth + && move.isCapture() && !move.isPromotion(); + + const bool givesCheckMove = (lmpCandidate || hmpCandidate || seeCandidate) + && !terminalKingCapture && pos.givesCheck(move); + + // Late Move Pruning (LMP): keep checking moves and all exclusions. + if (lmpCandidate && !givesCheckMove) 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. - // SEE pruning is not applied to TT moves, promotions, checks, or mate-window searches. - if (!pvNode - && !inCheckNode - && depth <= tuning.seePruneMaxDepth - && legalMoves > 0 - && move.isCapture() - && !move.isPromotion() - && move != ttMove - && !givesCheckMove - && !moveMateWindow) { + // History Move Pruning (HMP): do not discard checking moves. + if (hmpCandidate && !givesCheckMove) + continue; + + // Static Exchange Evaluation (SEE) Pruning: only investigate + // losing captures if a check does not already protect the move. + if (seeCandidate && !givesCheckMove) { const int seeThreshold = -tuning.seePruneMarginPerDepth * depth; if (!seeGE(pos, move, seeThreshold, SeeMode::Legal)) continue; From 0a146d36b3d15786635170079bc099e5c07c1a5f Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 19 Oct 2026 00:36:22 -0500 Subject: [PATCH 5/7] Fix shared RFP/NMP guard braces and indentation --- src/search.cpp | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/search.cpp b/src/search.cpp index 2d95e7a..64c37fe 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -766,20 +766,20 @@ namespace ironphoenix { if (canStaticPrune && depth <= REVERSE_FUTILITY_MAX_DEPTH) { const int staticEval = evaluate(pos); recordStackEvaluation(ply, staticEval); - // Search-stack evaluations are already team-relative. If a - // comparable same-team ancestor shows the position improving, - // use a smaller margin; otherwise keep the base margin. - // The bounded discount never turns this into a zero-margin - // static-evaluation cutoff, even at depth one. - const int improvingDiscount = ss.improving - ? std::min(REVERSE_FUTILITY_MAX_IMPROVING_DISCOUNT, ss.improvingRate / 3) - : 0; - const int margin = std::max(REVERSE_FUTILITY_MIN_MARGIN, - REVERSE_FUTILITY_MARGIN_PER_DEPTH * depth - improvingDiscount); - const int estimate = staticEval - margin; - if (staticEval > -MATE_THRESHOLD && staticEval < MATE_THRESHOLD - && estimate >= beta && reverseFutilitySafe(pos)) - return estimate; + // Search-stack evaluations are already team-relative. If a + // comparable same-team ancestor shows the position improving, + // use a smaller margin; otherwise keep the base margin. + // The bounded discount never turns this into a zero-margin + // static-evaluation cutoff, even at depth one. + const int improvingDiscount = ss.improving + ? std::min(REVERSE_FUTILITY_MAX_IMPROVING_DISCOUNT, ss.improvingRate / 3) + : 0; + const int margin = std::max(REVERSE_FUTILITY_MIN_MARGIN, + REVERSE_FUTILITY_MARGIN_PER_DEPTH * depth - improvingDiscount); + const int estimate = staticEval - margin; + if (staticEval > -MATE_THRESHOLD && staticEval < MATE_THRESHOLD + && estimate >= beta && reverseFutilitySafe(pos)) + return estimate; } // Null Move Pruning (NMP): reuse the stack's raw evaluation when RFP @@ -789,7 +789,7 @@ namespace ironphoenix { if (!ss.staticEvalValid) recordStackEvaluation(ply, staticEval); - if (staticEval >= beta) { + if (staticEval >= beta) { StateInfo nullState; pos.makeNullMove(nullState); searchStack_[ply + 1].history = HistoryContext{}; @@ -815,6 +815,7 @@ namespace ironphoenix { // Never manufacture a mate score from a synthetic pass. if (nullScore >= beta && nullScore < MATE_THRESHOLD) return nullScore; + } } MoveList moves; From f2ca06ba2ec8bba508f4135714b36846e00d7273 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 19 Oct 2026 00:48:45 -0500 Subject: [PATCH 6/7] Add team-aware attacked-piece pressure to HCE on RFP branch --- CMakeLists.txt | 6 ++- docs/attacking-pieces.md | 34 ++++++++++++ include/ironphoenix/tuning.hpp | 2 + src/eval.cpp | 50 +++++++++++++++--- src/tuning.cpp | 9 +++- tests/attacking_pieces.cpp | 96 ++++++++++++++++++++++++++++++++++ 6 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 docs/attacking-pieces.md create mode 100644 tests/attacking_pieces.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0b67f88..2114194 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -190,7 +190,11 @@ if(IRONPHOENIX_BUILD_TESTS) target_link_libraries(ironphoenix_reverse_futility PRIVATE IronPhoenix::Core) add_test(NAME ironphoenix_reverse_futility COMMAND ironphoenix_reverse_futility) - set_target_properties(ironphoenix_smoke ironphoenix_property ironphoenix_search_stack ironphoenix_mate_stop ironphoenix_reverse_futility PROPERTIES + add_executable(ironphoenix_attacking_pieces tests/attacking_pieces.cpp) + target_link_libraries(ironphoenix_attacking_pieces PRIVATE IronPhoenix::Core) + add_test(NAME ironphoenix_attacking_pieces COMMAND ironphoenix_attacking_pieces) + + set_target_properties(ironphoenix_smoke ironphoenix_property ironphoenix_search_stack ironphoenix_mate_stop ironphoenix_reverse_futility ironphoenix_attacking_pieces PROPERTIES FOLDER "Tests" ) endif() diff --git a/docs/attacking-pieces.md b/docs/attacking-pieces.md new file mode 100644 index 0000000..0618189 --- /dev/null +++ b/docs/attacking-pieces.md @@ -0,0 +1,34 @@ +# Attacking-piece evaluation for IronPhoenix Teams HCE + +This experiment adds a small **direct-pressure bonus against enemy pieces** to +the existing handcrafted material and mobility evaluation. + +The two teammates' attack maps are united before evaluating threats. A target +is counted at most once per TEAM, even when multiple allied pieces attack it. +Pawn capture squares count as attacks, not mobility. King attacks and the king +as a target are excluded; mate/check assessment remains in search. + +| Attacked enemy piece | Default bonus (cp) | +|---|---:| +| Pawn | 4 | +| Knight or bishop | 10 | +| Rook | 16 | +| Queen | 28 | + +The UCI tuning option `EvalAttackingPiecesWeight` ranges from 0–300. +`100` is the default percentage, `0` disables the bonus for testing: + +```text +setoption name EvalAttackingPiecesWeight value 0 +``` + +This is **geometric pressure**, not proof of a free winning capture. Checks, +pins, defenders, and SEE are intentionally not fully resolved here to avoid +making every static evaluation dramatically more expensive. + +**Only HCE uses this feature.** If a PhoenixNet NNUE is loaded, its output +remains unchanged rather than mixing differently calibrated scores. Threat +bonuses can influence static-eval decisions in RFP/NMP; benchmark NPS and +run an SPRT before assuming a strength increase. + +Tests: `ironphoenix_attacking_pieces`. diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index e2cbd21..5ed4eaf 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -34,6 +34,8 @@ namespace ironphoenix { int bishopMobility = 4; int rookMobility = 2; int queenMobility = 1; + // 100 = default pressure; 0 = disabled, for A/B tests. + int attackingPiecesWeight = 100; }; [[nodiscard]] SearchParameters& search() noexcept; diff --git a/src/eval.cpp b/src/eval.cpp index 9166ada..57e9456 100644 --- a/src/eval.cpp +++ b/src/eval.cpp @@ -50,8 +50,9 @@ namespace ironphoenix::Eval { return material; } - int mobilityForColor(const Position& pos, Color c) noexcept { + int mobilityForColor(const Position& pos, Color c, Bitboard& teamAttacks) noexcept { const Bitboard blockedByTeam = teamOccupancy(pos, c); + const bool scoreThreats = Tuning::eval().attackingPiecesWeight != 0; int score = 0; @@ -81,6 +82,8 @@ namespace ironphoenix::Eval { const int mobility = (attacks & ~blockedByTeam).popcount(); score += mobility * mobilityWeight(pt); + if (scoreThreats) + teamAttacks |= attacks; } }; @@ -89,28 +92,59 @@ namespace ironphoenix::Eval { scorePieces(ROOK); scorePieces(QUEEN); + // Pawns' capture directions are threats, not mobility. + if (scoreThreats) { + Bitboard pawns = pos.pieces(c, PAWN); + while (pawns) + teamAttacks |= Geometry::PawnAttacks[c][popLsb(pawns)]; + } return score; } - int playerScore(const Position& pos, Color c) noexcept { - return materialForColor(pos, c) - + mobilityForColor(pos, c); + int attackingPiecesForTeam(const Position& pos, int team, Bitboard attacks) noexcept { + const int weight = Tuning::eval().attackingPiecesWeight; + if (weight == 0) + return 0; + + Bitboard pawns{}, minors{}, rooks{}, queens{}; + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const Color victim = static_cast(ci); + if (teamOf(victim) == team || !pos.isAlive(victim)) + continue; + pawns |= pos.pieces(victim, PAWN); + minors |= pos.pieces(victim, KNIGHT) | pos.pieces(victim, BISHOP); + rooks |= pos.pieces(victim, ROOK); + queens |= pos.pieces(victim, QUEEN); + } + + // Per distinct enemy target across both teammates, not per attack. + // Keep king threats out of this term: mate search handles kings. + const int pressure = 4 * (attacks & pawns).popcount() + + 10 * (attacks & minors).popcount() + + 16 * (attacks & rooks).popcount() + + 28 * (attacks & queens).popcount(); + return pressure * weight / 100; } int handcraftedEvaluate(const Position& pos) noexcept { - int team0 = 0; - int team1 = 0; + int team0 = 0, team1 = 0; + Bitboard attacks[2]{}; for (unsigned ci = 0; ci < COLOR_NB; ++ci) { const Color c = static_cast(ci); - const int score = playerScore(pos, c); - + if (!pos.isAlive(c)) + continue; + const int score = materialForColor(pos, c) + + mobilityForColor(pos, c, attacks[teamOf(c)]); if (teamOf(c) == 0) team0 += score; else team1 += score; } + team0 += attackingPiecesForTeam(pos, 0, attacks[0]); + team1 += attackingPiecesForTeam(pos, 1, attacks[1]); + const int score = team0 - team1; return teamOf(pos.sideToMove()) == 0 ? score : -score; } diff --git a/src/tuning.cpp b/src/tuning.cpp index bffd563..d9f07aa 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -58,7 +58,8 @@ namespace ironphoenix::Tuning { || name == "EvalKnightMobility" || name == "EvalBishopMobility" || name == "EvalRookMobility" - || name == "EvalQueenMobility"; + || name == "EvalQueenMobility" + || name == "EvalAttackingPiecesWeight"; } bool isLmrParameter(std::string_view name) noexcept { @@ -155,6 +156,8 @@ namespace ironphoenix::Tuning { p.rookMobility = value; else if (name == "EvalQueenMobility" && inRange(value, -100, 100)) p.queenMobility = value; + else if (name == "EvalAttackingPiecesWeight" && inRange(value, 0, 300)) + p.attackingPiecesWeight = value; else return false; @@ -228,6 +231,7 @@ namespace ironphoenix::Tuning { << "option name EvalBishopMobility type spin default 4 min -100 max 100\n" << "option name EvalRookMobility type spin default 2 min -100 max 100\n" << "option name EvalQueenMobility type spin default 1 min -100 max 100\n" + << "option name EvalAttackingPiecesWeight type spin default 100 min 0 max 300\n" << "option name ResetTuning type button\n"; } @@ -256,7 +260,8 @@ namespace ironphoenix::Tuning { << " EvalKnightMobility=" << e.knightMobility << " EvalBishopMobility=" << e.bishopMobility << " EvalRookMobility=" << e.rookMobility - << " EvalQueenMobility=" << e.queenMobility << '\n'; + << " EvalQueenMobility=" << e.queenMobility + << " EvalAttackingPiecesWeight=" << e.attackingPiecesWeight << '\n'; } } // namespace ironphoenix::Tuning diff --git a/tests/attacking_pieces.cpp b/tests/attacking_pieces.cpp new file mode 100644 index 0000000..b42fada --- /dev/null +++ b/tests/attacking_pieces.cpp @@ -0,0 +1,96 @@ +#include "ironphoenix/eval.hpp" +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/nnue.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/tuning.hpp" + +#include + +using namespace ironphoenix; + +namespace { +int failures = 0; +void expect(bool yes, const char* message) { + if (!yes) { + ++failures; + std::cerr << "FAIL: " << message << '\n'; + } +} +Square sq(char file, int rank) { return Geometry::square(file - 'a', rank - 1); } +Position kings() { + Position pos; + pos.clear(); + pos.placePiece(sq('g', 1), makePiece(RED, KING)); + pos.placePiece(sq('a', 7), makePiece(BLUE, KING)); + pos.placePiece(sq('g', 14), makePiece(YELLOW, KING)); + pos.placePiece(sq('n', 7), makePiece(GREEN, KING)); + pos.setSideToMove(RED); + pos.finalizeSetup(); + return pos; +} +// Compare the same position with threat weights enabled/disabled; material +// and mobility remain identical between the two evaluations. +int bonus(const Position& pos, int weight = 100) { + const Tuning::EvalParameters saved = Tuning::eval(); + Tuning::eval().attackingPiecesWeight = 0; + const int base = Eval::evaluate(pos); + Tuning::eval().attackingPiecesWeight = weight; + const int withThreats = Eval::evaluate(pos); + Tuning::eval() = saved; + return withThreats - base; +} +} + +int main() { + Geometry::init(); + expect(!NNUE::loaded(), "tests require HCE mode (no loaded NNUE)"); + Tuning::resetEval(); + expect(Tuning::eval().attackingPiecesWeight == 100, "pressure default 100%"); + + { + Position pos = kings(); + expect(bonus(pos) == 0, "only kings: no attacked pieces"); + pos.placePiece(sq('c', 5), makePiece(RED, KNIGHT)); + pos.placePiece(sq('d', 7), makePiece(BLUE, QUEEN)); + pos.finalizeSetup(); + expect(bonus(pos) == 28, "knight threatens enemy queen"); + expect(bonus(pos, 200) == 56, "scaling threat weight to 200%"); + expect(bonus(pos, 0) == 0, "threat evaluation can be disabled"); + pos.setSideToMove(BLUE); + pos.finalizeSetup(); + expect(bonus(pos) == -28, "team-relative sign reversal"); + } + + { + Position pos = kings(); + // Rook from d6 and teammate rook from g7 both attack d7. + pos.placePiece(sq('d', 6), makePiece(RED, ROOK)); + pos.placePiece(sq('g', 7), makePiece(YELLOW, ROOK)); + pos.placePiece(sq('d', 7), makePiece(BLUE, BISHOP)); + pos.finalizeSetup(); + expect(bonus(pos) == 10, "both teammates get one bishop-threat bonus"); + pos.erasePiece(sq('d', 7)); + pos.placePiece(sq('d', 7), makePiece(YELLOW, BISHOP)); + pos.finalizeSetup(); + expect(bonus(pos) == 0, "friendly attacked pieces never receive bonus"); + } + { + Position pos = kings(); + pos.placePiece(sq('f', 5), makePiece(RED, PAWN)); + pos.placePiece(sq('g', 6), makePiece(BLUE, ROOK)); + pos.finalizeSetup(); + expect(bonus(pos) == 16, "pawn attacks enemy rook"); + } + + expect(Tuning::setEvalParameter("EvalAttackingPiecesWeight", 0), "weight 0 accepted"); + expect(Tuning::eval().attackingPiecesWeight == 0, "weight 0 stored"); + expect(Tuning::setEvalParameter("EvalAttackingPiecesWeight", 200), "weight 200 accepted"); + expect(Tuning::eval().attackingPiecesWeight == 200, "weight 200 stored"); + expect(!Tuning::setEvalParameter("EvalAttackingPiecesWeight", -1), "negative rejected"); + expect(!Tuning::setEvalParameter("EvalAttackingPiecesWeight", 301), "above 300 rejected"); + + Tuning::resetEval(); + if (!failures) + std::cout << "Attacking-piece evaluation tests: PASS\n"; + return failures ? 1 : 0; +} From f50e617cf85970bfc3cac96afb325c16e2f7158a Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 19 Oct 2026 00:50:16 -0500 Subject: [PATCH 7/7] Keep legacy material and mobility behavior for all players --- src/eval.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/eval.cpp b/src/eval.cpp index 57e9456..c03ef03 100644 --- a/src/eval.cpp +++ b/src/eval.cpp @@ -132,8 +132,6 @@ namespace ironphoenix::Eval { for (unsigned ci = 0; ci < COLOR_NB; ++ci) { const Color c = static_cast(ci); - if (!pos.isAlive(c)) - continue; const int score = materialForColor(pos, c) + mobilityForColor(pos, c, attacks[teamOf(c)]); if (teamOf(c) == 0)