From 7da6919dd0067f5f6e291fa998f95f165b1db18e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:57:07 +0000 Subject: [PATCH 1/8] Initial plan From d170eaae2ef1c8ff5c41cbdad4a7800bca96a1a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:19:34 +0000 Subject: [PATCH 2/8] Add VCN (Victory by Continuous N-level Attack) search mode implementation Co-authored-by: dhbloo <32353216+dhbloo@users.noreply.github.com> --- Rapfi/core/types.h | 16 ++++++ Rapfi/search/ab/search.cpp | 92 +++++++++++++++++++++++++++++++---- Rapfi/search/ab/searchstack.h | 1 + Rapfi/search/movepick.cpp | 32 +++++++++++- Rapfi/search/movepick.h | 3 ++ Rapfi/search/searchcommon.h | 4 ++ 6 files changed, 138 insertions(+), 10 deletions(-) diff --git a/Rapfi/core/types.h b/Rapfi/core/types.h index f4db6206..ead895ef 100644 --- a/Rapfi/core/types.h +++ b/Rapfi/core/types.h @@ -254,3 +254,19 @@ enum class CandidateRange { FULL_BOARD, CAND_RANGE_NB, }; + +// ------------------------------------------------- + +/// VCNMode stores configuration for Victory by Continuous N-level Attack (VCN) search. +/// In VCN search, the attacker must win while the defender can pass at most (5-N) times. +/// N=4 corresponds to VCF (Victory by Continuous Four), where the defender can pass once. +/// N=5 means the attacker must win immediately (defender can never pass). +/// N<=3 allows progressively more passes for the defender. +struct VCNMode +{ + Color attacker = BLACK; ///< Side that is the attacker in VCN mode + int n = 0; ///< Level N (2-5); 0 means VCN mode is disabled + + /// Check if VCN mode is enabled (N is in the valid range [2, 5]). + bool enabled() const { return n >= 2 && n <= 5; } +}; diff --git a/Rapfi/search/ab/search.cpp b/Rapfi/search/ab/search.cpp index ee570b0b..b51b7e4d 100644 --- a/Rapfi/search/ab/search.cpp +++ b/Rapfi/search/ab/search.cpp @@ -243,6 +243,13 @@ void ABSearcher::search(SearchThread &th) int firstMateDepth = 0, firstSingularDepth = 0; MainSearchThread *mainThread = (&th == th.threads.main() ? th.threads.main() : nullptr); + // Init VCN level for root and all plies + if (options.vcnMode.enabled()) { + SearchStack *root = stackArray.rootStack(); + for (int i = -StackArray::plyBeforeRoot; i < MAX_PLY + StackArray::plyAfterMax; i++) + (root + i)->vcnLevel = static_cast(options.vcnMode.n); + } + // Init search depth range int maxDepth = std::min(options.maxDepth, std::clamp(Config::MaxSearchDepth, 2, MAX_DEPTH)); int startDepth = std::clamp(options.startDepth, 1, maxDepth); @@ -634,6 +641,11 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth uint16_t oppo5 = board.p4Count(oppo, A_FIVE); // opponent five uint16_t oppo4 = oppo5 + board.p4Count(oppo, B_FLEX4); // opponent straight four and five + // VCN mode state for this node + const bool vcnEnabled = options.vcnMode.enabled(); + const bool vcnIsAttacker = vcnEnabled && (self == options.vcnMode.attacker); + const int vcnLevel = ss->vcnLevel; + // Dive into vcf search when the depth reaches zero (~17 elo) if (depth <= 0.0f) { return oppo5 ? vcfdefend(board, ss, alpha, beta) @@ -672,6 +684,13 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth return value; } + // VCN mode: when attacker has level >= 5, they must win immediately (A_FIVE). + // quickWinCheck above already handles the A_FIVE win case. If we reach here + // without a win and vcnLevel >= 5, the attacker has no immediate win, so it + // is a loss (the defender has exhausted all allowed passes). + if (vcnEnabled && vcnIsAttacker && vcnLevel >= 5) + return mated_in(ss->ply); + // Step 3. Mate distance pruning. alpha = std::max(mated_in(ss->ply), alpha); beta = std::min(mate_in(ss->ply + 1), beta); @@ -684,16 +703,24 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // statScore of the previous grandchild. (ss + 2)->statScore = 0; - // Pass current number of null moves to next ply + // Pass current number of null moves and VCN level to next ply (ss + 1)->numNullMoves = ss->numNullMoves; + (ss + 1)->vcnLevel = ss->vcnLevel; } else searchData->rootDelta = beta - alpha; // Step 4. Transposition table lookup. // Use a different hash key in case of an skip move to avoid overriding full search result. + // In VCN mode, also XOR a vcnLevel-specific value to separate VCN TT entries from regular + // ones and from other VCN levels (since the same board position can have different vcnLevels). Pos skipMove = ss->skipMove; - HashKey posKey = board.zobristKey() ^ (skipMove ? Hash::LCHash(skipMove) : 0); + HashKey vcnHashXor = + vcnEnabled + ? Hash::LCHash(static_cast(vcnLevel) + ^ (static_cast(options.vcnMode.attacker + 1) << 32)) + : 0; + HashKey posKey = board.zobristKey() ^ vcnHashXor ^ (skipMove ? Hash::LCHash(skipMove) : 0); Value ttValue = VALUE_NONE; Value ttEval = VALUE_NONE; bool ttIsPv = false; @@ -852,8 +879,11 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth return eval; // Step 9. Null move pruning (~3 elo) + // Disabled in VCN mode: VCN has its own pass-move mechanism for the defender, + // and the attacker should not pass (would give the defender a free move). if (!PvNode && !oppo4 && !skipMove && eval >= beta && board.getLastMove() != Pos::PASS // No consecutive pass moves + && !vcnEnabled // Disabled in VCN mode && ss->staticEval >= beta + nullMoveMargin(depth)) { Depth r = nullMoveReduction(depth); ss->currentMove = Pos::PASS; @@ -927,12 +957,19 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // Indicate cutNode that will probably fail high if current eval is far above beta bool likelyFailHigh = !PvNode && cutNode && eval >= beta + failHighMargin(depth, oppo4); + // In VCN mode, generate pass move for the defender (but not in DEFENDFIVE mode, + // since the attacker poses an immediate threat the defender must respond to). + const bool vcnDefenderPass = vcnEnabled && !vcnIsAttacker && !oppo5; + MovePicker mp(Rule, board, MovePicker::ExtraArgs { ttMove, &searchData->mainHistory, &searchData->counterMoveHistory, + false, + 1.0f, + vcnDefenderPass, }); // Step 11. Loop through all legal moves until no moves remain @@ -982,9 +1019,16 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth move); // Initialize heruistic information - ss->moveCount = ++moveCount; - ss->moveP4[BLACK] = board.cell(move).pattern4[BLACK]; - ss->moveP4[WHITE] = board.cell(move).pattern4[WHITE]; + ss->moveCount = ++moveCount; + if (move != Pos::PASS) { + ss->moveP4[BLACK] = board.cell(move).pattern4[BLACK]; + ss->moveP4[WHITE] = board.cell(move).pattern4[WHITE]; + } + else { + // Pass move does not place a stone, so patterns are unchanged (treat as NONE) + ss->moveP4[BLACK] = NONE; + ss->moveP4[WHITE] = NONE; + } // False forbidden move in Renju is considered as important move bool importantMove = ss->moveP4[self] >= J_FLEX2_2X || ss->moveP4[oppo] >= H_FLEX3 @@ -1004,11 +1048,14 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth continue; // Skip trivial moves at lower depth (~2 elo at LTC) - if (trivialMove && depth < TRIVIAL_PRUN_DEPTH) + // Do not prune the VCN defender's pass move, which is treated as trivial + if (trivialMove && depth < TRIVIAL_PRUN_DEPTH && move != Pos::PASS) continue; // Policy based pruning (~10 elo) - if (mp.hasPolicyScore() && mp.curMoveScore() < policyPruningScore(depth)) + // Skip policy pruning for pass moves (they have no policy score) + if (move != Pos::PASS && mp.hasPolicyScore() + && mp.curMoveScore() < policyPruningScore(depth)) continue; // Prun distract defence move which is likely to delay a winning (~2 elo) @@ -1016,6 +1063,14 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth continue; } + // VCN mode: when the attacker is at level 4, only VCF moves are allowed (E_BLOCK4+). + // If the opponent already has A_FIVE or B_FLEX4 (oppo4 > 0), the movepicker enters + // a defend stage and all generated moves are valid defense responses (no filtering needed). + if (vcnEnabled && vcnIsAttacker && vcnLevel == 4 && !oppo4) { + if (ss->moveP4[self] < E_BLOCK4) + continue; + } + // Step 13. Extensions Depth extension = 0; @@ -1026,7 +1081,8 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // Singular extension: only one move fails high while other moves fails low on a search of // (alpha-s, beta-s), then this move is singular and should be extended. (~52 elo) else if (!RootNode && depth >= SE_DEPTH && move == ttMove - && !skipMove // No recursive singular search + && move != Pos::PASS // No singular extension for pass + && !skipMove // No recursive singular search && std::abs(ttValue) < VALUE_MATE_IN_MAX_PLY // ttmove value is not a mate && (ttBound & BOUND_LOWER) // ttMove failed high last time && ttDepth >= depth - SE_TTE_DEPTH // ttEntry has enough depth to trust @@ -1095,6 +1151,14 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth ss->currentMove = move; ss->extraExtension = (ss - 1)->extraExtension + std::max(extension - 1.0f, 0.0f); + // In VCN mode, propagate the VCN level to the child node. + // If the defender passes, increase the level by 1 (attacker's next turn is more restricted). + // We always set it here (not just for pass) so that after a pass move the level is + // correctly reset for the next non-pass move in the loop. + if (vcnEnabled) + (ss + 1)->vcnLevel = + static_cast(vcnLevel + (!vcnIsAttacker && move == Pos::PASS ? 1 : 0)); + // Step 14. Make the move board.move(move); TT.prefetch(board.zobristKey()); @@ -1364,12 +1428,22 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // Step 20. Update database record Bound bound = bestValue >= beta ? BOUND_LOWER : PvNode && bestMove ? BOUND_EXACT : BOUND_UPPER; + // In VCN mode, only write to the database if it is a proven win for the attacker. + // "isWin" means current player (self) wins; "isLoss" means current player loses. + // Attacker wins when: self == attacker and isWin, OR self == defender and isLoss. + bool vcnAttackerWins = vcnEnabled + && ((vcnIsAttacker && bestValue > VALUE_MATE_IN_MAX_PLY + && (bound & BOUND_LOWER)) + || (!vcnIsAttacker && bestValue < VALUE_MATED_IN_MAX_PLY + && (bound & BOUND_UPPER))); if (thisThread->dbClient && !Config::DatabaseReadonlyMode // Never write in database readonly mode && !options.balanceMode // Never write when we are doing balanced search && (!skipMove || ss->dbChildWritten) // Never write when in singular extension && ss->numNullMoves == 0 // Never write when in null move search - && !(RootNode && (searchData->pvIdx || options.blockMoves.size()))) { + && !(RootNode && (searchData->pvIdx || options.blockMoves.size())) + && (!vcnEnabled || vcnAttackerWins) // In VCN mode, only write proven attacker wins + ) { bool exact = PvNode && bound == BOUND_EXACT; bool isWin = bestValue > VALUE_MATE_IN_MAX_PLY && (bound & BOUND_LOWER); bool isLoss = bestValue < VALUE_MATED_IN_MAX_PLY && (bound & BOUND_UPPER); diff --git a/Rapfi/search/ab/searchstack.h b/Rapfi/search/ab/searchstack.h index 163a1ea8..98987240 100644 --- a/Rapfi/search/ab/searchstack.h +++ b/Rapfi/search/ab/searchstack.h @@ -43,6 +43,7 @@ struct SearchStack Pos killers[2]; Pattern4 moveP4[SIDE_NB]; int16_t numNullMoves; + int8_t vcnLevel; /// Current VCN level at this ply (N value; 0 if VCN mode is disabled) bool ttPv; bool dbChildWritten; diff --git a/Rapfi/search/movepick.cpp b/Rapfi/search/movepick.cpp index 6bdac1c3..1d39d18a 100644 --- a/Rapfi/search/movepick.cpp +++ b/Rapfi/search/movepick.cpp @@ -28,15 +28,18 @@ namespace { /// Move picking stages. -/// Usual procedure: X_TT -> X_MOVES -> ALLMOVES. +/// Usual procedure: X_TT -> X_PASS (optional) -> X_MOVES -> ALLMOVES. enum Stages { MAIN_TT, + MAIN_PASS, // pass move stage for VCN defender (before main moves) MAIN_MOVES, DEFENDFIVE_TT, DEFENDFIVE_MOVES, DEFENDFOUR_TT, + DEFENDFOUR_PASS, // pass move stage for VCN defender (before defend-four moves) DEFENDFOUR_MOVES, DEFENDB4F3_TT, + DEFENDB4F3_PASS, // pass move stage for VCN defender (before defend-b4f3 moves) DEFENDB4F3_MOVES, QVCF_TT, QVCF_MOVES, @@ -96,6 +99,7 @@ MovePicker::MovePicker(Rule rule, const Board &board, ExtraArgs= DEPTH_QVCF_FULL || (args.previousSelfP4[0] >= D_BLOCK4_PLUS && args.previousSelfP4[1] >= D_BLOCK4_PLUS)) + , generatePassMove(false) , hasPolicy(false) , useNormalizedPolicy(false) , normalizedPolicyTemp(1.0f) @@ -354,6 +360,30 @@ Pos MovePicker::operator()() case DEFENDB4F3_TT: case QVCF_TT: ++stage; return ttMove; + case MAIN_PASS: + stage = MAIN_MOVES; + if (generatePassMove && board.getLastMove() != Pos::PASS && ttMove != Pos::PASS) { + curScore = 0; + return Pos::PASS; + } + goto top; + + case DEFENDFOUR_PASS: + stage = DEFENDFOUR_MOVES; + if (generatePassMove && board.getLastMove() != Pos::PASS && ttMove != Pos::PASS) { + curScore = 0; + return Pos::PASS; + } + goto top; + + case DEFENDB4F3_PASS: + stage = DEFENDB4F3_MOVES; + if (generatePassMove && board.getLastMove() != Pos::PASS && ttMove != Pos::PASS) { + curScore = 0; + return Pos::PASS; + } + goto top; + case MAIN_MOVES: assert(!board.p4Count(~board.sideToMove(), A_FIVE)); assert(!board.p4Count(~board.sideToMove(), B_FLEX4)); diff --git a/Rapfi/search/movepick.h b/Rapfi/search/movepick.h index 069580ad..b13f4058 100644 --- a/Rapfi/search/movepick.h +++ b/Rapfi/search/movepick.h @@ -90,6 +90,7 @@ class MovePicker Rule rule; Pos ttMove; bool allowPlainB4InVCF; + bool generatePassMove; bool hasPolicy; bool useNormalizedPolicy; float normalizedPolicyTemp; @@ -115,6 +116,8 @@ struct MovePicker::ExtraArgs CounterMoveHistory *counterMoveHistory; bool useNormalizedPolicy = false; float normalizedPolicyTemp = 1.0f; + /// Whether to generate a pass move as a high-priority move for the VCN defender. + bool generatePassMove = false; }; template <> diff --git a/Rapfi/search/searchcommon.h b/Rapfi/search/searchcommon.h index 0ff15be0..54facfb8 100644 --- a/Rapfi/search/searchcommon.h +++ b/Rapfi/search/searchcommon.h @@ -160,6 +160,10 @@ struct SearchOptions /// Blocked moves, which are filtered out before searching std::vector blockMoves; + /// VCN (Victory by Continuous N-level Attack) mode configuration. + /// When enabled, only searches for attacker-wins satisfying the VCN constraint. + VCNMode vcnMode; + /// Checks if we are in analysis mode. bool isAnalysisMode() const { return !timeLimit && !maxNodes; } /// Set time control config according to the rule: From 100d49329cd4fba38e04b6b09e2ddef07d559566 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:24:28 +0000 Subject: [PATCH 3/8] Fix review comments: improve comment clarity in movepick and search code Co-authored-by: dhbloo <32353216+dhbloo@users.noreply.github.com> --- Rapfi/search/ab/search.cpp | 8 ++++---- Rapfi/search/movepick.cpp | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Rapfi/search/ab/search.cpp b/Rapfi/search/ab/search.cpp index b51b7e4d..894529de 100644 --- a/Rapfi/search/ab/search.cpp +++ b/Rapfi/search/ab/search.cpp @@ -1025,7 +1025,7 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth ss->moveP4[WHITE] = board.cell(move).pattern4[WHITE]; } else { - // Pass move does not place a stone, so patterns are unchanged (treat as NONE) + // Pass move does not place a stone; use NONE as a sentinel for no pattern change ss->moveP4[BLACK] = NONE; ss->moveP4[WHITE] = NONE; } @@ -1152,9 +1152,9 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth ss->extraExtension = (ss - 1)->extraExtension + std::max(extension - 1.0f, 0.0f); // In VCN mode, propagate the VCN level to the child node. - // If the defender passes, increase the level by 1 (attacker's next turn is more restricted). - // We always set it here (not just for pass) so that after a pass move the level is - // correctly reset for the next non-pass move in the loop. + // Always set vcnLevel for every move (not just for pass) so that after a pass move + // the level is correctly reset to the current vcnLevel for the next non-pass move. + // The level is incremented by 1 only when the defender makes a pass move. if (vcnEnabled) (ss + 1)->vcnLevel = static_cast(vcnLevel + (!vcnIsAttacker && move == Pos::PASS ? 1 : 0)); diff --git a/Rapfi/search/movepick.cpp b/Rapfi/search/movepick.cpp index 1d39d18a..19ab4cd3 100644 --- a/Rapfi/search/movepick.cpp +++ b/Rapfi/search/movepick.cpp @@ -31,15 +31,15 @@ namespace { /// Usual procedure: X_TT -> X_PASS (optional) -> X_MOVES -> ALLMOVES. enum Stages { MAIN_TT, - MAIN_PASS, // pass move stage for VCN defender (before main moves) + MAIN_PASS, // Pass move stage for VCN defender (before main moves) MAIN_MOVES, DEFENDFIVE_TT, DEFENDFIVE_MOVES, DEFENDFOUR_TT, - DEFENDFOUR_PASS, // pass move stage for VCN defender (before defend-four moves) + DEFENDFOUR_PASS, // Pass move stage for VCN defender (before defend-four moves) DEFENDFOUR_MOVES, DEFENDB4F3_TT, - DEFENDB4F3_PASS, // pass move stage for VCN defender (before defend-b4f3 moves) + DEFENDB4F3_PASS, // Pass move stage for VCN defender (before defend-b4f3 moves) DEFENDB4F3_MOVES, QVCF_TT, QVCF_MOVES, From fe8db28a8016742a8e2adce254b52cc954a121eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 07:32:20 +0000 Subject: [PATCH 4/8] Refactor VCN: VCNLevel enum, move VCNMode to searchcommon.h, VC4/VC5 quick checks Co-authored-by: dhbloo <32353216+dhbloo@users.noreply.github.com> --- Rapfi/core/types.h | 14 ----- Rapfi/search/ab/search.cpp | 98 ++++++++++++++++++++++------------- Rapfi/search/ab/searchstack.h | 3 +- Rapfi/search/movepick.cpp | 3 +- Rapfi/search/movepick.h | 3 ++ Rapfi/search/searchcommon.h | 24 +++++++++ 6 files changed, 94 insertions(+), 51 deletions(-) diff --git a/Rapfi/core/types.h b/Rapfi/core/types.h index ead895ef..673ee402 100644 --- a/Rapfi/core/types.h +++ b/Rapfi/core/types.h @@ -256,17 +256,3 @@ enum class CandidateRange { }; // ------------------------------------------------- - -/// VCNMode stores configuration for Victory by Continuous N-level Attack (VCN) search. -/// In VCN search, the attacker must win while the defender can pass at most (5-N) times. -/// N=4 corresponds to VCF (Victory by Continuous Four), where the defender can pass once. -/// N=5 means the attacker must win immediately (defender can never pass). -/// N<=3 allows progressively more passes for the defender. -struct VCNMode -{ - Color attacker = BLACK; ///< Side that is the attacker in VCN mode - int n = 0; ///< Level N (2-5); 0 means VCN mode is disabled - - /// Check if VCN mode is enabled (N is in the valid range [2, 5]). - bool enabled() const { return n >= 2 && n <= 5; } -}; diff --git a/Rapfi/search/ab/search.cpp b/Rapfi/search/ab/search.cpp index 894529de..572d4684 100644 --- a/Rapfi/search/ab/search.cpp +++ b/Rapfi/search/ab/search.cpp @@ -59,9 +59,22 @@ Value search(Rule rule, template Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth, bool cutNode); template -Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth = 0.0f); +Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth = 0.0f, bool vcnAllowB4 = false); template -Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth = 0.0f); +Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth = 0.0f, bool vcnAllowB4 = false); + +/// Increment a VCNLevel by 1, capped at VC5. +inline VCNLevel vcnLevelIncrement(VCNLevel level) +{ + return level < VC5 ? static_cast(static_cast(level) + 1) : VC5; +} + +/// Quick check: under VC5 rules the attacker must have A_FIVE on the board. +/// Returns true (attacker loses immediately) if they don't have one. +inline bool vcnVC5AttackerLoses(const Board &board, Color attacker) +{ + return board.p4Count(attacker, A_FIVE) == 0; +} } // namespace @@ -247,7 +260,7 @@ void ABSearcher::search(SearchThread &th) if (options.vcnMode.enabled()) { SearchStack *root = stackArray.rootStack(); for (int i = -StackArray::plyBeforeRoot; i < MAX_PLY + StackArray::plyAfterMax; i++) - (root + i)->vcnLevel = static_cast(options.vcnMode.n); + (root + i)->vcnLevel = options.vcnMode.n; } // Init search depth range @@ -642,9 +655,25 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth uint16_t oppo4 = oppo5 + board.p4Count(oppo, B_FLEX4); // opponent straight four and five // VCN mode state for this node - const bool vcnEnabled = options.vcnMode.enabled(); - const bool vcnIsAttacker = vcnEnabled && (self == options.vcnMode.attacker); - const int vcnLevel = ss->vcnLevel; + const bool vcnEnabled = options.vcnMode.enabled(); + const bool vcnIsAttacker = vcnEnabled && (self == options.vcnMode.attacker); + const VCNLevel vcnLevel = ss->vcnLevel; + + // VCN mode early exits for the attacker at VC4: must come before the depth<=0 check + // so the VC4 path (dropping to vcfsearch with forceAllowB4=true) is always taken. + if (vcnEnabled && vcnIsAttacker && vcnLevel == VC4) { + // At VC4, the attacker may only play moves with pattern4 >= E_BLOCK4. + // If no such move exists, the attacker loses in 2 steps. + bool hasB4 = board.p4Count(self, A_FIVE) || board.p4Count(self, B_FLEX4) + || board.p4Count(self, C_BLOCK4_FLEX3) + || board.p4Count(self, D_BLOCK4_PLUS) + || board.p4Count(self, E_BLOCK4); + if (!hasB4) + return mated_in(ss->ply + 2); + // Drop to vcfsearch with forceAllowB4InVCF=true so all E_BLOCK4 moves are considered. + return oppo5 ? vcfdefend(board, ss, alpha, beta, 0.0f, true) + : vcfsearch(board, ss, alpha, beta, 0.0f, true); + } // Dive into vcf search when the depth reaches zero (~17 elo) if (depth <= 0.0f) { @@ -675,6 +704,11 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth if (ss->ply >= MAX_PLY) return Evaluation::evaluate(board, alpha, beta); + // VCN VC5 quick check: if the attacker has no A_FIVE, they lose immediately. + // The attacker must win in the very next move under VC5 rules. + if (vcnEnabled && vcnIsAttacker && vcnLevel == VC5 && vcnVC5AttackerLoses(board, self)) + return mated_in(ss->ply); + // Check for immediate winning if ((value = quickWinCheck(board, ss->ply, beta)) != VALUE_ZERO) { // Do not return mate that longer than maxMoves option @@ -684,13 +718,6 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth return value; } - // VCN mode: when attacker has level >= 5, they must win immediately (A_FIVE). - // quickWinCheck above already handles the A_FIVE win case. If we reach here - // without a win and vcnLevel >= 5, the attacker has no immediate win, so it - // is a loss (the defender has exhausted all allowed passes). - if (vcnEnabled && vcnIsAttacker && vcnLevel >= 5) - return mated_in(ss->ply); - // Step 3. Mate distance pruning. alpha = std::max(mated_in(ss->ply), alpha); beta = std::min(mate_in(ss->ply + 1), beta); @@ -717,7 +744,7 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth Pos skipMove = ss->skipMove; HashKey vcnHashXor = vcnEnabled - ? Hash::LCHash(static_cast(vcnLevel) + ? Hash::LCHash(static_cast(static_cast(vcnLevel)) ^ (static_cast(options.vcnMode.attacker + 1) << 32)) : 0; HashKey posKey = board.zobristKey() ^ vcnHashXor ^ (skipMove ? Hash::LCHash(skipMove) : 0); @@ -1063,14 +1090,6 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth continue; } - // VCN mode: when the attacker is at level 4, only VCF moves are allowed (E_BLOCK4+). - // If the opponent already has A_FIVE or B_FLEX4 (oppo4 > 0), the movepicker enters - // a defend stage and all generated moves are valid defense responses (no filtering needed). - if (vcnEnabled && vcnIsAttacker && vcnLevel == 4 && !oppo4) { - if (ss->moveP4[self] < E_BLOCK4) - continue; - } - // Step 13. Extensions Depth extension = 0; @@ -1152,12 +1171,12 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth ss->extraExtension = (ss - 1)->extraExtension + std::max(extension - 1.0f, 0.0f); // In VCN mode, propagate the VCN level to the child node. - // Always set vcnLevel for every move (not just for pass) so that after a pass move - // the level is correctly reset to the current vcnLevel for the next non-pass move. - // The level is incremented by 1 only when the defender makes a pass move. + // Always set vcnLevel for every move so that after a pass move the level is + // correctly reset for the next non-pass move. The level is incremented by 1 + // only when the defender makes a pass move (capped at VC5). if (vcnEnabled) (ss + 1)->vcnLevel = - static_cast(vcnLevel + (!vcnIsAttacker && move == Pos::PASS ? 1 : 0)); + (!vcnIsAttacker && move == Pos::PASS) ? vcnLevelIncrement(vcnLevel) : vcnLevel; // Step 14. Make the move board.move(move); @@ -1547,7 +1566,7 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth /// The VCF search function only searches continuous VCF moves to avoid /// search explosion. It returns the best evaluation in a VCF tree. template -Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth) +Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth, bool vcnAllowB4) { constexpr bool PvNode = NT == PV || NT == Root; @@ -1558,8 +1577,8 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de assert(0 <= ss->ply && ss->ply < MAX_PLY); // Step 1. Initialize node - SearchThread *thisThread = board.thisThread(); - ABSearchData *searchData = thisThread->searchDataAs(); + SearchThread *thisThread = board.thisThread(); + ABSearchData *searchData = thisThread->searchDataAs(); thisThread->numNodes.fetch_add(1, std::memory_order_relaxed); Color self = board.sideToMove(), oppo = ~self; @@ -1585,6 +1604,14 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de if (ss->ply >= MAX_PLY) return Evaluation::evaluate(board, alpha, beta); + // VCN VC5 quick check: if the attacker has no A_FIVE, they lose immediately. + { + const SearchOptions &vcnOptions = thisThread->options(); + if (vcnOptions.vcnMode.enabled() && self == vcnOptions.vcnMode.attacker + && ss->vcnLevel == VC5 && vcnVC5AttackerLoses(board, self)) + return mated_in(ss->ply); + } + // Check for immediate winning if ((value = quickWinCheck(board, ss->ply, beta)) != VALUE_ZERO) { // Do not return mate that longer than maxMoves option @@ -1681,7 +1708,8 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de board, MovePicker::ExtraArgs {ttMove, depth, - {(ss - 2)->moveP4[self], (ss - 4)->moveP4[self]}}); + {(ss - 2)->moveP4[self], (ss - 4)->moveP4[self]}, + vcnAllowB4}); while (Pos move = mp()) { assert(board.isLegal(move)); @@ -1696,8 +1724,8 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de // Step 8. Make and search the move board.move(move); - // Call defence-side vcf search - value = -vcfdefend(board, ss + 1, -beta, -alpha, depth - 1); + // Call defence-side vcf search, propagating vcnAllowB4 + value = -vcfdefend(board, ss + 1, -beta, -alpha, depth - 1, vcnAllowB4); board.undo(); @@ -1741,7 +1769,7 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de /// The search function for defend node in VCF search. template -Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth) +Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth, bool vcnAllowB4) { constexpr bool PvNode = NT == PV || NT == Root; @@ -1795,9 +1823,9 @@ Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de board.move(move); TT.prefetch(board.zobristKey()); - // Call attack-side vcf search + // Call attack-side vcf search, propagating vcnAllowB4 // Note that we do not reduce depth for vcf defence move. - value = -vcfsearch(board, ss + 1, -beta, -alpha, depth); + value = -vcfsearch(board, ss + 1, -beta, -alpha, depth, vcnAllowB4); board.undo(); diff --git a/Rapfi/search/ab/searchstack.h b/Rapfi/search/ab/searchstack.h index 98987240..bac28edb 100644 --- a/Rapfi/search/ab/searchstack.h +++ b/Rapfi/search/ab/searchstack.h @@ -20,6 +20,7 @@ #include "../../core/pos.h" #include "../../core/types.h" +#include "../searchcommon.h" #include #include @@ -43,7 +44,7 @@ struct SearchStack Pos killers[2]; Pattern4 moveP4[SIDE_NB]; int16_t numNullMoves; - int8_t vcnLevel; /// Current VCN level at this ply (N value; 0 if VCN mode is disabled) + VCNLevel vcnLevel; /// Current VCN level at this ply (VC_NONE if VCN mode is disabled) bool ttPv; bool dbChildWritten; diff --git a/Rapfi/search/movepick.cpp b/Rapfi/search/movepick.cpp index 19ab4cd3..f601a562 100644 --- a/Rapfi/search/movepick.cpp +++ b/Rapfi/search/movepick.cpp @@ -206,7 +206,8 @@ MovePicker::MovePicker(Rule rule, const Board &board, ExtraArgs= DEPTH_QVCF_FULL + args.forceAllowB4InVCF + || args.depth >= DEPTH_QVCF_FULL || (args.previousSelfP4[0] >= D_BLOCK4_PLUS && args.previousSelfP4[1] >= D_BLOCK4_PLUS)) , generatePassMove(false) , hasPolicy(false) diff --git a/Rapfi/search/movepick.h b/Rapfi/search/movepick.h index b13f4058..3182c981 100644 --- a/Rapfi/search/movepick.h +++ b/Rapfi/search/movepick.h @@ -126,6 +126,9 @@ struct MovePicker::ExtraArgs Pos ttMove; Depth depth; // negative depth in qvcf search Pattern4 previousSelfP4[2]; + /// Force allowPlainB4InVCF=true regardless of depth/previous patterns. + /// Used when entering from VCN VC4 mode so all E_BLOCK4 moves are enumerated. + bool forceAllowB4InVCF = false; }; } // namespace Search diff --git a/Rapfi/search/searchcommon.h b/Rapfi/search/searchcommon.h index 54facfb8..56c8447f 100644 --- a/Rapfi/search/searchcommon.h +++ b/Rapfi/search/searchcommon.h @@ -30,6 +30,30 @@ class Board; namespace Search { +/// VCNLevel is the level N in VCN (Victory by Continuous N-level Attack) search. +/// In VCN search, the attacker must win while the defender can pass at most (5-N) times. +/// VC4 corresponds to VCF (Victory by Continuous Four), where the defender can pass once. +/// VC5 means the attacker must win immediately (defender can never pass). +/// VC2/VC3 allow progressively more passes for the defender. +enum VCNLevel { + VC_NONE, ///< VCN mode disabled + VC2, ///< Defender can pass at most 3 times + VC3, ///< Defender can pass at most 2 times + VC4, ///< Defender can pass at most 1 time (equivalent to VCF) + VC5, ///< Defender can never pass (attacker must win immediately) +}; + +/// VCNMode stores configuration for Victory by Continuous N-level Attack (VCN) search. +/// The attacker must win while the defender can pass at most (5-N) times. +struct VCNMode +{ + Color attacker = BLACK; ///< Side that is the attacker in VCN mode + VCNLevel n = VC_NONE; ///< VCN level; VC_NONE means VCN mode is disabled + + /// Check if VCN mode is enabled. + bool enabled() const { return n != VC_NONE; } +}; + /// Make a balanced value from original value and balance bias. /// @param value The original value. /// @param bias Value bias for balanced value. From ffc8ec9d6849d4f57c95583751da99f4e9aa5dad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 07:54:01 +0000 Subject: [PATCH 5/8] Fix VCN TT key in vcfsearch, remove vcnAllowB4 params, merge PASS stages Co-authored-by: dhbloo <32353216+dhbloo@users.noreply.github.com> --- Rapfi/search/ab/search.cpp | 51 ++++++++++++++++++++++++-------------- Rapfi/search/movepick.cpp | 16 +----------- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/Rapfi/search/ab/search.cpp b/Rapfi/search/ab/search.cpp index 572d4684..2dd158b1 100644 --- a/Rapfi/search/ab/search.cpp +++ b/Rapfi/search/ab/search.cpp @@ -59,9 +59,9 @@ Value search(Rule rule, template Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth, bool cutNode); template -Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth = 0.0f, bool vcnAllowB4 = false); +Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth = 0.0f); template -Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth = 0.0f, bool vcnAllowB4 = false); +Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth = 0.0f); /// Increment a VCNLevel by 1, capped at VC5. inline VCNLevel vcnLevelIncrement(VCNLevel level) @@ -76,6 +76,16 @@ inline bool vcnVC5AttackerLoses(const Board &board, Color attacker) return board.p4Count(attacker, A_FIVE) == 0; } +/// Compute the VCN-specific hash XOR for transposition table key segregation. +/// This ensures TT entries from different VCN levels and non-VCN search don't collide. +inline HashKey computeVcnHashXor(const SearchOptions &opts, VCNLevel vcnLevel) +{ + if (!opts.vcnMode.enabled()) + return 0; + return Hash::LCHash(static_cast(static_cast(vcnLevel)) + ^ (static_cast(opts.vcnMode.attacker + 1) << 32)); +} + } // namespace void ABSearchData::clearData(SearchThread &th) @@ -670,9 +680,9 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth || board.p4Count(self, E_BLOCK4); if (!hasB4) return mated_in(ss->ply + 2); - // Drop to vcfsearch with forceAllowB4InVCF=true so all E_BLOCK4 moves are considered. - return oppo5 ? vcfdefend(board, ss, alpha, beta, 0.0f, true) - : vcfsearch(board, ss, alpha, beta, 0.0f, true); + // Drop to vcfsearch; it will recompute vcnAllowB4=true from the VC4 vcnLevel. + return oppo5 ? vcfdefend(board, ss, alpha, beta) + : vcfsearch(board, ss, alpha, beta); } // Dive into vcf search when the depth reaches zero (~17 elo) @@ -741,12 +751,8 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // Use a different hash key in case of an skip move to avoid overriding full search result. // In VCN mode, also XOR a vcnLevel-specific value to separate VCN TT entries from regular // ones and from other VCN levels (since the same board position can have different vcnLevels). - Pos skipMove = ss->skipMove; - HashKey vcnHashXor = - vcnEnabled - ? Hash::LCHash(static_cast(static_cast(vcnLevel)) - ^ (static_cast(options.vcnMode.attacker + 1) << 32)) - : 0; + Pos skipMove = ss->skipMove; + HashKey vcnHashXor = computeVcnHashXor(options, vcnLevel); HashKey posKey = board.zobristKey() ^ vcnHashXor ^ (skipMove ? Hash::LCHash(skipMove) : 0); Value ttValue = VALUE_NONE; Value ttEval = VALUE_NONE; @@ -1566,7 +1572,7 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth /// The VCF search function only searches continuous VCF moves to avoid /// search explosion. It returns the best evaluation in a VCF tree. template -Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth, bool vcnAllowB4) +Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth) { constexpr bool PvNode = NT == PV || NT == Root; @@ -1628,7 +1634,13 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de return alpha; // Step 4. Transposition table lookup - HashKey posKey = board.zobristKey(); + // Compute the same VCN hash XOR as in search() so VCN TT entries are properly segregated. + const SearchOptions &opts = thisThread->options(); + const bool vcnEnabled = opts.vcnMode.enabled(); + const bool vcnAllowB4 = + vcnEnabled && self == opts.vcnMode.attacker && ss->vcnLevel == VC4; + const HashKey vcnHashXor = computeVcnHashXor(opts, ss->vcnLevel); + HashKey posKey = board.zobristKey() ^ vcnHashXor; Value ttValue = VALUE_NONE; Value ttEval = VALUE_NONE; bool ttIsPv = false; @@ -1724,8 +1736,8 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de // Step 8. Make and search the move board.move(move); - // Call defence-side vcf search, propagating vcnAllowB4 - value = -vcfdefend(board, ss + 1, -beta, -alpha, depth - 1, vcnAllowB4); + // Call defence-side vcf search (vcfdefend recomputes vcnAllowB4 from vcn mode) + value = -vcfdefend(board, ss + 1, -beta, -alpha, depth - 1); board.undo(); @@ -1769,7 +1781,7 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de /// The search function for defend node in VCF search. template -Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth, bool vcnAllowB4) +Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth) { constexpr bool PvNode = NT == PV || NT == Root; @@ -1821,11 +1833,12 @@ Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de (ss + 1)->pv[0] = Pos::NONE; board.move(move); - TT.prefetch(board.zobristKey()); + // Prefetch TT using the same VCN-XORed key that vcfsearch will use. + TT.prefetch(board.zobristKey() ^ computeVcnHashXor(thisThread->options(), ss->vcnLevel)); - // Call attack-side vcf search, propagating vcnAllowB4 + // Call attack-side vcf search (vcfsearch recomputes vcnAllowB4 from vcn mode) // Note that we do not reduce depth for vcf defence move. - value = -vcfsearch(board, ss + 1, -beta, -alpha, depth, vcnAllowB4); + value = -vcfsearch(board, ss + 1, -beta, -alpha, depth); board.undo(); diff --git a/Rapfi/search/movepick.cpp b/Rapfi/search/movepick.cpp index f601a562..ce6e3f52 100644 --- a/Rapfi/search/movepick.cpp +++ b/Rapfi/search/movepick.cpp @@ -362,23 +362,9 @@ Pos MovePicker::operator()() case QVCF_TT: ++stage; return ttMove; case MAIN_PASS: - stage = MAIN_MOVES; - if (generatePassMove && board.getLastMove() != Pos::PASS && ttMove != Pos::PASS) { - curScore = 0; - return Pos::PASS; - } - goto top; - case DEFENDFOUR_PASS: - stage = DEFENDFOUR_MOVES; - if (generatePassMove && board.getLastMove() != Pos::PASS && ttMove != Pos::PASS) { - curScore = 0; - return Pos::PASS; - } - goto top; - case DEFENDB4F3_PASS: - stage = DEFENDB4F3_MOVES; + stage = stage + 1; // advance to the corresponding MOVES stage if (generatePassMove && board.getLastMove() != Pos::PASS && ttMove != Pos::PASS) { curScore = 0; return Pos::PASS; From 3a8a3264722069528ab5b8df0365ab0f6eab1513 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 08:24:41 +0000 Subject: [PATCH 6/8] Vcn namespace, options consolidation, vcnLevel from parent, PASS/TT fix Co-authored-by: dhbloo <32353216+dhbloo@users.noreply.github.com> --- Rapfi/search/ab/search.cpp | 112 ++++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 46 deletions(-) diff --git a/Rapfi/search/ab/search.cpp b/Rapfi/search/ab/search.cpp index 2dd158b1..5d1bc987 100644 --- a/Rapfi/search/ab/search.cpp +++ b/Rapfi/search/ab/search.cpp @@ -63,22 +63,24 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de template Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth = 0.0f); +/// VCN (Victory by Continuous N-level Attack) helper functions. +namespace Vcn { + /// Increment a VCNLevel by 1, capped at VC5. -inline VCNLevel vcnLevelIncrement(VCNLevel level) +inline VCNLevel levelIncrement(VCNLevel level) { return level < VC5 ? static_cast(static_cast(level) + 1) : VC5; } -/// Quick check: under VC5 rules the attacker must have A_FIVE on the board. -/// Returns true (attacker loses immediately) if they don't have one. -inline bool vcnVC5AttackerLoses(const Board &board, Color attacker) +/// Returns true if the VC5 attacker has no winning five on the board (they lose immediately). +inline bool vc5AttackerLoses(const Board &board, Color attacker) { return board.p4Count(attacker, A_FIVE) == 0; } /// Compute the VCN-specific hash XOR for transposition table key segregation. /// This ensures TT entries from different VCN levels and non-VCN search don't collide. -inline HashKey computeVcnHashXor(const SearchOptions &opts, VCNLevel vcnLevel) +inline HashKey hashXor(const SearchOptions &opts, VCNLevel vcnLevel) { if (!opts.vcnMode.enabled()) return 0; @@ -86,6 +88,8 @@ inline HashKey computeVcnHashXor(const SearchOptions &opts, VCNLevel vcnLevel) ^ (static_cast(opts.vcnMode.attacker + 1) << 32)); } +} // namespace Vcn + } // namespace void ABSearchData::clearData(SearchThread &th) @@ -665,9 +669,20 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth uint16_t oppo4 = oppo5 + board.p4Count(oppo, B_FLEX4); // opponent straight four and five // VCN mode state for this node - const bool vcnEnabled = options.vcnMode.enabled(); - const bool vcnIsAttacker = vcnEnabled && (self == options.vcnMode.attacker); - const VCNLevel vcnLevel = ss->vcnLevel; + const bool vcnEnabled = options.vcnMode.enabled(); + const bool vcnIsAttacker = vcnEnabled && (self == options.vcnMode.attacker); + // Derive vcnLevel from the parent's state and store it back to the search stack. + // At root, use the pre-initialized value. For non-root nodes, vcnLevel is inherited + // from the parent and incremented by one when the parent (as defender) played a pass move. + const VCNLevel vcnLevel = [&]() -> VCNLevel { + if (RootNode) + return ss->vcnLevel; // use pre-initialized value at root + const VCNLevel parentLevel = (ss - 1)->vcnLevel; + if (vcnEnabled && vcnIsAttacker && (ss - 1)->currentMove == Pos::PASS) + return Vcn::levelIncrement(parentLevel); + return parentLevel; + }(); + ss->vcnLevel = vcnLevel; // VCN mode early exits for the attacker at VC4: must come before the depth<=0 check // so the VC4 path (dropping to vcfsearch with forceAllowB4=true) is always taken. @@ -716,7 +731,7 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // VCN VC5 quick check: if the attacker has no A_FIVE, they lose immediately. // The attacker must win in the very next move under VC5 rules. - if (vcnEnabled && vcnIsAttacker && vcnLevel == VC5 && vcnVC5AttackerLoses(board, self)) + if (vcnEnabled && vcnIsAttacker && vcnLevel == VC5 && Vcn::vc5AttackerLoses(board, self)) return mated_in(ss->ply); // Check for immediate winning @@ -740,9 +755,9 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // statScore of the previous grandchild. (ss + 2)->statScore = 0; - // Pass current number of null moves and VCN level to next ply + // Pass current number of null moves to next ply + // (vcnLevel is now derived by each child from its parent at the start of search) (ss + 1)->numNullMoves = ss->numNullMoves; - (ss + 1)->vcnLevel = ss->vcnLevel; } else searchData->rootDelta = beta - alpha; @@ -752,7 +767,7 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // In VCN mode, also XOR a vcnLevel-specific value to separate VCN TT entries from regular // ones and from other VCN levels (since the same board position can have different vcnLevels). Pos skipMove = ss->skipMove; - HashKey vcnHashXor = computeVcnHashXor(options, vcnLevel); + HashKey vcnHashXor = Vcn::hashXor(options, vcnLevel); HashKey posKey = board.zobristKey() ^ vcnHashXor ^ (skipMove ? Hash::LCHash(skipMove) : 0); Value ttValue = VALUE_NONE; Value ttEval = VALUE_NONE; @@ -1176,14 +1191,6 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth ss->currentMove = move; ss->extraExtension = (ss - 1)->extraExtension + std::max(extension - 1.0f, 0.0f); - // In VCN mode, propagate the VCN level to the child node. - // Always set vcnLevel for every move so that after a pass move the level is - // correctly reset for the next non-pass move. The level is incremented by 1 - // only when the defender makes a pass move (capped at VC5). - if (vcnEnabled) - (ss + 1)->vcnLevel = - (!vcnIsAttacker && move == Pos::PASS) ? vcnLevelIncrement(vcnLevel) : vcnLevel; - // Step 14. Make the move board.move(move); TT.prefetch(board.zobristKey()); @@ -1447,8 +1454,9 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth [=](RootMove &rm) { rm.value = bestValue; }); } } - // If we have found a best move, update move heruistics - else if (bestMove) + // If we have found a best move, update move heuristics. + // PASS moves are not tracked in history tables (board.cell(PASS) is invalid). + else if (bestMove && bestMove != Pos::PASS) histTracker.updateBestmoveStats(depth, bestMove, bestValue); // Step 20. Update database record @@ -1561,9 +1569,18 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth ss->ttPv = ss->ttPv && (ss + 1)->ttPv; // Don't save partial result in singular extension, multi pv at root or balance mode. + // Do not store a PASS move as TT best move: board.cell(PASS) is invalid and history tables + // cannot index PASS, so callers would crash if they tried to use it. if (!skipMove && !(RootNode && (searchData->pvIdx || options.balanceMode || options.blockMoves.size()))) - TT.store(posKey, bestValue, ss->staticEval, ss->ttPv, bound, bestMove, (int)depth, ss->ply); + TT.store(posKey, + bestValue, + ss->staticEval, + ss->ttPv, + bound, + bestMove == Pos::PASS ? Pos::NONE : bestMove, + (int)depth, + ss->ply); assert(bestValue > -VALUE_INFINITE && bestValue < VALUE_INFINITE); return bestValue; @@ -1583,8 +1600,9 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de assert(0 <= ss->ply && ss->ply < MAX_PLY); // Step 1. Initialize node - SearchThread *thisThread = board.thisThread(); - ABSearchData *searchData = thisThread->searchDataAs(); + SearchThread *thisThread = board.thisThread(); + ABSearchData *searchData = thisThread->searchDataAs(); + const SearchOptions &options = thisThread->options(); thisThread->numNodes.fetch_add(1, std::memory_order_relaxed); Color self = board.sideToMove(), oppo = ~self; @@ -1603,26 +1621,23 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de static_cast(thisThread)->checkExit(); // Check if the board has been filled or we have reached the max game ply. - if (board.movesLeft() == 0 || board.nonPassMoveCount() >= thisThread->options().maxMoves) - return getDrawValue(board, thisThread->options(), ss->ply); + if (board.movesLeft() == 0 || board.nonPassMoveCount() >= options.maxMoves) + return getDrawValue(board, options, ss->ply); // Check if we reached the max ply if (ss->ply >= MAX_PLY) return Evaluation::evaluate(board, alpha, beta); // VCN VC5 quick check: if the attacker has no A_FIVE, they lose immediately. - { - const SearchOptions &vcnOptions = thisThread->options(); - if (vcnOptions.vcnMode.enabled() && self == vcnOptions.vcnMode.attacker - && ss->vcnLevel == VC5 && vcnVC5AttackerLoses(board, self)) - return mated_in(ss->ply); - } + if (options.vcnMode.enabled() && self == options.vcnMode.attacker + && ss->vcnLevel == VC5 && Vcn::vc5AttackerLoses(board, self)) + return mated_in(ss->ply); // Check for immediate winning if ((value = quickWinCheck(board, ss->ply, beta)) != VALUE_ZERO) { // Do not return mate that longer than maxMoves option - if (board.nonPassMoveCount() + mate_step(value, ss->ply) > thisThread->options().maxMoves) - value = getDrawValue(board, thisThread->options(), ss->ply); + if (board.nonPassMoveCount() + mate_step(value, ss->ply) > options.maxMoves) + value = getDrawValue(board, options, ss->ply); return value; } @@ -1634,12 +1649,10 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de return alpha; // Step 4. Transposition table lookup - // Compute the same VCN hash XOR as in search() so VCN TT entries are properly segregated. - const SearchOptions &opts = thisThread->options(); - const bool vcnEnabled = opts.vcnMode.enabled(); - const bool vcnAllowB4 = - vcnEnabled && self == opts.vcnMode.attacker && ss->vcnLevel == VC4; - const HashKey vcnHashXor = computeVcnHashXor(opts, ss->vcnLevel); + // Use the same VCN hash XOR as in search() so VCN TT entries are properly segregated. + const bool vcnEnabled = options.vcnMode.enabled(); + const bool vcnAllowB4 = vcnEnabled && self == options.vcnMode.attacker && ss->vcnLevel == VC4; + const HashKey vcnHashXor = Vcn::hashXor(options, ss->vcnLevel); HashKey posKey = board.zobristKey() ^ vcnHashXor; Value ttValue = VALUE_NONE; Value ttEval = VALUE_NONE; @@ -1733,6 +1746,9 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de if (PvNode) (ss + 1)->pv[0] = Pos::NONE; + // Propagate vcnLevel to child (stays constant across the vcfsearch/vcfdefend chain) + (ss + 1)->vcnLevel = ss->vcnLevel; + // Step 8. Make and search the move board.move(move); @@ -1792,7 +1808,8 @@ Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de assert(0 <= ss->ply && ss->ply < MAX_PLY); // Step 1. Initialize node - SearchThread *thisThread = board.thisThread(); + SearchThread *thisThread = board.thisThread(); + const SearchOptions &options = thisThread->options(); thisThread->numNodes.fetch_add(1, std::memory_order_relaxed); Color self = board.sideToMove(), oppo = ~self; @@ -1809,8 +1826,8 @@ Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de return Evaluation::evaluate(board, alpha, beta); // Check if the board has been filled or we have reached the max game ply. - if (board.movesLeft() == 0 || board.nonPassMoveCount() >= thisThread->options().maxMoves) - return getDrawValue(board, thisThread->options(), ss->ply); + if (board.movesLeft() == 0 || board.nonPassMoveCount() >= options.maxMoves) + return getDrawValue(board, options, ss->ply); // Check if we reached the max ply if (ss->ply >= MAX_PLY) @@ -1832,11 +1849,14 @@ Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de if (PvNode) (ss + 1)->pv[0] = Pos::NONE; + // Propagate vcnLevel to child (stays constant across the vcfsearch/vcfdefend chain) + (ss + 1)->vcnLevel = ss->vcnLevel; + board.move(move); // Prefetch TT using the same VCN-XORed key that vcfsearch will use. - TT.prefetch(board.zobristKey() ^ computeVcnHashXor(thisThread->options(), ss->vcnLevel)); + TT.prefetch(board.zobristKey() ^ Vcn::hashXor(options, (ss + 1)->vcnLevel)); - // Call attack-side vcf search (vcfsearch recomputes vcnAllowB4 from vcn mode) + // Call attack-side vcf search. // Note that we do not reduce depth for vcf defence move. value = -vcfsearch(board, ss + 1, -beta, -alpha, depth); From 71d7f2821381e24a25d7622c2fe9123aabf6238d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 08:45:46 +0000 Subject: [PATCH 7/8] VC4 stand-pat fix, VC5 check ordering, PASS/TT revert with movepicker/history fix Co-authored-by: dhbloo <32353216+dhbloo@users.noreply.github.com> --- Rapfi/search/ab/history.cpp | 3 ++ Rapfi/search/ab/search.cpp | 57 ++++++++++++++++++------------------- Rapfi/search/movepick.cpp | 13 +++++---- 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/Rapfi/search/ab/history.cpp b/Rapfi/search/ab/history.cpp index 86d31ff0..18dacdaf 100644 --- a/Rapfi/search/ab/history.cpp +++ b/Rapfi/search/ab/history.cpp @@ -85,6 +85,9 @@ void HistoryTracker::updateTTMoveStats(Depth depth, Pos ttMove, Value ttValue, V // Validate ttMove first if (!board.isLegal(ttMove)) return; + // PASS is legal but has no board cell; it doesn't map to history tables. + if (ttMove == Pos::PASS) + return; Color self = board.sideToMove(), oppo = ~self; bool oppo5 = board.p4Count(oppo, A_FIVE); diff --git a/Rapfi/search/ab/search.cpp b/Rapfi/search/ab/search.cpp index 5d1bc987..4bd5ab1b 100644 --- a/Rapfi/search/ab/search.cpp +++ b/Rapfi/search/ab/search.cpp @@ -721,6 +721,12 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth if (thisThread->isMainThread()) static_cast(thisThread)->checkExit(); + // VCN VC5 quick check: if the attacker has no A_FIVE, they lose immediately. + // This is checked before the draw check so that a lost attacker doesn't incorrectly + // get a draw score when the board is full. + if (vcnEnabled && vcnIsAttacker && vcnLevel == VC5 && Vcn::vc5AttackerLoses(board, self)) + return mated_in(ss->ply); + // Check if the board has been filled or we have reached the max game ply. if (board.movesLeft() == 0 || board.nonPassMoveCount() >= options.maxMoves) return getDrawValue(board, options, ss->ply); @@ -729,11 +735,6 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth if (ss->ply >= MAX_PLY) return Evaluation::evaluate(board, alpha, beta); - // VCN VC5 quick check: if the attacker has no A_FIVE, they lose immediately. - // The attacker must win in the very next move under VC5 rules. - if (vcnEnabled && vcnIsAttacker && vcnLevel == VC5 && Vcn::vc5AttackerLoses(board, self)) - return mated_in(ss->ply); - // Check for immediate winning if ((value = quickWinCheck(board, ss->ply, beta)) != VALUE_ZERO) { // Do not return mate that longer than maxMoves option @@ -1569,18 +1570,9 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth ss->ttPv = ss->ttPv && (ss + 1)->ttPv; // Don't save partial result in singular extension, multi pv at root or balance mode. - // Do not store a PASS move as TT best move: board.cell(PASS) is invalid and history tables - // cannot index PASS, so callers would crash if they tried to use it. if (!skipMove && !(RootNode && (searchData->pvIdx || options.balanceMode || options.blockMoves.size()))) - TT.store(posKey, - bestValue, - ss->staticEval, - ss->ttPv, - bound, - bestMove == Pos::PASS ? Pos::NONE : bestMove, - (int)depth, - ss->ply); + TT.store(posKey, bestValue, ss->staticEval, ss->ttPv, bound, bestMove, (int)depth, ss->ply); assert(bestValue > -VALUE_INFINITE && bestValue < VALUE_INFINITE); return bestValue; @@ -1620,19 +1612,17 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de if (thisThread->isMainThread()) static_cast(thisThread)->checkExit(); - // Check if the board has been filled or we have reached the max game ply. - if (board.movesLeft() == 0 || board.nonPassMoveCount() >= options.maxMoves) - return getDrawValue(board, options, ss->ply); - - // Check if we reached the max ply - if (ss->ply >= MAX_PLY) - return Evaluation::evaluate(board, alpha, beta); - // VCN VC5 quick check: if the attacker has no A_FIVE, they lose immediately. + // This is checked before the draw check so that a lost attacker doesn't incorrectly + // get a draw score when the board is full. if (options.vcnMode.enabled() && self == options.vcnMode.attacker && ss->vcnLevel == VC5 && Vcn::vc5AttackerLoses(board, self)) return mated_in(ss->ply); + // Check if the board has been filled or we have reached the max game ply. + if (board.movesLeft() == 0 || board.nonPassMoveCount() >= options.maxMoves) + return getDrawValue(board, options, ss->ply); + // Check for immediate winning if ((value = quickWinCheck(board, ss->ply, beta)) != VALUE_ZERO) { // Do not return mate that longer than maxMoves option @@ -1674,7 +1664,15 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de } // Step 5. Static position evaluation - if (ttHit) { + // In VC4 mode (attacker must play a VCF move), stand-pat does not apply. + // We initialise bestValue to the worst case (mated in 2 steps) so that: + // - Stand-pat / delta-pruning are bypassed (they check !vcnAllowB4 below). + // - If no VCF moves are found, this worst-case score is returned. + // - If VCF moves are found, bestValue is updated to a better score by the loop. + if (vcnAllowB4) { + bestValue = ss->staticEval = mated_in(ss->ply + 2); + } + else if (ttHit) { // Never assume anything about values stored in TT bestValue = ss->staticEval = ttEval; if (bestValue == VALUE_NONE) @@ -1692,8 +1690,9 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de : Evaluation::evaluate(board, alpha, beta); } - // Stand pat. Return immediately if static value is at least beta - if (bestValue >= beta) { + // Stand pat. Return immediately if static value is at least beta. + // Not applicable in VC4 mode (attacker must play). + if (!vcnAllowB4 && bestValue >= beta) { // Save static evaluation into transposition table if (!ttHit) TT.store(posKey, @@ -1708,11 +1707,11 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de return bestValue; } // Keep improving alpha since we can stop anywhere in the move limited search. - else if (PvNode && bestValue > alpha) + if (PvNode && bestValue > alpha) alpha = bestValue; - // Step 6. Delta pruning at non-PV node - if (!PvNode && bestValue + qvcfDeltaMargin(depth) < alpha) { + // Step 6. Delta pruning at non-PV node (not applicable in VC4 mode). + if (!vcnAllowB4 && !PvNode && bestValue + qvcfDeltaMargin(depth) < alpha) { // Save static evaluation into transposition table if (!ttHit) TT.store(posKey, diff --git a/Rapfi/search/movepick.cpp b/Rapfi/search/movepick.cpp index ce6e3f52..32c6560e 100644 --- a/Rapfi/search/movepick.cpp +++ b/Rapfi/search/movepick.cpp @@ -173,14 +173,15 @@ MovePicker::MovePicker(Rule rule, const Board &board, ExtraArgs= E_BLOCK4 || ttCell.pattern4[BLACK] == FORBID - || ttCell.pattern4[WHITE] >= E_BLOCK4; + ttmValid = args.ttMove != Pos::PASS + && (board.cell(args.ttMove).pattern4[BLACK] >= E_BLOCK4 + || board.cell(args.ttMove).pattern4[BLACK] == FORBID + || board.cell(args.ttMove).pattern4[WHITE] >= E_BLOCK4); } else if (board.p4Count(oppo, C_BLOCK4_FLEX3) && (rule != Rule::RENJU || validateOpponentCMove(board))) { @@ -219,11 +220,11 @@ MovePicker::MovePicker(Rule rule, const Board &board, ExtraArgs= E_BLOCK4; + ttmValid = args.ttMove != Pos::PASS && board.cell(args.ttMove).pattern4[self] >= E_BLOCK4; } // check legality for defence ttmove From 1248ea25a3111d2012bc55b72351679e39c0fef2 Mon Sep 17 00:00:00 2001 From: dhbloo <1084714805@qq.com> Date: Sat, 7 Mar 2026 18:06:27 +0800 Subject: [PATCH 8/8] search: fix VCN pass handling in VC4/VCF flow --- Rapfi/core/types.h | 2 - Rapfi/search/ab/history.cpp | 11 ++-- Rapfi/search/ab/search.cpp | 118 ++++++++++++++++------------------ Rapfi/search/ab/searchstack.h | 2 +- Rapfi/search/movepick.cpp | 27 ++++---- Rapfi/search/movepick.h | 2 +- Rapfi/search/searchcommon.h | 8 +-- 7 files changed, 80 insertions(+), 90 deletions(-) diff --git a/Rapfi/core/types.h b/Rapfi/core/types.h index 673ee402..f4db6206 100644 --- a/Rapfi/core/types.h +++ b/Rapfi/core/types.h @@ -254,5 +254,3 @@ enum class CandidateRange { FULL_BOARD, CAND_RANGE_NB, }; - -// ------------------------------------------------- diff --git a/Rapfi/search/ab/history.cpp b/Rapfi/search/ab/history.cpp index 18dacdaf..a49a5b95 100644 --- a/Rapfi/search/ab/history.cpp +++ b/Rapfi/search/ab/history.cpp @@ -85,17 +85,16 @@ void HistoryTracker::updateTTMoveStats(Depth depth, Pos ttMove, Value ttValue, V // Validate ttMove first if (!board.isLegal(ttMove)) return; - // PASS is legal but has no board cell; it doesn't map to history tables. - if (ttMove == Pos::PASS) - return; Color self = board.sideToMove(), oppo = ~self; bool oppo5 = board.p4Count(oppo, A_FIVE); bool oppo4 = oppo5 || board.p4Count(oppo, B_FLEX4); - Pattern4 selfP4 = board.cell(ttMove).pattern4[self]; - int bonus = statBonus(depth); - + + // If ttMove is a Pass, we always update the stats, since it might be a refute move in VCN search. + Pattern4 selfP4 = ttMove == Pos::PASS ? NONE : board.cell(ttMove).pattern4[self]; if (!oppo4 && selfP4 < H_FLEX3) { + int bonus = statBonus(depth); + // Bonus for a quiet ttMove that fails high if (ttValue >= beta) updateQuietStats(ttMove, bonus); diff --git a/Rapfi/search/ab/search.cpp b/Rapfi/search/ab/search.cpp index 4bd5ab1b..6c211cde 100644 --- a/Rapfi/search/ab/search.cpp +++ b/Rapfi/search/ab/search.cpp @@ -63,6 +63,8 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de template Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth = 0.0f); +} // namespace + /// VCN (Victory by Continuous N-level Attack) helper functions. namespace Vcn { @@ -90,8 +92,6 @@ inline HashKey hashXor(const SearchOptions &opts, VCNLevel vcnLevel) } // namespace Vcn -} // namespace - void ABSearchData::clearData(SearchThread &th) { multiPv = 1; @@ -674,34 +674,18 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // Derive vcnLevel from the parent's state and store it back to the search stack. // At root, use the pre-initialized value. For non-root nodes, vcnLevel is inherited // from the parent and incremented by one when the parent (as defender) played a pass move. - const VCNLevel vcnLevel = [&]() -> VCNLevel { - if (RootNode) - return ss->vcnLevel; // use pre-initialized value at root - const VCNLevel parentLevel = (ss - 1)->vcnLevel; + if (!RootNode) { if (vcnEnabled && vcnIsAttacker && (ss - 1)->currentMove == Pos::PASS) - return Vcn::levelIncrement(parentLevel); - return parentLevel; - }(); - ss->vcnLevel = vcnLevel; - - // VCN mode early exits for the attacker at VC4: must come before the depth<=0 check - // so the VC4 path (dropping to vcfsearch with forceAllowB4=true) is always taken. - if (vcnEnabled && vcnIsAttacker && vcnLevel == VC4) { - // At VC4, the attacker may only play moves with pattern4 >= E_BLOCK4. - // If no such move exists, the attacker loses in 2 steps. - bool hasB4 = board.p4Count(self, A_FIVE) || board.p4Count(self, B_FLEX4) - || board.p4Count(self, C_BLOCK4_FLEX3) - || board.p4Count(self, D_BLOCK4_PLUS) - || board.p4Count(self, E_BLOCK4); - if (!hasB4) - return mated_in(ss->ply + 2); - // Drop to vcfsearch; it will recompute vcnAllowB4=true from the VC4 vcnLevel. - return oppo5 ? vcfdefend(board, ss, alpha, beta) - : vcfsearch(board, ss, alpha, beta); + ss->vcnLevel = Vcn::levelIncrement((ss - 1)->vcnLevel); + else + ss->vcnLevel = (ss - 1)->vcnLevel; } + // Drop to vcfsearch for the attacker at VC4 mode + if (vcnEnabled && vcnIsAttacker && vcnLevel == VC4) + return vcfsearch(board, ss, alpha, beta); // Dive into vcf search when the depth reaches zero (~17 elo) - if (depth <= 0.0f) { + else if (depth <= 0.0f) { return oppo5 ? vcfdefend(board, ss, alpha, beta) : vcfsearch(board, ss, alpha, beta); } @@ -769,14 +753,14 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // ones and from other VCN levels (since the same board position can have different vcnLevels). Pos skipMove = ss->skipMove; HashKey vcnHashXor = Vcn::hashXor(options, vcnLevel); - HashKey posKey = board.zobristKey() ^ vcnHashXor ^ (skipMove ? Hash::LCHash(skipMove) : 0); - Value ttValue = VALUE_NONE; - Value ttEval = VALUE_NONE; - bool ttIsPv = false; - Bound ttBound = BOUND_NONE; - Pos ttMove = Pos::NONE; - int ttDepth = 0; - bool ttHit = TT.probe(posKey, ttValue, ttEval, ttIsPv, ttBound, ttMove, ttDepth, ss->ply); + HashKey posKey = board.zobristKey() ^ vcnHashXor ^ (skipMove ? Hash::LCHash(skipMove) : 0); + Value ttValue = VALUE_NONE; + Value ttEval = VALUE_NONE; + bool ttIsPv = false; + Bound ttBound = BOUND_NONE; + Pos ttMove = Pos::NONE; + int ttDepth = 0; + bool ttHit = TT.probe(posKey, ttValue, ttEval, ttIsPv, ttBound, ttMove, ttDepth, ss->ply); if (RootNode && searchData->completedDepth.load(std::memory_order_relaxed)) ttMove = thisThread->rootMoves[0].pv[options.balanceMode == SearchOptions::BALANCE_TWO]; if (!skipMove) @@ -1122,8 +1106,8 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // Singular extension: only one move fails high while other moves fails low on a search of // (alpha-s, beta-s), then this move is singular and should be extended. (~52 elo) else if (!RootNode && depth >= SE_DEPTH && move == ttMove - && move != Pos::PASS // No singular extension for pass - && !skipMove // No recursive singular search + && move != Pos::PASS // No singular extension for pass + && !skipMove // No recursive singular search && std::abs(ttValue) < VALUE_MATE_IN_MAX_PLY // ttmove value is not a mate && (ttBound & BOUND_LOWER) // ttMove failed high last time && ttDepth >= depth - SE_TTE_DEPTH // ttEntry has enough depth to trust @@ -1465,18 +1449,17 @@ Value search(Board &board, SearchStack *ss, Value alpha, Value beta, Depth depth // In VCN mode, only write to the database if it is a proven win for the attacker. // "isWin" means current player (self) wins; "isLoss" means current player loses. // Attacker wins when: self == attacker and isWin, OR self == defender and isLoss. - bool vcnAttackerWins = vcnEnabled - && ((vcnIsAttacker && bestValue > VALUE_MATE_IN_MAX_PLY - && (bound & BOUND_LOWER)) - || (!vcnIsAttacker && bestValue < VALUE_MATED_IN_MAX_PLY - && (bound & BOUND_UPPER))); + bool vcnAttackerWins = + vcnEnabled + && ((vcnIsAttacker && bestValue > VALUE_MATE_IN_MAX_PLY && (bound & BOUND_LOWER)) + || (!vcnIsAttacker && bestValue < VALUE_MATED_IN_MAX_PLY && (bound & BOUND_UPPER))); if (thisThread->dbClient && !Config::DatabaseReadonlyMode // Never write in database readonly mode && !options.balanceMode // Never write when we are doing balanced search && (!skipMove || ss->dbChildWritten) // Never write when in singular extension && ss->numNullMoves == 0 // Never write when in null move search && !(RootNode && (searchData->pvIdx || options.blockMoves.size())) - && (!vcnEnabled || vcnAttackerWins) // In VCN mode, only write proven attacker wins + && (!vcnEnabled || vcnAttackerWins) // In VCN mode, only write proven attacker wins ) { bool exact = PvNode && bound == BOUND_EXACT; bool isWin = bestValue > VALUE_MATE_IN_MAX_PLY && (bound & BOUND_LOWER); @@ -1615,8 +1598,10 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de // VCN VC5 quick check: if the attacker has no A_FIVE, they lose immediately. // This is checked before the draw check so that a lost attacker doesn't incorrectly // get a draw score when the board is full. - if (options.vcnMode.enabled() && self == options.vcnMode.attacker - && ss->vcnLevel == VC5 && Vcn::vc5AttackerLoses(board, self)) + const bool vcnEnabled = options.vcnMode.enabled(); + const bool vcnIsAttacker = vcnEnabled && self == options.vcnMode.attacker; + const bool vcfOnly = vcnIsAttacker && ss->vcnLevel == VC4; + if (vcnIsAttacker && ss->vcnLevel == VC5 && Vcn::vc5AttackerLoses(board, self)) return mated_in(ss->ply); // Check if the board has been filled or we have reached the max game ply. @@ -1640,17 +1625,15 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de // Step 4. Transposition table lookup // Use the same VCN hash XOR as in search() so VCN TT entries are properly segregated. - const bool vcnEnabled = options.vcnMode.enabled(); - const bool vcnAllowB4 = vcnEnabled && self == options.vcnMode.attacker && ss->vcnLevel == VC4; const HashKey vcnHashXor = Vcn::hashXor(options, ss->vcnLevel); - HashKey posKey = board.zobristKey() ^ vcnHashXor; - Value ttValue = VALUE_NONE; - Value ttEval = VALUE_NONE; - bool ttIsPv = false; - Bound ttBound = BOUND_NONE; - Pos ttMove = Pos::NONE; - int ttDepth = (int)DEPTH_LOWER_BOUND; - bool ttHit = TT.probe(posKey, ttValue, ttEval, ttIsPv, ttBound, ttMove, ttDepth, ss->ply); + HashKey posKey = board.zobristKey() ^ vcnHashXor; + Value ttValue = VALUE_NONE; + Value ttEval = VALUE_NONE; + bool ttIsPv = false; + Bound ttBound = BOUND_NONE; + Pos ttMove = Pos::NONE; + int ttDepth = (int)DEPTH_LOWER_BOUND; + bool ttHit = TT.probe(posKey, ttValue, ttEval, ttIsPv, ttBound, ttMove, ttDepth, ss->ply); // Check for an early TT cutoff (for all types of nodes) if (ttHit && ttDepth >= depth && (!PvNode || !thisThread->isMainThread()) // Show full PV @@ -1666,10 +1649,10 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de // Step 5. Static position evaluation // In VC4 mode (attacker must play a VCF move), stand-pat does not apply. // We initialise bestValue to the worst case (mated in 2 steps) so that: - // - Stand-pat / delta-pruning are bypassed (they check !vcnAllowB4 below). + // - Stand-pat / delta-pruning are bypassed (they check !vcfOnly below). // - If no VCF moves are found, this worst-case score is returned. // - If VCF moves are found, bestValue is updated to a better score by the loop. - if (vcnAllowB4) { + if (vcfOnly) { bestValue = ss->staticEval = mated_in(ss->ply + 2); } else if (ttHit) { @@ -1691,8 +1674,7 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de } // Stand pat. Return immediately if static value is at least beta. - // Not applicable in VC4 mode (attacker must play). - if (!vcnAllowB4 && bestValue >= beta) { + if (bestValue >= beta) { // Save static evaluation into transposition table if (!ttHit) TT.store(posKey, @@ -1707,11 +1689,12 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de return bestValue; } // Keep improving alpha since we can stop anywhere in the move limited search. - if (PvNode && bestValue > alpha) + // Not applicable in VC4 mode (attacker must play). + if (!vcfOnly && PvNode && bestValue > alpha) alpha = bestValue; // Step 6. Delta pruning at non-PV node (not applicable in VC4 mode). - if (!vcnAllowB4 && !PvNode && bestValue + qvcfDeltaMargin(depth) < alpha) { + if (!vcfOnly && !PvNode && bestValue + qvcfDeltaMargin(depth) < alpha) { // Save static evaluation into transposition table if (!ttHit) TT.store(posKey, @@ -1744,7 +1727,6 @@ Value vcfsearch(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de ss->moveP4[WHITE] = board.cell(move).pattern4[WHITE]; if (PvNode) (ss + 1)->pv[0] = Pos::NONE; - // Propagate vcnLevel to child (stays constant across the vcfsearch/vcfdefend chain) (ss + 1)->vcnLevel = ss->vcnLevel; @@ -1820,9 +1802,18 @@ Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de thisThread->selDepth = ss->ply + 1; // Step 2. Check for immediate evaluation, draw and winning - // Return evaluation immediately if there is no vcf threat - if (!oppo5) + if (!oppo5) { + // If we are the defender side in VCN mode and the attacker currently has no A_FIVE, + // then we can immediately win in 1 step by playing a Pass move. + const bool vcnEnabled = options.vcnMode.enabled(); + const bool vcnIsDefender = vcnEnabled && self != options.vcnMode.attacker; + const bool vcfOnly = vcnIsAttacker && ss->vcnLevel == VC4; + if (vcfOnly) + return mate_in(ss->ply + 1); + + // Return evaluation immediately if there is no vcf threat return Evaluation::evaluate(board, alpha, beta); + } // Check if the board has been filled or we have reached the max game ply. if (board.movesLeft() == 0 || board.nonPassMoveCount() >= options.maxMoves) @@ -1847,7 +1838,6 @@ Value vcfdefend(Board &board, SearchStack *ss, Value alpha, Value beta, Depth de ss->moveP4[WHITE] = board.cell(move).pattern4[WHITE]; if (PvNode) (ss + 1)->pv[0] = Pos::NONE; - // Propagate vcnLevel to child (stays constant across the vcfsearch/vcfdefend chain) (ss + 1)->vcnLevel = ss->vcnLevel; diff --git a/Rapfi/search/ab/searchstack.h b/Rapfi/search/ab/searchstack.h index bac28edb..6d6e3447 100644 --- a/Rapfi/search/ab/searchstack.h +++ b/Rapfi/search/ab/searchstack.h @@ -44,7 +44,7 @@ struct SearchStack Pos killers[2]; Pattern4 moveP4[SIDE_NB]; int16_t numNullMoves; - VCNLevel vcnLevel; /// Current VCN level at this ply (VC_NONE if VCN mode is disabled) + VCNLevel vcnLevel; /// VCN level at this ply (defaults to VC_NONE if disabled) bool ttPv; bool dbChildWritten; diff --git a/Rapfi/search/movepick.cpp b/Rapfi/search/movepick.cpp index 32c6560e..2ddbecfe 100644 --- a/Rapfi/search/movepick.cpp +++ b/Rapfi/search/movepick.cpp @@ -31,7 +31,7 @@ namespace { /// Usual procedure: X_TT -> X_PASS (optional) -> X_MOVES -> ALLMOVES. enum Stages { MAIN_TT, - MAIN_PASS, // Pass move stage for VCN defender (before main moves) + MAIN_PASS, // Pass move stage for VCN defender (before main moves) MAIN_MOVES, DEFENDFIVE_TT, DEFENDFIVE_MOVES, @@ -173,15 +173,16 @@ MovePicker::MovePicker(Rule rule, const Board &board, ExtraArgs= E_BLOCK4 - || board.cell(args.ttMove).pattern4[BLACK] == FORBID - || board.cell(args.ttMove).pattern4[WHITE] >= E_BLOCK4); + ttmValid = args.ttMove == Pos::PASS // Allow pass move for VCN defender + || board.cell(args.ttMove).pattern4[BLACK] >= E_BLOCK4 + || board.cell(args.ttMove).pattern4[BLACK] == FORBID + || board.cell(args.ttMove).pattern4[WHITE] >= E_BLOCK4; } else if (board.p4Count(oppo, C_BLOCK4_FLEX3) && (rule != Rule::RENJU || validateOpponentCMove(board))) { @@ -207,8 +208,7 @@ MovePicker::MovePicker(Rule rule, const Board &board, ExtraArgs= DEPTH_QVCF_FULL + args.forceAllowB4InVCF || args.depth >= DEPTH_QVCF_FULL || (args.previousSelfP4[0] >= D_BLOCK4_PLUS && args.previousSelfP4[1] >= D_BLOCK4_PLUS)) , generatePassMove(false) , hasPolicy(false) @@ -365,9 +365,14 @@ Pos MovePicker::operator()() case MAIN_PASS: case DEFENDFOUR_PASS: case DEFENDB4F3_PASS: - stage = stage + 1; // advance to the corresponding MOVES stage - if (generatePassMove && board.getLastMove() != Pos::PASS && ttMove != Pos::PASS) { - curScore = 0; + stage = stage + 1; // advance to the corresponding MOVES stage + if (generatePassMove // generate pass move only when this flag is on + && board.getLastMove() != Pos::PASS // never do consecutive passes + && ttMove != Pos::PASS // If we did pass move in tt phase, skip it + ) { + curScore = 0; + curPolicy = 0.0f; + curPolicyScore = 0; return Pos::PASS; } goto top; diff --git a/Rapfi/search/movepick.h b/Rapfi/search/movepick.h index 3182c981..79983756 100644 --- a/Rapfi/search/movepick.h +++ b/Rapfi/search/movepick.h @@ -127,7 +127,7 @@ struct MovePicker::ExtraArgs Depth depth; // negative depth in qvcf search Pattern4 previousSelfP4[2]; /// Force allowPlainB4InVCF=true regardless of depth/previous patterns. - /// Used when entering from VCN VC4 mode so all E_BLOCK4 moves are enumerated. + /// Used when entering from VC4 mode so all E_BLOCK4 moves are enumerated. bool forceAllowB4InVCF = false; }; diff --git a/Rapfi/search/searchcommon.h b/Rapfi/search/searchcommon.h index 56c8447f..a50c30c6 100644 --- a/Rapfi/search/searchcommon.h +++ b/Rapfi/search/searchcommon.h @@ -35,7 +35,7 @@ namespace Search { /// VC4 corresponds to VCF (Victory by Continuous Four), where the defender can pass once. /// VC5 means the attacker must win immediately (defender can never pass). /// VC2/VC3 allow progressively more passes for the defender. -enum VCNLevel { +enum VCNLevel : int8_t { VC_NONE, ///< VCN mode disabled VC2, ///< Defender can pass at most 3 times VC3, ///< Defender can pass at most 2 times @@ -180,13 +180,11 @@ struct SearchOptions RES_BLACK_WIN, RES_WHITE_WIN, } drawResult = RES_DRAW; - - /// Blocked moves, which are filtered out before searching - std::vector blockMoves; - /// VCN (Victory by Continuous N-level Attack) mode configuration. /// When enabled, only searches for attacker-wins satisfying the VCN constraint. VCNMode vcnMode; + /// Blocked moves, which are filtered out before searching + std::vector blockMoves; /// Checks if we are in analysis mode. bool isAnalysisMode() const { return !timeLimit && !maxNodes; }