diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 350d8f2..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)) @@ -212,7 +182,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(); @@ -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/Resources/chess_engine.dll b/JoshHeaps.Net/Resources/chess_engine.dll index 992d57e..9db5240 100644 Binary files a/JoshHeaps.Net/Resources/chess_engine.dll and b/JoshHeaps.Net/Resources/chess_engine.dll differ diff --git a/JoshHeaps.Net/Services/Implementations/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/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/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); +} 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);