diff --git a/CMakeLists.txt b/CMakeLists.txt index 8372b9f..9136da5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -194,6 +194,12 @@ if(IRONPHOENIX_BUILD_TESTS) target_compile_features(ironphoenix_mate_stop PRIVATE cxx_std_20) add_test(NAME ironphoenix_mate_stop COMMAND ironphoenix_mate_stop) + add_executable(ironphoenix_futility tests/futility.cpp) + target_link_libraries(ironphoenix_futility PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_futility PRIVATE cxx_std_20) + add_test(NAME ironphoenix_futility COMMAND ironphoenix_futility) + set_target_properties(ironphoenix_futility PROPERTIES FOLDER "Tests") + 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) diff --git a/docs/futility-pruning.md b/docs/futility-pruning.md new file mode 100644 index 0000000..051463b --- /dev/null +++ b/docs/futility-pruning.md @@ -0,0 +1,41 @@ +# Forward futility pruning — move loop + +This is separate from **reverse futility pruning (RFP)**. RFP may return +a node-level static cutoff; forward futility **never returns an evaluation +instead of searching a node**. It only skips *later quiet moves* in the +`SearchEngine::negamax` move loop. + +## UCI tunable parameters + +| UCI name | Default | Range | Meaning | +|---|---:|---:|---| +| `SearchFutilityMaxDepth` | 2 | 0–16 | Maximum remaining depth; 0 disables | +| `SearchFutilityBaseMargin` | 100 | 0–5000 | Static margin | +| `SearchFutilityMarginPerDepth` | 150 | 0–5000 | Additional margin per remaining ply | + +The optimistic estimate is `staticEval + baseMargin + depth * marginPerDepth`. +A later move is skipped when the estimate is at or below the **current alpha**. + +## 4PC-specific safety rules + +Eligible moves must be quiet (no capture/promotion), must not give check, +must not be the TT move, and must come after at least one **searched legal** +move. The node must be non-PV, non-root, not in the null-move subtree, not +in a mate window, and neither moving team's king can be checked. +The existing non-pawn-material condition also applies. +No search is pruned because of the static estimate alone if the move checks +a king. `pos.givesCheck` is evaluated only for plausible pruning candidates. + +Static evaluations are reused from the search stack when available; otherwise +computed once per eligible node. Existing RFP, NMP, LMP, HMP, SEE and Lazy SMP +search behavior is preserved. Defaults for all previously tuned search and +evaluation options are unchanged. IIR is not part of this feature. + +## Testing + +Run `ironphoenix_futility` or `ctest` from the build directory. The +dedicated test checks UCI bounds/defaults and node-count differences, plus +PV, root, partner-check, own-check, null-move and maximum-depth exclusions. + +Use a paired SPRT match against `SearchFutilityMaxDepth=0` before +merging. A lower node count does not establish an Elo gain. diff --git a/include/ironphoenix/search.hpp b/include/ironphoenix/search.hpp index c638319..493ef53 100644 --- a/include/ironphoenix/search.hpp +++ b/include/ironphoenix/search.hpp @@ -73,6 +73,7 @@ namespace ironphoenix { // Exposed only to the mate-stop regression test. friend struct MateStopTestAccess; friend struct ReverseFutilityTestAccess; + friend struct FutilityPruningTestAccess; [[nodiscard]] bool reverseFutilitySafe(Position& pos) const; struct TimeBudget final { diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index 2c14704..eb263fd 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -31,6 +31,10 @@ namespace ironphoenix { int rfpMarginPerDepth = 141; int rfpMinMargin = 93; int rfpMaxImprovingDiscount = 82; + // Move-loop futility pruning (0 max depth disables). + int futilityMaxDepth = 2; + int futilityBaseMargin = 100; + int futilityMarginPerDepth = 150; }; struct EvalParameters final { diff --git a/src/search.cpp b/src/search.cpp index 640ca22..898c508 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -882,6 +882,21 @@ namespace ironphoenix { } } + // Forward futility pruning, evaluated once per node. Unlike RFP, + // this NEVER returns a static score: it only skips later quiet moves + // whose optimistic static estimate remains below alpha. + // Use the same 4PC king/partner safety checks as RFP/NMP. + int futilityEstimate = INF; + if (canStaticPrune && tuning.futilityMaxDepth > 0 + && depth <= tuning.futilityMaxDepth) { + const int staticEval = ss.staticEvalValid ? ss.staticEval : evaluate(pos); + if (!ss.staticEvalValid) + recordStackEvaluation(ply, staticEval); + if (staticEval > -MATE_THRESHOLD && staticEval < MATE_THRESHOLD) + futilityEstimate = staticEval + tuning.futilityBaseMargin + + depth * tuning.futilityMarginPerDepth; + } + MoveList moves; generatePseudoLegalMoves(pos, moves); @@ -965,10 +980,21 @@ namespace ironphoenix { const bool seeCandidate = canPruneMove && legalMoves > 0 && depth <= tuning.seePruneMaxDepth && move.isCapture() && !move.isPromotion(); - - const bool givesCheckMove = (lmpCandidate || hmpCandidate || seeCandidate) + // No quiet move is pruned until at least one LEGAL move has been + // searched. In particular never drop the only legal escape or + // a tactical checking move, promotion, or king capture. + const bool futilityCandidate = canPruneMove && quiet + && legalMoves > 0 && futilityEstimate <= alpha; + + const bool givesCheckMove = (lmpCandidate || hmpCandidate + || seeCandidate || futilityCandidate) && !terminalKingCapture && pos.givesCheck(move); + // Forward futility pruning: skip late, non-checking quiet moves + // if even the optimistic static score cannot raise alpha. + if (futilityCandidate && !givesCheckMove) + continue; + // Late Move Pruning (LMP): keep checking moves and all exclusions. if (lmpCandidate && !givesCheckMove) continue; diff --git a/src/tuning.cpp b/src/tuning.cpp index 1bd6b06..dcce141 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -55,7 +55,10 @@ namespace ironphoenix::Tuning { || name == "SearchRFPMaxDepth" || name == "SearchRFPMarginPerDepth" || name == "SearchRFPMinMargin" - || name == "SearchRFPMaxImprovingDiscount"; + || name == "SearchRFPMaxImprovingDiscount" + || name == "SearchFutilityMaxDepth" + || name == "SearchFutilityBaseMargin" + || name == "SearchFutilityMarginPerDepth"; } bool isEvalParameter(std::string_view name) noexcept { @@ -164,6 +167,12 @@ namespace ironphoenix::Tuning { p.rfpMinMargin = value; else if (name == "SearchRFPMaxImprovingDiscount" && inRange(value, 0, 5000)) p.rfpMaxImprovingDiscount = value; + else if (name == "SearchFutilityMaxDepth" && inRange(value, 0, 16)) + p.futilityMaxDepth = value; + else if (name == "SearchFutilityBaseMargin" && inRange(value, 0, 5000)) + p.futilityBaseMargin = value; + else if (name == "SearchFutilityMarginPerDepth" && inRange(value, 0, 5000)) + p.futilityMarginPerDepth = value; else changed = false; @@ -293,6 +302,9 @@ namespace ironphoenix::Tuning { << "option name SearchRFPMarginPerDepth type spin default " << searchDefaults.rfpMarginPerDepth << " min 0 max 5000\n" << "option name SearchRFPMinMargin type spin default " << searchDefaults.rfpMinMargin << " min 0 max 5000\n" << "option name SearchRFPMaxImprovingDiscount type spin default " << searchDefaults.rfpMaxImprovingDiscount << " min 0 max 5000\n" + << "option name SearchFutilityMaxDepth type spin default " << searchDefaults.futilityMaxDepth << " min 0 max 16\n" + << "option name SearchFutilityBaseMargin type spin default " << searchDefaults.futilityBaseMargin << " min 0 max 5000\n" + << "option name SearchFutilityMarginPerDepth type spin default " << searchDefaults.futilityMarginPerDepth << " min 0 max 5000\n" << "option name EvalPawnValue type spin default " << defaults.pawnValue << " min 0 max 5000\n" << "option name EvalKnightValue type spin default " << defaults.knightValue << " min 0 max 5000\n" << "option name EvalBishopValue type spin default " << defaults.bishopValue << " min 0 max 5000\n" @@ -341,7 +353,10 @@ namespace ironphoenix::Tuning { << " SearchRFPMaxDepth=" << s.rfpMaxDepth << " SearchRFPMarginPerDepth=" << s.rfpMarginPerDepth << " SearchRFPMinMargin=" << s.rfpMinMargin - << " SearchRFPMaxImprovingDiscount=" << s.rfpMaxImprovingDiscount << '\n'; + << " SearchRFPMaxImprovingDiscount=" << s.rfpMaxImprovingDiscount + << " SearchFutilityMaxDepth=" << s.futilityMaxDepth + << " SearchFutilityBaseMargin=" << s.futilityBaseMargin + << " SearchFutilityMarginPerDepth=" << s.futilityMarginPerDepth << '\n'; out << "info string EvalPawnValue=" << e.pawnValue << " EvalKnightValue=" << e.knightValue diff --git a/tests/futility.cpp b/tests/futility.cpp new file mode 100644 index 0000000..b2e9591 --- /dev/null +++ b/tests/futility.cpp @@ -0,0 +1,183 @@ +#include "ironphoenix/search.hpp" +#include "ironphoenix/tuning.hpp" +#include "ironphoenix/movegen.hpp" + +#include +#include +#include +#include +#include + +using namespace ironphoenix; + +namespace ironphoenix { +struct FutilityPruningTestAccess { + static std::uint64_t nodes(SearchEngine& engine) { return engine.nodes_; } + static bool evaluatedAt(SearchEngine& engine, int ply) { + return engine.searchStack_[ply].staticEvalValid; + } + static int search(SearchEngine& engine, Position& pos, int depth, + int alpha, int beta, int ply = 1, bool pv = false, + bool allowNull = true) { + engine.stopRequested_.store(false, std::memory_order_relaxed); + engine.limits_ = SearchLimits{}; + engine.limits_.nodes = 500'000; + engine.tt_.clear(); + engine.history_.clear(); + engine.searchStack_.fill(SearchStackEntry{}); + engine.nodes_ = 0; + engine.nextTimeCheckNode_ = 0; + engine.pvLength_.fill(0); + return engine.negamax(pos, depth, alpha, beta, ply, pv, allowNull); + } +}; +} + +namespace { +int fails = 0; +void expect(bool ok, const char* description) { + if (!ok) { std::cerr << "FAIL: " << description << '\n'; ++fails; } +} +Square sq(char file, int rank) { return Geometry::square(file - 'a', rank - 1); } +Position quietPosition() { + Position p; + p.clear(); + p.placePiece(sq('g', 1), makePiece(RED, KING)); + p.placePiece(sq('a', 7), makePiece(BLUE, KING)); + p.placePiece(sq('g', 14), makePiece(YELLOW, KING)); + p.placePiece(sq('n', 7), makePiece(GREEN, KING)); + p.placePiece(sq('f', 5), makePiece(RED, KNIGHT)); + p.placePiece(sq('i', 10), makePiece(YELLOW, KNIGHT)); + p.placePiece(sq('d', 6), makePiece(BLUE, KNIGHT)); + p.placePiece(sq('k', 9), makePiece(GREEN, KNIGHT)); + p.setSideToMove(RED); + p.finalizeSetup(); + return p; +} + +std::uint64_t count(Position pos, bool pruning, bool pv = false, + int depth = 2, int alpha = 8000, bool allowNull = true, + int ply = 1, bool* parentStaticEvaluated = nullptr) { + Tuning::search().futilityMaxDepth = pruning ? 2 : 0; + auto engine = std::make_unique(); + engine->setHashSizeMB(1); + FutilityPruningTestAccess::search(*engine, pos, depth, alpha, alpha + 1, + ply, pv, allowNull); + if (parentStaticEvaluated) + *parentStaticEvaluated = + FutilityPruningTestAccess::evaluatedAt(*engine, ply); + return FutilityPruningTestAccess::nodes(*engine); +} + +void testOptions() { + Tuning::resetSearch(); + const Tuning::SearchParameters defaults{}; + expect(defaults.futilityMaxDepth == 2, "default depth 2"); + expect(defaults.futilityBaseMargin == 100, "default base margin 100"); + expect(defaults.futilityMarginPerDepth == 150, "default margin per ply 150"); + + struct Parameter { + const char* name; + int Tuning::SearchParameters::*member; + int good; + int invalid; + }; + const Parameter parameters[] = { + {"SearchFutilityMaxDepth", &Tuning::SearchParameters::futilityMaxDepth, 4, 17}, + {"SearchFutilityBaseMargin", &Tuning::SearchParameters::futilityBaseMargin, 220, -1}, + {"SearchFutilityMarginPerDepth", &Tuning::SearchParameters::futilityMarginPerDepth, 250, 5001}, + }; + for (const auto& p : parameters) { + expect(Tuning::setSearchParameter(p.name, p.good), "UCI setter accepts in-range"); + expect(Tuning::search().*(p.member) == p.good, "UCI setter updates value"); + expect(!Tuning::setSearchParameter(p.name, p.invalid), "UCI setter rejects out-of-range"); + expect(Tuning::search().*(p.member) == p.good, "invalid value leaves setting unchanged"); + } + std::ostringstream uci; + Tuning::printUciOptions(uci); + expect(uci.str().find("option name SearchFutilityMaxDepth type spin default 2 min 0 max 16") + != std::string::npos, "depth option advertised"); + expect(uci.str().find("option name SearchFutilityBaseMargin type spin default 100 min 0 max 5000") + != std::string::npos, "base margin option advertised"); + expect(uci.str().find("option name SearchFutilityMarginPerDepth type spin default 150 min 0 max 5000") + != std::string::npos, "per-depth option advertised"); + + std::ostringstream state; + Tuning::printCurrent(state); + expect(state.str().find("SearchFutilityMaxDepth=4") != std::string::npos, + "current parameter diagnostics include move-loop futility"); + + Tuning::resetSearch(); + for (const auto& p : parameters) + expect(Tuning::search().*(p.member) == defaults.*(p.member), + "reset restores tuning default"); +} + +void testSearch() { + Tuning::resetSearch(); + // Disable unrelated pruning so node differences isolate futility. + auto& tune = Tuning::search(); + tune.rfpMaxDepth = 0; + tune.nullMoveMinDepth = 32; + tune.hmpMaxDepth = 0; + tune.lmpHistoryThreshold = 0; + tune.seePruneMaxDepth = 0; + + const Position pos = quietPosition(); + const Key before = pos.key(); + expect(!pos.inCheck(RED) && !pos.inCheck(YELLOW), "fixture not checked"); + + const auto unpruned = count(pos, false); + const auto pruned = count(pos, true); + expect(unpruned > 1, "baseline searches more than one node"); + expect(pruned < unpruned, "move-loop futility actually avoids child searches"); + expect(pos.key() == before && pos.verify(), "caller position stays unchanged"); + + // Futility requires non-PV internal nodes, not the first/only legal move. + const auto pvBase = count(pos, false, true); + const auto pvPruned = count(pos, true, true); + expect(pvBase == pvPruned, "PV search excludes futility"); + const auto rootBase = count(pos, false, false, 1, 8000, true, 0); + const auto rootPruned = count(pos, true, false, 1, 8000, true, 0); + expect(rootBase == rootPruned, "root excluded from futility"); + + // Child nodes at depth 2 may still prune below a depth-3 parent. + // Verify the parent itself does not compute an FP static estimate. + bool parentEvaluated = true; + (void)count(pos, true, false, 3, 8000, true, 1, &parentEvaluated); + expect(!parentEvaluated, "depth-3 node skips futility static estimate"); + + const auto nullBase = count(pos, false, false, 2, 8000, false); + const auto nullPruned = count(pos, true, false, 2, 8000, false); + expect(nullBase == nullPruned, "null subtree excluded"); + + auto partnerChecked = quietPosition(); + // Rook attacks YELLOW across rank 14 but cannot attack RED on g1. + partnerChecked.placePiece(sq('e', 14), makePiece(GREEN, ROOK)); + partnerChecked.finalizeSetup(); + expect(!partnerChecked.inCheck(RED) && partnerChecked.inCheck(YELLOW), + "partner check setup valid"); + const auto partnerBase = count(partnerChecked, false); + const auto partnerPruned = count(partnerChecked, true); + expect(partnerBase == partnerPruned, "partner check disables futility"); + + auto checked = quietPosition(); + checked.placePiece(sq('g', 4), makePiece(BLUE, ROOK)); + checked.finalizeSetup(); + expect(checked.inCheck(RED), "own check setup valid"); + const auto checkBase = count(checked, false); + const auto checkPruned = count(checked, true); + expect(checkBase == checkPruned, "own check disables futility"); + + Tuning::resetSearch(); +} +} + +int main() { + Geometry::init(); + testOptions(); + testSearch(); + if (!fails) + std::cout << "Move-loop futility pruning tests: PASS\n"; + return fails ? 1 : 0; +}