Gitea/workflows #1

Closed
jheaps wants to merge 47 commits from gitea/workflows into AddProject
23 changed files with 1286 additions and 61 deletions
Showing only changes of commit f09ca60315 - Show all commits
+3
View File
@@ -3,6 +3,9 @@
## ##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# Learned chess engine weights (runtime training output, not source)
chess-data/
# User-specific files # User-specific files
*.rsuser *.rsuser
*.suo *.suo
+180 -18
View File
@@ -15,6 +15,7 @@ public class ChessController(
IBackgroundTaskQueue queue, IBackgroundTaskQueue queue,
IChessEngineFactory engineFactory, IChessEngineFactory engineFactory,
IComputerMoveOrchestrator orchestrator, IComputerMoveOrchestrator orchestrator,
ILearnedWeightsStore weightsStore,
IHubContext<ChessHub> chessHub) : ControllerBase IHubContext<ChessHub> chessHub) : ControllerBase
{ {
/// <summary> /// <summary>
@@ -30,6 +31,10 @@ public class ChessController(
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1); private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30); 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.
/// </summary> /// </summary>
@@ -52,22 +57,24 @@ public class ChessController(
_ => Random.Shared.Next(2) == 0, _ => Random.Shared.Next(2) == 0,
}; };
gameState.Computer = engineFactory.Create(difficulty); var computer = engineFactory.Create(difficulty);
if (isWhite) if (isWhite)
{ {
gameState.WhitePlayerId = playerId; gameState.WhitePlayerId = playerId;
gameState.BlackPlayerId = computerId; gameState.BlackPlayerId = computerId;
gameState.BlackComputer = computer;
} }
else else
{ {
gameState.WhitePlayerId = computerId; gameState.WhitePlayerId = computerId;
gameState.BlackPlayerId = playerId; gameState.BlackPlayerId = playerId;
gameState.WhiteComputer = computer;
queue.Queue(async () => queue.Queue(async () =>
{ {
// Give user's browser time to connect to signalR and such. // Give user's browser time to connect to signalR and such.
await Task.Delay(TimeSpan.FromSeconds(1)); await Task.Delay(TimeSpan.FromSeconds(1));
await orchestrator.PlayAsync(gameState, gameState.Computer!); await orchestrator.PlayAsync(gameState);
}); });
} }
@@ -82,13 +89,22 @@ public class ChessController(
} }
/// <summary> /// <summary>
/// Create a game the computer plays against itself and auto-play it move by move, /// Create a computer-vs-computer game and auto-play it move by move, broadcasting each
/// broadcasting each move so it can be watched on the spectator page. /// move so it can be watched on the spectator page. Each side's engine and skill can be
/// chosen independently; when the learned engine plays, the game also trains it.
/// </summary> /// </summary>
[HttpGet("watch/cpu")] [HttpGet("watch/cpu")]
[HttpGet("watch/cpu/{difficulty}")] [HttpGet("watch/cpu/{difficulty}")]
public ActionResult CreateSelfPlayGame(int difficulty = 4) public ActionResult CreateSelfPlayGame(
int difficulty = 4,
string whiteEngine = "custom",
string blackEngine = "custom",
int? whiteSkill = null,
int? blackSkill = null)
{ {
var whiteKind = ParseEngineKind(whiteEngine);
var blackKind = ParseEngineKind(blackEngine);
var gameState = chessService.CreateNewGame(); var gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState; _games[gameState.GameId] = gameState;
@@ -98,7 +114,14 @@ public class ChessController(
gameState.BlackJoined = true; gameState.BlackJoined = true;
gameState.WhitePlayerId = Guid.NewGuid(); gameState.WhitePlayerId = Guid.NewGuid();
gameState.BlackPlayerId = Guid.NewGuid(); gameState.BlackPlayerId = Guid.NewGuid();
gameState.Computer = engineFactory.Create(difficulty); 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); ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
StartSelfPlay(gameState); StartSelfPlay(gameState);
@@ -106,6 +129,13 @@ public class ChessController(
return Ok(new { gameState.GameId }); return Ok(new { gameState.GameId });
} }
private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch
{
"stockfish" => ChessEngineKind.Stockfish,
"customlearned" or "learned" => ChessEngineKind.CustomLearned,
_ => ChessEngineKind.Custom
};
/// <summary> /// <summary>
/// Joins the "pool" of chess players. /// Joins the "pool" of chess players.
/// Test code expects to receive a GUID for the player /// Test code expects to receive a GUID for the player
@@ -163,6 +193,8 @@ public class ChessController(
g.GameId, g.GameId,
g.IsVsComputer, g.IsVsComputer,
g.IsComputerVsComputer, g.IsComputerVsComputer,
WhiteEngine = g.WhiteEngineKind.ToString(),
BlackEngine = g.BlackEngineKind.ToString(),
CurrentPlayer = g.CurrentPlayer.ToString(), CurrentPlayer = g.CurrentPlayer.ToString(),
MoveCount = g.MoveHistory.Count, MoveCount = g.MoveHistory.Count,
g.IsCheck g.IsCheck
@@ -172,6 +204,26 @@ public class ChessController(
return Ok(activeGames); return Ok(activeGames);
} }
/// <summary>
/// The learned engine's piece-square bonus table, for the weights-visualization page:
/// one 64-entry array per piece type (Pawn..King), white-relative (A1=0 .. H8=63).
/// </summary>
[HttpGet("weights")]
public ActionResult GetLearnedWeights()
{
var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" };
var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" };
var snapshot = weightsStore.Snapshot();
return Ok(new
{
mg = snapshot.Mg.Select((squares, i) => new { name = names[i], squares }),
eg = snapshot.Eg.Select((squares, i) => new { name = names[i], squares }),
features = snapshot.Features.Select((value, i) => new { name = featureNames[i], value })
});
}
/// <summary> /// <summary>
/// Get the state of an existing game by ID. /// Get the state of an existing game by ID.
/// </summary> /// </summary>
@@ -232,8 +284,12 @@ public class ChessController(
await chessHub.Clients.Group(gameState.GameId.ToString()) await chessHub.Clients.Group(gameState.GameId.ToString())
.SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state); .SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state);
if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null) var sideToMoveEngine = gameState.CurrentPlayer == PieceColor.White
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!)); ? gameState.WhiteComputer
: gameState.BlackComputer;
if (!isGameOver && gameState.IsVsComputer && sideToMoveEngine is not null)
queue.Queue(() => orchestrator.PlayAsync(gameState));
return Ok(new { result, state }); return Ok(new { result, state });
} }
@@ -314,8 +370,9 @@ public class ChessController(
} }
/// <summary> /// <summary>
/// Drives a computer-vs-computer game: keeps asking the engine for the side-to-move's /// 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. /// 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> /// </summary>
private void StartSelfPlay(GameState gameState) private void StartSelfPlay(GameState gameState)
{ {
@@ -324,15 +381,19 @@ public class ChessController(
// Give spectators a moment to join the SignalR group before the first move. // Give spectators a moment to join the SignalR group before the first move.
await Task.Delay(TimeSpan.FromSeconds(1)); await Task.Delay(TimeSpan.FromSeconds(1));
while (_games.ContainsKey(gameState.GameId) // Training games open with random moves so they don't replay the same line.
&& !gameState.IsCheckmate if (gameState.Trainer != nint.Zero)
&& !gameState.IsStalemate for (int i = 0; i < _openingRandomPlies && _games.ContainsKey(gameState.GameId) && !IsGameOver(gameState); i++)
&& !gameState.IsThreefoldRepetition {
&& !gameState.IsForfeited) await orchestrator.PlayRandomMoveAsync(gameState);
await Task.Delay(_selfPlayMoveDelay);
}
while (_games.ContainsKey(gameState.GameId) && !IsGameOver(gameState))
{ {
try try
{ {
await orchestrator.PlayAsync(gameState, gameState.Computer!); await orchestrator.PlayAsync(gameState);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -343,12 +404,100 @@ public class ChessController(
await Task.Delay(_selfPlayMoveDelay); await Task.Delay(_selfPlayMoveDelay);
} }
ApplyLearning(gameState);
// Leave the finished game in place briefly so spectators can see the result. // Leave the finished game in place briefly so spectators can see the result.
if (_games.ContainsKey(gameState.GameId)) if (_games.ContainsKey(gameState.GameId))
ScheduleRemoveGame(gameState.GameId, _selfPlayResultTimeout); 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) private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
{ {
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts)) if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
@@ -366,8 +515,21 @@ public class ChessController(
{ {
await Task.Delay(delay, cts.Token); await Task.Delay(delay, cts.Token);
if (_games.TryGetValue(id, out var game) && game.Computer is not null) if (_games.TryGetValue(id, out var game))
await game.Computer.DisposeAsync(); {
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 _); _games.Remove(id, out _);
} }
+16 -2
View File
@@ -1,4 +1,5 @@
using JoshHeaps.Net.Services.Interfaces; using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Models; namespace JoshHeaps.Net.Models;
@@ -59,7 +60,20 @@ public class GameState
public bool IsVsComputer { get; set; } = false; public bool IsVsComputer { get; set; } = false;
public bool IsComputerVsComputer { get; set; } = false; public bool IsComputerVsComputer { get; set; } = false;
public IChessEngine? Computer { get; set; } // The engine playing each side (null for a human). In a human-vs-computer game only the
// computer's side is set; the orchestrator picks the engine for whoever is to move.
public IChessEngine? WhiteComputer { get; set; }
public IChessEngine? BlackComputer { get; set; }
// Which engine implementation each side uses — lets game-over handling know which side(s)
// are the learning engine, and lets spectators see who is playing.
public ChessEngineKind WhiteEngineKind { get; set; }
public ChessEngineKind BlackEngineKind { get; set; }
// Native per-game training accumulator (nint.Zero when this game isn't training the
// learned engine). The engine records each played position into it and applies the
// result on game over.
public nint Trainer { get; set; }
// optional: convenience // optional: convenience
public bool IsOpen => !WhiteJoined || !BlackJoined; public bool IsOpen => !WhiteJoined || !BlackJoined;
@@ -0,0 +1,9 @@
namespace JoshHeaps.Net.Models;
/// <summary>
/// A copy of the learned engine's weights for display: midgame and endgame piece-square
/// tables (one 64-entry array per piece, in canonical Pawn..King order, white-relative
/// A1=0..H8=63) plus the feature weights (mobility N/B/R/Q, passed, isolated, doubled,
/// king safety).
/// </summary>
public sealed record LearnedWeightsSnapshot(int[][] Mg, int[][] Eg, int[] Features);
+29 -7
View File
@@ -9,16 +9,38 @@
<h1>Live Chess</h1> <h1>Live Chess</h1>
<p id="watchStatus">Loading games…</p> <p id="watchStatus">Loading games…</p>
<div id="watchControls"> <div id="watchControls">
<label for="cpuDifficulty">Difficulty</label> <fieldset class="enginePicker">
<select id="cpuDifficulty"> <legend>White</legend>
@for (int i = 1; i <= 20; i++) <select id="whiteEngine">
{ <option value="custom">Custom (v1)</option>
<option value="@i" @(i == 4 ? "selected" : "")>@i</option> <option value="customLearned" selected>Custom (Learned)</option>
} <option value="stockfish">Stockfish</option>
</select> </select>
<select id="whiteSkill">
@for (int i = 1; i <= 20; i++)
{
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
}
</select>
</fieldset>
<fieldset class="enginePicker">
<legend>Black</legend>
<select id="blackEngine">
<option value="custom" selected>Custom (v1)</option>
<option value="customLearned">Custom (Learned)</option>
<option value="stockfish">Stockfish</option>
</select>
<select id="blackSkill">
@for (int i = 1; i <= 20; i++)
{
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
}
</select>
</fieldset>
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button> <button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
</div> </div>
<a id="backToPlay" href="/chess">← Play a game</a> <a id="backToPlay" href="/chess">← Play a game</a>
<a id="viewWeights" href="/weights">View learned weights →</a>
</div> </div>
<div id="gamesFeed"></div> <div id="gamesFeed"></div>
+44
View File
@@ -0,0 +1,44 @@
@page
@model JoshHeaps.Net.Pages.WeightsModel
@{
Layout = "_Layout";
ViewData["Title"] = "Learned Weights";
}
<div id="weightsHeader">
<h1>Learned Piece-Square Weights</h1>
<p id="weightsStatus">Where the learned engine thinks each piece belongs.</p>
<div id="weightsControls">
<div class="heatLegend">
<span>low</span>
<span class="legendBar"></span>
<span>high</span>
</div>
<button id="refreshWeights" onclick="Weights.refresh()">Refresh</button>
</div>
<a id="backToWatch" href="/watch">← Watch / train</a>
</div>
<section class="weightsSection">
<h2>Feature weights</h2>
<p class="sectionHint">Learned value of each contextual feature (per normalized unit). Mobility is per piece type; passed pawns are endgame-weighted, king safety midgame-weighted.</p>
<div id="featureWeights" class="featurePanel"></div>
</section>
<section class="weightsSection">
<h2>Midgame tables</h2>
<div id="weightsGridMg" class="weightsGrid"></div>
</section>
<section class="weightsSection">
<h2>Endgame tables</h2>
<div id="weightsGridEg" class="weightsGrid"></div>
</section>
@section Scripts {
<script src="~/js/ChessScripts/Weights.js"></script>
}
@section Styles {
<link rel="stylesheet" href="~/css/chess/weights.css?v=@ViewData["cssVersion"]" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class WeightsModel : PageModel
{
public void OnGet()
{
}
}
}
+1
View File
@@ -23,6 +23,7 @@ builder.Services.AddSingleton<IChessService, ChessService>();
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>(); builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName)); builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
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>();
Binary file not shown.
@@ -7,7 +7,10 @@ namespace JoshHeaps.Net.Services.Implementations;
public enum ChessEngineKind public enum ChessEngineKind
{ {
Stockfish, Stockfish,
Custom Custom,
/// <summary>The custom engine with the reinforcement-learned piece-square evaluation.</summary>
CustomLearned
} }
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary> /// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
@@ -19,14 +22,19 @@ public sealed class ChessEngineOptions
} }
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary> /// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
public sealed class ChessEngineFactory(IOptions<ChessEngineOptions> options) : IChessEngineFactory public sealed class ChessEngineFactory(
IOptions<ChessEngineOptions> options,
ILearnedWeightsStore weightsStore) : IChessEngineFactory
{ {
private readonly ChessEngineKind _kind = options.Value.Engine; private readonly ChessEngineKind _kind = options.Value.Engine;
public IChessEngine Create(int skill) => _kind switch public IChessEngine Create(int skill) => Create(skill, _kind);
public IChessEngine Create(int skill, ChessEngineKind kind) => kind switch
{ {
ChessEngineKind.Custom => new CustomChessEngine(skill), ChessEngineKind.Custom => new CustomChessEngine(skill),
ChessEngineKind.CustomLearned => new CustomChessEngine(skill, EngineVariant.Learned, weightsStore.WeightsFilePath),
ChessEngineKind.Stockfish => new Stockfish(skill), ChessEngineKind.Stockfish => new Stockfish(skill),
_ => throw new InvalidOperationException($"Unknown chess engine '{_kind}'.") _ => throw new InvalidOperationException($"Unknown chess engine '{kind}'.")
}; };
} }
@@ -11,23 +11,60 @@ namespace JoshHeaps.Net.Services.Implementations;
/// </summary> /// </summary>
public sealed class ComputerMoveOrchestrator( public sealed class ComputerMoveOrchestrator(
IHubContext<ChessHub> chessHub, IHubContext<ChessHub> chessHub,
IChessService chessService) : IComputerMoveOrchestrator IChessService chessService,
ILearnedWeightsStore weightsStore) : IComputerMoveOrchestrator
{ {
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine) public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state)
{ {
var engine = (state.CurrentPlayer == PieceColor.White ? state.WhiteComputer : state.BlackComputer)
?? throw new InvalidOperationException($"No engine is set for {state.CurrentPlayer} in game {state.GameId}.");
var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory()); var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
var move = uci.ToMoveDto(state, CurrentPlayerId(state));
var move = uci.ToMoveDto( return await ApplyAndBroadcastAsync(state, move);
state, }
state.CurrentPlayer == PieceColor.White
? state.WhitePlayerId
: state.BlackPlayerId);
public async Task PlayRandomMoveAsync(GameState state)
{
var options = chessService.GetAllLegalMoves(state);
// No legal moves means the game is already over; let the caller's loop detect it.
if (options.Count == 0)
return;
var (piece, moves) = options[Random.Shared.Next(options.Count)];
var target = moves[Random.Shared.Next(moves.Count)];
var move = new MoveDto
{
GameId = state.GameId,
PlayerId = CurrentPlayerId(state),
PieceId = piece.Id,
SourceRow = piece.Position.Row,
SourceCol = piece.Position.Col,
TargetRow = target.Row,
TargetCol = target.Col,
PromotionChoice = null // a pawn cannot reach the last rank within the opening plies
};
await ApplyAndBroadcastAsync(state, move);
}
private async Task<(MoveDto move, MoveResultDto result)> ApplyAndBroadcastAsync(GameState state, MoveDto move)
{
var result = chessService.MakeMove(state, move); var result = chessService.MakeMove(state, move);
// Record the played position for training (no-op for non-training games).
if (state.Trainer != nint.Zero)
weightsStore.Record(state.Trainer, state.ToFen());
await chessHub.Clients.Group(state.GameId.ToString()) await chessHub.Clients.Group(state.GameId.ToString())
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto()); .SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto());
return (move, result); return (move, result);
} }
private static Guid CurrentPlayerId(GameState state) =>
state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId;
} }
@@ -5,6 +5,16 @@ using System.Runtime.InteropServices;
namespace JoshHeaps.Net.Services.Implementations; namespace JoshHeaps.Net.Services.Implementations;
/// <summary>Which evaluation the native engine uses.</summary>
public enum EngineVariant
{
/// <summary>The hand-crafted evaluation.</summary>
Classic,
/// <summary>Material plus a learned per-square bonus table loaded from a weights file.</summary>
Learned
}
/// <summary> /// <summary>
/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so). /// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so).
/// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable. /// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable.
@@ -16,11 +26,17 @@ public sealed partial class CustomChessEngine : IChessEngine
public int Skill { get; } public int Skill { get; }
public CustomChessEngine(int skill = 20) public CustomChessEngine(int skill = 20, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null)
{ {
Skill = skill; Skill = skill;
var handle = NativeMethods.engine_create($"skill={skill}"); // weights= must come last: the native side reads the path as the rest of the
// string, which lets it contain ';' and spaces.
var options = variant == EngineVariant.Learned
? $"skill={skill};variant=learned;weights={weightsPath}"
: $"skill={skill}";
var handle = NativeMethods.engine_create(options);
if (handle == IntPtr.Zero) if (handle == IntPtr.Zero)
throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null)."); throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null).");
@@ -79,8 +95,9 @@ public sealed partial class CustomChessEngine : IChessEngine
/// <summary> /// <summary>
/// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name /// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name
/// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish). /// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish).
/// Internal so <see cref="LearnedWeightsStore"/> can share the single import resolver.
/// </summary> /// </summary>
private static partial class NativeMethods internal static partial class NativeMethods
{ {
private const string LibName = "chess_engine"; private const string LibName = "chess_engine";
@@ -115,5 +132,31 @@ public sealed partial class CustomChessEngine : IChessEngine
[LibraryImport(LibName)] [LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void engine_destroy(IntPtr engine); internal static partial void engine_destroy(IntPtr engine);
// ---- Learned-weights / training ABI (see chess_engine.h) ----
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void learned_load(string path);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial int weights_snapshot(int* outBuf, int outLen);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial IntPtr trainer_create();
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void trainer_record(IntPtr trainer, string fen);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void trainer_apply(IntPtr trainer, int winner, double weight);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void trainer_destroy(IntPtr trainer);
} }
} }
@@ -0,0 +1,64 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Managed facade over the native learned-weights model (see <see cref="ILearnedWeightsStore"/>).
/// On construction it points the native engine at the weights file; everything else delegates
/// to the shared native ABI in <see cref="CustomChessEngine"/>.
/// </summary>
public sealed class LearnedWeightsStore : ILearnedWeightsStore
{
private const int Pieces = 6; // Pawn..King
private const int Squares = 64;
private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety
public string WeightsFilePath { get; }
public LearnedWeightsStore(IHostEnvironment env)
{
WeightsFilePath = Path.Combine(env.ContentRootPath, "chess-data", "learned-weights.txt");
Directory.CreateDirectory(Path.GetDirectoryName(WeightsFilePath)!);
CustomChessEngine.NativeMethods.learned_load(WeightsFilePath);
}
public nint CreateTrainer() => CustomChessEngine.NativeMethods.trainer_create();
public void Record(nint trainer, string fen) =>
CustomChessEngine.NativeMethods.trainer_record(trainer, fen);
public void ApplyResult(nint trainer, PieceColor winner, double weight) =>
CustomChessEngine.NativeMethods.trainer_apply(trainer, winner == PieceColor.White ? 0 : 1, weight);
public void DestroyTrainer(nint trainer) =>
CustomChessEngine.NativeMethods.trainer_destroy(trainer);
public LearnedWeightsSnapshot Snapshot()
{
const int total = Pieces * Squares * 2 + Features;
var buffer = new int[total];
unsafe
{
fixed (int* p = buffer)
CustomChessEngine.NativeMethods.weights_snapshot(p, total);
}
var mg = new int[Pieces][];
var eg = new int[Pieces][];
for (int piece = 0; piece < Pieces; piece++)
{
mg[piece] = new int[Squares];
eg[piece] = new int[Squares];
Array.Copy(buffer, piece * Squares, mg[piece], 0, Squares);
Array.Copy(buffer, Pieces * Squares + piece * Squares, eg[piece], 0, Squares);
}
var features = new int[Features];
Array.Copy(buffer, Pieces * Squares * 2, features, 0, Features);
return new LearnedWeightsSnapshot(mg, eg, features);
}
}
@@ -1,3 +1,5 @@
using JoshHeaps.Net.Services.Implementations;
namespace JoshHeaps.Net.Services.Interfaces; namespace JoshHeaps.Net.Services.Interfaces;
/// <summary> /// <summary>
@@ -6,9 +8,20 @@ namespace JoshHeaps.Net.Services.Interfaces;
public interface IChessEngineFactory public interface IChessEngineFactory
{ {
/// <summary> /// <summary>
/// Creates a new engine instance for a single game. The caller owns and disposes it. /// Creates a new engine instance for a single game using the configured default engine.
/// The caller owns and disposes it.
/// </summary> /// </summary>
/// <param name="skill">The desired playing strength / search depth.</param> /// <param name="skill">The desired playing strength / search depth.</param>
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns> /// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
IChessEngine Create(int skill); IChessEngine Create(int skill);
/// <summary>
/// Creates a new engine instance for a single game using an explicitly chosen engine
/// (e.g. for picking a different engine per side in a CPU-vs-CPU game). The caller owns
/// and disposes it.
/// </summary>
/// <param name="skill">The desired playing strength / search depth.</param>
/// <param name="kind">The engine implementation to create.</param>
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
IChessEngine Create(int skill, ChessEngineKind kind);
} }
@@ -3,16 +3,24 @@ using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces; namespace JoshHeaps.Net.Services.Interfaces;
/// <summary> /// <summary>
/// Drives a computer move: asks the engine for a move, applies it through the rules /// Drives a computer move: asks the side-to-move's engine for a move, applies it through
/// service, and broadcasts the result to the game's clients. /// the rules service, and broadcasts the result to the game's clients.
/// </summary> /// </summary>
public interface IComputerMoveOrchestrator public interface IComputerMoveOrchestrator
{ {
/// <summary> /// <summary>
/// Has the engine pick a move for the current position, applies it, and broadcasts it. /// Has the side-to-move's engine pick a move for the current position, applies it, and
/// broadcasts it. The engine is taken from the game's per-side computer assignments.
/// </summary> /// </summary>
/// <param name="state">The game to play a move in.</param> /// <param name="state">The game to play a move in.</param>
/// <param name="engine">The engine that selects the move.</param>
/// <returns>The applied move and its result.</returns> /// <returns>The applied move and its result.</returns>
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine); Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state);
/// <summary>
/// Plays a uniformly-random legal move for the side to move (no engine), applying and
/// broadcasting it. Used to randomize the opening of training games so self-play and
/// engine-vs-engine games don't replay the same line every time.
/// </summary>
/// <param name="state">The game to play a random move in.</param>
Task PlayRandomMoveAsync(GameState state);
} }
@@ -0,0 +1,33 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Thin managed facade over the native learned-weights model. The weights, all feature
/// computation, per-game accumulation, the update rule, and persistence live in the native
/// engine; this just points it at the weights file, hands out per-game trainers, and reads
/// the table back for visualization.
/// </summary>
public interface ILearnedWeightsStore
{
/// <summary>Absolute path to the weights file the native engine loads and saves.</summary>
string WeightsFilePath { get; }
/// <summary>A copy of the current weights (midgame/endgame tables + feature weights).</summary>
LearnedWeightsSnapshot Snapshot();
/// <summary>Creates a per-game training accumulator. The caller owns it (see <see cref="DestroyTrainer"/>).</summary>
nint CreateTrainer();
/// <summary>Records one played position (post-move FEN) into a trainer.</summary>
void Record(nint trainer, string fen);
/// <summary>
/// Applies a finished game's outcome to the global weights (and saves): rewards the
/// winner's squares/features, punishes the loser's, scaled by <paramref name="weight"/>.
/// </summary>
void ApplyResult(nint trainer, PieceColor winner, double weight);
/// <summary>Frees a trainer. Safe to call with <see cref="nint.Zero"/>.</summary>
void DestroyTrainer(nint trainer);
}
+18 -2
View File
@@ -21,11 +21,27 @@ html, body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; flex-wrap: wrap;
gap: 0.75rem;
margin: 1rem 0; margin: 1rem 0;
} }
#cpuDifficulty { .enginePicker {
display: flex;
align-items: center;
gap: 0.4rem;
border: 1px solid #444;
border-radius: 8px;
padding: 0.3rem 0.6rem 0.5rem;
}
.enginePicker legend {
color: #9a9a9a;
font-size: 0.8rem;
padding: 0 0.3rem;
}
#watchControls select {
background-color: #1e1e1e; background-color: #1e1e1e;
color: #d6d6d6; color: #d6d6d6;
border: 1px solid #444; border: 1px solid #444;
+187
View File
@@ -0,0 +1,187 @@
body {
margin: 0;
background-color: #141414;
color: #d6d6d6;
font-family: "Segoe UI", system-ui, sans-serif;
}
#weightsHeader {
text-align: center;
padding: 1.5rem 1rem 0.5rem;
}
#weightsHeader h1 {
margin: 0 0 0.25rem;
font-size: 1.6rem;
}
#weightsStatus {
color: #9a9a9a;
margin: 0.25rem 0 1rem;
}
#weightsControls {
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
margin-bottom: 0.5rem;
}
.heatLegend {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #9a9a9a;
}
.legendBar {
width: 120px;
height: 10px;
border-radius: 5px;
background: linear-gradient(to right, rgba(232, 74, 74, 1), #2a2a2a, rgba(74, 134, 232, 1));
}
#refreshWeights {
background-color: #8cd5ed;
color: #262626;
border: 0;
border-radius: 30px;
padding: 0.5rem 1.1rem;
cursor: pointer;
}
#refreshWeights:hover {
background-color: #a5e0f2;
}
#backToWatch {
display: inline-block;
margin-top: 0.5rem;
color: #8cd5ed;
text-decoration: none;
}
.weightsSection {
margin: 0 auto;
max-width: 1200px;
padding: 0.5rem 1rem;
}
.weightsSection h2 {
text-align: center;
font-size: 1.2rem;
margin: 1rem 0 0.25rem;
}
.sectionHint {
text-align: center;
color: #9a9a9a;
font-size: 0.8rem;
margin: 0 0 0.75rem;
}
.weightsGrid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1.25rem;
padding: 0.5rem 0;
}
.featurePanel {
max-width: 520px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.featureRow {
display: flex;
align-items: center;
gap: 0.6rem;
}
.featureLabel {
flex: 0 0 6.5rem;
font-size: 0.8rem;
color: #c8c8c8;
text-align: right;
}
.featureTrack {
flex: 1 1 auto;
height: 14px;
background-color: #232323;
border-radius: 7px;
overflow: hidden;
}
.featureBar {
height: 100%;
border-radius: 7px;
min-width: 2px;
}
.featureValue {
flex: 0 0 3rem;
font-size: 0.8rem;
color: #e8e8e8;
text-align: left;
}
.weightBoard {
background-color: #1c1c1c;
border: 1px solid #2e2e2e;
border-radius: 10px;
padding: 0.75rem;
}
.weightBoardHeader {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.weightBoardIcon {
width: 22px;
height: 22px;
}
.weightRange {
margin-left: auto;
font-weight: 400;
font-size: 0.75rem;
color: #888;
}
.miniHeat {
display: grid;
grid-template-columns: 1.1rem repeat(8, 34px);
}
.heatSquare {
width: 34px;
height: 34px;
box-sizing: border-box;
border: 1px solid #2a2a2a;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
color: #f0f0f0;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.heatLabel {
display: flex;
align-items: center;
justify-content: center;
padding: 2px 0;
font-size: 0.6rem;
color: #777;
}
@@ -27,10 +27,15 @@ const Spectate = {
}, },
async startCpuGame() { async startCpuGame() {
const difficulty = document.getElementById("cpuDifficulty").value; const params = new URLSearchParams({
whiteEngine: document.getElementById("whiteEngine").value,
whiteSkill: document.getElementById("whiteSkill").value,
blackEngine: document.getElementById("blackEngine").value,
blackSkill: document.getElementById("blackSkill").value
});
try { try {
await fetch(`/api/chess/watch/cpu/${difficulty}`); await fetch(`/api/chess/watch/cpu?${params}`);
await this.refreshGames(); await this.refreshGames();
} catch (err) { } catch (err) {
console.error("❌ Could not start CPU vs CPU game.", err); console.error("❌ Could not start CPU vs CPU game.", err);
@@ -68,7 +73,9 @@ const Spectate = {
async addGame(game) { async addGame(game) {
this.games.set(game.gameId, { this.games.set(game.gameId, {
isVsComputer: game.isVsComputer, isVsComputer: game.isVsComputer,
isComputerVsComputer: game.isComputerVsComputer isComputerVsComputer: game.isComputerVsComputer,
whiteEngine: game.whiteEngine,
blackEngine: game.blackEngine
}); });
const card = document.createElement("div"); const card = document.createElement("div");
@@ -183,11 +190,21 @@ const Spectate = {
}, },
gameLabel(stored) { gameLabel(stored) {
if (stored.isComputerVsComputer) return "CPU vs CPU"; if (stored.isComputerVsComputer)
return `${this.engineName(stored.whiteEngine)} (W) vs ${this.engineName(stored.blackEngine)} (B)`;
if (stored.isVsComputer) return "Vs CPU"; if (stored.isVsComputer) return "Vs CPU";
return "Player vs Player"; return "Player vs Player";
}, },
engineName(kind) {
switch (kind) {
case "CustomLearned": return "Learned";
case "Custom": return "Custom";
case "Stockfish": return "Stockfish";
default: return kind || "CPU";
}
},
headerText(stored, currentPlayer, moveCount, isCheck) { headerText(stored, currentPlayer, moveCount, isCheck) {
if (stored.result) if (stored.result)
return `${this.gameLabel(stored)} · move ${moveCount} · final`; return `${this.gameLabel(stored)} · move ${moveCount} · final`;
@@ -0,0 +1,158 @@
const Weights = {
async init() {
await this.refresh();
},
async refresh() {
let data;
try {
const response = await fetch("/api/chess/weights");
data = await response.json();
} catch (err) {
console.error("❌ Could not load weights.", err);
document.getElementById("weightsStatus").textContent = "Could not load weights.";
return;
}
this.renderBoardSet(data.mg, "weightsGridMg");
this.renderBoardSet(data.eg, "weightsGridEg");
this.renderFeatures(data.features);
const trained = [...data.mg, ...data.eg].some(b => b.squares.some(v => v !== 0))
|| data.features.some(f => f.value !== 0);
document.getElementById("weightsStatus").textContent = trained
? "Where the learned engine thinks each piece belongs. Blue = preferred, red = avoided. Midgame vs endgame tables are blended by how much material is left."
: "No training yet — everything is neutral. Run some learned games on the Watch page.";
},
// pieces: [{ name, squares[64] }]. Prepends an "Overall" board summing the set.
renderBoardSet(pieces, containerId) {
const overall = new Array(64).fill(0);
for (const piece of pieces)
for (let sq = 0; sq < 64; sq++)
overall[sq] += piece.squares[sq];
const boards = [{ name: "Overall", squares: overall }, ...pieces];
const grid = document.getElementById(containerId);
grid.innerHTML = "";
boards.forEach(board => grid.appendChild(this.buildBoard(board)));
},
buildBoard(board) {
const wrapper = document.createElement("div");
wrapper.className = "weightBoard";
const maxAbs = board.squares.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
const header = document.createElement("div");
header.className = "weightBoardHeader";
if (board.name !== "Overall") {
const icon = document.createElement("img");
icon.src = `/images/Chess Images/White${board.name}.svg`;
icon.alt = board.name;
icon.className = "weightBoardIcon";
header.appendChild(icon);
}
const title = document.createElement("span");
title.textContent = board.name;
header.appendChild(title);
const range = document.createElement("span");
range.className = "weightRange";
range.textContent = maxAbs === 0 ? "neutral" : `±${maxAbs}`;
header.appendChild(range);
wrapper.appendChild(header);
const heat = document.createElement("div");
heat.className = "miniHeat";
for (let row = 0; row < 8; row++) {
const rankLabel = document.createElement("div");
rankLabel.className = "heatLabel";
rankLabel.textContent = 8 - row; // rank 8 at top, 1 at bottom
heat.appendChild(rankLabel);
for (let col = 0; col < 8; col++) {
const rank = 7 - row; // rank index, 0 = rank 1
const sq = rank * 8 + col; // white-relative square (A1 = 0)
heat.appendChild(this.buildSquare(board.squares[sq], sq, maxAbs));
}
}
heat.appendChild(this.cornerSpacer());
for (let col = 0; col < 8; col++) {
const fileLabel = document.createElement("div");
fileLabel.className = "heatLabel";
fileLabel.textContent = String.fromCharCode(97 + col);
heat.appendChild(fileLabel);
}
wrapper.appendChild(heat);
return wrapper;
},
buildSquare(value, sq, maxAbs) {
const cell = document.createElement("div");
cell.className = "heatSquare";
if (value !== 0 && maxAbs > 0) {
const ratio = Math.abs(value) / maxAbs;
const alpha = (0.12 + 0.88 * ratio).toFixed(3);
cell.style.backgroundColor = value > 0
? `rgba(74, 134, 232, ${alpha})` // high -> blue
: `rgba(232, 74, 74, ${alpha})`; // low -> red
cell.textContent = value;
}
const file = String.fromCharCode(97 + (sq & 7));
const rank = (sq >> 3) + 1;
cell.title = `${file}${rank}: ${value}`;
return cell;
},
cornerSpacer() {
const spacer = document.createElement("div");
spacer.className = "heatLabel";
return spacer;
},
// features: [{ name, value }]
renderFeatures(features) {
const panel = document.getElementById("featureWeights");
panel.innerHTML = "";
const maxAbs = features.reduce((m, f) => Math.max(m, Math.abs(f.value)), 0);
features.forEach(feature => {
const row = document.createElement("div");
row.className = "featureRow";
const label = document.createElement("span");
label.className = "featureLabel";
label.textContent = feature.name;
const track = document.createElement("div");
track.className = "featureTrack";
const bar = document.createElement("div");
bar.className = "featureBar";
const ratio = maxAbs === 0 ? 0 : Math.abs(feature.value) / maxAbs;
bar.style.width = `${(ratio * 100).toFixed(1)}%`;
bar.style.backgroundColor = feature.value >= 0
? "rgba(74, 134, 232, 0.85)"
: "rgba(232, 74, 74, 0.85)";
track.appendChild(bar);
const value = document.createElement("span");
value.className = "featureValue";
value.textContent = feature.value;
row.append(label, track, value);
panel.appendChild(row);
});
}
};
window.addEventListener("load", () => Weights.init());
console.log("Weights.js loaded");
@@ -32,6 +32,9 @@ extern "C" { /* prevent C++ name mangling */
* Internally it points to your engine state object. */ * Internally it points to your engine state object. */
typedef struct ChessEngine* EngineHandle; typedef struct ChessEngine* EngineHandle;
/* Opaque per-game training accumulator (see the learned-weights ABI at the bottom). */
typedef struct Trainer* TrainerHandle;
/* Return codes. 0 == success; negative == error. Keep these values stable. */ /* Return codes. 0 == success; negative == error. Keep these values stable. */
enum { enum {
CHESS_OK = 0, CHESS_OK = 0,
@@ -79,6 +82,32 @@ CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len);
/* Destroy an instance created by engine_create. Safe to call with NULL. */ /* Destroy an instance created by engine_create. Safe to call with NULL. */
CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine); CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine);
/* ---- Learned-weights / training ABI ----
* The learned engine's weights live process-globally here. The host orchestrates games but
* owns no chess logic: it points the engine at the weights file, records each played
* position, and applies the game's result. */
/* Load the global learned weights from `path` and remember it for later saves. Idempotent;
* a missing/short file leaves the weights neutral. Call once before learned play/training. */
CHESS_API void CHESS_CALL learned_load(const char* path);
/* Copy the global weights out for visualization: 6*64 midgame + 6*64 endgame (PAWN..KING,
* squares 0..63) + feature weights. Returns the count written, or CHESS_ERR_BUFFER if
* out_len is too small (needs >= 776). */
CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len);
/* Create / destroy a per-game training accumulator. Safe to destroy NULL. */
CHESS_API TrainerHandle CHESS_CALL trainer_create(void);
CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle trainer);
/* Record one played position (post-move FEN) into the accumulator. */
CHESS_API void CHESS_CALL trainer_record(TrainerHandle trainer, const char* fen);
/* Apply a finished game's outcome to the global weights and save: rewards the winner's
* occupied squares / features and punishes the loser's, scaled by `weight` (e.g. 0.5 for a
* material-imbalance draw). winner: 0 = white, 1 = black. */
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle trainer, int winner, double weight);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif
+338 -8
View File
@@ -20,10 +20,12 @@
#include <algorithm> #include <algorithm>
#include <atomic> #include <atomic>
#include <cmath>
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
#include <fstream>
#include <mutex> #include <mutex>
#include <new> #include <new>
#include <string> #include <string>
@@ -76,10 +78,63 @@ static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast<uint16_
static int tt_depth(uint64_t d) { return static_cast<int>(static_cast<uint8_t>(d >> 48)); } 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 Bound tt_bound(uint64_t d) { return static_cast<Bound>(static_cast<uint8_t>(d >> 56)); }
/* Internal engine state. One ChessEngine = one game. The table is NOT here: it is the /* Eval variant for an engine handle. CLASSIC = the hand-crafted evaluate(); LEARNED =
* shared g_tt above. */ * 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 */
};
/* 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) {
@@ -108,6 +163,48 @@ static int parse_skill(const char* options, int fallback) {
return v < 1 ? 1 : v > 20 ? 20 : v; return v < 1 ? 1 : v > 20 ? 20 : v;
} }
/* "variant=learned" in the options selects the learned eval; anything else is classic. */
static int parse_variant(const char* options) {
if (!options) return EVAL_CLASSIC;
const char* p = std::strstr(options, "variant=");
if (!p) return 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 /* 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. */ * quiescence yet, so deep fixed-depth runs get expensive quickly. */
static int depth_for_skill(int skill) { static int depth_for_skill(int skill) {
@@ -279,10 +376,136 @@ static int evaluate(const chess::Position& pos) {
return score; 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 (×(1phase)) 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 /* evaluate() is white-positive (absolute). Negamax needs it relative to the side to
* move, so flip the sign when black is to move. */ * move, so flip the sign when black is to move. */
static int evaluate_stm(const chess::Position& pos, bool whiteToMove) { static int evaluate_stm(chess::Position& pos, bool whiteToMove, const EvalParams& ep) {
int s = evaluate(pos); int s = (ep.variant == EVAL_LEARNED) ? evaluateLearned(pos, ep) : evaluate(pos);
return whiteToMove ? s : -s; return whiteToMove ? s : -s;
} }
@@ -358,6 +581,15 @@ CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
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);
if (e->eval.variant == EVAL_LEARNED) {
/* Snapshot the current global weights so the search reads a stable copy (training
* updates the global between games; the weights path is owned by learned_load). */
std::lock_guard<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;
} }
@@ -376,8 +608,9 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine,
static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<= 20) */ static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<= 20) */
struct SearchContext { struct SearchContext {
uint64_t nodes = 0; uint64_t nodes = 0;
chess::Move killers[MAX_PLY][2] = {}; /* [ply][slot]; MOVE_NONE until filled */ 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 /* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching
@@ -394,7 +627,7 @@ static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
return 0; return 0;
if (depth <= 0) if (depth <= 0)
return evaluate_stm(pos, whiteToMove); return evaluate_stm(pos, whiteToMove, *ctx.eval);
const uint64_t key = pos.key(); const uint64_t key = pos.key();
TTEntry& slot = g_tt.entries[key & g_tt.mask]; TTEntry& slot = g_tt.entries[key & g_tt.mask];
@@ -509,6 +742,7 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
return CHESS_ERR_NO_MOVE; return CHESS_ERR_NO_MOVE;
SearchContext ctx; SearchContext ctx;
ctx.eval = &engine->eval;
int maxDepth = depth_for_skill(engine->skill); int maxDepth = depth_for_skill(engine->skill);
chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */ chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */
@@ -554,4 +788,100 @@ CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine) {
delete engine; /* delete nullptr is safe */ delete engine; /* delete nullptr is safe */
} }
/* ---- Learned-weights / training C ABI --------------------------------------------------
* The managed side orchestrates games but owns no chess logic: it tells the engine where
* to load/save the global weights, records each played position, and applies the result. */
CHESS_API void CHESS_CALL learned_load(const char* path) {
std::lock_guard<std::mutex> lock(g_weightsMutex);
g_weightsPath = path ? path : "";
load_global_weights(path);
}
CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len) {
const int need = 6 * 64 * 2 + FEATURE_NB; /* mg + eg (PAWN..KING) + features = 776 */
if (!out || out_len < need) return CHESS_ERR_BUFFER;
std::lock_guard<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) {
return new (std::nothrow) Trainer();
}
CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
if (!t || !fen || !*fen) return;
ensure_initialized();
chess::Position pos = chess::Position::from_fen(fen);
double phase = game_phase(pos);
/* Per-square occupancy, split into midgame/endgame by phase, white-relative. */
chess::Bitboard occ = pos.pieces();
while (occ) {
chess::Square s = chess::pop_lsb(occ);
chess::Piece pc = pos.piece_on(s);
chess::Color c = chess::color_of(pc);
chess::PieceType pt = chess::type_of(pc);
int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56);
t->mgOcc[c][pt][relSq] += (1.0 - phase);
t->egOcc[c][pt][relSq] += phase;
}
/* Per-side feature activations. */
double w[FEATURE_NB], b[FEATURE_NB];
compute_features(pos, chess::WHITE, phase, w);
compute_features(pos, chess::BLACK, phase, b);
for (int i = 0; i < FEATURE_NB; ++i) {
t->featAcc[chess::WHITE][i] += w[i];
t->featAcc[chess::BLACK][i] += b[i];
}
t->plies++;
}
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) {
if (!t) return;
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) {
delete t; /* delete nullptr is safe */
}
} /* extern "C" */ } /* extern "C" */
+16
View File
@@ -47,6 +47,22 @@ public:
void do_move(Move m); void do_move(Move m);
void undo_move(Move m); void undo_move(Move m);
// Legal moves for a SPECIFIED color (for mobility eval of either side). When c is not
// the side to move, temporarily flips side-to-move (and clears the en-passant square,
// which belongs to the other side) so generate_legal runs for c, then restores. The
// Zobrist key is untouched and unused by move generation, so this leaves the position
// observably unchanged.
void generate_legal_for(Color c, MoveList& list) {
if (sideToMove == c) { generate_legal(list); return; }
Color savedSide = sideToMove;
Square savedEp = epSquare;
sideToMove = c;
epSquare = SQ_NONE;
generate_legal(list);
sideToMove = savedSide;
epSquare = savedEp;
}
// Seed prior-position keys (oldest first, excluding the current position) so // Seed prior-position keys (oldest first, excluding the current position) so
// is_draw() can see game history the FEN doesn't carry. Call once, right after // is_draw() can see game history the FEN doesn't carry. Call once, right after
// from_fen and before any do_move. // from_fen and before any do_move.