Compare commits

..
Author SHA1 Message Date
jheapsandClaude Sonnet 5 ddb309f6db Learn fractional win-rate advantage instead of unbounded plus/minus
Piece-square and feature weights now converge to a presence-weighted
fractional win-rate per cell (percent of games won with a piece there
minus percent lost), tracked via running visits/score sums instead of
an incrementally clamped nudge. The training signal is also scaled by
how much of a game's result is NOT already explained by the winner's
average material edge (via the standard centipawn win-probability
curve), so a piece no longer gets credit merely for existing on the
winning side of a material blowout in the endgame.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 13:40:57 -06:00
jheaps 2db4048ed2 Merge pull request 'Time out Stockfish process exit so finished self-play games cant wedge in the store' (#8) from fix/stockfish-dispose-hang into master
Build and Deploy / build-deploy (push) Successful in 1m18s
Reviewed-on: #8
2026-08-31 12:22:28 -06:00
jheapsandClaude Sonnet 5 2f53c2f0ed Time out Stockfish process exit so finished self-play games can't wedge in the store
DisposeAsync awaited WaitForExitAsync with no timeout; a Stockfish process that
doesn't exit on "quit" blocked GameStore's removal task forever, leaving the
finished game stuck in the store and cluttering the live-watch list indefinitely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 11:23:59 -06:00
jheaps 7562ded92a Merge pull request 'Bound how long the blog cache serves stale entries' (#7) from fix/blog-cache-staleness into master
Build and Deploy / build-deploy (push) Successful in 1m4s
Reviewed-on: #7
2026-08-26 17:24:29 -06:00
Josh-HeapsandClaude Opus 5 392b9ec212 Bound how long the blog cache serves stale entries
A failed fetch fell back to the cached value with no limit, so an unreachable
blog API kept the post list rendering while every uncached slug returned 404.
Stale entries now survive an hour past their TTL, then get dropped and logged
at error so the outage surfaces the same way for every key.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 17:22:51 -06:00
jheaps 769c7819e0 Merge pull request 'fix workflows take 2' (#6) from fix/workflows into master
Build and Deploy / build-deploy (push) Successful in 1m6s
Reviewed-on: #6
2026-08-26 15:46:12 -06:00
Josh-Heaps c048529654 fix workflows take 2 2026-08-26 15:45:29 -06:00
jheaps e8eb90a085 Merge pull request 'Put workflow in the right spot' (#4) from fix/workflows into master
Build and Deploy / build-deploy (push) Failing after 1m7s
Reviewed-on: #4
2026-08-26 15:39:47 -06:00
Josh-Heaps fc167ae2b0 Put workflow in the right spot 2026-08-26 15:39:11 -06:00
jheaps 088f05e0d2 Merge pull request 'add deploy workflow for gitea' (#3) from gitea/workflows into master
Build and Deploy / build (push) Failing after 34s
Build and Deploy / deploy (push) Skipped
Playwright Tests / playwright-tests (push) Failing after 50s
Reviewed-on: #3
2026-08-26 15:33:17 -06:00
5 changed files with 194 additions and 47 deletions
@@ -1,4 +1,4 @@
# .gitea/workflows/deploy.yml
# .gitea/workflows/deploy.yml
name: Build and Deploy
on:
@@ -12,13 +12,17 @@ concurrency:
jobs:
build-deploy:
# Gitea's runner images are lean, so the toolchain is installed below rather
# than assumed. The glibc that libchess_engine.so links against comes from
# this image, not from the runner host — keep it matched to the prod server.
runs-on: ubuntu-latest
steps:
- name: Install deploy tools
- name: Install toolchain
run: |
apt-get update
apt-get install -y --no-install-recommends rsync openssh-client
apt-get install -y --no-install-recommends \
cmake build-essential rsync openssh-client
- name: Checkout
uses: actions/checkout@v4
@@ -29,10 +33,26 @@ jobs:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
- name: Build native chess engine (libchess_engine.so)
run: |
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/chess_engine/build
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
# Publishing the project rather than the solution keeps chess_engine.vcxproj
# (Windows-only MSBuild C++ targets) and the UiTests assemblies out of it.
- name: Publish
run: dotnet publish -c Release -o ./publish
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
- name: Verify payload
run: |
set -euo pipefail
test -f publish/Resources/libchess_engine.so \
|| { echo "libchess_engine.so missing from publish output"; exit 1; }
test -x publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt \
|| { echo "stockfish is not executable"; exit 1; }
- name: Prepare SSH
env:
@@ -47,6 +67,8 @@ jobs:
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# --exclude chess-data: never let --delete remove the learned-engine training
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
- name: Rsync to server
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
@@ -56,11 +78,11 @@ jobs:
run: |
set -euo pipefail
PORT="${SSH_PORT:-22}"
rsync -az --delete \
rsync -az --delete --exclude 'chess-data' \
-e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"
- name: Restart service
- name: Reload and restart service
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT }}
@@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Text.Json;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
@@ -10,6 +10,7 @@ public class BlogService : IBlogService
private readonly HttpClient _httpClient;
private readonly ILogger<BlogService> _logger;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
@@ -65,7 +66,9 @@ public class BlogService : IBlogService
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
{
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow)
_cache.TryGetValue(key, out var entry);
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
return (T?)entry.Value;
var result = await factory();
@@ -73,15 +76,26 @@ public class BlogService : IBlogService
if (result is not null)
{
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
return result;
}
else if (entry is not null)
return entry is null ? null : ServeStale<T>(key, entry);
}
// Stale entries are dropped once the grace window closes so that a dead API fails the same
// way for every key. Serving them indefinitely let a cached post list render next to 404s on
// the posts themselves, which reads as a site bug rather than an outage.
private T? ServeStale<T>(string key, CacheEntry entry) where T : class
{
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
{
// API unreachable — serve stale cache
_logger.LogWarning("Serving stale cache for key {Key}", key);
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
return (T?)entry.Value;
}
return result;
_cache.TryRemove(key, out _);
_logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key);
return null;
}
public void ClearCache()
@@ -100,11 +100,26 @@ public sealed class Stockfish : IChessEngine
if (line == token) break;
}
// If Stockfish doesn't exit on "quit" (crashed, zombie, ignores stdin close), don't hang
// here forever — that would block GameStore's removal task and leave the finished game
// stuck in the store, cluttering the watch page indefinitely.
private static readonly TimeSpan _exitTimeout = TimeSpan.FromSeconds(5);
public async ValueTask DisposeAsync()
{
Send("quit");
_stdin.Close();
await _p.WaitForExitAsync();
try
{
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
}
catch (TimeoutException)
{
if (!_p.HasExited)
_p.Kill(entireProcessTree: true);
}
_p.Dispose();
}
}
+7 -3
View File
@@ -103,9 +103,13 @@ 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. */
/* Apply a finished game's outcome to the global weights and save. Each touched cell (a piece
* on a square, or a feature) tracks a presence-weighted running average of the game's
* "advantage" — the game's outcome (scaled by `weight`, e.g. 0.5 for a material-imbalance
* draw) minus how much of that outcome the winner's average material edge already explains —
* so mg/eg/featW end up as a fractional win-rate (percent of games won with that piece there,
* minus percent lost), not an unbounded plus/minus tally, and a piece doesn't get credit
* merely for existing on the winning side of a material blowout. winner: 0 = white, 1 = black. */
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle trainer, int winner, double weight);
#ifdef __cplusplus
+122 -30
View File
@@ -26,6 +26,7 @@
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iomanip>
#include <mutex>
#include <new>
#include <string>
@@ -115,11 +116,25 @@ struct ChessEngine {
/* 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). */
* per-handle copy, so it never touches this concurrently).
*
* mg/eg/featW are DERIVED, not accumulated: each is always exactly OUT_SCALE * (score/visits)
* for its cell (see trainer_apply), recomputed whenever that cell's stats change. visits is
* the presence-weighted count of games that touched the cell (how much to trust the average);
* score is the presence-weighted sum of each game's material-adjusted advantage (see
* trainer_apply) — so score/visits is literally "fraction of (presence-weighted) games won
* with this piece here, minus fraction lost," bounded to [-1,1] by construction. */
struct LearnedWeights {
int mg[chess::PIECE_TYPE_NB][64] = {};
int eg[chess::PIECE_TYPE_NB][64] = {};
int featW[FEATURE_NB] = {};
double mgVisits[chess::PIECE_TYPE_NB][64] = {};
double mgScore [chess::PIECE_TYPE_NB][64] = {};
double egVisits[chess::PIECE_TYPE_NB][64] = {};
double egScore [chess::PIECE_TYPE_NB][64] = {};
double featVisits[FEATURE_NB] = {};
double featScore [FEATURE_NB] = {};
};
static LearnedWeights g_weights;
@@ -127,13 +142,15 @@ 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
* each side's pieces sat (split into midgame/endgame by phase), each side's feature
* activations, and the white-minus-black material balance; trainer_apply turns the totals
* into a presence-weighted update to the running per-cell averages. 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] = {};
double matDiffAcc = 0.0; /* sum over plies of (white material - black material), cp */
int plies = 0;
};
@@ -172,9 +189,12 @@ static int parse_variant(const char* options) {
}
/* 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. */
* FEATURE_NB feature ints, then the backing visits/score doubles for each (mgVisits, mgScore,
* egVisits, egScore, featVisits, featScore) in the same shapes, all 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" — and a file written by the pre-fractional-learning format (which
* ends after the feature ints) loads its mg/eg/featW fine and simply starts every cell's
* visits/score history fresh from the next game that touches it. Caller holds g_weightsMutex. */
static void load_global_weights(const char* path) {
g_weights = LearnedWeights{}; /* reset to neutral before loading */
@@ -190,9 +210,29 @@ static void load_global_weights(const char* path) {
if (!(f >> g_weights.eg[pt][sq])) return;
for (int i = 0; i < FEATURE_NB; ++i)
if (!(f >> g_weights.featW[i])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.mgVisits[pt][sq])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.mgScore[pt][sq])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.egVisits[pt][sq])) return;
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq)
if (!(f >> g_weights.egScore[pt][sq])) return;
for (int i = 0; i < FEATURE_NB; ++i)
if (!(f >> g_weights.featVisits[i])) return;
for (int i = 0; i < FEATURE_NB; ++i)
if (!(f >> g_weights.featScore[i])) return;
}
/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Caller holds the lock. */
/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Full round-trip
* precision on the doubles (visits/score are running sums that keep accumulating across many
* games, so truncating them would drift the derived mg/eg/featW over time). Caller holds the
* lock. */
static void save_global_weights() {
if (g_weightsPath.empty()) return;
std::ofstream f(g_weightsPath);
@@ -203,6 +243,18 @@ static void save_global_weights() {
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' : ' ');
f << std::setprecision(17);
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.mgScore[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.egVisits[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
for (int sq = 0; sq < 64; ++sq) f << g_weights.egScore[pt][sq] << (sq == 63 ? '\n' : ' ');
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featVisits[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featScore[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
}
/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no
@@ -382,13 +434,18 @@ static int evaluate(const chess::Position& pos) {
* 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;
/* Per-game-outcome learning constants. Squares and features both learn a presence/activation-
* weighted AVERAGE advantage in [-1,1] (see trainer_apply) — OUT_SCALE just converts that
* average into a centipawn-ish int for the eval/table to add directly. FEAT_SCALE normalizes
* each feature's raw per-ply activation to a comparable magnitude before it's used as a weight
* (so high-count mobility doesn't dwarf the small pawn-structure terms), same as before.
* MATERIAL_ELO_SCALE is the classic centipawn-to-win-probability curve
* (expected = 1/(1+10^(-cp/400))) used to compute how much of a result material already
* explains, so training credits placement/features only for the part of the result that
* material didn't already predict. */
static constexpr int SQ_OUT_SCALE = 250;
static constexpr int FEAT_OUT_SCALE = 500;
static constexpr double MATERIAL_ELO_SCALE = 400.0;
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
@@ -823,7 +880,9 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* 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. */
/* Per-square occupancy, split into midgame/endgame by phase, white-relative; and the
* white-minus-black material balance at this ply, used at trainer_apply time to strip
* "material already explains this" out of the training signal. */
chess::Bitboard occ = pos.pieces();
while (occ) {
chess::Square s = chess::pop_lsb(occ);
@@ -833,6 +892,7 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56);
t->mgOcc[c][pt][relSq] += (1.0 - phase);
t->egOcc[c][pt][relSq] += phase;
t->matDiffAcc += (c == chess::WHITE ? 1 : -1) * piece_value(pt);
}
/* Per-side feature activations. */
@@ -847,34 +907,66 @@ CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
t->plies++;
}
/* Applies a finished game's outcome as a presence-weighted running average per cell, instead
* of an unbounded incremental nudge:
*
* score[cell] / visits[cell] == "fraction of (presence-weighted) games won with this piece
* on this square, minus fraction lost"
*
* `weight` is the caller's confidence in the result (1.0 for a checkmate, 0.5 for a
* material-imbalance draw, as before). On top of that, the update is scaled by how much of the
* result ISN'T already explained by material: `expected` is the win probability the winner's
* average material edge over the game implies (the same centipawn->win-probability curve
* engines use for Elo estimates); `1 - expected` is the "surprise" left over once material is
* accounted for. A win where the winner was already up a rook the whole game is unsurprising
* (expected ~= 1, ~no update) — the eval already knows material matters via piece_value(), so
* square/feature tables shouldn't get credit for "a piece existed"; a win pulled off (or a draw
* held) while materially behind is highly informative and gets a correspondingly large update.
* This same magnitude applies to BOTH the winner's reward and the loser's punishment: by the
* symmetry of win probabilities (P(loser wins) = 1 - P(winner wins)), the loser's own advantage
* — actual(0) minus their expected win probability — has exactly the same magnitude, `1 -
* expected`, just negative. */
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) {
if (!t) return;
if (!t || t->plies <= 0) return;
double avgMaterialDiff = t->matDiffAcc / t->plies; /* white - black, cp */
double winnerMaterialDiff = (winner == chess::WHITE) ? avgMaterialDiff : -avgMaterialDiff;
double expected = 1.0 / (1.0 + std::pow(10.0, -winnerMaterialDiff / MATERIAL_ELO_SCALE));
double advantageMag = weight * (1.0 - expected);
std::lock_guard<std::mutex> lock(g_weightsMutex);
/* pass 0 = winner (reward, +1); pass 1 = loser (punish, -1). */
/* pass 0 = winner (positive advantage); pass 1 = loser (negative advantage). */
for (int pass = 0; pass < 2; ++pass) {
chess::Color side = chess::Color((pass == 0 ? winner : (winner ^ 1)) & 1);
int sign = pass == 0 ? 1 : -1;
double advantage = pass == 0 ? advantageMag : -advantageMag;
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);
double mgFrac = t->mgOcc[side][pt][sq] / t->plies; /* fraction of the game spent here, [0,1] */
if (mgFrac != 0.0) {
g_weights.mgVisits[pt][sq] += mgFrac;
g_weights.mgScore [pt][sq] += mgFrac * advantage;
g_weights.mg[pt][sq] = int(std::lround(
SQ_OUT_SCALE * g_weights.mgScore[pt][sq] / g_weights.mgVisits[pt][sq]));
}
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);
double egFrac = t->egOcc[side][pt][sq] / t->plies;
if (egFrac != 0.0) {
g_weights.egVisits[pt][sq] += egFrac;
g_weights.egScore [pt][sq] += egFrac * advantage;
g_weights.eg[pt][sq] = int(std::lround(
SQ_OUT_SCALE * g_weights.egScore[pt][sq] / g_weights.egVisits[pt][sq]));
}
}
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);
}
for (int i = 0; i < FEATURE_NB; ++i) {
double activation = (t->featAcc[side][i] / t->plies) / FEAT_SCALE[i]; /* per-ply average, normalized */
if (activation == 0.0) continue;
g_weights.featVisits[i] += std::abs(activation);
g_weights.featScore [i] += activation * advantage;
g_weights.featW[i] = int(std::lround(
FEAT_OUT_SCALE * g_weights.featScore[i] / g_weights.featVisits[i]));
}
}
save_global_weights();