From d71e4856111669cbffc1eeacf790a464a845cf1c Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Sat, 13 Jun 2026 17:11:07 -0600 Subject: [PATCH 1/2] New learning strategy --- JoshHeaps.Net/Controllers/ChessController.cs | 2 +- JoshHeaps.Net/Resources/chess_engine.dll | Bin 249344 -> 60928 bytes .../Implementations/LearnedWeightsStore.cs | 2 +- native/chess_engine/CMakeLists.txt | 3 + .../chess_engine/chess_engine.vcxproj | 6 + native/chess_engine/src/chess_engine.cpp | 776 +----------------- native/chess_engine/src/eval.cpp | 272 ++++++ native/chess_engine/src/eval.h | 59 ++ native/chess_engine/src/learned_model.cpp | 247 ++++++ native/chess_engine/src/learned_model.h | 40 + native/chess_engine/src/search.cpp | 304 +++++++ native/chess_engine/src/search.h | 14 + 12 files changed, 974 insertions(+), 751 deletions(-) create mode 100644 native/chess_engine/src/eval.cpp create mode 100644 native/chess_engine/src/eval.h create mode 100644 native/chess_engine/src/learned_model.cpp create mode 100644 native/chess_engine/src/learned_model.h create mode 100644 native/chess_engine/src/search.cpp create mode 100644 native/chess_engine/src/search.h 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 992d57e623f320164f8be16f4543ebcd864faa13..9db5240e667046cc0d850a5c902c5cafd9941c29 100644 GIT binary patch literal 60928 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

Sc=d(` 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¥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 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); From aeef320cb432fe45d22e6d7b10eeb15ee9f69ab4 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Sat, 13 Jun 2026 17:30:09 -0600 Subject: [PATCH 2/2] auto train --- JoshHeaps.Net/Controllers/ChessController.cs | 252 ++---------------- JoshHeaps.Net/Program.cs | 9 + .../Implementations/AutoTrainingService.cs | 55 ++++ .../Implementations/ChessEngineFactory.cs | 7 + .../Services/Implementations/GameStore.cs | 69 +++++ .../Implementations/SelfPlayCoordinator.cs | 189 +++++++++++++ .../Services/Interfaces/IGameStore.cs | 29 ++ .../Interfaces/ISelfPlayCoordinator.cs | 23 ++ 8 files changed, 404 insertions(+), 229 deletions(-) create mode 100644 JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs create mode 100644 JoshHeaps.Net/Services/Implementations/GameStore.cs create mode 100644 JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs create mode 100644 JoshHeaps.Net/Services/Interfaces/IGameStore.cs create mode 100644 JoshHeaps.Net/Services/Interfaces/ISelfPlayCoordinator.cs diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index fa31b5d..9f5f7c1 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -4,7 +4,6 @@ using JoshHeaps.Net.Services.Implementations; using JoshHeaps.Net.Services.Interfaces; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; -using System.Collections.Concurrent; namespace JoshHeaps.Net.Controllers; @@ -16,24 +15,13 @@ public class ChessController( IChessEngineFactory engineFactory, IComputerMoveOrchestrator orchestrator, ILearnedWeightsStore weightsStore, + IGameStore gameStore, + ISelfPlayCoordinator selfPlay, IHubContext chessHub) : ControllerBase { - ///

- /// Store of ongoing games. - /// - private static readonly ConcurrentDictionary _games = []; - private static readonly ConcurrentDictionary _gameRemovalTasks = []; - private static readonly ConcurrentDictionary _gameRemovalCancellationTokens = []; - private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1); private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1); private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1); - private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1); - private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30); - - // Plies of random legal moves at the start of a training game, so self-play and - // engine-vs-engine games explore different lines instead of replaying one game. - private const int _openingRandomPlies = 4; /// /// Create a new chess game and store it in-memory. @@ -43,7 +31,7 @@ public class ChessController( public ActionResult CreateGame(int difficulty = 20, string color = "random") { var gameState = chessService.CreateNewGame(); - _games[gameState.GameId] = gameState; + gameStore.Add(gameState); gameState.IsVsComputer = true; gameState.WhiteJoined = true; @@ -78,7 +66,7 @@ public class ChessController( }); } - ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); + gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout); return Ok(new { @@ -102,31 +90,13 @@ public class ChessController( int? whiteSkill = null, int? blackSkill = null) { - var whiteKind = ParseEngineKind(whiteEngine); - var blackKind = ParseEngineKind(blackEngine); + var config = new SelfPlayConfig( + ParseEngineKind(whiteEngine), whiteSkill ?? difficulty, + ParseEngineKind(blackEngine), blackSkill ?? difficulty); - var gameState = chessService.CreateNewGame(); - _games[gameState.GameId] = gameState; + var (gameId, _) = selfPlay.StartGame(config); - gameState.IsVsComputer = true; - gameState.IsComputerVsComputer = true; - gameState.WhiteJoined = true; - gameState.BlackJoined = true; - gameState.WhitePlayerId = Guid.NewGuid(); - gameState.BlackPlayerId = Guid.NewGuid(); - gameState.WhiteEngineKind = whiteKind; - gameState.BlackEngineKind = blackKind; - gameState.WhiteComputer = engineFactory.Create(whiteSkill ?? difficulty, whiteKind); - gameState.BlackComputer = engineFactory.Create(blackSkill ?? difficulty, blackKind); - - // When the learned engine is playing, attach a trainer so the outcome can train it. - if (whiteKind == ChessEngineKind.CustomLearned || blackKind == ChessEngineKind.CustomLearned) - gameState.Trainer = weightsStore.CreateTrainer(); - - ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); - StartSelfPlay(gameState); - - return Ok(new { gameState.GameId }); + return Ok(new { GameId = gameId }); } private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch @@ -145,12 +115,12 @@ public class ChessController( public ActionResult JoinGame() { Console.WriteLine("joining game"); - GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen); + GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen); if (gameState == null) { gameState = chessService.CreateNewGame(); - _games[gameState.GameId] = gameState; + gameStore.Add(gameState); } Guid playerId = Guid.NewGuid(); @@ -168,7 +138,7 @@ public class ChessController( isWhite = false; } - ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); + gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout); return Ok(new { @@ -184,7 +154,7 @@ public class ChessController( [HttpGet("active")] public ActionResult GetActiveGames() { - var activeGames = _games.Values + var activeGames = gameStore.All // In-progress games, plus finished computer-vs-computer games still in their result window. .Where(g => g.WhiteJoined && g.BlackJoined && ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer)) @@ -230,7 +200,7 @@ public class ChessController( [HttpGet("{gameId}")] public ActionResult GetGameState(Guid gameId) { - if (!_games.TryGetValue(gameId, out var gameState)) + if (!gameStore.TryGet(gameId, out var gameState)) return NotFound("Game not found"); return Ok(gameState.ToDto()); @@ -243,7 +213,7 @@ public class ChessController( [HttpPost("move")] public async Task MakeMove([FromBody] MoveDto moveDto) { - if (!_games.TryGetValue(moveDto.GameId, out var gameState)) + if (!gameStore.TryGet(moveDto.GameId, out var gameState)) return NotFound("Game not found"); // Check if player is authorized to move @@ -270,11 +240,11 @@ public class ChessController( var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition; if (isGameOver) - ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout); + gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout); else if (gameState.IsVsComputer) - ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); + gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout); else - ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); + gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout); var state = gameState.ToDto(); @@ -301,7 +271,7 @@ public class ChessController( [HttpPost("forfeit")] public async Task Forfeit([FromBody] ForfeitDto forfeit) { - if (!_games.TryGetValue(forfeit.GameId, out var gameState)) + if (!gameStore.TryGet(forfeit.GameId, out var gameState)) return NotFound("Game not found"); if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited) @@ -319,7 +289,7 @@ public class ChessController( await chessHub.Clients.Group(gameState.GameId.ToString()) .SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit"); - ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout); + gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout); return Ok(); } @@ -330,7 +300,7 @@ public class ChessController( [HttpGet("{gameId}/pgn")] public ActionResult GetPgn(Guid gameId) { - if (!_games.TryGetValue(gameId, out var gameState)) + if (!gameStore.TryGet(gameId, out var gameState)) return NotFound("Game not found"); return Content(gameState.ToPgn(), "application/x-chess-pgn"); @@ -342,7 +312,7 @@ public class ChessController( [HttpGet("{gameId}/legalMoves/{pieceId}")] public ActionResult GetLegalMoves(Guid gameId, string pieceId) { - if (!_games.TryGetValue(gameId, out var gameState)) + if (!gameStore.TryGet(gameId, out var gameState)) return NotFound("Game not found"); var moves = chessService.GetLegalMovesForPiece(gameState, pieceId); @@ -356,7 +326,7 @@ public class ChessController( [HttpGet("{gameId}/legalMoves")] public ActionResult GetAllLegalMoves(Guid gameId) { - if (!_games.TryGetValue(gameId, out var gameState)) + if (!gameStore.TryGet(gameId, out var gameState)) return NotFound("Game not found"); var allMoves = chessService.GetAllLegalMoves(gameState) @@ -369,180 +339,4 @@ public class ChessController( return Ok(allMoves); } - /// - /// Drives a computer-vs-computer game: keeps asking the side-to-move's engine for its - /// move (which applies and broadcasts it) until the game ends or is removed. Training - /// games get a randomized opening and feed their result back into the learned weights. - /// - private void StartSelfPlay(GameState gameState) - { - queue.Queue(async () => - { - // Give spectators a moment to join the SignalR group before the first move. - await Task.Delay(TimeSpan.FromSeconds(1)); - - // Training games open with random moves so they don't replay the same line. - if (gameState.Trainer != nint.Zero) - for (int i = 0; i < _openingRandomPlies && _games.ContainsKey(gameState.GameId) && !IsGameOver(gameState); i++) - { - await orchestrator.PlayRandomMoveAsync(gameState); - await Task.Delay(_selfPlayMoveDelay); - } - - while (_games.ContainsKey(gameState.GameId) && !IsGameOver(gameState)) - { - try - { - await orchestrator.PlayAsync(gameState); - } - catch (Exception ex) - { - Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}"); - break; - } - - await Task.Delay(_selfPlayMoveDelay); - } - - ApplyLearning(gameState); - - // Leave the finished game in place briefly so spectators can see the result. - if (_games.ContainsKey(gameState.GameId)) - ScheduleRemoveGame(gameState.GameId, _selfPlayResultTimeout); - }); - } - - private static bool IsGameOver(GameState gameState) => - gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited; - - /// - /// Feeds a finished training game's result into the learned weights, then frees the - /// trainer. Both sides teach the table — the winner's squares/features up, the loser's - /// down. A checkmate is a full-strength result; a material-imbalance draw is a half- - /// strength win for the lower-material side (holding a draw while down material is a - /// success; only drawing while up is a failure). A balanced draw, forfeit, or unfinished - /// game teaches nothing (but the trainer is still freed). - /// - private void ApplyLearning(GameState gameState) - { - if (gameState.Trainer == nint.Zero) - return; - - if (TryDetermineOutcome(gameState, out var winner, out var weight)) - weightsStore.ApplyResult(gameState.Trainer, winner, weight); - - weightsStore.DestroyTrainer(gameState.Trainer); - gameState.Trainer = nint.Zero; - } - - /// - /// Determines the trainable outcome of a finished game: the winning color and the reward - /// weight. Returns false when the game teaches nothing (balanced draw, forfeit, unfinished). - /// - private static bool TryDetermineOutcome(GameState gameState, out PieceColor winner, out double weight) - { - winner = PieceColor.White; - weight = 1.0; - - if (gameState.IsCheckmate) - { - // The side to move is the mated one, so the winner is the other color. - winner = gameState.CurrentPlayer == PieceColor.White ? PieceColor.Black : PieceColor.White; - return true; - } - - if (gameState.IsStalemate || gameState.IsThreefoldRepetition) - { - var (white, black) = MaterialCounts(gameState); - - if (white == black) - return false; // a balanced draw carries no signal - - winner = white < black ? PieceColor.White : PieceColor.Black; - weight = 0.5; - return true; - } - - return false; // forfeit / unfinished - } - - /// Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication. - private static (int white, int black) MaterialCounts(GameState gameState) - { - int white = 0, black = 0; - - for (int row = 0; row < 8; row++) - for (int col = 0; col < 8; col++) - { - var piece = gameState.Board[row, col]; - - if (piece is null) - continue; - - int value = piece.Type switch - { - PieceType.Pawn => 1, - PieceType.Knight => 3, - PieceType.Bishop => 3, - PieceType.Rook => 5, - PieceType.Queen => 9, - _ => 0 - }; - - if (piece.Color == PieceColor.White) - white += value; - else - black += value; - } - - return (white, black); - } - - private static void ScheduleRemoveGame(Guid id, TimeSpan delay) - { - if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts)) - { - oldCts.Cancel(); - oldCts.Dispose(); - } - - var cts = new CancellationTokenSource(); - _gameRemovalCancellationTokens[id] = cts; - - _gameRemovalTasks[id] = Task.Run(async () => - { - try - { - await Task.Delay(delay, cts.Token); - - if (_games.TryGetValue(id, out var game)) - { - if (game.WhiteComputer is not null) - await game.WhiteComputer.DisposeAsync(); - if (game.BlackComputer is not null) - await game.BlackComputer.DisposeAsync(); - - // Free the trainer if the game never reached ApplyLearning (e.g. timed out). - // The native ABI is shared via CustomChessEngine's import resolver. - if (game.Trainer != nint.Zero) - { - CustomChessEngine.NativeMethods.trainer_destroy(game.Trainer); - game.Trainer = nint.Zero; - } - } - - _games.Remove(id, out _); - } - catch (OperationCanceledException) { } - finally - { - if (_gameRemovalCancellationTokens.TryGetValue(id, out var currentCts) && currentCts == cts) - { - _gameRemovalCancellationTokens.TryRemove(id, out _); - } - - cts.Dispose(); - } - }); - } } diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index 0657f4e..936efe1 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -26,10 +26,19 @@ builder.Services.Configure(configuration.GetSection(ChessEng builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); if (!builder.Environment.IsDevelopment()) +{ builder.Services.AddHostedService(); + // Continuously train the learned engine against Stockfish in the background. Toggle off + // via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy. + if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true)) + builder.Services.AddHostedService(); +} + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs b/JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs new file mode 100644 index 0000000..6b5c2f3 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs @@ -0,0 +1,55 @@ +using JoshHeaps.Net.Services.Interfaces; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// Continuously trains the learned engine in the background: two self-play games run in +/// parallel, the learned engine (skill 6) against Stockfish (skill 20), one with Stockfish as +/// black and one as white. Each slot is independent — when its game finishes it immediately +/// starts another under the same conditions, without waiting on the other slot. Registered +/// only outside Development and gated by the ChessEngine:AutoTrain config flag. +/// +public sealed class AutoTrainingService( + ISelfPlayCoordinator coordinator, + ILogger logger) : BackgroundService +{ + private const int LearnedSkill = 6; + private const int StockfishSkill = 20; + private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5); + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + // Learned plays both colors so the model trains symmetrically; each slot is its own loop. + var stockfishBlack = RunSlot( + new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill), + stoppingToken); + var stockfishWhite = RunSlot( + new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill), + stoppingToken); + + return Task.WhenAll(stockfishBlack, stockfishWhite); + } + + private async Task RunSlot(SelfPlayConfig config, CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await coordinator.StartGame(config, stoppingToken).Completion; + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + // Most likely an engine failing to start (e.g. Stockfish). Back off so a + // persistent failure doesn't spin a tight loop, then try again. + logger.LogError(ex, "Auto-training game failed to start; retrying after backoff."); + try { await Task.Delay(_restartBackoff, stoppingToken); } + catch (OperationCanceledException) { break; } + } + } + } +} diff --git a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs index 6d18441..8fcad4c 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs @@ -29,6 +29,13 @@ public sealed class ChessEngineOptions /// clashes. Override via the ChessEngine__WeightsPath environment variable. /// public string? WeightsPath { get; set; } + + /// + /// When true (and outside Development), a background service continuously plays the learned + /// engine against Stockfish to train it. Set to false to stop auto-training without a + /// redeploy. Override via the ChessEngine__AutoTrain environment variable. + /// + public bool AutoTrain { get; set; } = true; } /// Creates the configured per game. diff --git a/JoshHeaps.Net/Services/Implementations/GameStore.cs b/JoshHeaps.Net/Services/Implementations/GameStore.cs new file mode 100644 index 0000000..af85de0 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/GameStore.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; +using JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Interfaces; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// In-memory game registry with a delayed-removal lifecycle. Singleton: the game state is +/// process-wide, not per-request, so it lives in a service rather than static controller fields. +/// +public sealed class GameStore(ILearnedWeightsStore weightsStore) : IGameStore +{ + private readonly ConcurrentDictionary _games = []; + private readonly ConcurrentDictionary _removalTasks = []; + private readonly ConcurrentDictionary _removalCts = []; + + public void Add(GameState game) => _games[game.GameId] = game; + + public bool TryGet(Guid id, out GameState game) => _games.TryGetValue(id, out game!); + + public bool Contains(Guid id) => _games.ContainsKey(id); + + public IReadOnlyCollection All => [.. _games.Values]; + + public void ScheduleRemove(Guid id, TimeSpan delay) + { + if (_removalCts.TryRemove(id, out var oldCts)) + { + oldCts.Cancel(); + oldCts.Dispose(); + } + + var cts = new CancellationTokenSource(); + _removalCts[id] = cts; + + _removalTasks[id] = Task.Run(async () => + { + try + { + await Task.Delay(delay, cts.Token); + + if (_games.TryGetValue(id, out var game)) + { + if (game.WhiteComputer is not null) + await game.WhiteComputer.DisposeAsync(); + if (game.BlackComputer is not null) + await game.BlackComputer.DisposeAsync(); + + // Free the trainer if the game never reached ApplyLearning (e.g. timed out). + if (game.Trainer != nint.Zero) + { + weightsStore.DestroyTrainer(game.Trainer); + game.Trainer = nint.Zero; + } + } + + _games.Remove(id, out _); + } + catch (OperationCanceledException) { } + finally + { + if (_removalCts.TryGetValue(id, out var currentCts) && currentCts == cts) + _removalCts.TryRemove(id, out _); + + cts.Dispose(); + } + }); + } +} diff --git a/JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs b/JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs new file mode 100644 index 0000000..6cca76f --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs @@ -0,0 +1,189 @@ +using JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Interfaces; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// Runs CPU-vs-CPU games: builds the game and engines, plays a randomized opening (for training +/// variety), drives the move loop to completion, then trains the learned engine from the result. +/// +public sealed class SelfPlayCoordinator( + IChessService chessService, + IChessEngineFactory engineFactory, + IComputerMoveOrchestrator orchestrator, + ILearnedWeightsStore weightsStore, + IGameStore gameStore) : ISelfPlayCoordinator +{ + private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1); + private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30); + + // Plies of random legal moves at the start of a training game, so self-play and + // engine-vs-engine games explore different lines instead of replaying one game. + private const int _openingRandomPlies = 4; + + public (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default) + { + var whiteComputer = engineFactory.Create(config.WhiteSkill, config.WhiteKind); + IChessEngine blackComputer; + try + { + blackComputer = engineFactory.Create(config.BlackSkill, config.BlackKind); + } + catch + { + // Don't leak the first engine if the second fails to start (e.g. Stockfish process). + whiteComputer.DisposeAsync().AsTask().GetAwaiter().GetResult(); + throw; + } + + var gameState = chessService.CreateNewGame(); + gameState.IsVsComputer = true; + gameState.IsComputerVsComputer = true; + gameState.WhiteJoined = true; + gameState.BlackJoined = true; + gameState.WhitePlayerId = Guid.NewGuid(); + gameState.BlackPlayerId = Guid.NewGuid(); + gameState.WhiteEngineKind = config.WhiteKind; + gameState.BlackEngineKind = config.BlackKind; + gameState.WhiteComputer = whiteComputer; + gameState.BlackComputer = blackComputer; + + // When the learned engine is playing, attach a trainer so the outcome can train it. + if (config.WhiteKind == ChessEngineKind.CustomLearned || config.BlackKind == ChessEngineKind.CustomLearned) + gameState.Trainer = weightsStore.CreateTrainer(); + + gameStore.Add(gameState); + gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout); + + var completion = Task.Run(() => RunAsync(gameState, cancellationToken)); + return (gameState.GameId, completion); + } + + /// + /// Drives the game to completion, then trains from it. Never throws — a failure just ends + /// the game (and the trainer is always freed), so callers can safely await or ignore it. + /// + private async Task RunAsync(GameState gameState, CancellationToken cancellationToken) + { + try + { + // Give spectators a moment to join the SignalR group before the first move. + await Task.Delay(_selfPlayMoveDelay, cancellationToken); + + // Training games open with random moves so they don't replay the same line. + if (gameState.Trainer != nint.Zero) + for (int i = 0; i < _openingRandomPlies && IsLive(gameState, cancellationToken); i++) + { + await orchestrator.PlayRandomMoveAsync(gameState); + await Task.Delay(_selfPlayMoveDelay, cancellationToken); + } + + while (IsLive(gameState, cancellationToken)) + { + await orchestrator.PlayAsync(gameState); + await Task.Delay(_selfPlayMoveDelay, cancellationToken); + } + } + catch (OperationCanceledException) { /* service shutting down */ } + catch (Exception ex) + { + Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}"); + } + + ApplyLearning(gameState); + + // Leave the finished game in place briefly so spectators can see the result. + if (gameStore.Contains(gameState.GameId)) + gameStore.ScheduleRemove(gameState.GameId, _selfPlayResultTimeout); + } + + private bool IsLive(GameState gameState, CancellationToken cancellationToken) => + !cancellationToken.IsCancellationRequested + && gameStore.Contains(gameState.GameId) + && !IsGameOver(gameState); + + private static bool IsGameOver(GameState gameState) => + gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited; + + /// + /// Feeds a finished training game's result into the learned weights, then frees the trainer. + /// A checkmate is a full-strength result; a material-imbalance draw is a half-strength win + /// for the lower-material side (holding a draw while down material is a success; only drawing + /// while up is a failure). A balanced draw, forfeit, or unfinished game teaches nothing. + /// + private void ApplyLearning(GameState gameState) + { + if (gameState.Trainer == nint.Zero) + return; + + if (TryDetermineOutcome(gameState, out var winner, out var weight)) + weightsStore.ApplyResult(gameState.Trainer, winner, weight); + + weightsStore.DestroyTrainer(gameState.Trainer); + gameState.Trainer = nint.Zero; + } + + /// + /// Determines the trainable outcome of a finished game: the winning color and the reward + /// weight. Returns false when the game teaches nothing (balanced draw, forfeit, unfinished). + /// + private static bool TryDetermineOutcome(GameState gameState, out PieceColor winner, out double weight) + { + winner = PieceColor.White; + weight = 1.0; + + if (gameState.IsCheckmate) + { + // The side to move is the mated one, so the winner is the other color. + winner = gameState.CurrentPlayer == PieceColor.White ? PieceColor.Black : PieceColor.White; + return true; + } + + if (gameState.IsStalemate || gameState.IsThreefoldRepetition) + { + var (white, black) = MaterialCounts(gameState); + + if (white == black) + return false; // a balanced draw carries no signal + + winner = white < black ? PieceColor.White : PieceColor.Black; + weight = 0.5; + return true; + } + + return false; // forfeit / unfinished + } + + /// Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication. + private static (int white, int black) MaterialCounts(GameState gameState) + { + int white = 0, black = 0; + + for (int row = 0; row < 8; row++) + for (int col = 0; col < 8; col++) + { + var piece = gameState.Board[row, col]; + + if (piece is null) + continue; + + int value = piece.Type switch + { + PieceType.Pawn => 1, + PieceType.Knight => 3, + PieceType.Bishop => 3, + PieceType.Rook => 5, + PieceType.Queen => 9, + _ => 0 + }; + + if (piece.Color == PieceColor.White) + white += value; + else + black += value; + } + + return (white, black); + } +} diff --git a/JoshHeaps.Net/Services/Interfaces/IGameStore.cs b/JoshHeaps.Net/Services/Interfaces/IGameStore.cs new file mode 100644 index 0000000..1794fff --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/IGameStore.cs @@ -0,0 +1,29 @@ +using JoshHeaps.Net.Models; + +namespace JoshHeaps.Net.Services.Interfaces; + +/// +/// Process-wide registry of in-memory games and their cleanup lifecycle. Shared by the HTTP +/// controller (human and single-computer games) and the self-play coordinator (CPU-vs-CPU and +/// auto-training games), so every game is reachable from one place for lookup and spectating. +/// +public interface IGameStore +{ + /// Add (or replace) a game in the registry. + void Add(GameState game); + + /// Look a game up by id. + bool TryGet(Guid id, out GameState game); + + /// Whether a game with this id is still in the registry. + bool Contains(Guid id); + + /// Snapshot of all games currently in the registry. + IReadOnlyCollection All { get; } + + /// + /// Schedule removal of a game after , cancelling any prior schedule + /// for it. On removal the game's engines are disposed and any training accumulator freed. + /// + void ScheduleRemove(Guid id, TimeSpan delay); +} diff --git a/JoshHeaps.Net/Services/Interfaces/ISelfPlayCoordinator.cs b/JoshHeaps.Net/Services/Interfaces/ISelfPlayCoordinator.cs new file mode 100644 index 0000000..fc17da9 --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/ISelfPlayCoordinator.cs @@ -0,0 +1,23 @@ +using JoshHeaps.Net.Services.Implementations; + +namespace JoshHeaps.Net.Services.Interfaces; + +/// Per-side engine and strength for a CPU-vs-CPU game. +public sealed record SelfPlayConfig( + ChessEngineKind WhiteKind, int WhiteSkill, + ChessEngineKind BlackKind, int BlackSkill); + +/// +/// Creates and runs CPU-vs-CPU games to completion: randomized opening, move loop, and (when +/// the learned engine plays) feeding the result back into the learned weights. Used by the +/// spectator "watch" endpoint and by the auto-trainer. +/// +public interface ISelfPlayCoordinator +{ + /// + /// Create, register, and start running a self-play game. Returns immediately with the new + /// game's id and a task that completes when the game finishes (or is cancelled). Callers + /// that only need the id can ignore the task; the auto-trainer awaits it to start the next. + /// + (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default); +}