diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2207802..49a282e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -54,12 +54,6 @@ jobs: name: site-publish path: publish - # GitHub artifacts don't preserve the Unix executable bit, so Stockfish (the only file - # the app spawns as a subprocess) arrives non-executable. Restore 755 here; rsync -a then - # carries it to the server, where the service user can run it regardless of file owner. - - name: Restore Stockfish executable bit - run: chmod 755 publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt - - name: Prepare SSH run: | install -m 700 -d ~/.ssh @@ -69,10 +63,7 @@ jobs: - name: Rsync to server run: | - # --exclude chess-data: never let --delete remove the learned-engine training - # data, which lives in the deploy dir unless ChessEngine__WeightsPath is set - # elsewhere. publish/ never contains it, so without this --delete wipes it every deploy. - rsync -az --delete --exclude 'chess-data' -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \ + rsync -az --delete -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \ publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/ - name: Reload and restart service diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 5344ae0..350d8f2 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -4,6 +4,7 @@ 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; @@ -15,14 +16,24 @@ public class ChessController( IChessEngineFactory engineFactory, IComputerMoveOrchestrator orchestrator, ILearnedWeightsStore weightsStore, - IGameStore gameStore, - ISelfPlayCoordinator selfPlay, - AutoTrainingSettings autoTraining, 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. @@ -32,7 +43,7 @@ public class ChessController( public ActionResult CreateGame(int difficulty = 20, string color = "random") { var gameState = chessService.CreateNewGame(); - gameStore.Add(gameState); + _games[gameState.GameId] = gameState; gameState.IsVsComputer = true; gameState.WhiteJoined = true; @@ -67,7 +78,7 @@ public class ChessController( }); } - gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout); + ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); return Ok(new { @@ -91,13 +102,31 @@ public class ChessController( int? whiteSkill = null, int? blackSkill = null) { - var config = new SelfPlayConfig( - ParseEngineKind(whiteEngine), whiteSkill ?? difficulty, - ParseEngineKind(blackEngine), blackSkill ?? difficulty); + var whiteKind = ParseEngineKind(whiteEngine); + var blackKind = ParseEngineKind(blackEngine); - var (gameId, _) = selfPlay.StartGame(config); + var gameState = chessService.CreateNewGame(); + _games[gameState.GameId] = gameState; - return Ok(new { GameId = gameId }); + 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 }); } private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch @@ -107,25 +136,6 @@ public class ChessController( _ => ChessEngineKind.Custom }; - /// - /// Current number of background auto-training games (and the allowed maximum). Auto-training - /// itself runs only outside Development; this reflects the target the service is keeping. - /// - [HttpGet("autotrain")] - public ActionResult GetAutoTrain() => - Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames }); - - /// - /// Set how many auto-training games run concurrently (clamped to 0..max; 0 pauses training). - /// Takes effect live — the background service tops up or drains toward the new count. - /// - [HttpPost("autotrain")] - public ActionResult SetAutoTrain([FromQuery] int count) - { - autoTraining.GameCount = count; // clamped inside the setter - return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames }); - } - /// /// Joins the "pool" of chess players. /// Test code expects to receive a GUID for the player @@ -135,12 +145,12 @@ public class ChessController( public ActionResult JoinGame() { Console.WriteLine("joining game"); - GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen); + GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen); if (gameState == null) { gameState = chessService.CreateNewGame(); - gameStore.Add(gameState); + _games[gameState.GameId] = gameState; } Guid playerId = Guid.NewGuid(); @@ -158,7 +168,7 @@ public class ChessController( isWhite = false; } - gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout); + ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); return Ok(new { @@ -174,7 +184,7 @@ public class ChessController( [HttpGet("active")] public ActionResult GetActiveGames() { - var activeGames = gameStore.All + var activeGames = _games.Values // 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)) @@ -202,7 +212,7 @@ public class ChessController( public ActionResult GetLearnedWeights() { var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" }; - var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Pawn links", "King safety" }; + var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" }; var snapshot = weightsStore.Snapshot(); @@ -220,7 +230,7 @@ public class ChessController( [HttpGet("{gameId}")] public ActionResult GetGameState(Guid gameId) { - if (!gameStore.TryGet(gameId, out var gameState)) + if (!_games.TryGetValue(gameId, out var gameState)) return NotFound("Game not found"); return Ok(gameState.ToDto()); @@ -233,7 +243,7 @@ public class ChessController( [HttpPost("move")] public async Task MakeMove([FromBody] MoveDto moveDto) { - if (!gameStore.TryGet(moveDto.GameId, out var gameState)) + if (!_games.TryGetValue(moveDto.GameId, out var gameState)) return NotFound("Game not found"); // Check if player is authorized to move @@ -260,11 +270,11 @@ public class ChessController( var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition; if (isGameOver) - gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout); + ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout); else if (gameState.IsVsComputer) - gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout); + ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); else - gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout); + ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); var state = gameState.ToDto(); @@ -291,7 +301,7 @@ public class ChessController( [HttpPost("forfeit")] public async Task Forfeit([FromBody] ForfeitDto forfeit) { - if (!gameStore.TryGet(forfeit.GameId, out var gameState)) + if (!_games.TryGetValue(forfeit.GameId, out var gameState)) return NotFound("Game not found"); if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited) @@ -309,7 +319,7 @@ public class ChessController( await chessHub.Clients.Group(gameState.GameId.ToString()) .SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit"); - gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout); + ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout); return Ok(); } @@ -320,7 +330,7 @@ public class ChessController( [HttpGet("{gameId}/pgn")] public ActionResult GetPgn(Guid gameId) { - if (!gameStore.TryGet(gameId, out var gameState)) + if (!_games.TryGetValue(gameId, out var gameState)) return NotFound("Game not found"); return Content(gameState.ToPgn(), "application/x-chess-pgn"); @@ -332,7 +342,7 @@ public class ChessController( [HttpGet("{gameId}/legalMoves/{pieceId}")] public ActionResult GetLegalMoves(Guid gameId, string pieceId) { - if (!gameStore.TryGet(gameId, out var gameState)) + if (!_games.TryGetValue(gameId, out var gameState)) return NotFound("Game not found"); var moves = chessService.GetLegalMovesForPiece(gameState, pieceId); @@ -346,7 +356,7 @@ public class ChessController( [HttpGet("{gameId}/legalMoves")] public ActionResult GetAllLegalMoves(Guid gameId) { - if (!gameStore.TryGet(gameId, out var gameState)) + if (!_games.TryGetValue(gameId, out var gameState)) return NotFound("Game not found"); var allMoves = chessService.GetAllLegalMoves(gameState) @@ -359,4 +369,180 @@ 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/Pages/Watch.cshtml b/JoshHeaps.Net/Pages/Watch.cshtml index b4718e9..3e04add 100644 --- a/JoshHeaps.Net/Pages/Watch.cshtml +++ b/JoshHeaps.Net/Pages/Watch.cshtml @@ -38,11 +38,6 @@ -
- Auto-train games - - -
← Play a game View learned weights → diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index cd006d7..0657f4e 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -26,20 +26,10 @@ builder.Services.Configure(configuration.GetSection(ChessEng builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); if (!builder.Environment.IsDevelopment()) -{ builder.Services.AddHostedService(); - // Continuously train the learned engine against Stockfish in the background. Toggle off - // via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy. - if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true)) - builder.Services.AddHostedService(); -} - var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs b/JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs deleted file mode 100644 index 607aeec..0000000 --- a/JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs +++ /dev/null @@ -1,73 +0,0 @@ -using JoshHeaps.Net.Services.Interfaces; - -namespace JoshHeaps.Net.Services.Implementations; - -/// -/// Continuously trains the learned engine in the background by keeping a configurable number of -/// self-play games running — the learned engine (skill 6) against Stockfish (skill 20), alternating -/// which color Stockfish takes so the model trains on both. The target count is read live from -/// (adjustable from the website): when a game finishes another -/// starts to refill the pool, raising the count starts more, and lowering it lets the surplus drain -/// as games finish (0 pauses training). Registered only outside Development and gated by the -/// ChessEngine:AutoTrain config flag. -/// -public sealed class AutoTrainingService( - ISelfPlayCoordinator coordinator, - AutoTrainingSettings settings, - ILogger logger) : BackgroundService -{ - private const int LearnedSkill = 6; - private const int StockfishSkill = 20; - private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5); - private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2); - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - var running = new List(); - int started = 0; - - while (!stoppingToken.IsCancellationRequested) - { - running.RemoveAll(t => t.IsCompleted); - - int desired = settings.GameCount; - bool startFailed = false; - - while (running.Count < desired && !stoppingToken.IsCancellationRequested) - { - // Alternate Stockfish's color so the learned engine trains as both white and black. - var config = started++ % 2 == 0 - ? new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill) - : new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill); - - try - { - var (_, completion) = coordinator.StartGame(config, stoppingToken); - running.Add(completion); - } - 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, "Failed to start an auto-training game; retrying after backoff."); - startFailed = true; - break; - } - } - - try - { - if (startFailed) - await Task.Delay(_restartBackoff, stoppingToken); - else if (running.Count > 0) - // Wake when any game finishes (to refill) or after a short poll (to pick up a - // count increase promptly). - await Task.WhenAny(Task.WhenAny(running), Task.Delay(_pollInterval, stoppingToken)); - else - // Pool is empty (count is 0) — just poll for the count to change. - await Task.Delay(_pollInterval, stoppingToken); - } - catch (OperationCanceledException) { break; } - } - } -} diff --git a/JoshHeaps.Net/Services/Implementations/AutoTrainingSettings.cs b/JoshHeaps.Net/Services/Implementations/AutoTrainingSettings.cs deleted file mode 100644 index 16e605f..0000000 --- a/JoshHeaps.Net/Services/Implementations/AutoTrainingSettings.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.Extensions.Options; - -namespace JoshHeaps.Net.Services.Implementations; - -/// -/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the -/// chess controller) is seen live by the background . Seeded from -/// and clamped to a sane range. -/// -public sealed class AutoTrainingSettings -{ - /// Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine). - public const int MaxGames = 16; - - private int _gameCount; - - public AutoTrainingSettings(IOptions options) - => _gameCount = Clamp(options.Value.AutoTrainGameCount); - - /// - /// Number of auto-training games to keep running concurrently. 0 pauses auto-training. - /// Reads/writes are atomic; the background service reads this every cycle. - /// - public int GameCount - { - get => Volatile.Read(ref _gameCount); - set => Volatile.Write(ref _gameCount, Clamp(value)); - } - - private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames); -} diff --git a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs index d0833a8..6d18441 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs @@ -29,20 +29,6 @@ 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; - - /// - /// How many auto-training games run concurrently (when is on). This is - /// the starting value; it can be changed at runtime from the website. Override the default via - /// the ChessEngine__AutoTrainGameCount environment variable. - /// - public int AutoTrainGameCount { get; set; } = 2; } /// Creates the configured per game. diff --git a/JoshHeaps.Net/Services/Implementations/GameStore.cs b/JoshHeaps.Net/Services/Implementations/GameStore.cs deleted file mode 100644 index af85de0..0000000 --- a/JoshHeaps.Net/Services/Implementations/GameStore.cs +++ /dev/null @@ -1,69 +0,0 @@ -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 e2d3563..1a1f72b 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 = 7; // mobility N/B/R/Q, passed, pawn links, king safety + private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety public string WeightsFilePath { get; } diff --git a/JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs b/JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs deleted file mode 100644 index af6ce84..0000000 --- a/JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs +++ /dev/null @@ -1,222 +0,0 @@ -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); - - // Abort a game if a single engine move takes longer than this — a stopgap for engines - // (usually Stockfish) that occasionally freeze and would otherwise hang the game. - private static readonly TimeSpan _moveTimeout = TimeSpan.FromSeconds(60); - - // 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 (or a frozen - /// engine) just ends the game and the trainer is always freed, so callers can await or ignore. - /// - private async Task RunAsync(GameState gameState, CancellationToken cancellationToken) - { - bool aborted = false; - - 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 PlayMoveWithTimeoutAsync(gameState, cancellationToken); - await Task.Delay(_selfPlayMoveDelay, cancellationToken); - } - } - catch (OperationCanceledException) { aborted = true; /* service shutting down */ } - catch (TimeoutException) - { - aborted = true; - Console.WriteLine($"Self-play game {gameState.GameId} aborted: a move took over " + - $"{_moveTimeout.TotalSeconds:n0}s (likely a frozen engine)."); - } - catch (Exception ex) - { - aborted = true; - Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}"); - } - - ApplyLearning(gameState); - - // A clean finish lingers briefly so spectators see the result; an aborted/hung game is - // torn down immediately so its engines (and any frozen Stockfish process) are released. - if (gameStore.Contains(gameState.GameId)) - gameStore.ScheduleRemove(gameState.GameId, aborted ? TimeSpan.Zero : _selfPlayResultTimeout); - } - - /// - /// Plays one engine move, abandoning it if it exceeds (throwing - /// ). The abandoned move's eventual fault — it errors once the - /// game's engines are disposed — is observed so it isn't an unobserved task exception. - /// - private async Task PlayMoveWithTimeoutAsync(GameState gameState, CancellationToken cancellationToken) - { - var play = orchestrator.PlayAsync(gameState); - try - { - await play.WaitAsync(_moveTimeout, cancellationToken); - } - catch (TimeoutException) - { - _ = play.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default); - throw; - } - } - - 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 deleted file mode 100644 index 1794fff..0000000 --- a/JoshHeaps.Net/Services/Interfaces/IGameStore.cs +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index fc17da9..0000000 --- a/JoshHeaps.Net/Services/Interfaces/ISelfPlayCoordinator.cs +++ /dev/null @@ -1,23 +0,0 @@ -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/JoshHeaps.Net/wwwroot/css/chess/spectate.css b/JoshHeaps.Net/wwwroot/css/chess/spectate.css index 410c16d..297fe93 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/spectate.css +++ b/JoshHeaps.Net/wwwroot/css/chess/spectate.css @@ -88,29 +88,16 @@ html, body { cursor: pointer; } -/* Overlaid on the bottom of the card at game end, so it never adds to the card's height - (which would shift the whole grid as games finish and are cleared). */ -.gameOverlay { - position: absolute; - left: 0; - right: 0; - bottom: 0; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.6rem; - padding: 0.9rem 1rem; - background-color: rgba(20, 20, 22, 0.92); - border-radius: 0 0 10px 10px; -} - .gameResult { text-align: center; font-weight: bold; color: #8cd5ed; + margin-top: 0.75rem; } .copyPgnBtn { + display: block; + margin: 0.75rem auto 0; background-color: #8cd5ed; color: #262626; border: 0; @@ -167,25 +154,6 @@ body.fullscreen-open { text-align: center; font-weight: bold; margin-bottom: 0.75rem; - /* Reserve two lines so the header doesn't reflow as the move count gains digits, - the side-to-move text changes, or the check tag toggles. */ - line-height: 1.3; - min-height: 2.6em; - display: flex; - align-items: center; - justify-content: center; -} - -/* Always occupies its space (it's only hidden, not removed) so toggling "check" never - re-centers or wraps the header line. */ -.checkTag { - margin-left: 0.4rem; - color: #e8a04a; - visibility: hidden; -} - -.checkTag.show { - visibility: visible; } .miniBoard { diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js index 695d017..d576798 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js @@ -23,40 +23,9 @@ const Spectate = { } await this.refreshGames(); - await this.loadAutoTrainCount(); setInterval(() => this.refreshGames(), 5000); }, - // Auto-training runs server-side; show its target count and let it be changed here. - async loadAutoTrainCount() { - const input = document.getElementById("autoTrainCount"); - if (!input) return; - - try { - const response = await fetch("/api/chess/autotrain"); - const data = await response.json(); - input.max = data.max; - // Don't clobber the value while the user is editing it. - if (document.activeElement !== input) - input.value = data.count; - } catch { - // Leave the control as-is if auto-training status can't be read. - } - }, - - async setAutoTrainCount() { - const input = document.getElementById("autoTrainCount"); - const count = Math.max(0, parseInt(input.value, 10) || 0); - - try { - const response = await fetch(`/api/chess/autotrain?count=${count}`, { method: "POST" }); - const data = await response.json(); - input.value = data.count; - } catch (err) { - console.error("❌ Could not set the auto-training game count.", err); - } - }, - async startCpuGame() { const params = new URLSearchParams({ whiteEngine: document.getElementById("whiteEngine").value, @@ -123,7 +92,7 @@ const Spectate = { const header = document.createElement("div"); header.className = "gameCardHeader"; header.id = `header-${game.gameId}`; - this.renderHeader(header, this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck); + header.textContent = this.headerText(this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck); card.appendChild(header); const board = document.createElement("div"); @@ -217,23 +186,7 @@ const Spectate = { const header = document.getElementById(`header-${gameId}`); if (stored && header) - this.renderHeader(header, stored, currentPlayer, moveCount, isCheck); - }, - - // Renders the header as a base text span plus a "check" tag that always occupies its slot - // (hidden when not in check), so toggling check never re-centers or wraps the line. - renderHeader(header, stored, currentPlayer, moveCount, isCheck) { - const showCheck = !stored.result && isCheck; - header.innerHTML = ""; - - const main = document.createElement("span"); - main.textContent = this.headerText(stored, currentPlayer, moveCount); - - const tag = document.createElement("span"); - tag.className = showCheck ? "checkTag show" : "checkTag"; - tag.textContent = "• check"; - - header.append(main, tag); + header.textContent = this.headerText(stored, currentPlayer, moveCount, isCheck); }, gameLabel(stored) { @@ -252,11 +205,12 @@ const Spectate = { } }, - headerText(stored, currentPlayer, moveCount) { + headerText(stored, currentPlayer, moveCount, isCheck) { if (stored.result) return `${this.gameLabel(stored)} · move ${moveCount} · final`; - return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move`; + const check = isCheck ? " • check" : ""; + return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move${check}`; }, resultTextFromState(state) { @@ -274,42 +228,34 @@ const Spectate = { if (!card) return; - // The result and Copy PGN button live in an overlay anchored over the board so showing - // them at game end never changes the card's height (which would shift the whole grid). - let overlay = card.querySelector(".gameOverlay"); + let banner = card.querySelector(".gameResult"); if (!text) { - overlay?.remove(); + banner?.remove(); + card.querySelector(".copyPgnBtn")?.remove(); card.classList.remove("over"); return; } - if (!overlay) { - overlay = document.createElement("div"); - overlay.className = "gameOverlay"; - - const banner = document.createElement("div"); + if (!banner) { + banner = document.createElement("div"); banner.className = "gameResult"; - overlay.appendChild(banner); - - card.appendChild(overlay); + card.appendChild(banner); } - overlay.querySelector(".gameResult").textContent = text; + banner.textContent = text; card.classList.add("over"); this.addCopyPgn(gameId, card); }, addCopyPgn(gameId, card) { - const overlay = card.querySelector(".gameOverlay"); - - if (!overlay || overlay.querySelector(".copyPgnBtn")) return; + if (card.querySelector(".copyPgnBtn")) return; const btn = document.createElement("button"); btn.className = "copyPgnBtn"; btn.textContent = "Copy PGN"; btn.onclick = (event) => { event.stopPropagation(); this.copyPgn(gameId); }; - overlay.appendChild(btn); + card.appendChild(btn); // Prefetch now (while the game is still in memory) so copy works during the // brief window before the finished game is cleaned up. diff --git a/native/chess_engine/CMakeLists.txt b/native/chess_engine/CMakeLists.txt index 760f226..af664b1 100644 --- a/native/chess_engine/CMakeLists.txt +++ b/native/chess_engine/CMakeLists.txt @@ -8,9 +8,6 @@ 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 8b7b467..5f99245 100644 --- a/native/chess_engine/chess_engine/chess_engine.vcxproj +++ b/native/chess_engine/chess_engine/chess_engine.vcxproj @@ -152,9 +152,6 @@ - - - @@ -164,9 +161,6 @@ - - - diff --git a/native/chess_engine/src/chess_engine.cpp b/native/chess_engine/src/chess_engine.cpp index 9932f0e..c32a252 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). * - * 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. + * 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. */ #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,24 +17,126 @@ #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 -/* Internal engine state. One ChessEngine = one game. The transposition table is NOT here: - * it is the shared table owned by search.cpp. */ + +/* 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. */ 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 */ @@ -69,16 +171,425 @@ 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) - learned::copy_weights_to(e->eval); /* stable per-handle copy of the global weights */ + 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); + } return e; } @@ -89,6 +600,113 @@ 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, @@ -99,6 +717,7 @@ 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. */ @@ -117,11 +736,48 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, pos.seed_history(priorKeys.data(), static_cast(priorKeys.size())); } - chess::Move best = find_best_move(pos, engine->eval, engine->skill); - if (best == chess::MOVE_NONE) + chess::MoveList moves; + pos.generate_legal(moves); + if (moves.size() == 0) return CHESS_ERR_NO_MOVE; - return copy_out(chess::move_to_uci(best).c_str(), out_buf, out_len); + 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); } CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len) { @@ -133,33 +789,99 @@ 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. Each - * export is a thin pass-through to the learned_model module. */ + * 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. */ CHESS_API void CHESS_CALL learned_load(const char* path) { - learned::load(path); + std::lock_guard lock(g_weightsMutex); + g_weightsPath = path ? path : ""; + load_global_weights(path); } CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len) { - return learned::snapshot(out, 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; } CHESS_API TrainerHandle CHESS_CALL trainer_create(void) { - return learned::create(); + return new (std::nothrow) Trainer(); } CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) { - ensure_initialized(); /* mobility needs the attack tables */ - learned::record(t, 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++; } CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) { - learned::apply(t, winner, 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(); } CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle t) { - learned::destroy(t); /* destroy(nullptr) is safe */ + delete t; /* delete nullptr is safe */ } } /* extern "C" */ diff --git a/native/chess_engine/src/eval.cpp b/native/chess_engine/src/eval.cpp deleted file mode 100644 index 0c979a2..0000000 --- a/native/chess_engine/src/eval.cpp +++ /dev/null @@ -1,272 +0,0 @@ -/* 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 deleted file mode 100644 index d4c78e6..0000000 --- a/native/chess_engine/src/eval.h +++ /dev/null @@ -1,59 +0,0 @@ -/* 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 deleted file mode 100644 index ea5ea1e..0000000 --- a/native/chess_engine/src/learned_model.cpp +++ /dev/null @@ -1,252 +0,0 @@ -/* 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; - bool fileExisted = false; - - if (path && *path) { - std::ifstream f(path); - if (f) { - fileExisted = true; - 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(); - - /* Only create a fresh file when none exists yet (first run): write it blank-but-versioned - * so there's a valid target to persist into. If a file IS present but couldn't be parsed - * (old format, corrupt, or a partial write), leave its bytes untouched — never destroy - * accumulated training data on startup. We just play from neutral weights this session; - * the next training apply() overwrites the file with a clean, current-format save. */ - if (!ok && !fileExisted) - 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 deleted file mode 100644 index cd6a3a5..0000000 --- a/native/chess_engine/src/learned_model.h +++ /dev/null @@ -1,40 +0,0 @@ -/* 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 deleted file mode 100644 index d83a308..0000000 --- a/native/chess_engine/src/search.cpp +++ /dev/null @@ -1,304 +0,0 @@ -/* 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 deleted file mode 100644 index e29a862..0000000 --- a/native/chess_engine/src/search.h +++ /dev/null @@ -1,14 +0,0 @@ -/* 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);