diff --git a/CMakeLists.txt b/CMakeLists.txt index 328c35a..2114194 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -186,7 +186,15 @@ if(IRONPHOENIX_BUILD_TESTS) target_compile_features(ironphoenix_mate_stop PRIVATE cxx_std_20) add_test(NAME ironphoenix_mate_stop COMMAND ironphoenix_mate_stop) - set_target_properties(ironphoenix_smoke ironphoenix_property ironphoenix_search_stack ironphoenix_mate_stop PROPERTIES + 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) + + 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/docs/reverse-futility-pruning.md b/docs/reverse-futility-pruning.md new file mode 100644 index 0000000..8bb6a80 --- /dev/null +++ b/docs/reverse-futility-pruning.md @@ -0,0 +1,61 @@ +# 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 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: +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. +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. + +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/include/ironphoenix/search.hpp b/include/ironphoenix/search.hpp index 6c65d6b..84da1c4 100644 --- a/include/ironphoenix/search.hpp +++ b/include/ironphoenix/search.hpp @@ -67,6 +67,8 @@ namespace ironphoenix { private: // Exposed only to the mate-stop regression test. friend struct MateStopTestAccess; + friend struct ReverseFutilityTestAccess; + [[nodiscard]] bool reverseFutilitySafe(Position& pos) const; struct TimeBudget final { std::int64_t minimumMs = 0; 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..c03ef03 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,57 @@ 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); - + 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/search.cpp b/src/search.cpp index eb06cf1..64c37fe 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -26,6 +26,12 @@ namespace ironphoenix { constexpr int NULL_MOVE_MIN_DEPTH = 2; constexpr int NULL_MOVE_DIVISOR = 190; + // 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 @@ -51,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. @@ -312,11 +314,9 @@ namespace ironphoenix { } bool SearchEngine::shouldStopOnWinningMate(int score, Move rootMove) const noexcept { - // Only a completed root search with an actual move and a positive - // mate score may terminate iterative deepening. A losing mate should - // keep searching for defenses and for ways to delay defeat. - // UCI infinite analysis and active pondering must wait for stop / - // ponderhit respectively, even when a mate is available. + // Completed root wins may stop immediately. Negative mate scores + // continue searching for defenses. Infinite/ponder searches retain + // their usual explicit-stop behavior. return !limits_.infinite && !pondering_ && static_cast(rootMove) && score >= MATE_THRESHOLD && score <= MATE_SCORE; } @@ -660,6 +660,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); @@ -710,22 +753,43 @@ 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. - 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 = evaluate(pos); + // 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 (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; + } + + // Null Move Pruning (NMP): reuse the stack's raw evaluation when RFP + // already evaluated this node. The existing NMP policy is unchanged. + 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) { + if (staticEval >= beta) { StateInfo nullState; pos.makeNullMove(nullState); searchStack_[ply + 1].history = HistoryContext{}; @@ -751,7 +815,6 @@ namespace ironphoenix { // Never manufacture a mate score from a synthetic pass. if (nullScore >= beta && nullScore < MATE_THRESHOLD) return nullScore; - } } } @@ -759,18 +822,16 @@ namespace ironphoenix { 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 @@ -815,11 +876,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; @@ -827,49 +885,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; @@ -1249,9 +1295,8 @@ namespace ironphoenix { pvLength_[0] ); - // This is a completed root iteration (not an aspiration bound). - // Return the proven winning mate immediately; do not spend - // additional time deepening an already decisive move. + // Stop after a completed root iteration, never on an + // intermediate aspiration lower/upper bound. if (shouldStopOnWinningMate(bestScore, bestMove)) break; } 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; +} diff --git a/tests/reverse_futility.cpp b/tests/reverse_futility.cpp new file mode 100644 index 0000000..8b574dd --- /dev/null +++ b/tests/reverse_futility.cpp @@ -0,0 +1,171 @@ +#include "ironphoenix/search.hpp" +#include "ironphoenix/eval.hpp" +#include "ironphoenix/movegen.hpp" +#include +#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, + 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; + 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, int ply = 1) { return engine.searchStack_[ply]; } +}; +} + +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 <= 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 - 129); + expect(Access::nodes(*engine) > 1, "insufficient margin searches children"); + Access::search(*engine, pos, 1, eval - 130, 1, true); + expect(Access::nodes(*engine) > 1, "PV node excluded"); + Access::search(*engine, pos, 1, eval - 130, 0); + expect(Access::nodes(*engine) > 1, "root excluded"); + 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, 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, 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, 3).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"); + } + { + 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 - 130); + 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) - 130); + 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; +}