Skip to content

Commit

Permalink
[RFC] Correct and extend PV lines with decisive TB score
Browse files Browse the repository at this point in the history
Currently (after official-stockfish#5407), SF has the property that any PV line with a decisive
TB score contains the corresponding TB position, with a score that correctly
identifies the depth at which TB are entered. The PV line that follows might
not preserve the game outcome, but can easily be verified and extended based on
TB information. This patch provides this functionality, simply extending the PV
lines on output, this doesn't affect search.

Indeed, if DTZ tables are available, search based PV lines that correspond to
decisive TB scores are verified to preserve game outcome, truncating the line
as needed. Subsequently, such PV lines are extended with a game outcome
preserving line until mate, as a possible continuation.  These lines are not
optimal mating lines, but are similar to what a user could produce on a website
like https://syzygy-tables.info/ clicking always the top ranked move, i.e.
minimizing or maximizing DTZ (with a simple tie-breaker for moves that have
identical DTZ), and are thus an just an illustration of how to game can be won.

A similar approach is already in established in
https://github.com/joergoster/Stockfish/tree/matefish2

This also contributes to addressing official-stockfish#5175 where SF can give an incorrect TB
win/loss for positions in TB with a movecounter that doesn't reflect optimal
play. While the full solution requires either TB generated differently, or a
search when ranking rootmoves, current SF will eventually find a draw in these
cases, in practice quite quickly, e.g.
`1kq5/q2r4/5K2/8/8/8/8/7Q w - - 96 1`
`8/8/6k1/3B4/3K4/4N3/8/8 w - - 54 106`

As repeated DTZ probing could be slow a procedure (100ms+ on HDD, a few ms on
SSD), the extension is only done as long as the time taken is less than half
the `Move Overhead` parameter. For tournaments where these lines might be of
interest to the user, a suitable `Move Overhead` might be needed (e.g. TCEC has
1000ms already).

No functional change
  • Loading branch information
vondele committed Jul 4, 2024
1 parent ee6fc7e commit 5f3001c
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 31 deletions.
177 changes: 161 additions & 16 deletions src/search.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

#include <algorithm>
#include <array>
#include <list>
#include <atomic>
#include <cassert>
#include <cmath>
Expand All @@ -28,6 +29,9 @@
#include <initializer_list>
#include <string>
#include <utility>
#include <chrono>
#include <iostream>
#include <ratio>

#include "evaluate.h"
#include "misc.h"
Expand All @@ -50,6 +54,12 @@ namespace Stockfish {

namespace TB = Tablebases;

void syzygy_extend_pv(const OptionsMap& options,
const Search::LimitsType& limits,
Stockfish::Position& pos,
Stockfish::Search::RootMove& rootMove,
Value& v);

using Eval::evaluate;
using namespace Search;

Expand Down Expand Up @@ -1957,18 +1967,145 @@ void SearchManager::check_time(Search::Worker& worker) {
worker.threads.stop = worker.threads.abortedSearch = true;
}

void SearchManager::pv(const Search::Worker& worker,
// Used to correct and extend PVs for moves that have a TB (but not a mate) score.
// Keeps the search based PV for as long as it is verified to maintain the game outcome, truncates afterwards.
// Finally, extends to mate the PV, providing a possible continuation (but not a proven mating line).
void syzygy_extend_pv(const OptionsMap& options,
const Search::LimitsType& limits,
Position& pos,
RootMove& rootMove,
Value& v) {

auto t_start = std::chrono::steady_clock::now();
int moveOverhead = int(options["Move Overhead"]);

// Do not use more than moveOverhead / 2 time, it time management is active.
auto time_abort = [&t_start, &moveOverhead, &limits]() -> bool {
auto t_end = std::chrono::steady_clock::now();
return limits.use_time_management()
&& 2 * std::chrono::duration<double, std::milli>(t_end - t_start).count()
> moveOverhead;
};

std::list<StateInfo> sts;

// Step 1, walk the PV to the last position in TB with correct decisive score
int ply = 0;
while (size_t(ply) < rootMove.pv.size())
{
Move& pvMove = rootMove.pv[ply];

RootMoves legalMoves;
for (const auto& m : MoveList<LEGAL>(pos))
legalMoves.emplace_back(m);

Tablebases::Config config = Tablebases::rank_root_moves(options, pos, legalMoves);
RootMove& rm = *std::find(legalMoves.begin(), legalMoves.end(), pvMove);

if (legalMoves[0].tbRank != rm.tbRank)
break;

ply++;

auto& st = sts.emplace_back();
pos.do_move(pvMove, st);

// don't allow for repetitions or drawing moves along the PV in TB regime.
if (config.rootInTB && pos.is_draw(ply))
{
pos.undo_move(pvMove);
ply--;
break;
}

// Full PV shown will thus be validated and end TB.
// If we can't validate the full PV in time, we don't show it.
if (config.rootInTB && time_abort())
break;
}

// resize the PV to the correct part
rootMove.pv.resize(ply);

// Step 2, now extend the PV to mate, as if the user explores syzygy-tables.info using
// top ranked moves (minimal DTZ), which gives optimal mates only for simple endgames e.g. KRvK
while (!pos.is_draw(0))
{
if (time_abort())
break;

RootMoves legalMoves;
for (const auto& m : MoveList<LEGAL>(pos))
{
auto& rm = legalMoves.emplace_back(m);
StateInfo tmpSI;
pos.do_move(m, tmpSI);
// Give a score of each move to break DTZ ties
// restricting opponent mobility, but not giving the opponent a capture.
for (const auto& mOpp : MoveList<LEGAL>(pos))
rm.tbRank -= pos.capture(mOpp) ? 100 : 1;
pos.undo_move(m);
}

// Mate found
if (legalMoves.size() == 0)
break;

// sort moves according to their above assigned rank,
// This will break ties for moves with equal DTZ in rank_root_moves.
std::stable_sort(
legalMoves.begin(), legalMoves.end(),
[](const Search::RootMove& a, const Search::RootMove& b) { return a.tbRank > b.tbRank; });

// The winning side tries to minimize DTZ, the losing side maximizes it.
Tablebases::Config config = Tablebases::rank_root_moves(options, pos, legalMoves, true);

// If DTZ is not available we might not find a mate, so we bail out.
if (!config.rootInTB || config.cardinality > 0)
break;

ply++;

Move& pvMove = legalMoves[0].pv[0];
rootMove.pv.push_back(pvMove);
auto& st = sts.emplace_back();
pos.do_move(pvMove, st);
}

// Finding a draw in this function is an exceptional case, that cannot happen during engine game play,
// since we have a winning score, and play correctly with TB support.
// However, it can be that a position is draw due to 50 move counter if it has been been reached
// on the board with a non-optimal 50 moves counter e.g. 8/8/6k1/3B4/3K4/4N3/8/8 w - - 54 106
// which TB with dtz counter rounding cannot always correctly rank. See also
// https://github.com/official-stockfish/Stockfish/issues/5175#issuecomment-2058893495
// We adjust the score to match the found PV. Note that a TB loss score can be displayed
// if the engine did not find a drawing move yet, but eventually search will figure it out.
// E.g. 1kq5/q2r4/5K2/8/8/8/8/7Q w - - 96 1
if (pos.is_draw(0))
v = VALUE_DRAW;

// Undo the PV moves.
for (auto it = rootMove.pv.rbegin(); it != rootMove.pv.rend(); ++it)
pos.undo_move(*it);

// Inform if we couldn't get a full extension in time.
if (time_abort())
sync_cout
<< "info string Syzygy based PV extension requires more time, increase Move Overhead as needed."
<< sync_endl;
}

void SearchManager::pv(Search::Worker& worker,
const ThreadPool& threads,
const TranspositionTable& tt,
Depth depth) const {
Depth depth) {

const auto nodes = threads.nodes_searched();
const auto& rootMoves = worker.rootMoves;
const auto& pos = worker.rootPos;
size_t pvIdx = worker.pvIdx;
TimePoint time = tm.elapsed_time() + 1;
size_t multiPV = std::min(size_t(worker.options["MultiPV"]), rootMoves.size());
uint64_t tbHits = threads.tb_hits() + (worker.tbConfig.rootInTB ? rootMoves.size() : 0);
const auto nodes = threads.nodes_searched();
auto& rootMoves = worker.rootMoves;
auto& pos = worker.rootPos;
size_t pvIdx = worker.pvIdx;
size_t multiPV = std::min(size_t(worker.options["MultiPV"]), rootMoves.size());
uint64_t tbHits = threads.tb_hits() + (worker.tbConfig.rootInTB ? rootMoves.size() : 0);

for (size_t i = 0; i < multiPV; ++i)
{
Expand All @@ -1986,6 +2123,13 @@ void SearchManager::pv(const Search::Worker& worker,
bool tb = worker.tbConfig.rootInTB && std::abs(v) <= VALUE_TB;
v = tb ? rootMoves[i].tbScore : v;

bool isExact = i != pvIdx || tb || !updated; // tablebase- and previous-scores are exact

// Potentially correct and extend the PV, and in exceptional cases v
if (std::abs(v) >= VALUE_TB_WIN_IN_MAX_PLY && std::abs(v) < VALUE_MATE_IN_MAX_PLY
&& ((!rootMoves[i].scoreLowerbound && !rootMoves[i].scoreUpperbound) || isExact))
syzygy_extend_pv(worker.options, worker.limits, pos, rootMoves[i], v);

std::string pv;
for (Move m : rootMoves[i].pv)
pv += UCIEngine::move(m, pos.is_chess960()) + " ";
Expand All @@ -2007,15 +2151,16 @@ void SearchManager::pv(const Search::Worker& worker,
info.score = {v, pos};
info.wdl = wdl;

if (i == pvIdx && !tb && updated) // tablebase- and previous-scores are exact
if (!isExact)
info.bound = bound;

info.timeMs = time;
info.nodes = nodes;
info.nps = nodes * 1000 / time;
info.tbHits = tbHits;
info.pv = pv;
info.hashfull = tt.hashfull();
TimePoint time = tm.elapsed_time() + 1;
info.timeMs = time;
info.nodes = nodes;
info.nps = nodes * 1000 / time;
info.tbHits = tbHits;
info.pv = pv;
info.hashfull = tt.hashfull();

updates.onUpdateFull(info);
}
Expand Down
4 changes: 2 additions & 2 deletions src/search.h
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,10 @@ class SearchManager: public ISearchManager {

void check_time(Search::Worker& worker) override;

void pv(const Search::Worker& worker,
void pv(Search::Worker& worker,
const ThreadPool& threads,
const TranspositionTable& tt,
Depth depth) const;
Depth depth);

Stockfish::TimeManagement tm;
double originalTimeAdjust;
Expand Down
29 changes: 18 additions & 11 deletions src/syzygy/tbprobe.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ namespace {

constexpr int TBPIECES = 7; // Max number of supported pieces
constexpr int MAX_DTZ =
1 << 18; // Max DTZ supported, large enough to deal with the syzygy TB limit.
1 << 18; // Max DTZ supported times 2, large enough to deal with the syzygy TB limit.

enum {
BigEndian,
Expand Down Expand Up @@ -1574,7 +1574,10 @@ int Tablebases::probe_dtz(Position& pos, ProbeState* result) {
// Use the DTZ tables to rank root moves.
//
// A return value false indicates that not all probes were successful.
bool Tablebases::root_probe(Position& pos, Search::RootMoves& rootMoves, bool rule50) {
bool Tablebases::root_probe(Position& pos,
Search::RootMoves& rootMoves,
bool rule50,
bool rankDTZ) {

ProbeState result = OK;
StateInfo st;
Expand All @@ -1585,7 +1588,7 @@ bool Tablebases::root_probe(Position& pos, Search::RootMoves& rootMoves, bool ru
// Check whether a position was repeated since the last zeroing move.
bool rep = pos.has_repeated();

int dtz, bound = rule50 ? (MAX_DTZ - 100) : 1;
int dtz, bound = rule50 ? (MAX_DTZ / 2 - 100) : 1;

// Probe and rank each move
for (auto& m : rootMoves)
Expand Down Expand Up @@ -1624,19 +1627,22 @@ bool Tablebases::root_probe(Position& pos, Search::RootMoves& rootMoves, bool ru

// Better moves are ranked higher. Certain wins are ranked equally.
// Losing moves are ranked equally unless a 50-move draw is in sight.
int r = dtz > 0 ? (dtz + cnt50 <= 99 && !rep ? MAX_DTZ : MAX_DTZ - (dtz + cnt50))
: dtz < 0 ? (-dtz * 2 + cnt50 < 100 ? -MAX_DTZ : -MAX_DTZ + (-dtz + cnt50))
int r = dtz > 0 ? (dtz + cnt50 <= 99 && !rep ? MAX_DTZ - (rankDTZ ? dtz : 0)
: MAX_DTZ / 2 - (dtz + cnt50))
: dtz < 0 ? (-dtz * 2 + cnt50 < 100 ? -MAX_DTZ - (rankDTZ ? dtz : 0)
: -MAX_DTZ / 2 + (-dtz + cnt50))
: 0;
m.tbRank = r;

// Determine the score to be displayed for this move. Assign at least
// 1 cp to cursed wins and let it grow to 49 cp as the positions gets
// closer to a real win.
m.tbScore = r >= bound ? VALUE_MATE - MAX_PLY - 1
: r > 0 ? Value((std::max(3, r - (MAX_DTZ - 200)) * int(PawnValue)) / 200)
: r == 0 ? VALUE_DRAW
: r > -bound ? Value((std::min(-3, r + (MAX_DTZ - 200)) * int(PawnValue)) / 200)
: -VALUE_MATE + MAX_PLY + 1;
: r > 0 ? Value((std::max(3, r - (MAX_DTZ / 2 - 200)) * int(PawnValue)) / 200)
: r == 0 ? VALUE_DRAW
: r > -bound
? Value((std::min(-3, r + (MAX_DTZ / 2 - 200)) * int(PawnValue)) / 200)
: -VALUE_MATE + MAX_PLY + 1;
}

return true;
Expand Down Expand Up @@ -1683,7 +1689,8 @@ bool Tablebases::root_probe_wdl(Position& pos, Search::RootMoves& rootMoves, boo

Config Tablebases::rank_root_moves(const OptionsMap& options,
Position& pos,
Search::RootMoves& rootMoves) {
Search::RootMoves& rootMoves,
bool rankDTZ) {
Config config;

if (rootMoves.empty())
Expand All @@ -1707,7 +1714,7 @@ Config Tablebases::rank_root_moves(const OptionsMap& options,
if (config.cardinality >= popcount(pos.pieces()) && !pos.can_castle(ANY_CASTLING))
{
// Rank moves using DTZ tables
config.rootInTB = root_probe(pos, rootMoves, options["Syzygy50MoveRule"]);
config.rootInTB = root_probe(pos, rootMoves, options["Syzygy50MoveRule"], rankDTZ);

if (!config.rootInTB)
{
Expand Down
7 changes: 5 additions & 2 deletions src/syzygy/tbprobe.h
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,12 @@ extern int MaxCardinality;
void init(const std::string& paths);
WDLScore probe_wdl(Position& pos, ProbeState* result);
int probe_dtz(Position& pos, ProbeState* result);
bool root_probe(Position& pos, Search::RootMoves& rootMoves, bool rule50);
bool root_probe(Position& pos, Search::RootMoves& rootMoves, bool rule50, bool rankDTZ);
bool root_probe_wdl(Position& pos, Search::RootMoves& rootMoves, bool rule50);
Config rank_root_moves(const OptionsMap& options, Position& pos, Search::RootMoves& rootMoves);
Config rank_root_moves(const OptionsMap& options,
Position& pos,
Search::RootMoves& rootMoves,
bool rankDTZ = false);

} // namespace Stockfish::Tablebases

Expand Down

0 comments on commit 5f3001c

Please sign in to comment.