From 21cd96b5148f4f87428c28d3a89a43b86dac658b Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 11 Aug 2026 17:52:46 +0200 Subject: [PATCH 1/3] search: prune losing quiet moves with SEE --- see_test.sh | 21 +++++++++++++++++++++ src/search.c | 30 +++++++++++++++++++++--------- src/search.h | 6 +++++- src/uci.c | 24 ++++++++++++++++++------ 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/see_test.sh b/see_test.sh index f431266..a59da20 100755 --- a/see_test.sh +++ b/see_test.sh @@ -56,5 +56,26 @@ for c in "${CASES[@]}"; do fi done done + +# Quiet moves must also traverse the exchange sequence. Qa1-a2 walks onto an +# undefended rook attack and therefore has SEE -900; Kh1-g1 is safe (SEE 0). +QUIET_CASES=( +"r6k/8/8/8/8/8/8/Q6K w - - 0 1|a1a2|-900" +"7k/8/8/8/8/8/8/7K w - - 0 1|h1g1|0" +) +for c in "${QUIET_CASES[@]}"; do + fen="${c%%|*}"; rest="${c#*|}"; mv="${rest%%|*}"; exp="${rest##*|}" + for delta in -1 0 1; do + threshold=$((exp + delta)) + if [ "$delta" -le 0 ]; then expected_ge=1; else expected_ge=0; fi + ge_out=$(printf 'position fen %s\nseege %s %d\nquit\n' "$fen" "$mv" "$threshold" | $ENG 2>/dev/null | grep "^seege $mv $threshold ") + got_ge=$(echo "$ge_out" | sed 's/.*= //') + if [ "$got_ge" = "$expected_ge" ]; then + pass=$((pass+1)) + else + fail=$((fail+1)); echo "FAIL quiet see_ge $mv threshold $threshold : erwartet $expected_ge, bekommen '${got_ge:-}' [$fen]" + fi + done +done echo "----" echo "PASS=$pass FAIL=$fail" diff --git a/src/search.c b/src/search.c index 57d6609..1f9d3b2 100644 --- a/src/search.c +++ b/src/search.c @@ -55,6 +55,7 @@ static struct { uint64_t singular_hits; uint64_t singular_extensions; uint64_t singular_multicuts; + uint64_t main_quiet_see; } pruning_stats; #define TT_STAT_INC(var) ((var)++) @@ -139,6 +140,7 @@ void search_params_init(SearchParams* params) { params->use_iir = true; params->use_probcut = true; params->use_singular = true; + params->use_main_quiet_see = true; // Late Move Pruning: skip quiets after base + depth^2 searched moves params->lmp_base = 6; @@ -146,6 +148,8 @@ void search_params_init(SearchParams* params) { params->main_capture_see_margin = 100; params->main_capture_see_max_depth = 16; + params->main_quiet_see_margin = 20; + params->main_quiet_see_max_depth = 8; // Internal Iterative Reduction: reduce one ply when move ordering has no // transposition-table move to guide a sufficiently deep non-root node. @@ -391,11 +395,6 @@ static int see(const Board* board, Move move) { victim_value = SEE_VALUES[1]; // Pawn } - // If capturing nothing (and not promoting), there is no exchange to evaluate - if (victim_value == 0 && !MOVE_IS_EN_PASSANT(move) && !MOVE_IS_PROMOTION(move)) { - return 0; - } - // Gain array to track material balance at each step int gain[32]; int depth = 0; @@ -491,9 +490,6 @@ static bool see_ge(const Board* board, Move move, int threshold) { int victim_value = get_piece_value(victim_type); if (MOVE_IS_EN_PASSANT(move)) victim_value = SEE_VALUES[1]; - if (victim_value == 0 && !MOVE_IS_EN_PASSANT(move) && !MOVE_IS_PROMOTION(move)) - return 0 >= threshold; - int64_t balance = (int64_t)victim_value + promo_gain - threshold; if (balance < 0) return false; @@ -1482,6 +1478,12 @@ static int negamax(Board* board, int depth, int alpha, int beta, SearchInfo* inf bool can_main_capture_see = info->params.use_main_capture_see && ply > 0 && !is_pv && !in_check && depth <= info->params.main_capture_see_max_depth && abs(alpha) < TB_SCORE_MIN && abs(beta) < TB_SCORE_MIN; + // Quiet SEE pruning is independent from capture SEE pruning and uses a + // quadratic depth margin: shallow moves that immediately hang material + // are discarded, while deeper nodes receive a rapidly widening allowance. + bool can_main_quiet_see = info->params.use_main_quiet_see && ply > 0 && + !is_pv && !in_check && depth <= info->params.main_quiet_see_max_depth && + abs(alpha) < TB_SCORE_MIN && abs(beta) < TB_SCORE_MIN; // Static eval at every eligible non-check node, reusing the TT copy when // available. This also feeds the per-ply stack used by improving-aware LMP. int raw_static_eval = TT_EVAL_NONE; @@ -1750,6 +1752,15 @@ static int negamax(Board* board, int depth, int alpha, int beta, SearchInfo* inf continue; } + // Main-search quiet SEE pruning. Preserve the TT move and the first + // legal candidate for the same correctness reasons as capture SEE. + if (can_main_quiet_see && !is_tactical && m != mp.tt_move && + moves_searched > 0 && + !see_ge(board, m, -info->params.main_quiet_see_margin * depth * depth)) { + PRUNING_STAT_INC(main_quiet_see); + continue; + } + // Prefetch TT entry for likely next position // (This is a simple optimization that can help cache performance) @@ -2330,7 +2341,7 @@ Move iterative_deepening_search(Board* board, SearchInfo* info) { pruning_stats.lmp + pruning_stats.lmr + pruning_stats.delta + pruning_stats.see_pruning + pruning_stats.probcut_cuts; - total_prunings += pruning_stats.main_capture_see; + total_prunings += pruning_stats.main_capture_see + pruning_stats.main_quiet_see; total_prunings += pruning_stats.iir; printf("info string Pruning stats (total=%llu):\n", (unsigned long long)total_prunings); printf("info string Null Move: %llu\n", (unsigned long long)pruning_stats.null_move); @@ -2353,6 +2364,7 @@ Move iterative_deepening_search(Board* board, SearchInfo* info) { (unsigned long long)pruning_stats.probcut_cuts, (unsigned long long)pruning_stats.probcut_nodes); printf("info string Main Cap SEE: %llu\n", (unsigned long long)pruning_stats.main_capture_see); + printf("info string Main Quiet SEE: %llu\n", (unsigned long long)pruning_stats.main_quiet_see); printf("info string IIR: %llu\n", (unsigned long long)pruning_stats.iir); int cmin = 0, cmax = 0, nonzero = 0, saturated = 0; for (int s = 0; s < 2; s++) diff --git a/src/search.h b/src/search.h index 33f4491..780adda 100644 --- a/src/search.h +++ b/src/search.h @@ -42,6 +42,7 @@ typedef struct { bool use_iir; // Enable Internal Iterative Reduction bool use_probcut; // Enable capture-only ProbCut bool use_singular; // Enable singular extension verification + bool use_main_quiet_see; // Enable main-search SEE pruning for quiet moves // Late Move Reduction parameters int lmr_full_depth_moves; // Number of moves before LMR kicks in (default: 4) @@ -55,6 +56,10 @@ typedef struct { int main_capture_see_margin; // Negative threshold per depth (default: 100) int main_capture_see_max_depth; // Maximum depth (default: 16) + // Main-search quiet SEE pruning (quadratic depth threshold) + int main_quiet_see_margin; // Negative threshold per depth squared (default: 20) + int main_quiet_see_max_depth; // Maximum depth (default: 8) + // Internal Iterative Reduction int iir_min_depth; // Minimum depth at a non-root node without a TT move @@ -67,7 +72,6 @@ typedef struct { int singular_min_depth; // Minimum depth for verification (default: 8) int singular_margin; // Singular beta margin per depth (default: 2) int singular_tt_depth_slack;// Allowed TT depth deficit (default: 3) - // Null Move Pruning parameters (reduction itself is adaptive in negamax) int null_move_min_depth; // Minimum depth for null move pruning (default: 3) diff --git a/src/uci.c b/src/uci.c index 903c8cd..790704b 100644 --- a/src/uci.c +++ b/src/uci.c @@ -264,6 +264,7 @@ void uci_loop() { printf("option name Use_IIR type check default true\n"); printf("option name Use_ProbCut type check default true\n"); printf("option name Use_Singular type check default true\n"); + printf("option name Use_MainQuietSEE type check default true\n"); // Search parameter options printf("option name LMR_FullDepthMoves type spin default 3 min 1 max 10\n"); printf("option name LMP_Base type spin default 6 min 1 max 20\n"); @@ -278,6 +279,8 @@ void uci_loop() { printf("option name Singular_MinDepth type spin default 8 min 4 max 32\n"); printf("option name Singular_Margin type spin default 2 min 0 max 16\n"); printf("option name Singular_TTDepthSlack type spin default 3 min 0 max 16\n"); + printf("option name MainQuietSEE_Margin type spin default 20 min 0 max 500\n"); + printf("option name MainQuietSEE_MaxDepth type spin default 8 min 1 max 16\n"); printf("option name LMR_ReductionLimit type spin default 1 min 1 max 6\n"); printf("option name NullMove_MinDepth type spin default 4 min 1 max 6\n"); printf("option name Futility_Margin type spin default 243 min 50 max 400\n"); @@ -321,19 +324,19 @@ void uci_loop() { } fflush(stdout); } else if (strncmp(line, "seege ", 6) == 0) { - // Debug: evaluate one generated capture/promotion against a threshold. + // Debug: evaluate one generated move against a threshold. char requested[6] = {0}; int threshold; if (sscanf(line + 6, "%5s %d", requested, &threshold) == 2) { - MoveList caps; - generateCaptureAndPromotionMoves(¤t_board, &caps); + MoveList moves; + generateMoves(¤t_board, &moves); bool found = false; - for (int i = 0; i < caps.count; i++) { + for (int i = 0; i < moves.count; i++) { char move_string[6]; - moveToString(caps.moves[i], move_string); + moveToString(moves.moves[i], move_string); if (strcmp(move_string, requested) == 0) { printf("seege %s %d = %d\n", requested, threshold, - see_ge_debug(¤t_board, caps.moves[i], threshold)); + see_ge_debug(¤t_board, moves.moves[i], threshold)); found = true; break; } @@ -414,6 +417,9 @@ void uci_loop() { } else if (strcmp(option_name, "Use_Singular") == 0) { search_params.use_singular = bool_value; printf("info string Set Use_Singular to %s\n", bool_value ? "true" : "false"); + } else if (strcmp(option_name, "Use_MainQuietSEE") == 0) { + search_params.use_main_quiet_see = bool_value; + printf("info string Set Use_MainQuietSEE to %s\n", bool_value ? "true" : "false"); // Numeric parameters } else if (strcmp(option_name, "LMP_Base") == 0) { search_params.lmp_base = value; @@ -448,6 +454,12 @@ void uci_loop() { } else if (strcmp(option_name, "Singular_TTDepthSlack") == 0) { search_params.singular_tt_depth_slack = value; printf("info string Set Singular_TTDepthSlack to %d\n", value); + } else if (strcmp(option_name, "MainQuietSEE_Margin") == 0) { + search_params.main_quiet_see_margin = value; + printf("info string Set MainQuietSEE_Margin to %d\n", value); + } else if (strcmp(option_name, "MainQuietSEE_MaxDepth") == 0) { + search_params.main_quiet_see_max_depth = value; + printf("info string Set MainQuietSEE_MaxDepth to %d\n", value); } else if (strcmp(option_name, "LMR_FullDepthMoves") == 0) { search_params.lmr_full_depth_moves = value; printf("info string Set LMR_FullDepthMoves to %d\n", value); From 9bf0a8afa15bc992ed7bf9397c40df2650ca9c60 Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 11 Aug 2026 18:20:16 +0200 Subject: [PATCH 2/3] search: gate quiet SEE by move ordering --- see_test.sh | 10 ++++++ src/search.c | 93 ++++++++++++++++++++++++++-------------------------- src/search.h | 4 +-- src/uci.c | 12 +++---- 4 files changed, 65 insertions(+), 54 deletions(-) diff --git a/see_test.sh b/see_test.sh index a59da20..3349cb4 100755 --- a/see_test.sh +++ b/see_test.sh @@ -62,9 +62,19 @@ done QUIET_CASES=( "r6k/8/8/8/8/8/8/Q6K w - - 0 1|a1a2|-900" "7k/8/8/8/8/8/8/7K w - - 0 1|h1g1|0" +"7k/8/8/8/4p3/8/6P1/6NK w - - 0 1|g1f3|-220" +"4k3/8/8/8/8/8/8/4K2R w K - 0 1|e1g1|0" +"4r2k/8/8/8/8/8/8/4K3 w - - 0 1|e1e2|-20000" ) for c in "${QUIET_CASES[@]}"; do fen="${c%%|*}"; rest="${c#*|}"; mv="${rest%%|*}"; exp="${rest##*|}" + see_out=$(printf 'position fen %s\nseedump\nquit\n' "$fen" | $ENG 2>/dev/null | grep "^see $mv ") + got_see=$(echo "$see_out" | sed 's/.*= //') + if [ "$got_see" = "$exp" ]; then + pass=$((pass+1)) + else + fail=$((fail+1)); echo "FAIL quiet SEE $mv : erwartet $exp, bekommen '${got_see:-}' [$fen]" + fi for delta in -1 0 1; do threshold=$((exp + delta)) if [ "$delta" -le 0 ]; then expected_ge=1; else expected_ge=0; fi diff --git a/src/search.c b/src/search.c index 1f9d3b2..fd3dae3 100644 --- a/src/search.c +++ b/src/search.c @@ -106,6 +106,36 @@ static void init_lmr_table(void) { lmr_table_initialized = true; } +static int quiet_lmr_reduction(const SearchInfo* info, int depth, int moves_searched, + bool is_pv, bool in_check, int move_score) { + if (!info->params.use_lmr || in_check || + depth < info->params.lmr_reduction_limit || + moves_searched < info->params.lmr_full_depth_moves) { + return 0; + } + + int lmr_depth = depth < MAX_PLY ? depth : MAX_PLY - 1; + int lmr_moves = moves_searched < 64 ? moves_searched : 63; + int reduction = lmr_table[lmr_depth][lmr_moves]; + + if (!is_pv) reduction++; + + if (move_score < info->params.lmr_stat_low2) { + reduction += 2; + } else if (move_score < info->params.lmr_stat_low1) { + reduction++; + } else if (move_score > info->params.lmr_stat_high2) { + reduction -= 2; + } else if (move_score > info->params.lmr_stat_high1) { + reduction--; + } + + if (reduction < 1) reduction = 1; + if (depth - 1 - reduction < 1) reduction = depth - 2; + if (reduction < 0) reduction = 0; + return reduction; +} + void set_search_silent(bool silent) { search_silent_mode = silent; } @@ -1478,9 +1508,9 @@ static int negamax(Board* board, int depth, int alpha, int beta, SearchInfo* inf bool can_main_capture_see = info->params.use_main_capture_see && ply > 0 && !is_pv && !in_check && depth <= info->params.main_capture_see_max_depth && abs(alpha) < TB_SCORE_MIN && abs(beta) < TB_SCORE_MIN; - // Quiet SEE pruning is independent from capture SEE pruning and uses a - // quadratic depth margin: shallow moves that immediately hang material - // are discarded, while deeper nodes receive a rapidly widening allowance. + // Quiet SEE pruning is independent from capture SEE pruning. Its threshold + // uses the move's history-adjusted LMR depth, so well-ordered moves receive + // a wider allowance than late moves that are already heavily reduced. bool can_main_quiet_see = info->params.use_main_quiet_see && ply > 0 && !is_pv && !in_check && depth <= info->params.main_quiet_see_max_depth && abs(alpha) < TB_SCORE_MIN && abs(beta) < TB_SCORE_MIN; @@ -1714,6 +1744,7 @@ static int negamax(Board* board, int depth, int alpha, int beta, SearchInfo* inf bool is_capture = MOVE_IS_CAPTURE(m); bool is_promotion = MOVE_IS_PROMOTION(m); bool is_tactical = is_capture || is_promotion; + int reduction = 0; if (!is_tactical && quiets_tried_count < MAX_MOVES) { quiets_tried[quiets_tried_count++] = m; @@ -1752,11 +1783,19 @@ static int negamax(Board* board, int depth, int alpha, int beta, SearchInfo* inf continue; } + if (!is_tactical) { + reduction = quiet_lmr_reduction(info, depth, moves_searched, + is_pv, in_check, move_score); + } + int lmr_search_depth = depth - 1 - reduction; + // Main-search quiet SEE pruning. Preserve the TT move and the first - // legal candidate for the same correctness reasons as capture SEE. + // well-ordered candidates so a cut node does not lose its likely + // cutoff move before SEE is considered. if (can_main_quiet_see && !is_tactical && m != mp.tt_move && - moves_searched > 0 && - !see_ge(board, m, -info->params.main_quiet_see_margin * depth * depth)) { + moves_searched >= info->params.lmr_full_depth_moves && + !see_ge(board, m, -info->params.main_quiet_see_margin * + lmr_search_depth * lmr_search_depth)) { PRUNING_STAT_INC(main_quiet_see); continue; } @@ -1847,45 +1886,6 @@ static int negamax(Board* board, int depth, int alpha, int beta, SearchInfo* inf info, ply + 1, true, false, 0); } else { // Late Move Reductions - logarithmic formula like Stockfish - int reduction = 0; - - if (info->params.use_lmr && !in_check && !is_tactical && - depth >= info->params.lmr_reduction_limit && - moves_searched >= info->params.lmr_full_depth_moves) { - - // Lookup from precomputed table (Stockfish-style formula) - int lmr_depth = depth < MAX_PLY ? depth : MAX_PLY - 1; - int lmr_moves = moves_searched < 64 ? moves_searched : 63; - reduction = lmr_table[lmr_depth][lmr_moves]; - - // Increase reduction for non-PV nodes - if (!is_pv) { - reduction++; - } - - // Adjust based on the combined history score (butterfly + - // continuation history = this quiet's ordering score, computed - // before applyMove). Thresholds match the SF-scale update - // magnitudes in history_bonus()/history_malus(). - int stat = move_score; - if (stat < info->params.lmr_stat_low2) { - reduction += 2; - } else if (stat < info->params.lmr_stat_low1) { - reduction++; - } else if (stat > info->params.lmr_stat_high2) { - reduction -= 2; - } else if (stat > info->params.lmr_stat_high1) { - reduction--; - } - - // Clamp reduction: at least 1, don't reduce below depth 1 - if (reduction < 1) reduction = 1; - if (depth - 1 - reduction < 1) { - reduction = depth - 2; - } - if (reduction < 0) reduction = 0; - } - // Null window search with possible reduction if (reduction > 0) { PRUNING_STAT_INC(lmr); @@ -2364,7 +2364,8 @@ Move iterative_deepening_search(Board* board, SearchInfo* info) { (unsigned long long)pruning_stats.probcut_cuts, (unsigned long long)pruning_stats.probcut_nodes); printf("info string Main Cap SEE: %llu\n", (unsigned long long)pruning_stats.main_capture_see); - printf("info string Main Quiet SEE: %llu\n", (unsigned long long)pruning_stats.main_quiet_see); + printf("info string Main Quiet: %llu\n", + (unsigned long long)pruning_stats.main_quiet_see); printf("info string IIR: %llu\n", (unsigned long long)pruning_stats.iir); int cmin = 0, cmax = 0, nonzero = 0, saturated = 0; for (int s = 0; s < 2; s++) diff --git a/src/search.h b/src/search.h index 780adda..04f36b9 100644 --- a/src/search.h +++ b/src/search.h @@ -56,8 +56,8 @@ typedef struct { int main_capture_see_margin; // Negative threshold per depth (default: 100) int main_capture_see_max_depth; // Maximum depth (default: 16) - // Main-search quiet SEE pruning (quadratic depth threshold) - int main_quiet_see_margin; // Negative threshold per depth squared (default: 20) + // Main-search quiet SEE pruning (quadratic reduced-depth threshold) + int main_quiet_see_margin; // Negative threshold per LMR depth squared (default: 20) int main_quiet_see_max_depth; // Maximum depth (default: 8) // Internal Iterative Reduction diff --git a/src/uci.c b/src/uci.c index 790704b..668f411 100644 --- a/src/uci.c +++ b/src/uci.c @@ -314,13 +314,13 @@ void uci_loop() { printf("readyok\n"); fflush(stdout); } else if (strncmp(line, "seedump", 7) == 0) { - // Debug: print SEE for every capture/promotion in current position - MoveList caps; - generateCaptureAndPromotionMoves(¤t_board, &caps); - for (int i = 0; i < caps.count; i++) { + // Debug: print SEE for every generated move in current position. + MoveList moves; + generateMoves(¤t_board, &moves); + for (int i = 0; i < moves.count; i++) { char s[6]; - moveToString(caps.moves[i], s); - printf("see %s = %d\n", s, see_debug(¤t_board, caps.moves[i])); + moveToString(moves.moves[i], s); + printf("see %s = %d\n", s, see_debug(¤t_board, moves.moves[i])); } fflush(stdout); } else if (strncmp(line, "seege ", 6) == 0) { From 57554b2e7e8da7309be5bdf1ae4c20dac83ad372 Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Thu, 13 Aug 2026 18:43:14 +0200 Subject: [PATCH 3/3] search: restrict quiet SEE to bad-history moves --- src/search.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/search.c b/src/search.c index fd3dae3..76c1009 100644 --- a/src/search.c +++ b/src/search.c @@ -1789,11 +1789,12 @@ static int negamax(Board* board, int depth, int alpha, int beta, SearchInfo* inf } int lmr_search_depth = depth - 1 - reduction; - // Main-search quiet SEE pruning. Preserve the TT move and the first - // well-ordered candidates so a cut node does not lose its likely - // cutoff move before SEE is considered. + // Main-search quiet SEE pruning. Preserve the TT move, the first + // well-ordered candidates, and quiets with non-negative history so a + // cut node does not lose its likely cutoff move before SEE is considered. if (can_main_quiet_see && !is_tactical && m != mp.tt_move && moves_searched >= info->params.lmr_full_depth_moves && + move_score < 0 && !see_ge(board, m, -info->params.main_quiet_see_margin * lmr_search_depth * lmr_search_depth)) { PRUNING_STAT_INC(main_quiet_see);