diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 350d8f2..fa31b5d 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -212,7 +212,7 @@ public class ChessController( public ActionResult GetLearnedWeights() { var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" }; - var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" }; + var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Pawn links", "King safety" }; var snapshot = weightsStore.Snapshot(); diff --git a/JoshHeaps.Net/Resources/chess_engine.dll b/JoshHeaps.Net/Resources/chess_engine.dll index 992d57e..9db5240 100644 Binary files a/JoshHeaps.Net/Resources/chess_engine.dll and b/JoshHeaps.Net/Resources/chess_engine.dll differ diff --git a/JoshHeaps.Net/Services/Implementations/LearnedWeightsStore.cs b/JoshHeaps.Net/Services/Implementations/LearnedWeightsStore.cs index 1a1f72b..e2d3563 100644 --- a/JoshHeaps.Net/Services/Implementations/LearnedWeightsStore.cs +++ b/JoshHeaps.Net/Services/Implementations/LearnedWeightsStore.cs @@ -13,7 +13,7 @@ public sealed class LearnedWeightsStore : ILearnedWeightsStore { private const int Pieces = 6; // Pawn..King private const int Squares = 64; - private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety + private const int Features = 7; // mobility N/B/R/Q, passed, pawn links, king safety public string WeightsFilePath { get; } diff --git a/native/chess_engine/CMakeLists.txt b/native/chess_engine/CMakeLists.txt index af664b1..760f226 100644 --- a/native/chess_engine/CMakeLists.txt +++ b/native/chess_engine/CMakeLists.txt @@ -8,6 +8,9 @@ set(CMAKE_CXX_EXTENSIONS OFF) # Shared library: chess_engine.dll (Windows) / libchess_engine.so (Linux). add_library(chess_engine SHARED src/chess_engine.cpp + src/eval.cpp + src/search.cpp + src/learned_model.cpp src/bitboard.cpp src/zobrist.cpp src/position.cpp diff --git a/native/chess_engine/chess_engine/chess_engine.vcxproj b/native/chess_engine/chess_engine/chess_engine.vcxproj index 5f99245..8b7b467 100644 --- a/native/chess_engine/chess_engine/chess_engine.vcxproj +++ b/native/chess_engine/chess_engine/chess_engine.vcxproj @@ -152,6 +152,9 @@ + + + @@ -161,6 +164,9 @@ + + + diff --git a/native/chess_engine/src/chess_engine.cpp b/native/chess_engine/src/chess_engine.cpp index c32a252..9932f0e 100644 --- a/native/chess_engine/src/chess_engine.cpp +++ b/native/chess_engine/src/chess_engine.cpp @@ -1,15 +1,15 @@ /* chess_engine.cpp - the DLL boundary (extern "C" ABI). * - * The rules layer (board, move generation, make/unmake, hashing, perft) lives in - * the other src/*.cpp files and is ready to use. engine_best_move is intentionally - * left for YOU: that is where your search/evaluation goes. Everything below the - * FEN-in / UCI-out boundary should stay native — the managed side crosses it once - * per move. + * This file is intentionally thin: it owns only the C ABI surface and the FEN/UCI string + * marshalling at the managed boundary. The real work lives in the modules it delegates to: + * - eval.{h,cpp} : classic + learned evaluation, feature computation + * - search.{h,cpp} : transposition table, move ordering, negamax + iterative deepening + * - learned_model.{h,cpp} : global learned weights (state/persistence) and the trainer + * The managed side crosses this boundary once per move; everything below it stays native. */ #ifndef CHESS_ENGINE_BUILD #define CHESS_ENGINE_BUILD /* fallback when not building via CMake (which defines it) */ #endif -#pragma once #include "chess_engine.h" #include "bitboard.h" @@ -17,126 +17,24 @@ #include "position.h" #include "movegen.h" #include "uci.h" +#include "eval.h" +#include "search.h" +#include "learned_model.h" -#include -#include -#include -#include -#include #include #include -#include -#include +#include #include #include -#include #include - -/* Search score constants. Scores are side-to-move-relative (negamax): positive is - * good for whoever is to move. MATE_BOUND is the threshold above which a score is a - * "mate in N" rather than a positional eval; INF is the window sentinel (kept above - * MATE so negating it can never hit signed-overflow UB the way INT_MIN would). */ -static constexpr int MATE = 200000; -static constexpr int MATE_BOUND = MATE - 1000; -static constexpr int INF = 1000000; - -/* Bound kind stored in a TT entry. LOWER = a fail-high (true score >= stored), - * UPPER = a fail-low (true score <= stored), EXACT = fully resolved. */ -enum class Bound : uint8_t { NONE, EXACT, LOWER, UPPER }; - -/* One shared, process-wide transposition table backs every game (every engine - * handle), so analysis persists and is reused across games. It is lock-free: each - * slot is two 64-bit words — `data` (the packed payload) and `xorKey` (the Zobrist - * key XOR-ed with `data`). A reader recovers the key as `xorKey ^ data`; if two - * concurrent searches tore the pair, the recovered key won't match and the read is - * treated as a miss — never a wrong-but-trusted entry (Hyatt's lockless hashing). */ -struct TTEntry { - std::atomic xorKey{0}; - std::atomic data{0}; -}; - -struct TranspositionTable { - std::unique_ptr entries; - size_t mask = 0; /* count - 1; count is a power of two */ -}; - -static TranspositionTable g_tt; -static constexpr size_t TT_MEGABYTES = 256; - -/* Pack/unpack the 64-bit payload: score(32) | move(16) | depth(8) | bound(8). A stored - * entry always has depth >= 1 and a non-NONE bound, so a real entry never packs to 0 — - * letting data == 0 mean "empty slot". */ -static uint64_t tt_pack(int score, chess::Move move, int depth, Bound bound) { - return static_cast(static_cast(score)) - | (static_cast(move.data) << 32) - | (static_cast(static_cast(depth)) << 48) - | (static_cast(static_cast(bound)) << 56); -} -static int tt_score(uint64_t d) { return static_cast(static_cast(d & 0xFFFFFFFFu)); } -static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast(d >> 32)); } -static int tt_depth(uint64_t d) { return static_cast(static_cast(d >> 48)); } -static Bound tt_bound(uint64_t d) { return static_cast(static_cast(d >> 56)); } - -/* Eval variant for an engine handle. CLASSIC = the hand-crafted evaluate(); LEARNED = - * material + learned phase-split piece-square tables + learned feature weights. */ -enum EvalVariant : int { EVAL_CLASSIC = 0, EVAL_LEARNED = 1 }; - -/* The learned feature knobs (beyond the piece-square tables). Each has one weight learned - * from game outcomes; its activation is computed by compute_features(). Mobility is per - * piece type. Order is fixed — it is the on-disk and snapshot layout after the two tables. */ -enum Feature : int { - FEAT_MOB_N, FEAT_MOB_B, FEAT_MOB_R, FEAT_MOB_Q, /* legal-move counts, per piece type */ - FEAT_PASSED, /* passed pawns, endgame-weighted */ - FEAT_ISOLATED, /* isolated pawns */ - FEAT_DOUBLED, /* doubled pawns */ - FEAT_KING, /* king pawn-shelter, midgame-weighted */ - FEATURE_NB -}; - -/* Per-handle eval configuration, snapshotted from the global learned weights at - * engine_create so the search reads a stable copy. The tables are white-relative: a black - * piece indexes the rank-mirrored square (sq ^ 56). `mg`/`eg` are blended by game phase. - * Indexed by chess::PieceType (PAWN..KING). Only consulted when variant == EVAL_LEARNED. */ -struct EvalParams { - int variant = EVAL_CLASSIC; - int mg[chess::PIECE_TYPE_NB][64] = {}; - int eg[chess::PIECE_TYPE_NB][64] = {}; - int featW[FEATURE_NB] = {}; -}; - -/* Internal engine state. One ChessEngine = one game. The transposition table is NOT - * here: it is the shared g_tt above. */ +/* Internal engine state. One ChessEngine = one game. The transposition table is NOT here: + * it is the shared table owned by search.cpp. */ struct ChessEngine { int skill = 20; /* 1..20 from the UI; controls search depth */ EvalParams eval; /* which evaluation the search uses, plus any learned weights */ }; -/* The process-global learned weights: the single source of truth, loaded from disk once and - * updated in place by training. Engine handles snapshot it at creation; the visualization - * snapshots it on demand. Guarded by g_weightsMutex for updates/saves (eval reads its own - * per-handle copy, so it never touches this concurrently). */ -struct LearnedWeights { - int mg[chess::PIECE_TYPE_NB][64] = {}; - int eg[chess::PIECE_TYPE_NB][64] = {}; - int featW[FEATURE_NB] = {}; -}; - -static LearnedWeights g_weights; -static std::mutex g_weightsMutex; -static std::string g_weightsPath; - -/* Per-game training accumulator (one per learned CPU-vs-CPU game). Records, per ply, where - * each side's pieces sat (split into midgame/endgame by phase) and each side's feature - * activations; trainer_apply turns the totals into weight nudges. Squares are white-relative - * (black indexes sq ^ 56), so a side's tally lines up with the shared white-relative table. */ -struct Trainer { - double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {}; - double egOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {}; - double featAcc[chess::COLOR_NB][FEATURE_NB] = {}; - int plies = 0; -}; - static int copy_out(const char* src, char* out_buf, int out_len) { if (!out_buf || out_len <= 0) return CHESS_ERR_BUFFER; const size_t need = std::strlen(src) + 1; /* + NUL */ @@ -171,425 +69,16 @@ static int parse_variant(const char* options) { return std::strncmp(p + 8, "learned", 7) == 0 ? EVAL_LEARNED : EVAL_CLASSIC; } -/* On-disk format: 6*64 mg ints (PAWN..KING, squares 0..63), then 6*64 eg ints, then - * FEATURE_NB feature ints, whitespace-separated. A missing file or short read leaves the - * rest neutral (0), so an absent weights file just means "train from a blank slate". - * Caller holds g_weightsMutex. */ -static void load_global_weights(const char* path) { - g_weights = LearnedWeights{}; /* reset to neutral before loading */ - - if (!path || !*path) return; - std::ifstream f(path); - if (!f) return; - - for (int pt = chess::PAWN; pt <= chess::KING; ++pt) - for (int sq = 0; sq < 64; ++sq) - if (!(f >> g_weights.mg[pt][sq])) return; - for (int pt = chess::PAWN; pt <= chess::KING; ++pt) - for (int sq = 0; sq < 64; ++sq) - if (!(f >> g_weights.eg[pt][sq])) return; - for (int i = 0; i < FEATURE_NB; ++i) - if (!(f >> g_weights.featW[i])) return; -} - -/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Caller holds the lock. */ -static void save_global_weights() { - if (g_weightsPath.empty()) return; - std::ofstream f(g_weightsPath); - if (!f) return; - - for (int pt = chess::PAWN; pt <= chess::KING; ++pt) - for (int sq = 0; sq < 64; ++sq) f << g_weights.mg[pt][sq] << (sq == 63 ? '\n' : ' '); - for (int pt = chess::PAWN; pt <= chess::KING; ++pt) - for (int sq = 0; sq < 64; ++sq) f << g_weights.eg[pt][sq] << (sq == 63 ? '\n' : ' '); - for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featW[i] << (i == FEATURE_NB - 1 ? '\n' : ' '); -} - -/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no - * quiescence yet, so deep fixed-depth runs get expensive quickly. */ -static int depth_for_skill(int skill) { - return skill; /* skill N -> N plies */ -} - -static size_t floor_pow2(size_t n) { - size_t p = 1; - while ((p << 1) != 0 && (p << 1) <= n) p <<= 1; - return p; -} - -/* Allocate the shared table exactly once, to the largest power-of-two entry count that - * fits in TT_MEGABYTES. Power-of-two count lets indexing use `key & mask`. Thread-safe: - * call_once guards the first concurrent engine_create. Entries start zeroed (empty). */ -static void ensure_tt() { - static std::once_flag once; - std::call_once(once, [] { - size_t count = floor_pow2((TT_MEGABYTES << 20) / sizeof(TTEntry)); - if (count < 1) count = 1; - g_tt.entries = std::make_unique(count); - g_tt.mask = count - 1; - }); -} - -/* Positional multiplier in [0.5, 2.0] based on a square's distance from the four - * center squares (d4/e4/d5/e5): 2.0 dead center, 0.5 in a corner, scaling linearly. - * Multiply a piece's base value by this to reward central placement. */ -static double center_multiplier(chess::Square s) { - /* |2*coord - 7| is the distance from center in half-squares: 1 (center) .. 7 (edge). */ - int fileDist = std::abs(2 * int(chess::file_of(s)) - 7); - int rankDist = std::abs(2 * int(chess::rank_of(s)) - 7); - int dist = fileDist > rankDist ? fileDist : rankDist; /* Chebyshev distance, 1 .. 7 */ - - return dist * 20; /* 1 -> 2.0, 7 -> 0.5 */ -} - -static int piece_mobility(const chess::Position& pos, chess::Square s, chess::Piece pc, chess::Color c) { - chess::Bitboard occ = pos.pieces(); - chess::Bitboard targets; - - switch (chess::type_of(pc)) { - case chess::KNIGHT: targets = chess::KnightAttacks[s]; break; - case chess::BISHOP: targets = chess::bishop_attacks(s, occ); break; - case chess::ROOK: targets = chess::rook_attacks(s, occ); break; - case chess::QUEEN: targets = chess::queen_attacks(s, occ); break; - case chess::KING: targets = chess::KingAttacks[s]; break; - default: return 0; // pawns: mobility usually handled via push/attack separately - } - - return chess::popcount(targets & ~pos.pieces(c)); // exclude squares blocked by own pieces -} - -static chess::Bitboard front_span(chess::Color c, chess::Square s) { - chess::File f = file_of(s); - chess::Bitboard files = file_bb(f); - if (f > chess::FILE_A) files |= chess::file_bb(chess::File(f - 1)); - if (f < chess::FILE_H) files |= chess::file_bb(chess::File(f + 1)); - - // Pawns never sit on rank 1 or 8, so rank is 1..6 and these shifts - // are always in [8,56] — no shift-by-64 UB to guard against. - chess::Rank r = rank_of(s); - chess::Bitboard ahead = (c == chess::WHITE) ? (~0ULL << (8 * (r + 1))) // ranks > r - : ((1ULL << (8 * r)) - 1); // ranks < r - return files & ahead; -} - -static chess::Bitboard front_span_file_only(chess::Color c, chess::Square s) { - chess::File f = file_of(s); - chess::Bitboard files = file_bb(f); - - // Pawns never sit on rank 1 or 8, so rank is 1..6 and these shifts - // are always in [8,56] — no shift-by-64 UB to guard against. - chess::Rank r = rank_of(s); - chess::Bitboard ahead = (c == chess::WHITE) ? (~0ULL << (8 * (r + 1))) // ranks > r - : ((1ULL << (8 * r)) - 1); // ranks < r - return files & ahead; -} - -static int evaluatePawn(const chess::Position& pos, const chess::Color c, const chess::Square s) { - chess::Bitboard span = front_span(c, s); - chess::Bitboard file_span = front_span_file_only(c, s); - chess::Rank r = rank_of(s); - int squaresToPromotion = (c == chess::WHITE) ? (chess::RANK_8 - r) : (r - chess::RANK_1);; - bool isPassed = !(span & pos.pieces(~c, chess::PAWN)); - bool isBlocked = (file_span & pos.pieces(c, chess::PAWN)) | (file_span & pos.pieces(~c, chess::PAWN)); - bool isDoubled = (file_span & pos.pieces(c, chess::PAWN)); - - int score = 100; - - if (isPassed && !isBlocked) - score += (6 - squaresToPromotion) * 100; // Bonus for passed pawns, more as they get closer to promotion - if (isDoubled) - score -= 20; // Penalty for doubled pawns - if (isBlocked) - score -= 20; // Penalty for blocked pawns - - return score; -} - -static int piece_value(chess::PieceType pt) { - switch (pt) { - case chess::PAWN: return 100; - case chess::KNIGHT: return 320; - case chess::BISHOP: return 330; - case chess::ROOK: return 500; - case chess::QUEEN: return 900; - default: return 0; - } -} - -static int castleIncentive(const chess::Position& pos, chess::Color c) { - chess::Bitboard pcs = pos.pieces(); - int total = 0; - while (pcs) { - chess::Square s = chess::pop_lsb(pcs); - chess::Piece pc = pos.piece_on(s); - chess::Color c = chess::color_of(pc); - total += piece_value(chess::type_of(pc)); - } - - chess::Square k = pos.king_square(c); - bool castled = (c == chess::WHITE) ? (k == chess::G1 || k == chess::C1) - : (k == chess::G8 || k == chess::C8); - - return castled ? (total / 10) : 0; -} - -static int evaluatePiece(const chess::Position& pos, const chess::Square& s, const chess::Piece& pc, const chess::Color& c) { - int score = 0; - switch (chess::type_of(pc)) { - case chess::PAWN: score = evaluatePawn(pos, c, s); break; - case chess::KNIGHT: score = 320; break; - case chess::BISHOP: score = 330; break; - case chess::ROOK: score = 500; break; - case chess::QUEEN: score = 900; break; - case chess::KING: score = castleIncentive(pos, c); break; - default: return 0; - } - - score += center_multiplier(s); - - if (pc != chess::B_PAWN && pc != chess::W_PAWN) - score += piece_mobility(pos, s, pc, c) * 25; - - return score; -} - -static int evaluate(const chess::Position& pos) { - int score = 0; - chess::Bitboard white = pos.pieces(chess::WHITE); - - while (white) { - chess::Square s = chess::pop_lsb(white); - chess::Piece pc = pos.piece_on(s); - chess::Color c = chess::color_of(pc); - score += evaluatePiece(pos, s, pc, c); - } - - chess::Bitboard black = pos.pieces(chess::BLACK); - - while (black) { - chess::Square s = chess::pop_lsb(black); - chess::Piece pc = pos.piece_on(s); - chess::Color c = chess::color_of(pc); - score -= evaluatePiece(pos, s, pc, c); - } - - return score; -} - -/* ---- Learned (phase-split tables + feature knobs) evaluation --------------------------- - * The model is a linear combination of features whose weights are learned from outcomes: - * eval = Σ pieces [ material + blend(mg, eg, phase) ] + Σ features featW[i]·activation[i] - * compute_features() is the single source of feature activations, used by BOTH the eval here - * and the trainer, so the two can never disagree. Constants below are the only tunables. */ - -/* Per-game-outcome learning rates and clamps. Squares accumulate occupancy (plies on a - * square, summed); features accumulate normalized per-ply activation (averaged, divided by a - * nominal scale so high-magnitude mobility doesn't dwarf the small pawn-structure terms). */ -static constexpr double SQUARE_LR = 0.5; -static constexpr int SQ_CLAMP = 250; -static constexpr double FEAT_LR = 2.0; -static constexpr int FEAT_CLAMP = 500; -static constexpr double FEAT_SCALE[FEATURE_NB] = { 4, 6, 8, 14, 2, 1, 1, 2 }; - -/* Game phase in [0,1] from remaining non-pawn material (PeSTO weights N=B=1, R=2, Q=4; max - * 24 for both full sides): 0 = opening, 1 = bare kings. Drives the mg/eg table blend and - * the phase weighting of the passed-pawn (×phase) and king-safety (×(1−phase)) features. */ -static double game_phase(const chess::Position& pos) { - int npm = chess::popcount(pos.pieces(chess::KNIGHT)) * 1 - + chess::popcount(pos.pieces(chess::BISHOP)) * 1 - + chess::popcount(pos.pieces(chess::ROOK)) * 2 - + chess::popcount(pos.pieces(chess::QUEEN)) * 4; - constexpr int MAX = 24; - if (npm >= MAX) return 0.0; - return double(MAX - npm) / MAX; -} - -/* Blend a midgame and endgame value by phase, rounding per-piece (so training credits a - * square the same way the eval reads it). */ -static int blend(int mg, int eg, double phase) { - return int(std::lround((1.0 - phase) * mg + phase * eg)); -} - -/* Fills `out[FEATURE_NB]` with one color's raw feature activations for a position. The piece- - * square tables handle "where pieces belong"; these capture context a static table can't: - * legal mobility (per piece type, so pins reduce it), passed pawns (endgame-weighted), pawn - * structure, and king shelter (midgame-weighted). Ported nowhere — this is the only copy. */ -static void compute_features(chess::Position& pos, chess::Color c, double phase, double out[FEATURE_NB]) { - for (int i = 0; i < FEATURE_NB; ++i) out[i] = 0.0; - - /* Mobility: legal moves for color c, bucketed by the moving piece's type. */ - chess::MoveList moves; - pos.generate_legal_for(c, moves); - for (int i = 0; i < moves.size(); ++i) { - switch (chess::type_of(pos.piece_on(moves.moves[i].from()))) { - case chess::KNIGHT: out[FEAT_MOB_N] += 1; break; - case chess::BISHOP: out[FEAT_MOB_B] += 1; break; - case chess::ROOK: out[FEAT_MOB_R] += 1; break; - case chess::QUEEN: out[FEAT_MOB_Q] += 1; break; - default: break; - } - } - - /* Pawn structure. */ - chess::Bitboard pawns = pos.pieces(c, chess::PAWN); - chess::Bitboard bb = pawns; - while (bb) { - chess::Square s = chess::pop_lsb(bb); - - if (!(front_span(c, s) & pos.pieces(~c, chess::PAWN))) { /* passed */ - chess::Rank r = chess::rank_of(s); - int toPromotion = (c == chess::WHITE) ? (chess::RANK_8 - r) : (r - chess::RANK_1); - out[FEAT_PASSED] += (6 - toPromotion) * phase; /* 0..5 ranks advanced, late-game */ - } - if (front_span_file_only(c, s) & pawns) /* doubled (friendly pawn ahead) */ - out[FEAT_DOUBLED] += 1; - - chess::File f = chess::file_of(s); - chess::Bitboard adjacent = 0; - if (f > chess::FILE_A) adjacent |= chess::file_bb(chess::File(f - 1)); - if (f < chess::FILE_H) adjacent |= chess::file_bb(chess::File(f + 1)); - if (!(adjacent & pawns)) /* isolated */ - out[FEAT_ISOLATED] += 1; - } - - /* King safety: friendly pawns sheltering the king (its file + adjacent files, the two - * ranks in front), worth more in the midgame. */ - chess::Square k = pos.king_square(c); - chess::File kf = chess::file_of(k); - chess::Rank kr = chess::rank_of(k); - chess::Bitboard kingFiles = chess::file_bb(kf); - if (kf > chess::FILE_A) kingFiles |= chess::file_bb(chess::File(kf - 1)); - if (kf < chess::FILE_H) kingFiles |= chess::file_bb(chess::File(kf + 1)); - chess::Bitboard shelterRanks = 0; - for (int d = 1; d <= 2; ++d) { - int rr = (c == chess::WHITE) ? (kr + d) : (kr - d); - if (rr >= 0 && rr <= 7) shelterRanks |= (0xFFULL << (8 * rr)); - } - out[FEAT_KING] += chess::popcount(kingFiles & shelterRanks & pawns) * (1.0 - phase); -} - -/* Learned eval (white-positive/absolute, like evaluate()): material + phase-blended piece- - * square tables + learned feature weights. Black pieces index the rank-mirrored square - * (s ^ 56) so both colors share one white-relative table. Non-const because mobility - * generates legal moves (which the position's move generator does via do/undo). */ -static int evaluateLearned(chess::Position& pos, const EvalParams& ep) { - double phase = game_phase(pos); - int score = 0; - - chess::Bitboard white = pos.pieces(chess::WHITE); - while (white) { - chess::Square s = chess::pop_lsb(white); - chess::PieceType pt = chess::type_of(pos.piece_on(s)); - score += piece_value(pt) + blend(ep.mg[pt][s], ep.eg[pt][s], phase); - } - - chess::Bitboard black = pos.pieces(chess::BLACK); - while (black) { - chess::Square s = chess::pop_lsb(black); - chess::PieceType pt = chess::type_of(pos.piece_on(s)); - score -= piece_value(pt) + blend(ep.mg[pt][s ^ 56], ep.eg[pt][s ^ 56], phase); - } - - double wFeat[FEATURE_NB], bFeat[FEATURE_NB]; - compute_features(pos, chess::WHITE, phase, wFeat); - compute_features(pos, chess::BLACK, phase, bFeat); - - double feature = 0.0; - for (int i = 0; i < FEATURE_NB; ++i) - feature += ep.featW[i] * (wFeat[i] - bFeat[i]) / FEAT_SCALE[i]; - score += int(std::lround(feature)); - - return score; -} - -/* evaluate() is white-positive (absolute). Negamax needs it relative to the side to - * move, so flip the sign when black is to move. */ -static int evaluate_stm(chess::Position& pos, bool whiteToMove, const EvalParams& ep) { - int s = (ep.variant == EVAL_LEARNED) ? evaluateLearned(pos, ep) : evaluate(pos); - return whiteToMove ? s : -s; -} - -/* Mate scores are "mate in N from THIS node", so they must be re-anchored to the - * probing node's ply when crossing the TT (store adds ply, retrieve subtracts it). - * Non-mate scores pass through untouched. */ -static int score_to_tt(int s, int ply) { return s >= MATE_BOUND ? s + ply : s <= -MATE_BOUND ? s - ply : s; } -static int score_from_tt(int s, int ply) { return s >= MATE_BOUND ? s - ply : s <= -MATE_BOUND ? s + ply : s; } - -/* Heuristic for searching the most promising moves first, which makes alpha-beta prune far - * more. Bands, highest first: the TT best move, then captures by MVV-LVA (most valuable - * victim, least valuable attacker), then the two killer moves for this ply (quiet moves that - * cut a sibling), then the remaining quiet moves. `killers` points at this ply's two-entry - * slot; `scoreChecks` gates the expensive gives_check term to near-leaf nodes. */ -static int order_score(chess::Position& pos, chess::Move m, chess::Move ttMove, - const chess::Move* killers, bool scoreChecks) { - if (m == ttMove) - return 2000000; /* dwarfs any capture/killer/check score below */ - - int score = 0; - - if (scoreChecks && pos.gives_check(m)) - score += 1000; - - chess::Piece victim = pos.piece_on(m.to()); -#ifdef BENCH_DISABLE_KILLERS - /* Benchmark A/B only (defined by bench.ps1): the pre-killer ordering — captures by - * MVV-LVA above quiet moves, no killer band — so the script can time the killer speedup. */ - (void)killers; - if (victim != chess::NO_PIECE) - score += 100 + 10 * piece_value(chess::type_of(victim)) - - piece_value(chess::type_of(pos.piece_on(m.from()))); - else if (m.type() == chess::EN_PASSANT) - score += 100 + 10 * piece_value(chess::PAWN); -#else - if (victim != chess::NO_PIECE) - score += 100000 + 10 * piece_value(chess::type_of(victim)) - - piece_value(chess::type_of(pos.piece_on(m.from()))); - else if (m.type() == chess::EN_PASSANT) - score += 100000 + 10 * piece_value(chess::PAWN); - else if (m == killers[0]) - score += 90000; /* quiet move that beta-cut a sibling at this ply */ - else if (m == killers[1]) - score += 80000; -#endif - - return score; -} - -/* Sort the move list in place, best-scoring first. Scores are computed once up - * front so gives_check isn't re-evaluated on every comparison. ttMove may be - * MOVE_NONE, in which case no move matches it and ordering falls back to captures. */ -static void order_moves(chess::Position& pos, chess::MoveList& moves, chess::Move ttMove, - const chess::Move* killers, bool scoreChecks) { - struct ScoredMove { int score; chess::Move move; }; - ScoredMove scored[256]; - - for (int i = 0; i < moves.size(); i++) - scored[i] = { order_score(pos, moves.moves[i], ttMove, killers, scoreChecks), moves.moves[i] }; - - std::sort(scored, scored + moves.size(), - [](const ScoredMove& a, const ScoredMove& b) { return a.score > b.score; }); - - for (int i = 0; i < moves.size(); i++) - moves.moves[i] = scored[i].move; -} - extern "C" { CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) { ensure_initialized(); - ensure_tt(); auto* e = new (std::nothrow) ChessEngine(); if (!e) return nullptr; e->skill = parse_skill(options, e->skill); e->eval.variant = parse_variant(options); - if (e->eval.variant == EVAL_LEARNED) { - /* Snapshot the current global weights so the search reads a stable copy (training - * updates the global between games; the weights path is owned by learned_load). */ - std::lock_guard lock(g_weightsMutex); - std::memcpy(e->eval.mg, g_weights.mg, sizeof e->eval.mg); - std::memcpy(e->eval.eg, g_weights.eg, sizeof e->eval.eg); - std::memcpy(e->eval.featW, g_weights.featW, sizeof e->eval.featW); - } + if (e->eval.variant == EVAL_LEARNED) + learned::copy_weights_to(e->eval); /* stable per-handle copy of the global weights */ return e; } @@ -600,113 +89,6 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine, return CHESS_OK; /* TODO: store options */ } -/* Per-search scratch, threaded through the recursion. Kept off global scope so two engine - * handles can search concurrently without sharing node counts or killer tables. killers[ply] - * holds up to two quiet moves that recently caused a beta cutoff at that ply; trying them - * early (right after captures) prunes far more — the quiet-move ordering the search otherwise - * lacks. */ -static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<= 20) */ - -struct SearchContext { - uint64_t nodes = 0; - const EvalParams* eval = nullptr; /* eval config for this search; set by engine_best_move */ - chess::Move killers[MAX_PLY][2] = {};/* [ply][slot]; MOVE_NONE until filled */ -}; - -/* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching - * bot's difficulty (its root depth); `depth` is remaining depth (draft); `ply` is - * distance from the root (mate scoring only). Scores are side-to-move-relative. - * Fail-soft: returns the true best found even outside [alpha, beta]. */ -static int negamax(chess::Position& pos, int maxDepth, int depth, int ply, - int alpha, int beta, bool whiteToMove, SearchContext& ctx) { - ctx.nodes++; - - /* A draw is 0 even at the search horizon, and the TT key doesn't encode repetition - * history, so this must come before both the leaf eval and any TT probe. */ - if (ply > 0 && pos.is_draw()) - return 0; - - if (depth <= 0) - return evaluate_stm(pos, whiteToMove, *ctx.eval); - - const uint64_t key = pos.key(); - TTEntry& slot = g_tt.entries[key & g_tt.mask]; - const uint64_t data = slot.data.load(std::memory_order_relaxed); - const uint64_t xkey = slot.xorKey.load(std::memory_order_relaxed); - - chess::Move ttMove = chess::MOVE_NONE; - - if (data != 0 && (xkey ^ data) == key) { /* lockless: XOR check rejects torn reads */ - ttMove = tt_move(data); /* always reusable for ordering */ - int edepth = tt_depth(data); - Bound b = tt_bound(data); - - /* Trust the score only if it was searched deep enough for this node AND no deeper - * than this bot's own strength — so a weak bot can't borrow a stronger game's - * deeper analysis (it still gets the move for ordering, which can't leak strength). */ - if (edepth >= depth && edepth <= maxDepth) { - int s = score_from_tt(tt_score(data), ply); - if (b == Bound::EXACT) return s; - if (b == Bound::LOWER && s >= beta) return s; - if (b == Bound::UPPER && s <= alpha) return s; - } - } - - chess::MoveList moves; - pos.generate_legal(moves); - - if (moves.size() == 0) - return pos.is_draw() ? 0 : -MATE + ply; /* checkmate against side to move */ - - order_moves(pos, moves, ttMove, ctx.killers[ply], depth <= 2); - - const int alphaOrig = alpha; - int best = -INF; - chess::Move bestMove = chess::MOVE_NONE; - - for (int i = 0; i < moves.size(); i++) { - chess::Move move = moves.moves[i]; - pos.do_move(move); - int score = -negamax(pos, maxDepth, depth - 1, ply + 1, -beta, -alpha, !whiteToMove, ctx); - pos.undo_move(move); - - if (score > best) { - best = score; - bestMove = move; - } - if (best > alpha) - alpha = best; - if (best >= beta) { - /* A quiet move good enough to fail high here is a strong candidate in sibling - * lines at this ply — remember it as a killer. pos is back to pre-move state - * after undo_move, so piece_on(to) still flags a capture correctly. */ - bool isCapture = pos.piece_on(move.to()) != chess::NO_PIECE - || move.type() == chess::EN_PASSANT; - if (!isCapture && ply < MAX_PLY && ctx.killers[ply][0] != move) { - ctx.killers[ply][1] = ctx.killers[ply][0]; - ctx.killers[ply][0] = move; - } - break; /* fail-high cutoff */ - } - } - - Bound flag = best <= alphaOrig ? Bound::UPPER - : best >= beta ? Bound::LOWER - : Bound::EXACT; - - /* Depth-preferred replacement: keep the deepest analysis of each slot. The stored - * payload is written before the xorKey so any concurrent reader that catches a - * half-update fails the XOR check and treats it as a miss. */ - int storedDepth = (data == 0) ? -1 : tt_depth(data); - if (depth >= storedDepth) { - uint64_t packed = tt_pack(score_to_tt(best, ply), bestMove, depth, flag); - slot.data.store(packed, std::memory_order_relaxed); - slot.xorKey.store(key ^ packed, std::memory_order_relaxed); - } - - return best; -} - CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, const char* fen, const char* history, @@ -717,7 +99,6 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, auto held = std::make_unique(chess::Position::from_fen(fen)); chess::Position& pos = *held; - bool whiteToMove = pos.side_to_move() == chess::WHITE; /* Seed the prior positions (one FEN per line) so is_draw() sees repetitions and * the 50-move count that the current FEN alone can't express. */ @@ -736,48 +117,11 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, pos.seed_history(priorKeys.data(), static_cast(priorKeys.size())); } - chess::MoveList moves; - pos.generate_legal(moves); - if (moves.size() == 0) + chess::Move best = find_best_move(pos, engine->eval, engine->skill); + if (best == chess::MOVE_NONE) return CHESS_ERR_NO_MOVE; - SearchContext ctx; - ctx.eval = &engine->eval; - int maxDepth = depth_for_skill(engine->skill); - chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */ - - /* Iterative deepening: each depth seeds the next depth's move ordering (via the - * previous best move and the TT it filled), which makes the deeper search prune - * far harder than searching to maxDepth cold. */ - for (int d = 1; d <= maxDepth; d++) { - int alpha = -INF, beta = INF; - chess::Move iterBest = bestMove; - int iterScore = -INF; - - order_moves(pos, moves, iterBest, ctx.killers[0], true); - - for (int i = 0; i < moves.size(); i++) { - chess::Move move = moves.moves[i]; - pos.do_move(move); - int score = -negamax(pos, maxDepth, d - 1, 1, -beta, -alpha, !whiteToMove, ctx); - pos.undo_move(move); - - if (score > iterScore) { - iterScore = score; - iterBest = move; - } - if (score > alpha) - alpha = score; - } - - bestMove = iterBest; /* commit only a fully completed iteration */ - - std::fprintf(stderr, "depth %d nodes %llu best %s score %d\n", - d, static_cast(ctx.nodes), - chess::move_to_uci(iterBest).c_str(), iterScore); - } - - return copy_out(chess::move_to_uci(bestMove).c_str(), out_buf, out_len); + return copy_out(chess::move_to_uci(best).c_str(), out_buf, out_len); } CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len) { @@ -789,99 +133,33 @@ CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine) { } /* ---- Learned-weights / training C ABI -------------------------------------------------- - * The managed side orchestrates games but owns no chess logic: it tells the engine where - * to load/save the global weights, records each played position, and applies the result. */ + * The managed side orchestrates games but owns no chess logic: it tells the engine where to + * load/save the global weights, records each played position, and applies the result. Each + * export is a thin pass-through to the learned_model module. */ CHESS_API void CHESS_CALL learned_load(const char* path) { - std::lock_guard lock(g_weightsMutex); - g_weightsPath = path ? path : ""; - load_global_weights(path); + learned::load(path); } CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len) { - const int need = 6 * 64 * 2 + FEATURE_NB; /* mg + eg (PAWN..KING) + features = 776 */ - if (!out || out_len < need) return CHESS_ERR_BUFFER; - - std::lock_guard lock(g_weightsMutex); - int n = 0; - for (int pt = chess::PAWN; pt <= chess::KING; ++pt) - for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.mg[pt][sq]; - for (int pt = chess::PAWN; pt <= chess::KING; ++pt) - for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.eg[pt][sq]; - for (int i = 0; i < FEATURE_NB; ++i) out[n++] = g_weights.featW[i]; - return n; + return learned::snapshot(out, out_len); } CHESS_API TrainerHandle CHESS_CALL trainer_create(void) { - return new (std::nothrow) Trainer(); + return learned::create(); } CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) { - if (!t || !fen || !*fen) return; - ensure_initialized(); - - chess::Position pos = chess::Position::from_fen(fen); - double phase = game_phase(pos); - - /* Per-square occupancy, split into midgame/endgame by phase, white-relative. */ - chess::Bitboard occ = pos.pieces(); - while (occ) { - chess::Square s = chess::pop_lsb(occ); - chess::Piece pc = pos.piece_on(s); - chess::Color c = chess::color_of(pc); - chess::PieceType pt = chess::type_of(pc); - int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56); - t->mgOcc[c][pt][relSq] += (1.0 - phase); - t->egOcc[c][pt][relSq] += phase; - } - - /* Per-side feature activations. */ - double w[FEATURE_NB], b[FEATURE_NB]; - compute_features(pos, chess::WHITE, phase, w); - compute_features(pos, chess::BLACK, phase, b); - for (int i = 0; i < FEATURE_NB; ++i) { - t->featAcc[chess::WHITE][i] += w[i]; - t->featAcc[chess::BLACK][i] += b[i]; - } - - t->plies++; + ensure_initialized(); /* mobility needs the attack tables */ + learned::record(t, fen); } CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) { - if (!t) return; - - std::lock_guard lock(g_weightsMutex); - - /* pass 0 = winner (reward, +1); pass 1 = loser (punish, -1). */ - for (int pass = 0; pass < 2; ++pass) { - chess::Color side = chess::Color((pass == 0 ? winner : (winner ^ 1)) & 1); - int sign = pass == 0 ? 1 : -1; - - for (int pt = chess::PAWN; pt <= chess::KING; ++pt) - for (int sq = 0; sq < 64; ++sq) { - if (t->mgOcc[side][pt][sq] != 0.0) { - int d = sign * int(std::lround(SQUARE_LR * t->mgOcc[side][pt][sq] * weight)); - g_weights.mg[pt][sq] = std::clamp(g_weights.mg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP); - } - if (t->egOcc[side][pt][sq] != 0.0) { - int d = sign * int(std::lround(SQUARE_LR * t->egOcc[side][pt][sq] * weight)); - g_weights.eg[pt][sq] = std::clamp(g_weights.eg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP); - } - } - - if (t->plies > 0) - for (int i = 0; i < FEATURE_NB; ++i) { - double avg = t->featAcc[side][i] / t->plies; /* per-ply average, normalized */ - int d = sign * int(std::lround(FEAT_LR * (avg / FEAT_SCALE[i]) * weight)); - g_weights.featW[i] = std::clamp(g_weights.featW[i] + d, -FEAT_CLAMP, FEAT_CLAMP); - } - } - - save_global_weights(); + learned::apply(t, winner, weight); } CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle t) { - delete t; /* delete nullptr is safe */ + learned::destroy(t); /* destroy(nullptr) is safe */ } } /* extern "C" */ diff --git a/native/chess_engine/src/eval.cpp b/native/chess_engine/src/eval.cpp new file mode 100644 index 0000000..0c979a2 --- /dev/null +++ b/native/chess_engine/src/eval.cpp @@ -0,0 +1,272 @@ +/* eval.cpp - classic and learned position evaluation, plus feature computation. + * See eval.h for the public surface. Everything else here is file-static. */ +#include "eval.h" +#include "bitboard.h" +#include "position.h" + +#include +#include + +/* ---- Shared piece values ------------------------------------------------------------- */ + +int piece_value(chess::PieceType pt) { + switch (pt) { + case chess::PAWN: return 100; + case chess::KNIGHT: return 320; + case chess::BISHOP: return 330; + case chess::ROOK: return 500; + case chess::QUEEN: return 900; + default: return 0; + } +} + +/* ---- Classic (hand-crafted) evaluation ----------------------------------------------- */ + +/* Positional bonus (centipawns) from a square's Chebyshev distance to the center, added to + * a piece's score by evaluatePiece. Returns 20 (dead center) .. 140 (edge / corner). */ +static int center_multiplier(chess::Square s) { + /* |2*coord - 7| is the distance from center in half-squares: 1 (center) .. 7 (edge). */ + int fileDist = std::abs(2 * int(chess::file_of(s)) - 7); + int rankDist = std::abs(2 * int(chess::rank_of(s)) - 7); + int dist = fileDist > rankDist ? fileDist : rankDist; /* Chebyshev distance, 1 .. 7 */ + + return (8-dist) * 20; +} + +static int piece_mobility(const chess::Position& pos, chess::Square s, chess::Piece pc, chess::Color c) { + chess::Bitboard occ = pos.pieces(); + chess::Bitboard targets; + + switch (chess::type_of(pc)) { + case chess::KNIGHT: targets = chess::KnightAttacks[s]; break; + case chess::BISHOP: targets = chess::bishop_attacks(s, occ); break; + case chess::ROOK: targets = chess::rook_attacks(s, occ); break; + case chess::QUEEN: targets = chess::queen_attacks(s, occ); break; + case chess::KING: targets = chess::KingAttacks[s]; break; + default: return 0; // pawns: mobility usually handled via push/attack separately + } + + return chess::popcount(targets & ~pos.pieces(c)); // exclude squares blocked by own pieces +} + +static chess::Bitboard front_span(chess::Color c, chess::Square s) { + chess::File f = file_of(s); + chess::Bitboard files = file_bb(f); + if (f > chess::FILE_A) files |= chess::file_bb(chess::File(f - 1)); + if (f < chess::FILE_H) files |= chess::file_bb(chess::File(f + 1)); + + // Pawns never sit on rank 1 or 8, so rank is 1..6 and these shifts + // are always in [8,56] — no shift-by-64 UB to guard against. + chess::Rank r = rank_of(s); + chess::Bitboard ahead = (c == chess::WHITE) ? (~0ULL << (8 * (r + 1))) // ranks > r + : ((1ULL << (8 * r)) - 1); // ranks < r + return files & ahead; +} + +static chess::Bitboard front_span_file_only(chess::Color c, chess::Square s) { + chess::File f = file_of(s); + chess::Bitboard files = file_bb(f); + + // Pawns never sit on rank 1 or 8, so rank is 1..6 and these shifts + // are always in [8,56] — no shift-by-64 UB to guard against. + chess::Rank r = rank_of(s); + chess::Bitboard ahead = (c == chess::WHITE) ? (~0ULL << (8 * (r + 1))) // ranks > r + : ((1ULL << (8 * r)) - 1); // ranks < r + return files & ahead; +} + +static int evaluatePawn(const chess::Position& pos, const chess::Color c, const chess::Square s) { + chess::Bitboard span = front_span(c, s); + chess::Bitboard file_span = front_span_file_only(c, s); + chess::Rank r = rank_of(s); + int squaresToPromotion = (c == chess::WHITE) ? (chess::RANK_8 - r) : (r - chess::RANK_1);; + bool isPassed = !(span & pos.pieces(~c, chess::PAWN)); + bool isBlocked = (file_span & pos.pieces(c, chess::PAWN)) | (file_span & pos.pieces(~c, chess::PAWN)); + bool isDoubled = (file_span & pos.pieces(c, chess::PAWN)); + + int score = 100; + + if (isPassed && !isBlocked) + score += (6 - squaresToPromotion) * 100; // Bonus for passed pawns, more as they get closer to promotion + if (isDoubled) + score -= 20; // Penalty for doubled pawns + if (isBlocked) + score -= 20; // Penalty for blocked pawns + + return score; +} + +static int castleIncentive(const chess::Position& pos, chess::Color c) { + chess::Bitboard pcs = pos.pieces(); + int total = 0; + while (pcs) { + chess::Square s = chess::pop_lsb(pcs); + chess::Piece pc = pos.piece_on(s); + chess::Color c = chess::color_of(pc); + total += piece_value(chess::type_of(pc)); + } + + chess::Square k = pos.king_square(c); + bool castled = (c == chess::WHITE) ? (k == chess::G1 || k == chess::C1) + : (k == chess::G8 || k == chess::C8); + + return castled ? (total / 10) : 0; +} + +static int evaluatePiece(const chess::Position& pos, const chess::Square& s, const chess::Piece& pc, const chess::Color& c) { + int score = 0; + switch (chess::type_of(pc)) { + case chess::PAWN: score = evaluatePawn(pos, c, s); break; + case chess::KNIGHT: score = 320; break; + case chess::BISHOP: score = 330; break; + case chess::ROOK: score = 500; break; + case chess::QUEEN: score = 900; break; + case chess::KING: score = castleIncentive(pos, c); break; + default: return 0; + } + + score += center_multiplier(s); + + if (pc != chess::B_PAWN && pc != chess::W_PAWN) + score += piece_mobility(pos, s, pc, c) * 25; + + return score; +} + +static int evaluate(const chess::Position& pos) { + int score = 0; + chess::Bitboard white = pos.pieces(chess::WHITE); + + while (white) { + chess::Square s = chess::pop_lsb(white); + chess::Piece pc = pos.piece_on(s); + chess::Color c = chess::color_of(pc); + score += evaluatePiece(pos, s, pc, c); + } + + chess::Bitboard black = pos.pieces(chess::BLACK); + + while (black) { + chess::Square s = chess::pop_lsb(black); + chess::Piece pc = pos.piece_on(s); + chess::Color c = chess::color_of(pc); + score -= evaluatePiece(pos, s, pc, c); + } + + return score; +} + +/* ---- Learned (phase-split tables + feature knobs) evaluation --------------------------- + * The model is a linear combination of features whose weights are learned from outcomes: + * eval = Σ pieces [ material + blend(mg, eg, phase) ] + Σ features featW[i]·activation[i] + * compute_features() is the single source of feature activations, used by BOTH the eval here + * and the trainer, so the two can never disagree. */ + +double game_phase(const chess::Position& pos) { + int npm = chess::popcount(pos.pieces(chess::KNIGHT)) * 1 + + chess::popcount(pos.pieces(chess::BISHOP)) * 1 + + chess::popcount(pos.pieces(chess::ROOK)) * 2 + + chess::popcount(pos.pieces(chess::QUEEN)) * 4; + constexpr int MAX = 24; + if (npm >= MAX) return 0.0; + return double(MAX - npm) / MAX; +} + +/* Blend a midgame and endgame value by phase, rounding per-piece (so training credits a + * square the same way the eval reads it). */ +static int blend(int mg, int eg, double phase) { + return int(std::lround((1.0 - phase) * mg + phase * eg)); +} + +void compute_features(chess::Position& pos, chess::Color c, double phase, double out[FEATURE_NB]) { + for (int i = 0; i < FEATURE_NB; ++i) out[i] = 0.0; + + /* Mobility: legal moves for color c, bucketed by the moving piece's type. */ + chess::MoveList moves; + pos.generate_legal_for(c, moves); + for (int i = 0; i < moves.size(); ++i) { + switch (chess::type_of(pos.piece_on(moves.moves[i].from()))) { + case chess::KNIGHT: out[FEAT_MOB_N] += 1; break; + case chess::BISHOP: out[FEAT_MOB_B] += 1; break; + case chess::ROOK: out[FEAT_MOB_R] += 1; break; + case chess::QUEEN: out[FEAT_MOB_Q] += 1; break; + default: break; + } + } + + /* Pawn structure. */ + chess::Bitboard pawns = pos.pieces(c, chess::PAWN); + chess::Bitboard bb = pawns; + while (bb) { + chess::Square s = chess::pop_lsb(bb); + + if (!(front_span(c, s) & pos.pieces(~c, chess::PAWN))) { /* passed */ + chess::Rank r = chess::rank_of(s); + int toPromotion = (c == chess::WHITE) ? (chess::RANK_8 - r) : (r - chess::RANK_1); + out[FEAT_PASSED] += (6 - toPromotion) * phase; /* 0..5 ranks advanced, late-game */ + } + } + + /* Pawn links: friendly pawns that are defended by another friendly pawn (one per + * defended pawn, regardless of how many defenders). */ + chess::Bitboard pawnAttacks = 0; + chess::Bitboard pp = pawns; + while (pp) pawnAttacks |= chess::PawnAttacks[c][chess::pop_lsb(pp)]; + out[FEAT_PAWN_LINK] += chess::popcount(pawns & pawnAttacks); + + /* King safety: friendly pawns sheltering the king (its file + adjacent files, the two + * ranks in front), worth more in the midgame. */ + chess::Square k = pos.king_square(c); + chess::File kf = chess::file_of(k); + chess::Rank kr = chess::rank_of(k); + chess::Bitboard kingFiles = chess::file_bb(kf); + if (kf > chess::FILE_A) kingFiles |= chess::file_bb(chess::File(kf - 1)); + if (kf < chess::FILE_H) kingFiles |= chess::file_bb(chess::File(kf + 1)); + chess::Bitboard shelterRanks = 0; + for (int d = 1; d <= 2; ++d) { + int rr = (c == chess::WHITE) ? (kr + d) : (kr - d); + if (rr >= 0 && rr <= 7) shelterRanks |= (0xFFULL << (8 * rr)); + } + out[FEAT_KING] += chess::popcount(kingFiles & shelterRanks & pawns) * (1.0 - phase); +} + +/* Learned eval (white-positive/absolute, like evaluate()): material + phase-blended piece- + * square tables + learned feature weights. Black pieces index the rank-mirrored square + * (s ^ 56) so both colors share one white-relative table. Non-const because mobility + * generates legal moves (which the position's move generator does via do/undo). */ +static int evaluateLearned(chess::Position& pos, const EvalParams& ep) { + double phase = game_phase(pos); + int score = 0; + + chess::Bitboard white = pos.pieces(chess::WHITE); + while (white) { + chess::Square s = chess::pop_lsb(white); + chess::PieceType pt = chess::type_of(pos.piece_on(s)); + score += piece_value(pt) + blend(ep.mg[pt][s], ep.eg[pt][s], phase); + } + + chess::Bitboard black = pos.pieces(chess::BLACK); + while (black) { + chess::Square s = chess::pop_lsb(black); + chess::PieceType pt = chess::type_of(pos.piece_on(s)); + score -= piece_value(pt) + blend(ep.mg[pt][s ^ 56], ep.eg[pt][s ^ 56], phase); + } + + double wFeat[FEATURE_NB], bFeat[FEATURE_NB]; + compute_features(pos, chess::WHITE, phase, wFeat); + compute_features(pos, chess::BLACK, phase, bFeat); + + double feature = 0.0; + for (int i = 0; i < FEATURE_NB; ++i) + feature += ep.featW[i] * (wFeat[i] - bFeat[i]) / FEAT_SCALE[i]; + score += int(std::lround(feature)); + + return score; +} + +/* evaluate() is white-positive (absolute). Negamax needs it relative to the side to + * move, so flip the sign when black is to move. */ +int evaluate_stm(chess::Position& pos, bool whiteToMove, const EvalParams& ep) { + int s = (ep.variant == EVAL_LEARNED) ? evaluateLearned(pos, ep) : evaluate(pos); + return whiteToMove ? s : -s; +} diff --git a/native/chess_engine/src/eval.h b/native/chess_engine/src/eval.h new file mode 100644 index 0000000..d4c78e6 --- /dev/null +++ b/native/chess_engine/src/eval.h @@ -0,0 +1,59 @@ +/* eval.h - position evaluation (classic + learned) and feature computation. + * + * This is the shared hub of the engine's "scoring" logic. The learned model's + * feature activations (compute_features) and game phase are used by BOTH the eval + * here and the trainer (learned_model.cpp), so they live in one place and can never + * diverge. The search (search.cpp) consumes evaluate_stm and piece_value. */ +#pragma once + +#include "types.h" +#include "position.h" + +/* Eval variant for an engine handle. CLASSIC = the hand-crafted evaluate(); LEARNED = + * material + learned phase-split piece-square tables + learned feature weights. */ +enum EvalVariant : int { EVAL_CLASSIC = 0, EVAL_LEARNED = 1 }; + +/* The learned feature knobs (beyond the piece-square tables). Each has one weight learned + * from game outcomes; its activation is computed by compute_features(). Mobility is per + * piece type. Order is fixed — it is the on-disk and snapshot layout after the two tables. */ +enum Feature : int { + FEAT_MOB_N, FEAT_MOB_B, FEAT_MOB_R, FEAT_MOB_Q, /* legal-move counts, per piece type */ + FEAT_PASSED, /* passed pawns, endgame-weighted */ + FEAT_PAWN_LINK, /* pawns defended by a friendly pawn */ + FEAT_KING, /* king pawn-shelter, midgame-weighted */ + FEATURE_NB +}; + +/* Per-feature nominal scale: feature activations are divided by this before being weighted, + * so high-magnitude mobility doesn't dwarf the small pawn-structure terms. Used by both the + * learned eval (to combine) and the trainer (to normalize activations), so it lives here. */ +inline constexpr double FEAT_SCALE[FEATURE_NB] = { 4, 6, 8, 14, 2, 3, 2 }; + +/* Per-handle eval configuration, snapshotted from the global learned weights at + * engine_create so the search reads a stable copy. The tables are white-relative: a black + * piece indexes the rank-mirrored square (sq ^ 56). `mg`/`eg` are blended by game phase. + * Indexed by chess::PieceType (PAWN..KING). Only consulted when variant == EVAL_LEARNED. */ +struct EvalParams { + int variant = EVAL_CLASSIC; + int mg[chess::PIECE_TYPE_NB][64] = {}; + int eg[chess::PIECE_TYPE_NB][64] = {}; + int featW[FEATURE_NB] = {}; +}; + +/* Centipawn material value of a piece type (0 for king / none). Shared with the search's + * MVV-LVA move ordering. */ +int piece_value(chess::PieceType pt); + +/* Game phase in [0,1] from remaining non-pawn material: 0 = opening, 1 = bare kings. Drives + * the mg/eg table blend and the phase weighting of the passed-pawn / king-safety features. */ +double game_phase(const chess::Position& pos); + +/* Fills `out[FEATURE_NB]` with one color's raw feature activations for a position (mobility, + * pawn structure, king shelter). The single source of feature activations, shared by the + * learned eval and the trainer. Non-const because mobility generates legal moves. */ +void compute_features(chess::Position& pos, chess::Color c, double phase, double out[FEATURE_NB]); + +/* Side-to-move-relative evaluation for negamax (positive = good for whoever is to move). + * Dispatches to the classic or learned eval per ep.variant. Non-const because the learned + * eval computes mobility via the move generator. */ +int evaluate_stm(chess::Position& pos, bool whiteToMove, const EvalParams& ep); diff --git a/native/chess_engine/src/learned_model.cpp b/native/chess_engine/src/learned_model.cpp new file mode 100644 index 0000000..4334d15 --- /dev/null +++ b/native/chess_engine/src/learned_model.cpp @@ -0,0 +1,247 @@ +/* learned_model.cpp - global learned weights and the per-game trainer. + * + * The model is trained by WIN RATE, not by additive nudges. For every (piece, phase, + * square) we keep two running totals across all games: `win` (turns the piece spent there in + * games that side won) and `total` (turns spent there in any game). The stored weight is + * derived: weight = (2·win/total − 1)·scale, i.e. win-rate 0→−scale, 0.5→0, 1→+scale. Same + * for each feature, totalling its activation per turn. This focuses training on "how much + * time on this square correlates with winning" and is far less volatile than per-game nudges. + * + * The counters are the persistent source of truth (saved to / loaded from disk); the integer + * weight tables in `g_weights` are recomputed from them. See learned_model.h for the public + * surface; feature/phase math is shared from eval.cpp. */ +#include "learned_model.h" +#include "chess_engine.h" /* CHESS_ERR_BUFFER */ +#include "eval.h" +#include "position.h" + +#include +#include +#include +#include +#include +#include + +/* Win-rate → weight scale. A 100%-win square/feature reaches +scale, a 0%-win one −scale, + * matching the ranges the additive trainer used to clamp at (squares ±250, features ±500). */ +static constexpr double SQ_WEIGHT_SCALE = 250.0; +static constexpr double FEAT_WEIGHT_SCALE = 500.0; + +/* On-disk format version, stored as the file's first token. On load, a missing or mismatched + * version means the file is stale (old layout / different feature set): its contents are + * wiped (the file itself is kept) and training restarts from neutral. Bump this whenever the + * counter layout or feature set changes — it replaces having to delete the file by hand. */ +static constexpr int LEARNED_VERSION = 1; + +/* Derived integer weight tables, read by eval (snapshotted per engine handle) and the viz. + * Recomputed from g_counts whenever the counters change. White-relative (black indexes + * sq ^ 56); mg/eg blended by game phase. */ +struct LearnedWeights { + int mg[chess::PIECE_TYPE_NB][64] = {}; + int eg[chess::PIECE_TYPE_NB][64] = {}; + int featW[FEATURE_NB] = {}; +}; + +/* The persistent training counters: the single source of truth. `win` is credited only to + * the winning side; `total` to both sides (scaled by the outcome weight). */ +struct WinCounters { + double winMg[chess::PIECE_TYPE_NB][64] = {}; + double totMg[chess::PIECE_TYPE_NB][64] = {}; + double winEg[chess::PIECE_TYPE_NB][64] = {}; + double totEg[chess::PIECE_TYPE_NB][64] = {}; + double winFeat[FEATURE_NB] = {}; + double totFeat[FEATURE_NB] = {}; +}; + +static LearnedWeights g_weights; +static WinCounters g_counts; +static std::mutex g_weightsMutex; +static std::string g_weightsPath; + +/* win/total → stored weight: win-rate 0 → −scale, 0.5 → 0, 1 → +scale. An untouched + * (total == 0) square/feature is neutral. */ +static int derive(double win, double total, double scale) { + if (total <= 0.0) return 0; + double rate = win / total; + return int(std::lround((2.0 * rate - 1.0) * scale)); +} + +/* Recompute every derived weight from the counters. Caller holds g_weightsMutex. */ +static void recompute_weights() { + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) { + g_weights.mg[pt][sq] = derive(g_counts.winMg[pt][sq], g_counts.totMg[pt][sq], SQ_WEIGHT_SCALE); + g_weights.eg[pt][sq] = derive(g_counts.winEg[pt][sq], g_counts.totEg[pt][sq], SQ_WEIGHT_SCALE); + } + for (int i = 0; i < FEATURE_NB; ++i) + g_weights.featW[i] = derive(g_counts.winFeat[i], g_counts.totFeat[i], FEAT_WEIGHT_SCALE); +} + +static void save_global_weights(); /* defined below; load rewrites stale files via it */ + +/* On-disk format: LEARNED_VERSION as the first token, then the counters as whitespace doubles + * in this order — winMg, totMg, winEg, totEg (each 6*64, PAWN..KING, squares 0..63), then + * winFeat, totFeat (each FEATURE_NB). If the version is missing/wrong or the file is short + * (old format, corrupt, or absent), the counters are left neutral and the file is rewritten + * blank-but-versioned — clearing stale contents while keeping the file. Caller holds the lock. */ +static void load_global_weights(const char* path) { + WinCounters loaded{}; + bool ok = false; + + if (path && *path) { + std::ifstream f(path); + if (f) { + int version = 0; + if ((f >> version) && version == LEARNED_VERSION) { + auto readTable = [&](double t[chess::PIECE_TYPE_NB][64]) -> bool { + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) + if (!(f >> t[pt][sq])) return false; + return true; + }; + ok = readTable(loaded.winMg) && readTable(loaded.totMg) + && readTable(loaded.winEg) && readTable(loaded.totEg); + for (int i = 0; ok && i < FEATURE_NB; ++i) if (!(f >> loaded.winFeat[i])) ok = false; + for (int i = 0; ok && i < FEATURE_NB; ++i) if (!(f >> loaded.totFeat[i])) ok = false; + } + } + } + + g_counts = ok ? loaded : WinCounters{}; + recompute_weights(); + + /* Stale / wrong-version / unreadable: wipe the file's contents (keep the file) by + * rewriting it blank-but-versioned, so the next load matches and we never reread garbage. */ + if (!ok) + save_global_weights(); +} + +/* Persist g_counts to g_weightsPath in the format load_global_weights reads. Caller holds the lock. */ +static void save_global_weights() { + if (g_weightsPath.empty()) return; + std::ofstream f(g_weightsPath); + if (!f) return; + + f << LEARNED_VERSION << '\n'; + + auto writeTable = [&](const double t[chess::PIECE_TYPE_NB][64]) { + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) f << t[pt][sq] << (sq == 63 ? '\n' : ' '); + }; + writeTable(g_counts.winMg); writeTable(g_counts.totMg); + writeTable(g_counts.winEg); writeTable(g_counts.totEg); + for (int i = 0; i < FEATURE_NB; ++i) f << g_counts.winFeat[i] << (i == FEATURE_NB - 1 ? '\n' : ' '); + for (int i = 0; i < FEATURE_NB; ++i) f << g_counts.totFeat[i] << (i == FEATURE_NB - 1 ? '\n' : ' '); +} + +/* ---- Per-game training accumulator ---------------------------------------------------- + * Records, per ply, where each side's pieces sat (split into midgame/endgame by phase) and + * each side's feature activations. learned::apply folds these per-side totals into the global + * win/total counters. Squares are white-relative (black indexes sq ^ 56), so a side's tally + * lines up with the shared white-relative table. */ +struct Trainer { + double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {}; + double egOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {}; + double featAcc[chess::COLOR_NB][FEATURE_NB] = {}; +}; + +namespace learned { + +void load(const char* path) { + std::lock_guard lock(g_weightsMutex); + g_weightsPath = path ? path : ""; + load_global_weights(path); +} + +int snapshot(int* out, int out_len) { + const int need = 6 * 64 * 2 + FEATURE_NB; /* mg + eg (PAWN..KING) + features */ + if (!out || out_len < need) return CHESS_ERR_BUFFER; + + std::lock_guard lock(g_weightsMutex); + int n = 0; + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.mg[pt][sq]; + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.eg[pt][sq]; + for (int i = 0; i < FEATURE_NB; ++i) out[n++] = g_weights.featW[i]; + return n; +} + +void copy_weights_to(EvalParams& ep) { + std::lock_guard lock(g_weightsMutex); + std::memcpy(ep.mg, g_weights.mg, sizeof ep.mg); + std::memcpy(ep.eg, g_weights.eg, sizeof ep.eg); + std::memcpy(ep.featW, g_weights.featW, sizeof ep.featW); +} + +Trainer* create() { + return new (std::nothrow) Trainer(); +} + +void destroy(Trainer* t) { + delete t; /* delete nullptr is safe */ +} + +void record(Trainer* t, const char* fen) { + if (!t || !fen || !*fen) return; + + chess::Position pos = chess::Position::from_fen(fen); + double phase = game_phase(pos); + + /* Per-square occupancy, split into midgame/endgame by phase, white-relative. */ + chess::Bitboard occ = pos.pieces(); + while (occ) { + chess::Square s = chess::pop_lsb(occ); + chess::Piece pc = pos.piece_on(s); + chess::Color c = chess::color_of(pc); + chess::PieceType pt = chess::type_of(pc); + int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56); + t->mgOcc[c][pt][relSq] += (1.0 - phase); + t->egOcc[c][pt][relSq] += phase; + } + + /* Per-side feature activations. */ + double w[FEATURE_NB], b[FEATURE_NB]; + compute_features(pos, chess::WHITE, phase, w); + compute_features(pos, chess::BLACK, phase, b); + for (int i = 0; i < FEATURE_NB; ++i) { + t->featAcc[chess::WHITE][i] += w[i]; + t->featAcc[chess::BLACK][i] += b[i]; + } +} + +void apply(Trainer* t, int winner, double weight) { + if (!t) return; + + std::lock_guard lock(g_weightsMutex); + + /* Fold each side's per-game tallies into the global counters: both sides credit `total`, + * only the winner credits `win`, each scaled by the outcome weight (1.0 for a decisive + * game, 0.5 for a material-imbalance draw). */ + for (int s = 0; s < chess::COLOR_NB; ++s) { + bool isWinner = (s == winner); + + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) { + double mg = weight * t->mgOcc[s][pt][sq]; + double eg = weight * t->egOcc[s][pt][sq]; + g_counts.totMg[pt][sq] += mg; + g_counts.totEg[pt][sq] += eg; + if (isWinner) { + g_counts.winMg[pt][sq] += mg; + g_counts.winEg[pt][sq] += eg; + } + } + + for (int i = 0; i < FEATURE_NB; ++i) { + double f = weight * t->featAcc[s][i]; + g_counts.totFeat[i] += f; + if (isWinner) g_counts.winFeat[i] += f; + } + } + + recompute_weights(); + save_global_weights(); +} + +} // namespace learned diff --git a/native/chess_engine/src/learned_model.h b/native/chess_engine/src/learned_model.h new file mode 100644 index 0000000..cd6a3a5 --- /dev/null +++ b/native/chess_engine/src/learned_model.h @@ -0,0 +1,40 @@ +/* learned_model.h - the learned engine's process-global weights and training. + * + * Owns the single source of truth for the learned weights (loaded from / saved to disk), + * the read-only snapshot for visualization, and the per-game training accumulator that + * turns played positions + a result into weight nudges. The DLL ABI (chess_engine.cpp) + * is a thin pass-through to the functions here; feature/phase math is shared from eval.h. */ +#pragma once + +#include "eval.h" + +/* Per-game training accumulator. Global-namespace `Trainer` so it matches the opaque + * `typedef struct Trainer* TrainerHandle` in the public ABI header. Defined in the .cpp. */ +struct Trainer; + +namespace learned { + +/* Set the global weights file path and load from it (idempotent; a missing/short file + * leaves the weights neutral). */ +void load(const char* path); + +/* Copy the global weights out for visualization: 6*64 midgame + 6*64 endgame + features. + * Returns the count written, or CHESS_ERR_BUFFER if out_len is too small (needs >= 776). */ +int snapshot(int* out, int out_len); + +/* Snapshot the current global weights into a fresh engine handle's eval config so the + * search reads a stable copy (training updates the global between games). */ +void copy_weights_to(EvalParams& ep); + +/* Per-game training lifecycle. */ +Trainer* create(); +void destroy(Trainer* t); + +/* Record one played position (post-move FEN) into the accumulator. */ +void record(Trainer* t, const char* fen); + +/* Apply a finished game's outcome to the global weights and persist: rewards the winner's + * occupied squares / features, punishes the loser's, scaled by `weight`. winner: 0=W, 1=B. */ +void apply(Trainer* t, int winner, double weight); + +} // namespace learned diff --git a/native/chess_engine/src/search.cpp b/native/chess_engine/src/search.cpp new file mode 100644 index 0000000..d83a308 --- /dev/null +++ b/native/chess_engine/src/search.cpp @@ -0,0 +1,304 @@ +/* search.cpp - negamax alpha-beta over a shared transposition table, driven by + * iterative deepening. See search.h for the (single-function) public surface. */ +#include "search.h" +#include "eval.h" +#include "position.h" +#include "movegen.h" +#include "uci.h" + +#include +#include +#include +#include +#include +#include + +/* Search score constants. Scores are side-to-move-relative (negamax): positive is + * good for whoever is to move. MATE_BOUND is the threshold above which a score is a + * "mate in N" rather than a positional eval; INF is the window sentinel (kept above + * MATE so negating it can never hit signed-overflow UB the way INT_MIN would). */ +static constexpr int MATE = 200000; +static constexpr int MATE_BOUND = MATE - 1000; +static constexpr int INF = 1000000; + +/* Bound kind stored in a TT entry. LOWER = a fail-high (true score >= stored), + * UPPER = a fail-low (true score <= stored), EXACT = fully resolved. */ +enum class Bound : uint8_t { NONE, EXACT, LOWER, UPPER }; + +/* One shared, process-wide transposition table backs every game (every engine + * handle), so analysis persists and is reused across games. It is lock-free: each + * slot is two 64-bit words — `data` (the packed payload) and `xorKey` (the Zobrist + * key XOR-ed with `data`). A reader recovers the key as `xorKey ^ data`; if two + * concurrent searches tore the pair, the recovered key won't match and the read is + * treated as a miss — never a wrong-but-trusted entry (Hyatt's lockless hashing). */ +struct TTEntry { + std::atomic xorKey{0}; + std::atomic data{0}; +}; + +struct TranspositionTable { + std::unique_ptr entries; + size_t mask = 0; /* count - 1; count is a power of two */ +}; + +static TranspositionTable g_tt; +static constexpr size_t TT_MEGABYTES = 256; + +/* Pack/unpack the 64-bit payload: score(32) | move(16) | depth(8) | bound(8). A stored + * entry always has depth >= 1 and a non-NONE bound, so a real entry never packs to 0 — + * letting data == 0 mean "empty slot". */ +static uint64_t tt_pack(int score, chess::Move move, int depth, Bound bound) { + return static_cast(static_cast(score)) + | (static_cast(move.data) << 32) + | (static_cast(static_cast(depth)) << 48) + | (static_cast(static_cast(bound)) << 56); +} +static int tt_score(uint64_t d) { return static_cast(static_cast(d & 0xFFFFFFFFu)); } +static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast(d >> 32)); } +static int tt_depth(uint64_t d) { return static_cast(static_cast(d >> 48)); } +static Bound tt_bound(uint64_t d) { return static_cast(static_cast(d >> 56)); } + +static size_t floor_pow2(size_t n) { + size_t p = 1; + while ((p << 1) != 0 && (p << 1) <= n) p <<= 1; + return p; +} + +/* Allocate the shared table exactly once, to the largest power-of-two entry count that + * fits in TT_MEGABYTES. Power-of-two count lets indexing use `key & mask`. Thread-safe: + * call_once guards the first concurrent search. Entries start zeroed (empty). */ +static void ensure_tt() { + static std::once_flag once; + std::call_once(once, [] { + size_t count = floor_pow2((TT_MEGABYTES << 20) / sizeof(TTEntry)); + if (count < 1) count = 1; + g_tt.entries = std::make_unique(count); + g_tt.mask = count - 1; + }); +} + +/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no + * quiescence yet, so deep fixed-depth runs get expensive quickly. */ +static int depth_for_skill(int skill) { + return skill; /* skill N -> N plies */ +} + +/* Mate scores are "mate in N from THIS node", so they must be re-anchored to the + * probing node's ply when crossing the TT (store adds ply, retrieve subtracts it). + * Non-mate scores pass through untouched. */ +static int score_to_tt(int s, int ply) { return s >= MATE_BOUND ? s + ply : s <= -MATE_BOUND ? s - ply : s; } +static int score_from_tt(int s, int ply) { return s >= MATE_BOUND ? s - ply : s <= -MATE_BOUND ? s + ply : s; } + +/* Heuristic for searching the most promising moves first, which makes alpha-beta prune far + * more. Bands, highest first: the TT best move, then captures by MVV-LVA (most valuable + * victim, least valuable attacker), then the two killer moves for this ply (quiet moves that + * cut a sibling), then the remaining quiet moves. `killers` points at this ply's two-entry + * slot; `scoreChecks` gates the expensive gives_check term to near-leaf nodes. */ +static int order_score(chess::Position& pos, chess::Move m, chess::Move ttMove, + const chess::Move* killers, bool scoreChecks) { + if (m == ttMove) + return 2000000; /* dwarfs any capture/killer/check score below */ + + int score = 0; + + if (scoreChecks && pos.gives_check(m)) + score += 1000; + + chess::Piece victim = pos.piece_on(m.to()); +#ifdef BENCH_DISABLE_KILLERS + /* Benchmark A/B only (defined by bench.ps1): the pre-killer ordering — captures by + * MVV-LVA above quiet moves, no killer band — so the script can time the killer speedup. */ + (void)killers; + if (victim != chess::NO_PIECE) + score += 100 + 10 * piece_value(chess::type_of(victim)) + - piece_value(chess::type_of(pos.piece_on(m.from()))); + else if (m.type() == chess::EN_PASSANT) + score += 100 + 10 * piece_value(chess::PAWN); +#else + if (victim != chess::NO_PIECE) + score += 100000 + 10 * piece_value(chess::type_of(victim)) + - piece_value(chess::type_of(pos.piece_on(m.from()))); + else if (m.type() == chess::EN_PASSANT) + score += 100000 + 10 * piece_value(chess::PAWN); + else if (m == killers[0]) + score += 90000; /* quiet move that beta-cut a sibling at this ply */ + else if (m == killers[1]) + score += 80000; +#endif + + return score; +} + +/* Sort the move list in place, best-scoring first. Scores are computed once up + * front so gives_check isn't re-evaluated on every comparison. ttMove may be + * MOVE_NONE, in which case no move matches it and ordering falls back to captures. */ +static void order_moves(chess::Position& pos, chess::MoveList& moves, chess::Move ttMove, + const chess::Move* killers, bool scoreChecks) { + struct ScoredMove { int score = 0; chess::Move move{}; }; + ScoredMove scored[256]; + + for (int i = 0; i < moves.size(); i++) + scored[i] = { order_score(pos, moves.moves[i], ttMove, killers, scoreChecks), moves.moves[i] }; + + std::sort(scored, scored + moves.size(), + [](const ScoredMove& a, const ScoredMove& b) { return a.score > b.score; }); + + for (int i = 0; i < moves.size(); i++) + moves.moves[i] = scored[i].move; +} + +/* Per-search scratch, threaded through the recursion. Kept off global scope so two engine + * handles can search concurrently without sharing node counts or killer tables. killers[ply] + * holds up to two quiet moves that recently caused a beta cutoff at that ply; trying them + * early (right after captures) prunes far more — the quiet-move ordering the search otherwise + * lacks. */ +static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<= 20) */ + +struct SearchContext { + uint64_t nodes = 0; + const EvalParams* eval = nullptr; /* eval config for this search; set by find_best_move */ + chess::Move killers[MAX_PLY][2] = {};/* [ply][slot]; MOVE_NONE until filled */ +}; + +/* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching + * bot's difficulty (its root depth); `depth` is remaining depth (draft); `ply` is + * distance from the root (mate scoring only). Scores are side-to-move-relative. + * Fail-soft: returns the true best found even outside [alpha, beta]. */ +static int negamax(chess::Position& pos, int maxDepth, int depth, int ply, + int alpha, int beta, bool whiteToMove, SearchContext& ctx) { + ctx.nodes++; + + /* A draw is 0 even at the search horizon, and the TT key doesn't encode repetition + * history, so this must come before both the leaf eval and any TT probe. */ + if (ply > 0 && pos.is_draw()) + return 0; + + if (depth <= 0) + return evaluate_stm(pos, whiteToMove, *ctx.eval); + + const uint64_t key = pos.key(); + TTEntry& slot = g_tt.entries[key & g_tt.mask]; + const uint64_t data = slot.data.load(std::memory_order_relaxed); + const uint64_t xkey = slot.xorKey.load(std::memory_order_relaxed); + + chess::Move ttMove = chess::MOVE_NONE; + + if (data != 0 && (xkey ^ data) == key) { /* lockless: XOR check rejects torn reads */ + ttMove = tt_move(data); /* always reusable for ordering */ + int edepth = tt_depth(data); + Bound b = tt_bound(data); + + /* Trust the score only if it was searched deep enough for this node AND no deeper + * than this bot's own strength — so a weak bot can't borrow a stronger game's + * deeper analysis (it still gets the move for ordering, which can't leak strength). */ + if (edepth >= depth && edepth <= maxDepth) { + int s = score_from_tt(tt_score(data), ply); + if (b == Bound::EXACT) return s; + if (b == Bound::LOWER && s >= beta) return s; + if (b == Bound::UPPER && s <= alpha) return s; + } + } + + chess::MoveList moves; + pos.generate_legal(moves); + + if (moves.size() == 0) + return pos.is_draw() ? 0 : -MATE + ply; /* checkmate against side to move */ + + order_moves(pos, moves, ttMove, ctx.killers[ply], depth <= 2); + + const int alphaOrig = alpha; + int best = -INF; + chess::Move bestMove = chess::MOVE_NONE; + + for (int i = 0; i < moves.size(); i++) { + chess::Move move = moves.moves[i]; + pos.do_move(move); + int score = -negamax(pos, maxDepth, depth - 1, ply + 1, -beta, -alpha, !whiteToMove, ctx); + pos.undo_move(move); + + if (score > best) { + best = score; + bestMove = move; + } + if (best > alpha) + alpha = best; + if (best >= beta) { + /* A quiet move good enough to fail high here is a strong candidate in sibling + * lines at this ply — remember it as a killer. pos is back to pre-move state + * after undo_move, so piece_on(to) still flags a capture correctly. */ + bool isCapture = pos.piece_on(move.to()) != chess::NO_PIECE + || move.type() == chess::EN_PASSANT; + if (!isCapture && ply < MAX_PLY && ctx.killers[ply][0] != move) { + ctx.killers[ply][1] = ctx.killers[ply][0]; + ctx.killers[ply][0] = move; + } + break; /* fail-high cutoff */ + } + } + + Bound flag = best <= alphaOrig ? Bound::UPPER + : best >= beta ? Bound::LOWER + : Bound::EXACT; + + /* Depth-preferred replacement: keep the deepest analysis of each slot. The stored + * payload is written before the xorKey so any concurrent reader that catches a + * half-update fails the XOR check and treats it as a miss. */ + int storedDepth = (data == 0) ? -1 : tt_depth(data); + if (depth >= storedDepth) { + uint64_t packed = tt_pack(score_to_tt(best, ply), bestMove, depth, flag); + slot.data.store(packed, std::memory_order_relaxed); + slot.xorKey.store(key ^ packed, std::memory_order_relaxed); + } + + return best; +} + +chess::Move find_best_move(chess::Position& pos, const EvalParams& ep, int skill) { + ensure_tt(); + + bool whiteToMove = pos.side_to_move() == chess::WHITE; + + chess::MoveList moves; + pos.generate_legal(moves); + if (moves.size() == 0) + return chess::MOVE_NONE; + + SearchContext ctx; + ctx.eval = &ep; + int maxDepth = depth_for_skill(skill); + chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */ + + /* Iterative deepening: each depth seeds the next depth's move ordering (via the + * previous best move and the TT it filled), which makes the deeper search prune + * far harder than searching to maxDepth cold. */ + for (int d = 1; d <= maxDepth; d++) { + int alpha = -INF, beta = INF; + chess::Move iterBest = bestMove; + int iterScore = -INF; + + order_moves(pos, moves, iterBest, ctx.killers[0], true); + + for (int i = 0; i < moves.size(); i++) { + chess::Move move = moves.moves[i]; + pos.do_move(move); + int score = -negamax(pos, maxDepth, d - 1, 1, -beta, -alpha, !whiteToMove, ctx); + pos.undo_move(move); + + if (score > iterScore) { + iterScore = score; + iterBest = move; + } + if (score > alpha) + alpha = score; + } + + bestMove = iterBest; /* commit only a fully completed iteration */ + + std::fprintf(stderr, "depth %d nodes %llu best %s score %d\n", + d, static_cast(ctx.nodes), + chess::move_to_uci(iterBest).c_str(), iterScore); + } + + return bestMove; +} diff --git a/native/chess_engine/src/search.h b/native/chess_engine/src/search.h new file mode 100644 index 0000000..e29a862 --- /dev/null +++ b/native/chess_engine/src/search.h @@ -0,0 +1,14 @@ +/* search.h - the engine's search: a single entry point. + * + * Everything else (the shared transposition table, move ordering, negamax, and the + * iterative-deepening driver) is an implementation detail of search.cpp. */ +#pragma once + +#include "position.h" +#include "eval.h" + +/* Best move for `pos` using evaluation `ep`, searched to the depth implied by `skill` + * (1..20). Seeds, allocates, and reuses the process-wide transposition table on first + * call. Returns chess::MOVE_NONE when there is no legal move (mate/stalemate). The + * position's repetition/50-move history should already be seeded by the caller. */ +chess::Move find_best_move(chess::Position& pos, const EvalParams& ep, int skill);