Merge pull request #23 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
This commit is contained in:
@@ -4,7 +4,6 @@ using JoshHeaps.Net.Services.Implementations;
|
|||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Controllers;
|
namespace JoshHeaps.Net.Controllers;
|
||||||
|
|
||||||
@@ -16,24 +15,13 @@ public class ChessController(
|
|||||||
IChessEngineFactory engineFactory,
|
IChessEngineFactory engineFactory,
|
||||||
IComputerMoveOrchestrator orchestrator,
|
IComputerMoveOrchestrator orchestrator,
|
||||||
ILearnedWeightsStore weightsStore,
|
ILearnedWeightsStore weightsStore,
|
||||||
|
IGameStore gameStore,
|
||||||
|
ISelfPlayCoordinator selfPlay,
|
||||||
IHubContext<ChessHub> chessHub) : ControllerBase
|
IHubContext<ChessHub> chessHub) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Store of ongoing games.
|
|
||||||
/// </summary>
|
|
||||||
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
|
||||||
private static readonly ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
|
|
||||||
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
|
|
||||||
|
|
||||||
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||||
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
||||||
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(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;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new chess game and store it in-memory.
|
/// Create a new chess game and store it in-memory.
|
||||||
@@ -43,7 +31,7 @@ public class ChessController(
|
|||||||
public ActionResult CreateGame(int difficulty = 20, string color = "random")
|
public ActionResult CreateGame(int difficulty = 20, string color = "random")
|
||||||
{
|
{
|
||||||
var gameState = chessService.CreateNewGame();
|
var gameState = chessService.CreateNewGame();
|
||||||
_games[gameState.GameId] = gameState;
|
gameStore.Add(gameState);
|
||||||
|
|
||||||
gameState.IsVsComputer = true;
|
gameState.IsVsComputer = true;
|
||||||
gameState.WhiteJoined = true;
|
gameState.WhiteJoined = true;
|
||||||
@@ -78,7 +66,7 @@ public class ChessController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -102,31 +90,13 @@ public class ChessController(
|
|||||||
int? whiteSkill = null,
|
int? whiteSkill = null,
|
||||||
int? blackSkill = null)
|
int? blackSkill = null)
|
||||||
{
|
{
|
||||||
var whiteKind = ParseEngineKind(whiteEngine);
|
var config = new SelfPlayConfig(
|
||||||
var blackKind = ParseEngineKind(blackEngine);
|
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
|
||||||
|
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
|
||||||
|
|
||||||
var gameState = chessService.CreateNewGame();
|
var (gameId, _) = selfPlay.StartGame(config);
|
||||||
_games[gameState.GameId] = gameState;
|
|
||||||
|
|
||||||
gameState.IsVsComputer = true;
|
return Ok(new { GameId = gameId });
|
||||||
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
|
private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch
|
||||||
@@ -145,12 +115,12 @@ public class ChessController(
|
|||||||
public ActionResult JoinGame()
|
public ActionResult JoinGame()
|
||||||
{
|
{
|
||||||
Console.WriteLine("joining game");
|
Console.WriteLine("joining game");
|
||||||
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
|
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
|
||||||
|
|
||||||
if (gameState == null)
|
if (gameState == null)
|
||||||
{
|
{
|
||||||
gameState = chessService.CreateNewGame();
|
gameState = chessService.CreateNewGame();
|
||||||
_games[gameState.GameId] = gameState;
|
gameStore.Add(gameState);
|
||||||
}
|
}
|
||||||
|
|
||||||
Guid playerId = Guid.NewGuid();
|
Guid playerId = Guid.NewGuid();
|
||||||
@@ -168,7 +138,7 @@ public class ChessController(
|
|||||||
isWhite = false;
|
isWhite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -184,7 +154,7 @@ public class ChessController(
|
|||||||
[HttpGet("active")]
|
[HttpGet("active")]
|
||||||
public ActionResult GetActiveGames()
|
public ActionResult GetActiveGames()
|
||||||
{
|
{
|
||||||
var activeGames = _games.Values
|
var activeGames = gameStore.All
|
||||||
// In-progress games, plus finished computer-vs-computer games still in their result window.
|
// In-progress games, plus finished computer-vs-computer games still in their result window.
|
||||||
.Where(g => g.WhiteJoined && g.BlackJoined
|
.Where(g => g.WhiteJoined && g.BlackJoined
|
||||||
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
|
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
|
||||||
@@ -212,7 +182,7 @@ public class ChessController(
|
|||||||
public ActionResult GetLearnedWeights()
|
public ActionResult GetLearnedWeights()
|
||||||
{
|
{
|
||||||
var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" };
|
var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" };
|
||||||
var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" };
|
var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Pawn links", "King safety" };
|
||||||
|
|
||||||
var snapshot = weightsStore.Snapshot();
|
var snapshot = weightsStore.Snapshot();
|
||||||
|
|
||||||
@@ -230,7 +200,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}")]
|
[HttpGet("{gameId}")]
|
||||||
public ActionResult GetGameState(Guid gameId)
|
public ActionResult GetGameState(Guid gameId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
return Ok(gameState.ToDto());
|
return Ok(gameState.ToDto());
|
||||||
@@ -243,7 +213,7 @@ public class ChessController(
|
|||||||
[HttpPost("move")]
|
[HttpPost("move")]
|
||||||
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
|
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
|
if (!gameStore.TryGet(moveDto.GameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
// Check if player is authorized to move
|
// Check if player is authorized to move
|
||||||
@@ -270,11 +240,11 @@ public class ChessController(
|
|||||||
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
||||||
|
|
||||||
if (isGameOver)
|
if (isGameOver)
|
||||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||||
else if (gameState.IsVsComputer)
|
else if (gameState.IsVsComputer)
|
||||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||||
else
|
else
|
||||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
var state = gameState.ToDto();
|
var state = gameState.ToDto();
|
||||||
|
|
||||||
@@ -301,7 +271,7 @@ public class ChessController(
|
|||||||
[HttpPost("forfeit")]
|
[HttpPost("forfeit")]
|
||||||
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
|
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(forfeit.GameId, out var gameState))
|
if (!gameStore.TryGet(forfeit.GameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
|
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
|
||||||
@@ -319,7 +289,7 @@ public class ChessController(
|
|||||||
await chessHub.Clients.Group(gameState.GameId.ToString())
|
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||||
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
|
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
@@ -330,7 +300,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/pgn")]
|
[HttpGet("{gameId}/pgn")]
|
||||||
public ActionResult GetPgn(Guid gameId)
|
public ActionResult GetPgn(Guid gameId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
||||||
@@ -342,7 +312,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
||||||
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
||||||
@@ -356,7 +326,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/legalMoves")]
|
[HttpGet("{gameId}/legalMoves")]
|
||||||
public ActionResult GetAllLegalMoves(Guid gameId)
|
public ActionResult GetAllLegalMoves(Guid gameId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var allMoves = chessService.GetAllLegalMoves(gameState)
|
var allMoves = chessService.GetAllLegalMoves(gameState)
|
||||||
@@ -369,180 +339,4 @@ public class ChessController(
|
|||||||
return Ok(allMoves);
|
return Ok(allMoves);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication.</summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,19 @@ builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEng
|
|||||||
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
|
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
|
||||||
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
||||||
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
||||||
|
builder.Services.AddSingleton<IGameStore, GameStore>();
|
||||||
|
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
||||||
|
|
||||||
if (!builder.Environment.IsDevelopment())
|
if (!builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||||
|
|
||||||
|
// 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<AutoTrainingService>();
|
||||||
|
}
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,55 @@
|
|||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Continuously trains the learned engine in the background: two self-play games run in
|
||||||
|
/// parallel, the learned engine (skill 6) against Stockfish (skill 20), one with Stockfish as
|
||||||
|
/// black and one as white. Each slot is independent — when its game finishes it immediately
|
||||||
|
/// starts another under the same conditions, without waiting on the other slot. Registered
|
||||||
|
/// only outside Development and gated by the ChessEngine:AutoTrain config flag.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutoTrainingService(
|
||||||
|
ISelfPlayCoordinator coordinator,
|
||||||
|
ILogger<AutoTrainingService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private const int LearnedSkill = 6;
|
||||||
|
private const int StockfishSkill = 20;
|
||||||
|
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// Learned plays both colors so the model trains symmetrically; each slot is its own loop.
|
||||||
|
var stockfishBlack = RunSlot(
|
||||||
|
new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill),
|
||||||
|
stoppingToken);
|
||||||
|
var stockfishWhite = RunSlot(
|
||||||
|
new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill),
|
||||||
|
stoppingToken);
|
||||||
|
|
||||||
|
return Task.WhenAll(stockfishBlack, stockfishWhite);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunSlot(SelfPlayConfig config, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await coordinator.StartGame(config, stoppingToken).Completion;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
|
||||||
|
// persistent failure doesn't spin a tight loop, then try again.
|
||||||
|
logger.LogError(ex, "Auto-training game failed to start; retrying after backoff.");
|
||||||
|
try { await Task.Delay(_restartBackoff, stoppingToken); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@ public sealed class ChessEngineOptions
|
|||||||
/// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable.
|
/// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? WeightsPath { get; set; }
|
public string? WeightsPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>ChessEngine__AutoTrain</c> environment variable.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoTrain { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameStore(ILearnedWeightsStore weightsStore) : IGameStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||||
|
private readonly ConcurrentDictionary<Guid, Task> _removalTasks = [];
|
||||||
|
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _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<GameState> 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ public sealed class LearnedWeightsStore : ILearnedWeightsStore
|
|||||||
{
|
{
|
||||||
private const int Pieces = 6; // Pawn..King
|
private const int Pieces = 6; // Pawn..King
|
||||||
private const int Squares = 64;
|
private const int Squares = 64;
|
||||||
private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety
|
private const int Features = 7; // mobility N/B/R/Q, passed, pawn links, king safety
|
||||||
|
|
||||||
public string WeightsFilePath { get; }
|
public string WeightsFilePath { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SelfPlayCoordinator(
|
||||||
|
IChessService chessService,
|
||||||
|
IChessEngineFactory engineFactory,
|
||||||
|
IComputerMoveOrchestrator orchestrator,
|
||||||
|
ILearnedWeightsStore weightsStore,
|
||||||
|
IGameStore gameStore) : ISelfPlayCoordinator
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||||
|
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
||||||
|
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
// Plies of random legal moves at the start of a training game, so self-play and
|
||||||
|
// engine-vs-engine games explore different lines instead of replaying one game.
|
||||||
|
private const int _openingRandomPlies = 4;
|
||||||
|
|
||||||
|
public (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var whiteComputer = engineFactory.Create(config.WhiteSkill, config.WhiteKind);
|
||||||
|
IChessEngine blackComputer;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
blackComputer = engineFactory.Create(config.BlackSkill, config.BlackKind);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Don't leak the first engine if the second fails to start (e.g. Stockfish process).
|
||||||
|
whiteComputer.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gameState = chessService.CreateNewGame();
|
||||||
|
gameState.IsVsComputer = true;
|
||||||
|
gameState.IsComputerVsComputer = true;
|
||||||
|
gameState.WhiteJoined = true;
|
||||||
|
gameState.BlackJoined = true;
|
||||||
|
gameState.WhitePlayerId = Guid.NewGuid();
|
||||||
|
gameState.BlackPlayerId = Guid.NewGuid();
|
||||||
|
gameState.WhiteEngineKind = config.WhiteKind;
|
||||||
|
gameState.BlackEngineKind = config.BlackKind;
|
||||||
|
gameState.WhiteComputer = whiteComputer;
|
||||||
|
gameState.BlackComputer = blackComputer;
|
||||||
|
|
||||||
|
// When the learned engine is playing, attach a trainer so the outcome can train it.
|
||||||
|
if (config.WhiteKind == ChessEngineKind.CustomLearned || config.BlackKind == ChessEngineKind.CustomLearned)
|
||||||
|
gameState.Trainer = weightsStore.CreateTrainer();
|
||||||
|
|
||||||
|
gameStore.Add(gameState);
|
||||||
|
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||||
|
|
||||||
|
var completion = Task.Run(() => RunAsync(gameState, cancellationToken));
|
||||||
|
return (gameState.GameId, completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the game to completion, then trains from it. Never throws — a failure just ends
|
||||||
|
/// the game (and the trainer is always freed), so callers can safely await or ignore it.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Give spectators a moment to join the SignalR group before the first move.
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
|
||||||
|
// Training games open with random moves so they don't replay the same line.
|
||||||
|
if (gameState.Trainer != nint.Zero)
|
||||||
|
for (int i = 0; i < _openingRandomPlies && IsLive(gameState, cancellationToken); i++)
|
||||||
|
{
|
||||||
|
await orchestrator.PlayRandomMoveAsync(gameState);
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (IsLive(gameState, cancellationToken))
|
||||||
|
{
|
||||||
|
await orchestrator.PlayAsync(gameState);
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* service shutting down */ }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyLearning(gameState);
|
||||||
|
|
||||||
|
// Leave the finished game in place briefly so spectators can see the result.
|
||||||
|
if (gameStore.Contains(gameState.GameId))
|
||||||
|
gameStore.ScheduleRemove(gameState.GameId, _selfPlayResultTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
|
||||||
|
!cancellationToken.IsCancellationRequested
|
||||||
|
&& gameStore.Contains(gameState.GameId)
|
||||||
|
&& !IsGameOver(gameState);
|
||||||
|
|
||||||
|
private static bool IsGameOver(GameState gameState) =>
|
||||||
|
gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGameStore
|
||||||
|
{
|
||||||
|
/// <summary>Add (or replace) a game in the registry.</summary>
|
||||||
|
void Add(GameState game);
|
||||||
|
|
||||||
|
/// <summary>Look a game up by id.</summary>
|
||||||
|
bool TryGet(Guid id, out GameState game);
|
||||||
|
|
||||||
|
/// <summary>Whether a game with this id is still in the registry.</summary>
|
||||||
|
bool Contains(Guid id);
|
||||||
|
|
||||||
|
/// <summary>Snapshot of all games currently in the registry.</summary>
|
||||||
|
IReadOnlyCollection<GameState> All { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedule removal of a game after <paramref name="delay"/>, cancelling any prior schedule
|
||||||
|
/// for it. On removal the game's engines are disposed and any training accumulator freed.
|
||||||
|
/// </summary>
|
||||||
|
void ScheduleRemove(Guid id, TimeSpan delay);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Per-side engine and strength for a CPU-vs-CPU game.</summary>
|
||||||
|
public sealed record SelfPlayConfig(
|
||||||
|
ChessEngineKind WhiteKind, int WhiteSkill,
|
||||||
|
ChessEngineKind BlackKind, int BlackSkill);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISelfPlayCoordinator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
(Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ set(CMAKE_CXX_EXTENSIONS OFF)
|
|||||||
# Shared library: chess_engine.dll (Windows) / libchess_engine.so (Linux).
|
# Shared library: chess_engine.dll (Windows) / libchess_engine.so (Linux).
|
||||||
add_library(chess_engine SHARED
|
add_library(chess_engine SHARED
|
||||||
src/chess_engine.cpp
|
src/chess_engine.cpp
|
||||||
|
src/eval.cpp
|
||||||
|
src/search.cpp
|
||||||
|
src/learned_model.cpp
|
||||||
src/bitboard.cpp
|
src/bitboard.cpp
|
||||||
src/zobrist.cpp
|
src/zobrist.cpp
|
||||||
src/position.cpp
|
src/position.cpp
|
||||||
|
|||||||
@@ -152,6 +152,9 @@
|
|||||||
</ItemDefinitionGroup>
|
</ItemDefinitionGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ClCompile Include="..\src\chess_engine.cpp" />
|
<ClCompile Include="..\src\chess_engine.cpp" />
|
||||||
|
<ClCompile Include="..\src\eval.cpp" />
|
||||||
|
<ClCompile Include="..\src\search.cpp" />
|
||||||
|
<ClCompile Include="..\src\learned_model.cpp" />
|
||||||
<ClCompile Include="..\src\bitboard.cpp" />
|
<ClCompile Include="..\src\bitboard.cpp" />
|
||||||
<ClCompile Include="..\src\zobrist.cpp" />
|
<ClCompile Include="..\src\zobrist.cpp" />
|
||||||
<ClCompile Include="..\src\position.cpp" />
|
<ClCompile Include="..\src\position.cpp" />
|
||||||
@@ -161,6 +164,9 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ClInclude Include="..\include\chess_engine.h" />
|
<ClInclude Include="..\include\chess_engine.h" />
|
||||||
|
<ClInclude Include="..\src\eval.h" />
|
||||||
|
<ClInclude Include="..\src\search.h" />
|
||||||
|
<ClInclude Include="..\src\learned_model.h" />
|
||||||
<ClInclude Include="..\src\types.h" />
|
<ClInclude Include="..\src\types.h" />
|
||||||
<ClInclude Include="..\src\bitboard.h" />
|
<ClInclude Include="..\src\bitboard.h" />
|
||||||
<ClInclude Include="..\src\zobrist.h" />
|
<ClInclude Include="..\src\zobrist.h" />
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
/* chess_engine.cpp - the DLL boundary (extern "C" ABI).
|
/* chess_engine.cpp - the DLL boundary (extern "C" ABI).
|
||||||
*
|
*
|
||||||
* The rules layer (board, move generation, make/unmake, hashing, perft) lives in
|
* This file is intentionally thin: it owns only the C ABI surface and the FEN/UCI string
|
||||||
* the other src/*.cpp files and is ready to use. engine_best_move is intentionally
|
* marshalling at the managed boundary. The real work lives in the modules it delegates to:
|
||||||
* left for YOU: that is where your search/evaluation goes. Everything below the
|
* - eval.{h,cpp} : classic + learned evaluation, feature computation
|
||||||
* FEN-in / UCI-out boundary should stay native — the managed side crosses it once
|
* - search.{h,cpp} : transposition table, move ordering, negamax + iterative deepening
|
||||||
* per move.
|
* - learned_model.{h,cpp} : global learned weights (state/persistence) and the trainer
|
||||||
|
* The managed side crosses this boundary once per move; everything below it stays native.
|
||||||
*/
|
*/
|
||||||
#ifndef CHESS_ENGINE_BUILD
|
#ifndef CHESS_ENGINE_BUILD
|
||||||
#define CHESS_ENGINE_BUILD /* fallback when not building via CMake (which defines it) */
|
#define CHESS_ENGINE_BUILD /* fallback when not building via CMake (which defines it) */
|
||||||
#endif
|
#endif
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "chess_engine.h"
|
#include "chess_engine.h"
|
||||||
#include "bitboard.h"
|
#include "bitboard.h"
|
||||||
@@ -17,126 +17,24 @@
|
|||||||
#include "position.h"
|
#include "position.h"
|
||||||
#include "movegen.h"
|
#include "movegen.h"
|
||||||
#include "uci.h"
|
#include "uci.h"
|
||||||
|
#include "eval.h"
|
||||||
|
#include "search.h"
|
||||||
|
#include "learned_model.h"
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <atomic>
|
|
||||||
#include <cmath>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <fstream>
|
#include <memory>
|
||||||
#include <mutex>
|
|
||||||
#include <new>
|
#include <new>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <memory>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
/* Internal engine state. One ChessEngine = one game. The transposition table is NOT here:
|
||||||
/* Search score constants. Scores are side-to-move-relative (negamax): positive is
|
* it is the shared table owned by search.cpp. */
|
||||||
* 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<uint64_t> xorKey{0};
|
|
||||||
std::atomic<uint64_t> data{0};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct TranspositionTable {
|
|
||||||
std::unique_ptr<TTEntry[]> 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<uint64_t>(static_cast<uint32_t>(score))
|
|
||||||
| (static_cast<uint64_t>(move.data) << 32)
|
|
||||||
| (static_cast<uint64_t>(static_cast<uint8_t>(depth)) << 48)
|
|
||||||
| (static_cast<uint64_t>(static_cast<uint8_t>(bound)) << 56);
|
|
||||||
}
|
|
||||||
static int tt_score(uint64_t d) { return static_cast<int32_t>(static_cast<uint32_t>(d & 0xFFFFFFFFu)); }
|
|
||||||
static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast<uint16_t>(d >> 32)); }
|
|
||||||
static int tt_depth(uint64_t d) { return static_cast<int>(static_cast<uint8_t>(d >> 48)); }
|
|
||||||
static Bound tt_bound(uint64_t d) { return static_cast<Bound>(static_cast<uint8_t>(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 {
|
struct ChessEngine {
|
||||||
int skill = 20; /* 1..20 from the UI; controls search depth */
|
int skill = 20; /* 1..20 from the UI; controls search depth */
|
||||||
EvalParams eval; /* which evaluation the search uses, plus any learned weights */
|
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) {
|
static int copy_out(const char* src, char* out_buf, int out_len) {
|
||||||
if (!out_buf || out_len <= 0) return CHESS_ERR_BUFFER;
|
if (!out_buf || out_len <= 0) return CHESS_ERR_BUFFER;
|
||||||
const size_t need = std::strlen(src) + 1; /* + NUL */
|
const size_t need = std::strlen(src) + 1; /* + NUL */
|
||||||
@@ -171,425 +69,16 @@ static int parse_variant(const char* options) {
|
|||||||
return std::strncmp(p + 8, "learned", 7) == 0 ? EVAL_LEARNED : EVAL_CLASSIC;
|
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<TTEntry[]>(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" {
|
extern "C" {
|
||||||
|
|
||||||
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
|
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
|
||||||
ensure_initialized();
|
ensure_initialized();
|
||||||
ensure_tt();
|
|
||||||
auto* e = new (std::nothrow) ChessEngine();
|
auto* e = new (std::nothrow) ChessEngine();
|
||||||
if (!e) return nullptr;
|
if (!e) return nullptr;
|
||||||
e->skill = parse_skill(options, e->skill);
|
e->skill = parse_skill(options, e->skill);
|
||||||
e->eval.variant = parse_variant(options);
|
e->eval.variant = parse_variant(options);
|
||||||
if (e->eval.variant == EVAL_LEARNED) {
|
if (e->eval.variant == EVAL_LEARNED)
|
||||||
/* Snapshot the current global weights so the search reads a stable copy (training
|
learned::copy_weights_to(e->eval); /* stable per-handle copy of the global weights */
|
||||||
* updates the global between games; the weights path is owned by learned_load). */
|
|
||||||
std::lock_guard<std::mutex> 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;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,113 +89,6 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine,
|
|||||||
return CHESS_OK; /* TODO: store options */
|
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,
|
CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
||||||
const char* fen,
|
const char* fen,
|
||||||
const char* history,
|
const char* history,
|
||||||
@@ -717,7 +99,6 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
|||||||
|
|
||||||
auto held = std::make_unique<chess::Position>(chess::Position::from_fen(fen));
|
auto held = std::make_unique<chess::Position>(chess::Position::from_fen(fen));
|
||||||
chess::Position& pos = *held;
|
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
|
/* 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. */
|
* the 50-move count that the current FEN alone can't express. */
|
||||||
@@ -736,48 +117,11 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
|||||||
pos.seed_history(priorKeys.data(), static_cast<int>(priorKeys.size()));
|
pos.seed_history(priorKeys.data(), static_cast<int>(priorKeys.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
chess::MoveList moves;
|
chess::Move best = find_best_move(pos, engine->eval, engine->skill);
|
||||||
pos.generate_legal(moves);
|
if (best == chess::MOVE_NONE)
|
||||||
if (moves.size() == 0)
|
|
||||||
return CHESS_ERR_NO_MOVE;
|
return CHESS_ERR_NO_MOVE;
|
||||||
|
|
||||||
SearchContext ctx;
|
return copy_out(chess::move_to_uci(best).c_str(), out_buf, out_len);
|
||||||
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<unsigned long long>(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) {
|
CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len) {
|
||||||
@@ -789,99 +133,33 @@ CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Learned-weights / training C ABI --------------------------------------------------
|
/* ---- Learned-weights / training C ABI --------------------------------------------------
|
||||||
* The managed side orchestrates games but owns no chess logic: it tells the engine where
|
* The managed side orchestrates games but owns no chess logic: it tells the engine where to
|
||||||
* to load/save the global weights, records each played position, and applies the result. */
|
* load/save the global weights, records each played position, and applies the result. Each
|
||||||
|
* export is a thin pass-through to the learned_model module. */
|
||||||
|
|
||||||
CHESS_API void CHESS_CALL learned_load(const char* path) {
|
CHESS_API void CHESS_CALL learned_load(const char* path) {
|
||||||
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
learned::load(path);
|
||||||
g_weightsPath = path ? path : "";
|
|
||||||
load_global_weights(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len) {
|
CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len) {
|
||||||
const int need = 6 * 64 * 2 + FEATURE_NB; /* mg + eg (PAWN..KING) + features = 776 */
|
return learned::snapshot(out, out_len);
|
||||||
if (!out || out_len < need) return CHESS_ERR_BUFFER;
|
|
||||||
|
|
||||||
std::lock_guard<std::mutex> 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) {
|
CHESS_API TrainerHandle CHESS_CALL trainer_create(void) {
|
||||||
return new (std::nothrow) Trainer();
|
return learned::create();
|
||||||
}
|
}
|
||||||
|
|
||||||
CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
|
CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
|
||||||
if (!t || !fen || !*fen) return;
|
ensure_initialized(); /* mobility needs the attack tables */
|
||||||
ensure_initialized();
|
learned::record(t, fen);
|
||||||
|
|
||||||
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) {
|
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) {
|
||||||
if (!t) return;
|
learned::apply(t, winner, weight);
|
||||||
|
|
||||||
std::lock_guard<std::mutex> 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) {
|
CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle t) {
|
||||||
delete t; /* delete nullptr is safe */
|
learned::destroy(t); /* destroy(nullptr) is safe */
|
||||||
}
|
}
|
||||||
|
|
||||||
} /* extern "C" */
|
} /* extern "C" */
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/* eval.cpp - classic and learned position evaluation, plus feature computation.
|
||||||
|
* See eval.h for the public surface. Everything else here is file-static. */
|
||||||
|
#include "eval.h"
|
||||||
|
#include "bitboard.h"
|
||||||
|
#include "position.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
/* ---- 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/* eval.h - position evaluation (classic + learned) and feature computation.
|
||||||
|
*
|
||||||
|
* This is the shared hub of the engine's "scoring" logic. The learned model's
|
||||||
|
* feature activations (compute_features) and game phase are used by BOTH the eval
|
||||||
|
* here and the trainer (learned_model.cpp), so they live in one place and can never
|
||||||
|
* diverge. The search (search.cpp) consumes evaluate_stm and piece_value. */
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "types.h"
|
||||||
|
#include "position.h"
|
||||||
|
|
||||||
|
/* Eval variant for an engine handle. CLASSIC = the hand-crafted evaluate(); LEARNED =
|
||||||
|
* material + learned phase-split piece-square tables + learned feature weights. */
|
||||||
|
enum EvalVariant : int { EVAL_CLASSIC = 0, EVAL_LEARNED = 1 };
|
||||||
|
|
||||||
|
/* The learned feature knobs (beyond the piece-square tables). Each has one weight learned
|
||||||
|
* from game outcomes; its activation is computed by compute_features(). Mobility is per
|
||||||
|
* piece type. Order is fixed — it is the on-disk and snapshot layout after the two tables. */
|
||||||
|
enum Feature : int {
|
||||||
|
FEAT_MOB_N, FEAT_MOB_B, FEAT_MOB_R, FEAT_MOB_Q, /* legal-move counts, per piece type */
|
||||||
|
FEAT_PASSED, /* passed pawns, endgame-weighted */
|
||||||
|
FEAT_PAWN_LINK, /* pawns defended by a friendly pawn */
|
||||||
|
FEAT_KING, /* king pawn-shelter, midgame-weighted */
|
||||||
|
FEATURE_NB
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Per-feature nominal scale: feature activations are divided by this before being weighted,
|
||||||
|
* so high-magnitude mobility doesn't dwarf the small pawn-structure terms. Used by both the
|
||||||
|
* learned eval (to combine) and the trainer (to normalize activations), so it lives here. */
|
||||||
|
inline constexpr double FEAT_SCALE[FEATURE_NB] = { 4, 6, 8, 14, 2, 3, 2 };
|
||||||
|
|
||||||
|
/* Per-handle eval configuration, snapshotted from the global learned weights at
|
||||||
|
* engine_create so the search reads a stable copy. The tables are white-relative: a black
|
||||||
|
* piece indexes the rank-mirrored square (sq ^ 56). `mg`/`eg` are blended by game phase.
|
||||||
|
* Indexed by chess::PieceType (PAWN..KING). Only consulted when variant == EVAL_LEARNED. */
|
||||||
|
struct EvalParams {
|
||||||
|
int variant = EVAL_CLASSIC;
|
||||||
|
int mg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int eg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int featW[FEATURE_NB] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Centipawn material value of a piece type (0 for king / none). Shared with the search's
|
||||||
|
* MVV-LVA move ordering. */
|
||||||
|
int piece_value(chess::PieceType pt);
|
||||||
|
|
||||||
|
/* Game phase in [0,1] from remaining non-pawn material: 0 = opening, 1 = bare kings. Drives
|
||||||
|
* the mg/eg table blend and the phase weighting of the passed-pawn / king-safety features. */
|
||||||
|
double game_phase(const chess::Position& pos);
|
||||||
|
|
||||||
|
/* Fills `out[FEATURE_NB]` with one color's raw feature activations for a position (mobility,
|
||||||
|
* pawn structure, king shelter). The single source of feature activations, shared by the
|
||||||
|
* learned eval and the trainer. Non-const because mobility generates legal moves. */
|
||||||
|
void compute_features(chess::Position& pos, chess::Color c, double phase, double out[FEATURE_NB]);
|
||||||
|
|
||||||
|
/* Side-to-move-relative evaluation for negamax (positive = good for whoever is to move).
|
||||||
|
* Dispatches to the classic or learned eval per ep.variant. Non-const because the learned
|
||||||
|
* eval computes mobility via the move generator. */
|
||||||
|
int evaluate_stm(chess::Position& pos, bool whiteToMove, const EvalParams& ep);
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/* learned_model.cpp - global learned weights and the per-game trainer.
|
||||||
|
*
|
||||||
|
* The model is trained by WIN RATE, not by additive nudges. For every (piece, phase,
|
||||||
|
* square) we keep two running totals across all games: `win` (turns the piece spent there in
|
||||||
|
* games that side won) and `total` (turns spent there in any game). The stored weight is
|
||||||
|
* derived: weight = (2·win/total − 1)·scale, i.e. win-rate 0→−scale, 0.5→0, 1→+scale. Same
|
||||||
|
* for each feature, totalling its activation per turn. This focuses training on "how much
|
||||||
|
* time on this square correlates with winning" and is far less volatile than per-game nudges.
|
||||||
|
*
|
||||||
|
* The counters are the persistent source of truth (saved to / loaded from disk); the integer
|
||||||
|
* weight tables in `g_weights` are recomputed from them. See learned_model.h for the public
|
||||||
|
* surface; feature/phase math is shared from eval.cpp. */
|
||||||
|
#include "learned_model.h"
|
||||||
|
#include "chess_engine.h" /* CHESS_ERR_BUFFER */
|
||||||
|
#include "eval.h"
|
||||||
|
#include "position.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
#include <mutex>
|
||||||
|
#include <new>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/* Win-rate → weight scale. A 100%-win square/feature reaches +scale, a 0%-win one −scale,
|
||||||
|
* matching the ranges the additive trainer used to clamp at (squares ±250, features ±500). */
|
||||||
|
static constexpr double SQ_WEIGHT_SCALE = 250.0;
|
||||||
|
static constexpr double FEAT_WEIGHT_SCALE = 500.0;
|
||||||
|
|
||||||
|
/* On-disk format version, stored as the file's first token. On load, a missing or mismatched
|
||||||
|
* version means the file is stale (old layout / different feature set): its contents are
|
||||||
|
* wiped (the file itself is kept) and training restarts from neutral. Bump this whenever the
|
||||||
|
* counter layout or feature set changes — it replaces having to delete the file by hand. */
|
||||||
|
static constexpr int LEARNED_VERSION = 1;
|
||||||
|
|
||||||
|
/* Derived integer weight tables, read by eval (snapshotted per engine handle) and the viz.
|
||||||
|
* Recomputed from g_counts whenever the counters change. White-relative (black indexes
|
||||||
|
* sq ^ 56); mg/eg blended by game phase. */
|
||||||
|
struct LearnedWeights {
|
||||||
|
int mg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int eg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int featW[FEATURE_NB] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The persistent training counters: the single source of truth. `win` is credited only to
|
||||||
|
* the winning side; `total` to both sides (scaled by the outcome weight). */
|
||||||
|
struct WinCounters {
|
||||||
|
double winMg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double totMg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double winEg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double totEg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double winFeat[FEATURE_NB] = {};
|
||||||
|
double totFeat[FEATURE_NB] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
static LearnedWeights g_weights;
|
||||||
|
static WinCounters g_counts;
|
||||||
|
static std::mutex g_weightsMutex;
|
||||||
|
static std::string g_weightsPath;
|
||||||
|
|
||||||
|
/* win/total → stored weight: win-rate 0 → −scale, 0.5 → 0, 1 → +scale. An untouched
|
||||||
|
* (total == 0) square/feature is neutral. */
|
||||||
|
static int derive(double win, double total, double scale) {
|
||||||
|
if (total <= 0.0) return 0;
|
||||||
|
double rate = win / total;
|
||||||
|
return int(std::lround((2.0 * rate - 1.0) * scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recompute every derived weight from the counters. Caller holds g_weightsMutex. */
|
||||||
|
static void recompute_weights() {
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) {
|
||||||
|
g_weights.mg[pt][sq] = derive(g_counts.winMg[pt][sq], g_counts.totMg[pt][sq], SQ_WEIGHT_SCALE);
|
||||||
|
g_weights.eg[pt][sq] = derive(g_counts.winEg[pt][sq], g_counts.totEg[pt][sq], SQ_WEIGHT_SCALE);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i)
|
||||||
|
g_weights.featW[i] = derive(g_counts.winFeat[i], g_counts.totFeat[i], FEAT_WEIGHT_SCALE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void save_global_weights(); /* defined below; load rewrites stale files via it */
|
||||||
|
|
||||||
|
/* On-disk format: LEARNED_VERSION as the first token, then the counters as whitespace doubles
|
||||||
|
* in this order — winMg, totMg, winEg, totEg (each 6*64, PAWN..KING, squares 0..63), then
|
||||||
|
* winFeat, totFeat (each FEATURE_NB). If the version is missing/wrong or the file is short
|
||||||
|
* (old format, corrupt, or absent), the counters are left neutral and the file is rewritten
|
||||||
|
* blank-but-versioned — clearing stale contents while keeping the file. Caller holds the lock. */
|
||||||
|
static void load_global_weights(const char* path) {
|
||||||
|
WinCounters loaded{};
|
||||||
|
bool ok = false;
|
||||||
|
|
||||||
|
if (path && *path) {
|
||||||
|
std::ifstream f(path);
|
||||||
|
if (f) {
|
||||||
|
int version = 0;
|
||||||
|
if ((f >> version) && version == LEARNED_VERSION) {
|
||||||
|
auto readTable = [&](double t[chess::PIECE_TYPE_NB][64]) -> bool {
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq)
|
||||||
|
if (!(f >> t[pt][sq])) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
ok = readTable(loaded.winMg) && readTable(loaded.totMg)
|
||||||
|
&& readTable(loaded.winEg) && readTable(loaded.totEg);
|
||||||
|
for (int i = 0; ok && i < FEATURE_NB; ++i) if (!(f >> loaded.winFeat[i])) ok = false;
|
||||||
|
for (int i = 0; ok && i < FEATURE_NB; ++i) if (!(f >> loaded.totFeat[i])) ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g_counts = ok ? loaded : WinCounters{};
|
||||||
|
recompute_weights();
|
||||||
|
|
||||||
|
/* Stale / wrong-version / unreadable: wipe the file's contents (keep the file) by
|
||||||
|
* rewriting it blank-but-versioned, so the next load matches and we never reread garbage. */
|
||||||
|
if (!ok)
|
||||||
|
save_global_weights();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Persist g_counts to g_weightsPath in the format load_global_weights reads. Caller holds the lock. */
|
||||||
|
static void save_global_weights() {
|
||||||
|
if (g_weightsPath.empty()) return;
|
||||||
|
std::ofstream f(g_weightsPath);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
f << LEARNED_VERSION << '\n';
|
||||||
|
|
||||||
|
auto writeTable = [&](const double t[chess::PIECE_TYPE_NB][64]) {
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) f << t[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||||
|
};
|
||||||
|
writeTable(g_counts.winMg); writeTable(g_counts.totMg);
|
||||||
|
writeTable(g_counts.winEg); writeTable(g_counts.totEg);
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) f << g_counts.winFeat[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) f << g_counts.totFeat[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Per-game training accumulator ----------------------------------------------------
|
||||||
|
* Records, per ply, where each side's pieces sat (split into midgame/endgame by phase) and
|
||||||
|
* each side's feature activations. learned::apply folds these per-side totals into the global
|
||||||
|
* win/total counters. Squares are white-relative (black indexes sq ^ 56), so a side's tally
|
||||||
|
* lines up with the shared white-relative table. */
|
||||||
|
struct Trainer {
|
||||||
|
double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double egOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double featAcc[chess::COLOR_NB][FEATURE_NB] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace learned {
|
||||||
|
|
||||||
|
void load(const char* path) {
|
||||||
|
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* learned_model.h - the learned engine's process-global weights and training.
|
||||||
|
*
|
||||||
|
* Owns the single source of truth for the learned weights (loaded from / saved to disk),
|
||||||
|
* the read-only snapshot for visualization, and the per-game training accumulator that
|
||||||
|
* turns played positions + a result into weight nudges. The DLL ABI (chess_engine.cpp)
|
||||||
|
* is a thin pass-through to the functions here; feature/phase math is shared from eval.h. */
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "eval.h"
|
||||||
|
|
||||||
|
/* Per-game training accumulator. Global-namespace `Trainer` so it matches the opaque
|
||||||
|
* `typedef struct Trainer* TrainerHandle` in the public ABI header. Defined in the .cpp. */
|
||||||
|
struct Trainer;
|
||||||
|
|
||||||
|
namespace learned {
|
||||||
|
|
||||||
|
/* Set the global weights file path and load from it (idempotent; a missing/short file
|
||||||
|
* leaves the weights neutral). */
|
||||||
|
void load(const char* path);
|
||||||
|
|
||||||
|
/* Copy the global weights out for visualization: 6*64 midgame + 6*64 endgame + features.
|
||||||
|
* Returns the count written, or CHESS_ERR_BUFFER if out_len is too small (needs >= 776). */
|
||||||
|
int snapshot(int* out, int out_len);
|
||||||
|
|
||||||
|
/* Snapshot the current global weights into a fresh engine handle's eval config so the
|
||||||
|
* search reads a stable copy (training updates the global between games). */
|
||||||
|
void copy_weights_to(EvalParams& ep);
|
||||||
|
|
||||||
|
/* Per-game training lifecycle. */
|
||||||
|
Trainer* create();
|
||||||
|
void destroy(Trainer* t);
|
||||||
|
|
||||||
|
/* Record one played position (post-move FEN) into the accumulator. */
|
||||||
|
void record(Trainer* t, const char* fen);
|
||||||
|
|
||||||
|
/* Apply a finished game's outcome to the global weights and persist: rewards the winner's
|
||||||
|
* occupied squares / features, punishes the loser's, scaled by `weight`. winner: 0=W, 1=B. */
|
||||||
|
void apply(Trainer* t, int winner, double weight);
|
||||||
|
|
||||||
|
} // namespace learned
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/* search.cpp - negamax alpha-beta over a shared transposition table, driven by
|
||||||
|
* iterative deepening. See search.h for the (single-function) public surface. */
|
||||||
|
#include "search.h"
|
||||||
|
#include "eval.h"
|
||||||
|
#include "position.h"
|
||||||
|
#include "movegen.h"
|
||||||
|
#include "uci.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
/* 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<uint64_t> xorKey{0};
|
||||||
|
std::atomic<uint64_t> data{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TranspositionTable {
|
||||||
|
std::unique_ptr<TTEntry[]> 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<uint64_t>(static_cast<uint32_t>(score))
|
||||||
|
| (static_cast<uint64_t>(move.data) << 32)
|
||||||
|
| (static_cast<uint64_t>(static_cast<uint8_t>(depth)) << 48)
|
||||||
|
| (static_cast<uint64_t>(static_cast<uint8_t>(bound)) << 56);
|
||||||
|
}
|
||||||
|
static int tt_score(uint64_t d) { return static_cast<int32_t>(static_cast<uint32_t>(d & 0xFFFFFFFFu)); }
|
||||||
|
static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast<uint16_t>(d >> 32)); }
|
||||||
|
static int tt_depth(uint64_t d) { return static_cast<int>(static_cast<uint8_t>(d >> 48)); }
|
||||||
|
static Bound tt_bound(uint64_t d) { return static_cast<Bound>(static_cast<uint8_t>(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<TTEntry[]>(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<unsigned long long>(ctx.nodes),
|
||||||
|
chess::move_to_uci(iterBest).c_str(), iterScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMove;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* search.h - the engine's search: a single entry point.
|
||||||
|
*
|
||||||
|
* Everything else (the shared transposition table, move ordering, negamax, and the
|
||||||
|
* iterative-deepening driver) is an implementation detail of search.cpp. */
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "position.h"
|
||||||
|
#include "eval.h"
|
||||||
|
|
||||||
|
/* Best move for `pos` using evaluation `ep`, searched to the depth implied by `skill`
|
||||||
|
* (1..20). Seeds, allocates, and reuses the process-wide transposition table on first
|
||||||
|
* call. Returns chess::MOVE_NONE when there is no legal move (mate/stalemate). The
|
||||||
|
* position's repetition/50-move history should already be seeded by the caller. */
|
||||||
|
chess::Move find_best_move(chess::Position& pos, const EvalParams& ep, int skill);
|
||||||
Reference in New Issue
Block a user