revert everything, these changes were a mistake

This commit is contained in:
Josh-Heaps
2026-06-13 20:30:26 -06:00
parent b99525ae51
commit e5e0c9cc48
23 changed files with 998 additions and 1611 deletions
@@ -1,73 +0,0 @@
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Continuously trains the learned engine in the background by keeping a configurable number of
/// self-play games running — the learned engine (skill 6) against Stockfish (skill 20), alternating
/// which color Stockfish takes so the model trains on both. The target count is read live from
/// <see cref="AutoTrainingSettings"/> (adjustable from the website): when a game finishes another
/// starts to refill the pool, raising the count starts more, and lowering it lets the surplus drain
/// as games finish (0 pauses training). Registered only outside Development and gated by the
/// ChessEngine:AutoTrain config flag.
/// </summary>
public sealed class AutoTrainingService(
ISelfPlayCoordinator coordinator,
AutoTrainingSettings settings,
ILogger<AutoTrainingService> logger) : BackgroundService
{
private const int LearnedSkill = 6;
private const int StockfishSkill = 20;
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var running = new List<Task>();
int started = 0;
while (!stoppingToken.IsCancellationRequested)
{
running.RemoveAll(t => t.IsCompleted);
int desired = settings.GameCount;
bool startFailed = false;
while (running.Count < desired && !stoppingToken.IsCancellationRequested)
{
// Alternate Stockfish's color so the learned engine trains as both white and black.
var config = started++ % 2 == 0
? new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill)
: new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill);
try
{
var (_, completion) = coordinator.StartGame(config, stoppingToken);
running.Add(completion);
}
catch (Exception ex)
{
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
// persistent failure doesn't spin a tight loop, then try again.
logger.LogError(ex, "Failed to start an auto-training game; retrying after backoff.");
startFailed = true;
break;
}
}
try
{
if (startFailed)
await Task.Delay(_restartBackoff, stoppingToken);
else if (running.Count > 0)
// Wake when any game finishes (to refill) or after a short poll (to pick up a
// count increase promptly).
await Task.WhenAny(Task.WhenAny(running), Task.Delay(_pollInterval, stoppingToken));
else
// Pool is empty (count is 0) — just poll for the count to change.
await Task.Delay(_pollInterval, stoppingToken);
}
catch (OperationCanceledException) { break; }
}
}
}
@@ -1,31 +0,0 @@
using Microsoft.Extensions.Options;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the
/// chess controller) is seen live by the background <see cref="AutoTrainingService"/>. Seeded from
/// <see cref="ChessEngineOptions.AutoTrainGameCount"/> and clamped to a sane range.
/// </summary>
public sealed class AutoTrainingSettings
{
/// <summary>Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine).</summary>
public const int MaxGames = 16;
private int _gameCount;
public AutoTrainingSettings(IOptions<ChessEngineOptions> options)
=> _gameCount = Clamp(options.Value.AutoTrainGameCount);
/// <summary>
/// Number of auto-training games to keep running concurrently. 0 pauses auto-training.
/// Reads/writes are atomic; the background service reads this every cycle.
/// </summary>
public int GameCount
{
get => Volatile.Read(ref _gameCount);
set => Volatile.Write(ref _gameCount, Clamp(value));
}
private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames);
}
@@ -29,20 +29,6 @@ public sealed class ChessEngineOptions
/// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable.
/// </summary>
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>
/// How many auto-training games run concurrently (when <see cref="AutoTrain"/> is on). This is
/// the starting value; it can be changed at runtime from the website. Override the default via
/// the <c>ChessEngine__AutoTrainGameCount</c> environment variable.
/// </summary>
public int AutoTrainGameCount { get; set; } = 2;
}
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
@@ -1,69 +0,0 @@
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 Squares = 64;
private const int Features = 7; // mobility N/B/R/Q, passed, pawn links, king safety
private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety
public string WeightsFilePath { get; }
@@ -1,222 +0,0 @@
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);
// Abort a game if a single engine move takes longer than this — a stopgap for engines
// (usually Stockfish) that occasionally freeze and would otherwise hang the game.
private static readonly TimeSpan _moveTimeout = TimeSpan.FromSeconds(60);
// Plies of random legal moves at the start of a training game, so self-play and
// engine-vs-engine games explore different lines instead of replaying one game.
private const int _openingRandomPlies = 4;
public (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default)
{
var whiteComputer = engineFactory.Create(config.WhiteSkill, config.WhiteKind);
IChessEngine blackComputer;
try
{
blackComputer = engineFactory.Create(config.BlackSkill, config.BlackKind);
}
catch
{
// Don't leak the first engine if the second fails to start (e.g. Stockfish process).
whiteComputer.DisposeAsync().AsTask().GetAwaiter().GetResult();
throw;
}
var gameState = chessService.CreateNewGame();
gameState.IsVsComputer = true;
gameState.IsComputerVsComputer = true;
gameState.WhiteJoined = true;
gameState.BlackJoined = true;
gameState.WhitePlayerId = Guid.NewGuid();
gameState.BlackPlayerId = Guid.NewGuid();
gameState.WhiteEngineKind = config.WhiteKind;
gameState.BlackEngineKind = config.BlackKind;
gameState.WhiteComputer = whiteComputer;
gameState.BlackComputer = blackComputer;
// When the learned engine is playing, attach a trainer so the outcome can train it.
if (config.WhiteKind == ChessEngineKind.CustomLearned || config.BlackKind == ChessEngineKind.CustomLearned)
gameState.Trainer = weightsStore.CreateTrainer();
gameStore.Add(gameState);
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
var completion = Task.Run(() => RunAsync(gameState, cancellationToken));
return (gameState.GameId, completion);
}
/// <summary>
/// Drives the game to completion, then trains from it. Never throws — a failure (or a frozen
/// engine) just ends the game and the trainer is always freed, so callers can await or ignore.
/// </summary>
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
{
bool aborted = false;
try
{
// Give spectators a moment to join the SignalR group before the first move.
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
// Training games open with random moves so they don't replay the same line.
if (gameState.Trainer != nint.Zero)
for (int i = 0; i < _openingRandomPlies && IsLive(gameState, cancellationToken); i++)
{
await orchestrator.PlayRandomMoveAsync(gameState);
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
}
while (IsLive(gameState, cancellationToken))
{
await PlayMoveWithTimeoutAsync(gameState, cancellationToken);
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
}
}
catch (OperationCanceledException) { aborted = true; /* service shutting down */ }
catch (TimeoutException)
{
aborted = true;
Console.WriteLine($"Self-play game {gameState.GameId} aborted: a move took over " +
$"{_moveTimeout.TotalSeconds:n0}s (likely a frozen engine).");
}
catch (Exception ex)
{
aborted = true;
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
}
ApplyLearning(gameState);
// A clean finish lingers briefly so spectators see the result; an aborted/hung game is
// torn down immediately so its engines (and any frozen Stockfish process) are released.
if (gameStore.Contains(gameState.GameId))
gameStore.ScheduleRemove(gameState.GameId, aborted ? TimeSpan.Zero : _selfPlayResultTimeout);
}
/// <summary>
/// Plays one engine move, abandoning it if it exceeds <see cref="_moveTimeout"/> (throwing
/// <see cref="TimeoutException"/>). The abandoned move's eventual fault — it errors once the
/// game's engines are disposed — is observed so it isn't an unobserved task exception.
/// </summary>
private async Task PlayMoveWithTimeoutAsync(GameState gameState, CancellationToken cancellationToken)
{
var play = orchestrator.PlayAsync(gameState);
try
{
await play.WaitAsync(_moveTimeout, cancellationToken);
}
catch (TimeoutException)
{
_ = play.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default);
throw;
}
}
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
!cancellationToken.IsCancellationRequested
&& gameStore.Contains(gameState.GameId)
&& !IsGameOver(gameState);
private static bool IsGameOver(GameState gameState) =>
gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited;
/// <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);
}
}