Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions docs/futility-pruning.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions include/ironphoenix/search.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions include/ironphoenix/tuning.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 28 additions & 2 deletions src/search.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand Down
19 changes: 17 additions & 2 deletions src/tuning.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
183 changes: 183 additions & 0 deletions tests/futility.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#include "ironphoenix/search.hpp"
#include "ironphoenix/tuning.hpp"
#include "ironphoenix/movegen.hpp"

#include <cstdint>
#include <iostream>
#include <memory>
#include <sstream>
#include <string>

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<SearchEngine>();
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;
}
Loading