From e25da70c24efb5757ce8a130b7d8f21c35233d04 Mon Sep 17 00:00:00 2001 From: Christopher Junius Date: Sun, 30 Aug 2026 20:34:47 -0400 Subject: [PATCH 01/10] chore: drop retired Go Report Card badge Co-Authored-By: Claude Sonnet 5 --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index d80e1d0..0797f92 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ [![CI](https://github.com/cjunius/goChess/actions/workflows/ci.yml/badge.svg)](https://github.com/cjunius/goChess/actions/workflows/ci.yml) [![Lint](https://github.com/cjunius/goChess/actions/workflows/lint.yml/badge.svg)](https://github.com/cjunius/goChess/actions/workflows/lint.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/cjunius/goChess.svg)](https://pkg.go.dev/github.com/cjunius/goChess) -[![Go Report Card](https://goreportcard.com/badge/github.com/cjunius/goChess)](https://goreportcard.com/report/github.com/cjunius/goChess) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) A UCI chess engine written in Go, built on the From 6e3f75d2afef3d85c843cb13815406afc3f58ff3 Mon Sep 17 00:00:00 2001 From: Christopher Junius Date: Sun, 30 Aug 2026 20:37:32 -0400 Subject: [PATCH 02/10] feat(search): principal variation search + real PV Replace the LMR-only null-window logic with full principal variation search: the first move at each node keeps a full window at full depth, later moves are scouted with a null window (and an LMR reduction for late quiets) and only re-searched at full depth / full window when the scout beats alpha. Add a triangular PV table to the searcher so the search reports the actual principal variation, not just the best move. SearchResult gains PV. Co-Authored-By: Claude Sonnet 5 --- internal/engine/search.go | 74 +++++++++++++++++++++++++++++----- internal/engine/search_test.go | 34 ++++++++++++++++ 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/internal/engine/search.go b/internal/engine/search.go index 9f7d900..bc76c8b 100644 --- a/internal/engine/search.go +++ b/internal/engine/search.go @@ -56,6 +56,9 @@ type SearchResult struct { Depth int Nodes int64 Elapsed time.Duration + // PV is the principal variation for the deepest completed iteration, best + // move first. PV[0] equals BestMove whenever the search completed a depth. + PV []dragontoothmg.Move } type searcher struct { @@ -72,6 +75,24 @@ type searcher struct { // so Lazy-SMP workers keep independent tables and need no synchronisation. killers [maxPly + 1][2]dragontoothmg.Move history [2][64][64]int + + // pv is a triangular principal-variation table: pv[ply][:pvLen[ply]] is the + // best line found from that ply down, maintained only at nodes that raise + // alpha. The root line is pv[0][:pvLen[0]]. + pv [maxPly + 1][maxPly + 1]dragontoothmg.Move + pvLen [maxPly + 1]int +} + +// setPV records m as the best move at ply and splices the child line at ply+1 +// behind it. Called whenever a move raises alpha. +func (s *searcher) setPV(ply int, m dragontoothmg.Move) { + s.pv[ply][0] = m + if ply+1 >= len(s.pv) { + s.pvLen[ply] = 1 + return + } + n := copy(s.pv[ply][1:], s.pv[ply+1][:s.pvLen[ply+1]]) + s.pvLen[ply] = n + 1 } func (s *searcher) timeUp() bool { @@ -180,6 +201,7 @@ func (s *searcher) runIterativeDeepening(b *dragontoothmg.Board, maxDepth, start break // out of time: keep the previous completed depth } res.BestMove, res.Score, res.Depth = move, score, depth + res.PV = append(res.PV[:0], s.pv[0][:s.pvLen[0]]...) if score >= mateThreshold || score <= -mateThreshold { if s.stop != nil { s.stop.Store(true) // forced mate: let the other workers stop too @@ -199,11 +221,21 @@ func (s *searcher) searchRoot(b *dragontoothmg.Board, depth int) (score int, bes _, ttMove, _ := s.tt.probe(key, depth, -infinity, infinity, 0) moves := s.orderMoves(b, b.GenerateLegalMoves(), ttMove, 0) + s.pvLen[0] = 0 alpha, beta := -infinity, infinity bestScore := -infinity - for _, m := range moves { + for i, m := range moves { unapply := b.Apply(m) - v := -s.negamax(b, depth-1, -beta, -alpha, 1, true) + var v int + if i == 0 { + v = -s.negamax(b, depth-1, -beta, -alpha, 1, true) + } else { + // Null-window scout; re-search with the full window if it beats alpha. + v = -s.negamax(b, depth-1, -alpha-1, -alpha, 1, true) + if v > alpha { + v = -s.negamax(b, depth-1, -beta, -alpha, 1, true) + } + } unapply() if s.stopped { return 0, dragontoothmg.Move(0), false @@ -213,6 +245,7 @@ func (s *searcher) searchRoot(b *dragontoothmg.Board, depth int) (score int, bes } if v > alpha { alpha = v + s.setPV(0, m) } } s.tt.store(key, depth, bestScore, boundExact, best, 0) @@ -254,6 +287,9 @@ func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int, if v, done := s.terminalScore(b, depth, alpha, beta, ply); done { return v } + if ply <= maxPly { + s.pvLen[ply] = 0 + } alphaOrig := alpha key := b.Hash() @@ -287,6 +323,9 @@ func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int, } if v > alpha { alpha = v + if ply < maxPly { + s.setPV(ply, m) + } } if alpha >= beta { if isQuiet(b, m) { @@ -308,9 +347,10 @@ func (s *searcher) negamax(b *dragontoothmg.Board, depth, alpha, beta, ply int, } // searchMove applies m, searches the resulting position, and returns its score -// from the current side's point of view. Late quiet moves are first searched at -// a reduced depth (LMR); a reduced search that beats alpha is repeated at full -// depth so the true score is never lost. +// from the current side's point of view. This is principal variation search: the +// first move gets a full window at full depth; every later move is first probed +// with a null window (and, for late quiets, a reduced depth — LMR), and only +// re-searched at full depth / full window when that probe beats alpha. func (s *searcher) searchMove(b *dragontoothmg.Board, m dragontoothmg.Move, moveIdx, depth, alpha, beta, ply int, inCheck bool) int { quiet := isQuiet(b, m) unapply := b.Apply(m) @@ -318,17 +358,29 @@ func (s *searcher) searchMove(b *dragontoothmg.Board, m dragontoothmg.Move, move givesCheck := b.OurKingInCheck() newDepth := depth - 1 + if moveIdx == 0 { + return -s.negamax(b, newDepth, -beta, -alpha, ply+1, true) + } + + red := 0 if depth >= lmrMinDepth && moveIdx >= lmrMinMove && quiet && !inCheck && !givesCheck { - red := 1 + red = 1 if moveIdx >= 6 && depth >= 5 { red = 2 } - v := -s.negamax(b, newDepth-red, -alpha-1, -alpha, ply+1, true) - if v <= alpha { - return v // stays fail-low even at full depth: no re-search needed - } } - return -s.negamax(b, newDepth, -beta, -alpha, ply+1, true) + + v := -s.negamax(b, newDepth-red, -alpha-1, -alpha, ply+1, true) + if v > alpha && red > 0 { + // The reduction was too aggressive: retry at full depth, still scouting. + v = -s.negamax(b, newDepth, -alpha-1, -alpha, ply+1, true) + } + if v > alpha && v < beta { + // Scout landed inside the window: this move may be part of the PV, so + // resolve its true score with the full window. + v = -s.negamax(b, newDepth, -beta, -alpha, ply+1, true) + } + return v } // tryNullMove implements null-move pruning: if handing the opponent a free move diff --git a/internal/engine/search_test.go b/internal/engine/search_test.go index 519c070..f3225ee 100644 --- a/internal/engine/search_test.go +++ b/internal/engine/search_test.go @@ -167,6 +167,40 @@ func TestSearchPrunedResultMatchesPlainOnTactics(t *testing.T) { } } +func TestSearchReportsPrincipalVariation(t *testing.T) { + b := dragontoothmg.ParseFen(dragontoothmg.Startpos) + res := engine.Search(&b, engine.SearchParams{MaxDepth: 6}) + if len(res.PV) < 2 { + t.Fatalf("PV = %v, want at least two moves deep", res.PV) + } + if res.PV[0] != res.BestMove { + t.Errorf("PV[0] = %s, want it to equal BestMove %s", res.PV[0].String(), res.BestMove.String()) + } + // Every PV move must be legal in sequence. + bb := dragontoothmg.ParseFen(dragontoothmg.Startpos) + for i, m := range res.PV { + legal := false + for _, lm := range bb.GenerateLegalMoves() { + if lm == m { + legal = true + break + } + } + if !legal { + t.Fatalf("PV move %d (%s) is not legal in the line", i, m.String()) + } + bb.Apply(m) + } +} + +func TestSearchMateInOnePVIsOneMove(t *testing.T) { + b := dragontoothmg.ParseFen("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1") + res := engine.Search(&b, engine.SearchParams{MaxDepth: 3}) + if len(res.PV) != 1 || res.PV[0].String() != "a1a8" { + t.Fatalf("PV = %v, want exactly [a1a8]", res.PV) + } +} + const ( mateThreshold = 1_000_000 - 64 maxDepthUnbounded = 64 From 6c08c1ce777ae90b6e9addd2c3d2ebfca3510394 Mon Sep 17 00:00:00 2001 From: Christopher Junius Date: Sun, 30 Aug 2026 20:39:49 -0400 Subject: [PATCH 03/10] feat(search): aspiration windows From depth 5, each iterative-deepening iteration first searches a narrow window centred on the previous iteration's score. searchRoot now takes an explicit [alpha, beta] and reports a fail-soft score; searchDepth wraps it, doubling the delta on whichever side fails until the score lands inside the window or it opens fully. Co-Authored-By: Claude Sonnet 5 --- internal/engine/search.go | 70 ++++++++++++++++++++++--- internal/engine/search_internal_test.go | 29 ++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/internal/engine/search.go b/internal/engine/search.go index bc76c8b..f5aa0c3 100644 --- a/internal/engine/search.go +++ b/internal/engine/search.go @@ -30,6 +30,14 @@ const ( // historyMax caps a history counter so repeated cut-offs cannot dwarf the // capture scores in move ordering. historyMax = 1 << 22 + + // Aspiration windows: from aspMinDepth, each iteration first searches a + // window aspBaseDelta wide centred on the previous score, doubling the delta + // on the side that fails until the score lands inside or the window has + // grown past aspMaxDelta (at which point it opens fully). + aspMinDepth = 5 + aspBaseDelta = 25 + aspMaxDelta = 400 ) // SearchParams controls a single Search call. @@ -195,13 +203,15 @@ func (s *searcher) runIterativeDeepening(b *dragontoothmg.Board, maxDepth, start } res.BestMove = root[0] + prevScore := 0 for depth := startDepth; depth <= maxDepth; depth++ { - score, move, ok := s.searchRoot(b, depth) + score, move, ok := s.searchDepth(b, depth, prevScore) if !ok { break // out of time: keep the previous completed depth } res.BestMove, res.Score, res.Depth = move, score, depth res.PV = append(res.PV[:0], s.pv[0][:s.pvLen[0]]...) + prevScore = score if score >= mateThreshold || score <= -mateThreshold { if s.stop != nil { s.stop.Store(true) // forced mate: let the other workers stop too @@ -216,13 +226,51 @@ func (s *searcher) runIterativeDeepening(b *dragontoothmg.Board, maxDepth, start return res } -func (s *searcher) searchRoot(b *dragontoothmg.Board, depth int) (score int, best dragontoothmg.Move, ok bool) { +// searchDepth runs one iterative-deepening iteration for depth. From aspMinDepth +// it wraps searchRoot in an aspiration window centred on the previous score, +// widening the failing side until the score lands inside; shallow depths and +// positions with a known mate score search the full window directly. +func (s *searcher) searchDepth(b *dragontoothmg.Board, depth, prevScore int) (int, dragontoothmg.Move, bool) { + if depth < aspMinDepth || prevScore >= mateThreshold || prevScore <= -mateThreshold { + return s.searchRoot(b, depth, -infinity, infinity) + } + + delta := aspBaseDelta + alpha := max(prevScore-delta, -infinity) + beta := min(prevScore+delta, infinity) + for { + score, move, ok := s.searchRoot(b, depth, alpha, beta) + if !ok { + return 0, dragontoothmg.Move(0), false + } + switch { + case score <= alpha: + alpha = max(alpha-delta, -infinity) + delta *= 2 + case score >= beta: + beta = min(beta+delta, infinity) + delta *= 2 + default: + return score, move, true + } + if delta > aspMaxDelta { + alpha, beta = -infinity, infinity + } + } +} + +// searchRoot searches every legal move at the root inside the window [alpha, +// beta] and returns the (fail-soft) score of the best one. The first move gets +// the full window; the rest are scouted with a null window and re-searched only +// when the scout lands inside. A score at or above beta means a move failed high +// and the remaining moves were skipped — the caller must widen and retry. +func (s *searcher) searchRoot(b *dragontoothmg.Board, depth, alpha, beta int) (score int, best dragontoothmg.Move, ok bool) { + alphaOrig := alpha key := b.Hash() _, ttMove, _ := s.tt.probe(key, depth, -infinity, infinity, 0) moves := s.orderMoves(b, b.GenerateLegalMoves(), ttMove, 0) s.pvLen[0] = 0 - alpha, beta := -infinity, infinity bestScore := -infinity for i, m := range moves { unapply := b.Apply(m) @@ -230,9 +278,8 @@ func (s *searcher) searchRoot(b *dragontoothmg.Board, depth int) (score int, bes if i == 0 { v = -s.negamax(b, depth-1, -beta, -alpha, 1, true) } else { - // Null-window scout; re-search with the full window if it beats alpha. v = -s.negamax(b, depth-1, -alpha-1, -alpha, 1, true) - if v > alpha { + if v > alpha && v < beta { v = -s.negamax(b, depth-1, -beta, -alpha, 1, true) } } @@ -247,8 +294,19 @@ func (s *searcher) searchRoot(b *dragontoothmg.Board, depth int) (score int, bes alpha = v s.setPV(0, m) } + if alpha >= beta { + break // fail-high: caller widens beta and re-searches + } + } + + bound := boundExact + switch { + case bestScore <= alphaOrig: + bound = boundUpper + case bestScore >= beta: + bound = boundLower } - s.tt.store(key, depth, bestScore, boundExact, best, 0) + s.tt.store(key, depth, bestScore, bound, best, 0) return bestScore, best, true } diff --git a/internal/engine/search_internal_test.go b/internal/engine/search_internal_test.go index b5bb0ad..4f0cc68 100644 --- a/internal/engine/search_internal_test.go +++ b/internal/engine/search_internal_test.go @@ -224,6 +224,35 @@ func TestNegamaxLeavesBoardUnmodified(t *testing.T) { } } +func TestAspirationWindowMatchesFullWindow(t *testing.T) { + fens := []string{ + dragontoothmg.Startpos, + "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3", + "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10", + } + const depth = 6 + for _, fen := range fens { + b := dragontoothmg.ParseFen(fen) + + full := &searcher{tt: NewTT(8), deadline: time.Now().Add(5 * time.Second)} + wantScore, wantMove, _ := full.searchRoot(&b, depth, -infinity, infinity) + + // Feed searchDepth the true score as the previous iteration's guess so the + // aspiration window is tight — the hardest case for it to get right. + asp := &searcher{tt: NewTT(8), deadline: time.Now().Add(5 * time.Second)} + gotScore, gotMove, ok := asp.searchDepth(&b, depth, wantScore) + if !ok { + t.Fatalf("%s: searchDepth timed out", fen) + } + if gotScore != wantScore { + t.Errorf("%s: aspiration score %d, full-window score %d", fen, gotScore, wantScore) + } + if gotMove != wantMove { + t.Errorf("%s: aspiration move %s, full-window move %s", fen, gotMove.String(), wantMove.String()) + } + } +} + // field returns the i-th space-separated field of a FEN string. func field(fen string, i int) string { start, n := 0, 0 From 68ee9a2d9eb6ad4db02cc8ebc3115b2034a3b59c Mon Sep 17 00:00:00 2001 From: Christopher Junius Date: Sun, 30 Aug 2026 20:41:53 -0400 Subject: [PATCH 04/10] =?UTF-8?q?feat(search):=20richer=20Lazy=20SMP=20?= =?UTF-8?q?=E2=80=94=20TT=20ageing,=20root-move=20&=20aspiration=20skew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TT entries carry a 6-bit generation; NewSearch() bumps it and store() treats older-generation entries as free space, so each search reclaims the previous search's table instead of being blocked by its deeper entries. Search() calls NewSearch() once per invocation. - Lazy-SMP helpers skew their root move order (each scouts a different alternative to the hash move first) and use a wider, asymmetric aspiration window so they diverge onto different parts of the tree. Co-Authored-By: Claude Sonnet 5 --- internal/engine/search.go | 35 ++++++++++++++++++++++++--- internal/engine/transposition.go | 33 +++++++++++++++++++------ internal/engine/transposition_test.go | 21 ++++++++++++++++ 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/internal/engine/search.go b/internal/engine/search.go index f5aa0c3..94e97a1 100644 --- a/internal/engine/search.go +++ b/internal/engine/search.go @@ -143,6 +143,7 @@ func Search(b *dragontoothmg.Board, p SearchParams) SearchResult { } stop := new(atomic.Bool) + tt.NewSearch() if threads > 1 { res = searchLazySMP(b, p.MaxDepth, deadline, stop, tt, threads) } else { @@ -235,9 +236,7 @@ func (s *searcher) searchDepth(b *dragontoothmg.Board, depth, prevScore int) (in return s.searchRoot(b, depth, -infinity, infinity) } - delta := aspBaseDelta - alpha := max(prevScore-delta, -infinity) - beta := min(prevScore+delta, infinity) + alpha, beta, delta := s.aspWindow(prevScore) for { score, move, ok := s.searchRoot(b, depth, alpha, beta) if !ok { @@ -259,6 +258,35 @@ func (s *searcher) searchDepth(b *dragontoothmg.Board, depth, prevScore int) (in } } +// aspWindow returns the initial aspiration window and delta for this searcher. +// Lazy-SMP helpers (id > 0) widen the delta and skew the window asymmetrically +// so they hit fail-high / fail-low boundaries at different points than worker 0, +// diverging onto different parts of the tree. +func (s *searcher) aspWindow(prev int) (alpha, beta, delta int) { + delta = aspBaseDelta + 10*s.id + lo, hi := delta, delta + switch { + case s.id == 0: + case s.id&1 == 1: + lo = 2 * delta + default: + hi = 2 * delta + } + return max(prev-lo, -infinity), min(prev+hi, infinity), delta +} + +// skewRootMoves perturbs the root move order for Lazy-SMP helpers: the k-th +// helper swaps a different later move into the second slot, so helpers scout a +// different alternative to the hash move first. Worker 0 and the single searcher +// leave the order untouched. +func (s *searcher) skewRootMoves(moves []dragontoothmg.Move) { + if s.id <= 0 || len(moves) < 3 { + return + } + j := 1 + (s.id-1)%(len(moves)-1) + moves[1], moves[j] = moves[j], moves[1] +} + // searchRoot searches every legal move at the root inside the window [alpha, // beta] and returns the (fail-soft) score of the best one. The first move gets // the full window; the rest are scouted with a null window and re-searched only @@ -270,6 +298,7 @@ func (s *searcher) searchRoot(b *dragontoothmg.Board, depth, alpha, beta int) (s _, ttMove, _ := s.tt.probe(key, depth, -infinity, infinity, 0) moves := s.orderMoves(b, b.GenerateLegalMoves(), ttMove, 0) + s.skewRootMoves(moves) s.pvLen[0] = 0 bestScore := -infinity for i, m := range moves { diff --git a/internal/engine/transposition.go b/internal/engine/transposition.go index 81ba735..55879ee 100644 --- a/internal/engine/transposition.go +++ b/internal/engine/transposition.go @@ -37,7 +37,8 @@ type ttEntry struct { // bits 16..47 score, int32 two's-complement (mate scores need > 16 bits) // bits 48..55 depth, uint8 // bits 56..57 bound flag -func packTT(move dragontoothmg.Move, score, depth, bound int) uint64 { +// bits 58..63 generation (6-bit search counter, for ageing) +func packTT(move dragontoothmg.Move, score, depth, bound, gen int) uint64 { switch { case depth < 0: depth = 0 @@ -49,14 +50,16 @@ func packTT(move dragontoothmg.Move, score, depth, bound int) uint64 { return uint64(uint16(move)) | (uint64(uint32(score)) << 16) | //nolint:gosec // deliberate low-32-bit pack; unpackTT restores the sign (uint64(depth&0xff) << 48) | - (uint64(bound&3) << 56) + (uint64(bound&3) << 56) | + (uint64(gen&0x3f) << 58) } -func unpackTT(data uint64) (move dragontoothmg.Move, score, depth, bound int) { +func unpackTT(data uint64) (move dragontoothmg.Move, score, depth, bound, gen int) { move = dragontoothmg.Move(data & 0xffff) score = int(int32(data >> 16)) //nolint:gosec // sign-extend the packed int32 score depth = int((data >> 48) & 0xff) bound = int((data >> 56) & 3) + gen = int((data >> 58) & 0x3f) return } @@ -66,6 +69,10 @@ func unpackTT(data uint64) (move dragontoothmg.Move, score, depth, bound int) { type TT struct { entries []ttEntry mask uint64 + // gen is bumped once per search (NewSearch). store treats entries from an + // older generation as free space, so a new search reclaims the previous + // search's table instead of being blocked by its deeper entries. + gen atomic.Uint32 } // NewTT returns a table that occupies about mb megabytes, rounded down to the @@ -82,6 +89,13 @@ func NewTT(mb int) *TT { return &TT{entries: make([]ttEntry, size), mask: size - 1} } +// NewSearch advances the table's generation. Call it once at the start of every +// search so entries left by the previous search become preferentially +// replaceable (see store). +func (t *TT) NewSearch() { + t.gen.Add(1) +} + // Clear empties every slot. Call it between games (UCI "ucinewgame"); stale // entries from an unrelated position are otherwise indistinguishable from live // ones once their key happens to collide. @@ -103,7 +117,7 @@ func (t *TT) probe(key uint64, depth, alpha, beta, ply int) (score int, move dra if data == 0 || lock^data != key { return 0, 0, false } - m, eScore, eDepth, bound := unpackTT(data) + m, eScore, eDepth, bound, _ := unpackTT(data) if eDepth < depth { return 0, m, false } @@ -123,16 +137,19 @@ func (t *TT) probe(key uint64, depth, alpha, beta, ply int) (score int, move dra return 0, m, false } -// store records a result for key. A shallower existing entry for the same key is -// overwritten; a deeper one is kept. +// store records a result for key. An existing entry is kept only when it is for +// the same key, from the current generation, and searched deeper — so within one +// search the table is depth-preferred, but a new search (NewSearch) overwrites +// the previous one's entries regardless of their depth. func (t *TT) store(key uint64, depth, score, bound int, move dragontoothmg.Move, ply int) { + gen := int(t.gen.Load() & 0x3f) e := &t.entries[key&t.mask] if old := e.data.Load(); old != 0 && e.lock.Load()^old == key { - if _, _, oldDepth, _ := unpackTT(old); oldDepth > depth { + if _, _, oldDepth, _, oldGen := unpackTT(old); oldGen == gen && oldDepth > depth { return } } - data := packTT(move, ttStoreScore(score, ply), depth, bound) + data := packTT(move, ttStoreScore(score, ply), depth, bound, gen) e.lock.Store(key ^ data) e.data.Store(data) } diff --git a/internal/engine/transposition_test.go b/internal/engine/transposition_test.go index 52abf42..d126d6e 100644 --- a/internal/engine/transposition_test.go +++ b/internal/engine/transposition_test.go @@ -84,6 +84,27 @@ func TestTTMateScoreIsRebasedByPly(t *testing.T) { } } +func TestTTAgeingReclaimsPreviousSearch(t *testing.T) { + tt := NewTT(1) + key := uint64(0x1234) + deep, _ := dragontoothmg.ParseMove("e2e4") + shallow, _ := dragontoothmg.ParseMove("d2d4") + + // Within one generation, a deeper entry is kept over a shallower store. + tt.store(key, 12, 5, boundExact, deep, 0) + tt.store(key, 4, 9, boundExact, shallow, 0) + if _, got, _ := tt.probe(key, 4, -100, 100, 0); got != deep { + t.Errorf("same-generation deeper entry was overwritten: got %s", got.String()) + } + + // A new search must overwrite it even with a shallower result. + tt.NewSearch() + tt.store(key, 4, 9, boundExact, shallow, 0) + if _, got, _ := tt.probe(key, 4, -100, 100, 0); got != shallow { + t.Errorf("stale-generation entry not reclaimed: got %s, want %s", got.String(), shallow.String()) + } +} + func TestTTMissOnKeyMismatch(t *testing.T) { tt := NewTT(1) tt.store(1, 5, 10, boundExact, 0, 0) From 160a0ea465e122cc0f7742a20c01eb1665b27ed8 Mon Sep 17 00:00:00 2001 From: Christopher Junius Date: Sun, 30 Aug 2026 20:47:53 -0400 Subject: [PATCH 05/10] feat(eval): tapered evaluation with pawn, mobility & king-safety terms Replace the single piece-square table set with the PeSTO middlegame/endgame tables and material values, interpolated by game phase. Layer on positional terms, each accumulated as an (mg, eg) pair and folded into the taper: - passed pawns, bonus scaled by rank (endgame-weighted) - piece mobility for knights/bishops/rooks/queens - king safety: attacker weight in the king ring + a missing-pawn-shield penalty (middlegame only) - a small tempo bonus for the side to move A colour-mirror symmetry test guards the whole evaluation. Co-Authored-By: Claude Sonnet 5 --- internal/engine/eval.go | 326 ++++++++++++++++++++++++---------- internal/engine/eval_terms.go | 223 +++++++++++++++++++++++ internal/engine/eval_test.go | 113 ++++++++++++ 3 files changed, 569 insertions(+), 93 deletions(-) create mode 100644 internal/engine/eval_terms.go create mode 100644 internal/engine/eval_test.go diff --git a/internal/engine/eval.go b/internal/engine/eval.go index 757807c..9e3f401 100644 --- a/internal/engine/eval.go +++ b/internal/engine/eval.go @@ -6,9 +6,9 @@ import ( "github.com/dylhunn/dragontoothmg" ) -// Centipawn material values, indexed by the dragontoothmg piece constants -// (Pawn=1 .. King=6). The king value only matters for move ordering, never -// for the returned evaluation, since both sides always have exactly one. +// Centipawn material values for move ordering only (MVV-LVA in orderMoves). The +// returned evaluation uses the tapered mgValue / egValue pairs below instead. +// The king value only matters for ordering, since both sides always have one. var pieceValue = [7]int{ dragontoothmg.Pawn: 100, dragontoothmg.Knight: 320, @@ -18,110 +18,250 @@ var pieceValue = [7]int{ dragontoothmg.King: 20000, } -const bishopPairBonus = 30 +// Tapered evaluation (PeSTO). Every positional term is accumulated as a +// (middlegame, endgame) pair and interpolated by game phase: a full board is +// pure middlegame, a bare-piece endgame is pure endgame. +// +// The piece-square tables below are the PeSTO tables written in the customary +// a8..h1 order (rank 8 first, as a board looks from White's side). dragontoothmg +// squares are little-endian (a1 = 0), so a White piece on square sq reads +// table[sq^56] and a Black piece reads table[sq]. +const ( + maxPhase = 24 + tempo = 8 + bishopPairMG = 22 + bishopPairEG = 40 +) + +// gamePhaseInc is added to the running phase for each piece of that type on the +// board (both sides); the sum is clamped to maxPhase. +var gamePhaseInc = [7]int{ + dragontoothmg.Knight: 1, + dragontoothmg.Bishop: 1, + dragontoothmg.Rook: 2, + dragontoothmg.Queen: 4, +} -// Piece-square tables (Michniewski's "simplified evaluation function"), -// written in a1..h8 order (rank 1 first). dragontoothmg uses little-endian -// rank-file square numbering, so a white piece on square sq reads pst[sq] -// directly and a black piece reads the vertically mirrored pst[sq^56]. var ( - pawnPST = [64]int{ - 0, 0, 0, 0, 0, 0, 0, 0, - 5, 10, 10, -20, -20, 10, 10, 5, - 5, -5, -10, 0, 0, -10, -5, 5, - 0, 0, 0, 20, 20, 0, 0, 0, - 5, 5, 10, 25, 25, 10, 5, 5, - 10, 10, 20, 30, 30, 20, 10, 10, - 50, 50, 50, 50, 50, 50, 50, 50, - 0, 0, 0, 0, 0, 0, 0, 0, + mgValue = [7]int{ + dragontoothmg.Pawn: 82, dragontoothmg.Knight: 337, dragontoothmg.Bishop: 365, + dragontoothmg.Rook: 477, dragontoothmg.Queen: 1025, dragontoothmg.King: 0, } - knightPST = [64]int{ - -50, -40, -30, -30, -30, -30, -40, -50, - -40, -20, 0, 5, 5, 0, -20, -40, - -30, 5, 10, 15, 15, 10, 5, -30, - -30, 0, 15, 20, 20, 15, 0, -30, - -30, 5, 15, 20, 20, 15, 5, -30, - -30, 0, 10, 15, 15, 10, 0, -30, - -40, -20, 0, 0, 0, 0, -20, -40, - -50, -40, -30, -30, -30, -30, -40, -50, - } - bishopPST = [64]int{ - -20, -10, -10, -10, -10, -10, -10, -20, - -10, 5, 0, 0, 0, 0, 5, -10, - -10, 10, 10, 10, 10, 10, 10, -10, - -10, 0, 10, 10, 10, 10, 0, -10, - -10, 5, 5, 10, 10, 5, 5, -10, - -10, 0, 5, 10, 10, 5, 0, -10, - -10, 0, 0, 0, 0, 0, 0, -10, - -20, -10, -10, -10, -10, -10, -10, -20, - } - rookPST = [64]int{ - 0, 0, 0, 5, 5, 0, 0, 0, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - 5, 10, 10, 10, 10, 10, 10, 5, - 0, 0, 0, 0, 0, 0, 0, 0, + egValue = [7]int{ + dragontoothmg.Pawn: 94, dragontoothmg.Knight: 281, dragontoothmg.Bishop: 297, + dragontoothmg.Rook: 512, dragontoothmg.Queen: 936, dragontoothmg.King: 0, + } + + mgPST = [7][64]int{ + dragontoothmg.Pawn: mgPawn, + dragontoothmg.Knight: mgKnight, + dragontoothmg.Bishop: mgBishop, + dragontoothmg.Rook: mgRook, + dragontoothmg.Queen: mgQueen, + dragontoothmg.King: mgKing, } - queenPST = [64]int{ - -20, -10, -10, -5, -5, -10, -10, -20, - -10, 0, 0, 0, 0, 0, 0, -10, - -10, 0, 5, 5, 5, 5, 0, -10, - -5, 0, 5, 5, 5, 5, 0, -5, - 0, 0, 5, 5, 5, 5, 0, -5, - -10, 5, 5, 5, 5, 5, 0, -10, - -10, 0, 5, 0, 0, 0, 0, -10, - -20, -10, -10, -5, -5, -10, -10, -20, - } - kingPST = [64]int{ - 20, 30, 10, 0, 0, 10, 30, 20, - 20, 20, 0, 0, 0, 0, 20, 20, - -10, -20, -20, -20, -20, -20, -20, -10, - -20, -30, -30, -40, -40, -30, -30, -20, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30, + egPST = [7][64]int{ + dragontoothmg.Pawn: egPawn, + dragontoothmg.Knight: egKnight, + dragontoothmg.Bishop: egBishop, + dragontoothmg.Rook: egRook, + dragontoothmg.Queen: egQueen, + dragontoothmg.King: egKing, } ) -// Evaluate returns a static score for the position in centipawns, from the -// point of view of the side to move (positive = better for that side). This is -// the sign convention negamax expects. +// Evaluate returns a static score for the position in centipawns from the point +// of view of the side to move (positive = better for that side) — the sign +// convention negamax expects. func Evaluate(b *dragontoothmg.Board) int { - score := evalSide(&b.White, true) - evalSide(&b.Black, false) - if b.Wtomove { - return score + wmg, weg := pstScore(&b.White, true) + bmg, beg := pstScore(&b.Black, false) + mg, eg := wmg-bmg, weg-beg + + tmg, teg := evalTerms(b) + mg += tmg + eg += teg + + phase := gamePhase(b) + score := (mg*phase + eg*(maxPhase-phase)) / maxPhase + if !b.Wtomove { + score = -score } - return -score + return score + tempo } -func evalSide(bb *dragontoothmg.Bitboards, white bool) int { - s := evalPiece(bb.Pawns, &pawnPST, pieceValue[dragontoothmg.Pawn], white) + - evalPiece(bb.Knights, &knightPST, pieceValue[dragontoothmg.Knight], white) + - evalPiece(bb.Bishops, &bishopPST, pieceValue[dragontoothmg.Bishop], white) + - evalPiece(bb.Rooks, &rookPST, pieceValue[dragontoothmg.Rook], white) + - evalPiece(bb.Queens, &queenPST, pieceValue[dragontoothmg.Queen], white) + - evalPiece(bb.Kings, &kingPST, pieceValue[dragontoothmg.King], white) - if bits.OnesCount64(bb.Bishops) >= 2 { - s += bishopPairBonus +// gamePhase sums gamePhaseInc over every non-pawn piece on the board, clamped to +// maxPhase (early promotions can otherwise overshoot). +func gamePhase(b *dragontoothmg.Board) int { + p := bits.OnesCount64(b.White.Knights|b.Black.Knights)*gamePhaseInc[dragontoothmg.Knight] + + bits.OnesCount64(b.White.Bishops|b.Black.Bishops)*gamePhaseInc[dragontoothmg.Bishop] + + bits.OnesCount64(b.White.Rooks|b.Black.Rooks)*gamePhaseInc[dragontoothmg.Rook] + + bits.OnesCount64(b.White.Queens|b.Black.Queens)*gamePhaseInc[dragontoothmg.Queen] + if p > maxPhase { + p = maxPhase } - return s + return p } -func evalPiece(board uint64, pst *[64]int, value int, white bool) int { - s := 0 - for board != 0 { - sq := bits.TrailingZeros64(board) - board &= board - 1 - s += value - if white { - s += pst[sq] - } else { - s += pst[sq^56] +// pstScore returns the (mg, eg) material + piece-square total for one side. +func pstScore(bb *dragontoothmg.Bitboards, white bool) (mg, eg int) { + for p := dragontoothmg.Pawn; p <= dragontoothmg.King; p++ { + board := pieceBoard(bb, p) + for board != 0 { + sq := bits.TrailingZeros64(board) + board &= board - 1 + idx := sq + if white { + idx ^= 56 + } + mg += mgValue[p] + mgPST[p][idx] + eg += egValue[p] + egPST[p][idx] } } - return s + if bits.OnesCount64(bb.Bishops) >= 2 { + mg += bishopPairMG + eg += bishopPairEG + } + return mg, eg } + +// pieceBoard returns the bitboard for piece type p within bb. +func pieceBoard(bb *dragontoothmg.Bitboards, p int) uint64 { + switch p { + case dragontoothmg.Pawn: + return bb.Pawns + case dragontoothmg.Knight: + return bb.Knights + case dragontoothmg.Bishop: + return bb.Bishops + case dragontoothmg.Rook: + return bb.Rooks + case dragontoothmg.Queen: + return bb.Queens + default: + return bb.Kings + } +} + +var ( + mgPawn = [64]int{ + 0, 0, 0, 0, 0, 0, 0, 0, + 98, 134, 61, 95, 68, 126, 34, -11, + -6, 7, 26, 31, 65, 56, 25, -20, + -14, 13, 6, 21, 23, 12, 17, -23, + -27, -2, -5, 12, 17, 6, 10, -25, + -26, -4, -4, -10, 3, 3, 33, -12, + -35, -1, -20, -23, -15, 24, 38, -22, + 0, 0, 0, 0, 0, 0, 0, 0, + } + egPawn = [64]int{ + 0, 0, 0, 0, 0, 0, 0, 0, + 178, 173, 158, 134, 147, 132, 165, 187, + 94, 100, 85, 67, 56, 53, 82, 84, + 32, 24, 13, 5, -2, 4, 17, 17, + 13, 9, -3, -7, -7, -8, 3, -1, + 4, 7, -6, 1, 0, -5, -1, -8, + 13, 8, 8, 10, 13, 0, 2, -7, + 0, 0, 0, 0, 0, 0, 0, 0, + } + mgKnight = [64]int{ + -167, -89, -34, -49, 61, -97, -15, -107, + -73, -41, 72, 36, 23, 62, 7, -17, + -47, 60, 37, 65, 84, 129, 73, 44, + -9, 17, 19, 53, 37, 69, 18, 22, + -13, 4, 16, 13, 28, 19, 21, -8, + -23, -9, 12, 10, 19, 17, 25, -16, + -29, -53, -12, -3, -1, 18, -14, -19, + -105, -21, -58, -33, -17, -28, -19, -23, + } + egKnight = [64]int{ + -58, -38, -13, -28, -31, -27, -63, -99, + -25, -8, -25, -2, -9, -25, -24, -52, + -24, -20, 10, 9, -1, -9, -19, -41, + -17, 3, 22, 22, 22, 11, 8, -18, + -18, -6, 16, 25, 16, 17, 4, -18, + -23, -3, -1, 15, 10, -3, -20, -22, + -42, -20, -10, -5, -2, -20, -23, -44, + -29, -51, -23, -15, -22, -18, -50, -64, + } + mgBishop = [64]int{ + -29, 4, -82, -37, -25, -42, 7, -8, + -26, 16, -18, -13, 30, 59, 18, -47, + -16, 37, 43, 40, 35, 50, 37, -2, + -4, 5, 19, 50, 37, 37, 7, -2, + -6, 13, 13, 26, 34, 12, 10, 4, + 0, 15, 15, 15, 14, 27, 18, 10, + 4, 15, 16, 0, 7, 21, 33, 1, + -33, -3, -14, -21, -13, -12, -39, -21, + } + egBishop = [64]int{ + -14, -21, -11, -8, -7, -9, -17, -24, + -8, -4, 7, -12, -3, -13, -4, -14, + 2, -8, 0, -1, -2, 6, 0, 4, + -3, 9, 12, 9, 14, 10, 3, 2, + -6, 3, 13, 19, 7, 10, -3, -9, + -12, -3, 8, 10, 13, 3, -7, -15, + -14, -18, -7, -1, 4, -9, -15, -27, + -23, -9, -23, -5, -9, -16, -5, -17, + } + mgRook = [64]int{ + 32, 42, 32, 51, 63, 9, 31, 43, + 27, 32, 58, 62, 80, 67, 26, 44, + -5, 19, 26, 36, 17, 45, 61, 16, + -24, -11, 7, 26, 24, 35, -8, -20, + -36, -26, -12, -1, 9, -7, 6, -23, + -45, -25, -16, -17, 3, 0, -5, -33, + -44, -16, -20, -9, -1, 11, -6, -71, + -19, -13, 1, 17, 16, 7, -37, -26, + } + egRook = [64]int{ + 13, 10, 18, 15, 12, 12, 8, 5, + 11, 13, 13, 11, -3, 3, 8, 3, + 7, 7, 7, 5, 4, -3, -5, -3, + 4, 3, 13, 1, 2, 1, -1, 2, + 3, 5, 8, 4, -5, -6, -8, -11, + -4, 0, -5, -1, -7, -12, -8, -16, + -6, -6, 0, 2, -9, -9, -11, -3, + -9, 2, 3, -1, -5, -13, 4, -20, + } + mgQueen = [64]int{ + -28, 0, 29, 12, 59, 44, 43, 45, + -24, -39, -5, 1, -16, 57, 28, 54, + -13, -17, 7, 8, 29, 56, 47, 57, + -27, -27, -16, -16, -1, 17, -2, 1, + -9, -26, -9, -10, -2, -4, 3, -3, + -14, 2, -11, -2, -5, 2, 14, 5, + -35, -8, 11, 2, 8, 15, -3, 1, + -1, -18, -9, 10, -15, -25, -31, -50, + } + egQueen = [64]int{ + -9, 22, 22, 27, 27, 19, 10, 20, + -17, 20, 32, 41, 58, 25, 30, 0, + -20, 6, 9, 49, 47, 35, 19, 9, + 3, 22, 24, 45, 57, 40, 57, 36, + -18, 28, 19, 47, 31, 34, 39, 23, + -16, -27, 15, 6, 9, 17, 10, 5, + -22, -23, -30, -16, -16, -23, -36, -32, + -33, -28, -22, -43, -5, -32, -20, -41, + } + mgKing = [64]int{ + -65, 23, 16, -15, -56, -34, 2, 13, + 29, -1, -20, -7, -8, -4, -38, -29, + -9, 24, 2, -16, -20, 6, 22, -22, + -17, -20, -12, -27, -30, -25, -14, -36, + -49, -1, -27, -39, -46, -44, -33, -51, + -14, -14, -22, -46, -44, -30, -15, -27, + 1, 7, -8, -64, -43, -16, 9, 8, + -15, 36, 12, -54, 8, -28, 24, 14, + } + egKing = [64]int{ + -74, -35, -18, -18, -11, 15, 4, -17, + -12, 17, 14, 17, 17, 38, 23, 11, + 10, 17, 23, 15, 20, 45, 44, 13, + -8, 22, 24, 27, 26, 33, 26, 3, + -18, -4, 21, 24, 27, 23, 9, -11, + -19, -3, 11, 21, 23, 16, 7, -9, + -27, -11, 4, 13, 14, 4, -5, -17, + -53, -34, -21, -11, -28, -14, -24, -43, + } +) diff --git a/internal/engine/eval_terms.go b/internal/engine/eval_terms.go new file mode 100644 index 0000000..ac8c81a --- /dev/null +++ b/internal/engine/eval_terms.go @@ -0,0 +1,223 @@ +package engine + +import ( + "math/bits" + + "github.com/dylhunn/dragontoothmg" +) + +// Positional evaluation terms layered on top of material + piece-square tables: +// passed pawns, piece mobility, and king safety. Each is returned as a +// (middlegame, endgame) delta from White's point of view and folded into the +// taper by Evaluate. + +// Mobility coefficients: score = k * (attacked-squares - pivot), where the pivot +// is roughly the average count so a typical piece scores near zero. +const ( + knightMobK = 4 + bishopMobKMG = 3 + bishopMobKEG = 3 + rookMobKMG = 2 + rookMobKEG = 4 + queenMobKMG = 1 + queenMobKEG = 2 + + pawnShieldPen = 14 +) + +// passed bonuses indexed by the pawn's rank from its own side (1..6). +var ( + passedMG = [8]int{0, 5, 10, 15, 25, 45, 70, 0} + passedEG = [8]int{0, 15, 20, 35, 60, 100, 160, 0} + + // kingDangerTable maps summed attacker "units" near the king to an mg penalty. + kingDangerTable = [21]int{ + 0, 0, 3, 6, 10, 15, 21, 28, 36, 45, 55, + 66, 78, 91, 105, 120, 136, 153, 171, 190, 210, + } +) + +var ( + fileMask [8]uint64 + knightAttacks [64]uint64 + kingRing [64]uint64 + whitePassedMask [64]uint64 + blackPassedMask [64]uint64 +) + +func init() { + for f := 0; f < 8; f++ { + fileMask[f] = 0x0101010101010101 << uint(f) + } + + knightDeltas := [8][2]int{{1, 2}, {2, 1}, {2, -1}, {1, -2}, {-1, -2}, {-2, -1}, {-2, 1}, {-1, 2}} + for sq := 0; sq < 64; sq++ { + f, r := sq%8, sq/8 + + for _, d := range knightDeltas { + nf, nr := f+d[0], r+d[1] + if nf >= 0 && nf < 8 && nr >= 0 && nr < 8 { + knightAttacks[sq] |= 1 << uint(nr*8+nf) + } + } + for df := -1; df <= 1; df++ { + for dr := -1; dr <= 1; dr++ { + if df == 0 && dr == 0 { + continue + } + nf, nr := f+df, r+dr + if nf >= 0 && nf < 8 && nr >= 0 && nr < 8 { + kingRing[sq] |= 1 << uint(nr*8+nf) + } + } + } + + files := fileMask[f] + if f > 0 { + files |= fileMask[f-1] + } + if f < 7 { + files |= fileMask[f+1] + } + var wFwd, bFwd uint64 + for rr := r + 1; rr < 8; rr++ { + wFwd |= 0xff << uint(rr*8) + } + for rr := 0; rr < r; rr++ { + bFwd |= 0xff << uint(rr*8) + } + whitePassedMask[sq] = files & wFwd + blackPassedMask[sq] = files & bFwd + } +} + +// evalTerms returns the summed (mg, eg) positional deltas from White's view. +func evalTerms(b *dragontoothmg.Board) (mg, eg int) { + pmg, peg := passedPawns(b) + mmg, meg := mobility(b) + return pmg + mmg + kingSafety(b), peg + meg +} + +func passedPawns(b *dragontoothmg.Board) (mg, eg int) { + wp, bp := b.White.Pawns, b.Black.Pawns + for x := wp; x != 0; x &= x - 1 { + sq := bits.TrailingZeros64(x) + if bp&whitePassedMask[sq] == 0 { + r := sq / 8 + mg += passedMG[r] + eg += passedEG[r] + } + } + for x := bp; x != 0; x &= x - 1 { + sq := bits.TrailingZeros64(x) + if wp&blackPassedMask[sq] == 0 { + r := 7 - sq/8 + mg -= passedMG[r] + eg -= passedEG[r] + } + } + return mg, eg +} + +func mobility(b *dragontoothmg.Board) (mg, eg int) { + all := b.White.All | b.Black.All + wmg, weg := sideMobility(&b.White, b.White.All, all) + bmg, beg := sideMobility(&b.Black, b.Black.All, all) + return wmg - bmg, weg - beg +} + +func sideMobility(bb *dragontoothmg.Bitboards, own, all uint64) (mg, eg int) { + for x := bb.Knights; x != 0; x &= x - 1 { + c := bits.OnesCount64(knightAttacks[bits.TrailingZeros64(x)] &^ own) + mg += knightMobK * (c - 4) + eg += knightMobK * (c - 4) + } + for x := bb.Bishops; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + c := bits.OnesCount64(dragontoothmg.CalculateBishopMoveBitboard(sq, all) &^ own) + mg += bishopMobKMG * (c - 6) + eg += bishopMobKEG * (c - 6) + } + for x := bb.Rooks; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + c := bits.OnesCount64(dragontoothmg.CalculateRookMoveBitboard(sq, all) &^ own) + mg += rookMobKMG * (c - 7) + eg += rookMobKEG * (c - 7) + } + for x := bb.Queens; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + att := dragontoothmg.CalculateBishopMoveBitboard(sq, all) | dragontoothmg.CalculateRookMoveBitboard(sq, all) + c := bits.OnesCount64(att &^ own) + mg += queenMobKMG * (c - 14) + eg += queenMobKEG * (c - 14) + } + return mg, eg +} + +// kingSafety returns the mg-only delta from White's view: each side's own king +// danger (attackers near the king + a missing-pawn-shield penalty) counts +// against it. +func kingSafety(b *dragontoothmg.Board) int { + all := b.White.All | b.Black.All + white := kingDanger(b.White.Kings, &b.Black, all) + pawnShield(b.White.Kings, b.White.Pawns, true) + black := kingDanger(b.Black.Kings, &b.White, all) + pawnShield(b.Black.Kings, b.Black.Pawns, false) + return black - white +} + +func kingDanger(kingBB uint64, attacker *dragontoothmg.Bitboards, all uint64) int { + if kingBB == 0 { + return 0 + } + ring := kingRing[bits.TrailingZeros64(kingBB)] + units := 0 + for x := attacker.Knights; x != 0; x &= x - 1 { + units += 2 * bits.OnesCount64(knightAttacks[bits.TrailingZeros64(x)]&ring) + } + for x := attacker.Bishops; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + units += 2 * bits.OnesCount64(dragontoothmg.CalculateBishopMoveBitboard(sq, all)&ring) + } + for x := attacker.Rooks; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + units += 3 * bits.OnesCount64(dragontoothmg.CalculateRookMoveBitboard(sq, all)&ring) + } + for x := attacker.Queens; x != 0; x &= x - 1 { + sq := uint8(bits.TrailingZeros64(x)) //nolint:gosec // square index, 0..63 + att := dragontoothmg.CalculateBishopMoveBitboard(sq, all) | dragontoothmg.CalculateRookMoveBitboard(sq, all) + units += 5 * bits.OnesCount64(att&ring) + } + if units >= len(kingDangerTable) { + units = len(kingDangerTable) - 1 + } + return kingDangerTable[units] +} + +// pawnShield penalises files in front of the king (its own file and the two +// neighbours) that have no friendly pawn on the next two ranks. +func pawnShield(kingBB, pawns uint64, white bool) int { + if kingBB == 0 { + return 0 + } + ksq := bits.TrailingZeros64(kingBB) + kf, kr := ksq%8, ksq/8 + pen := 0 + for f := kf - 1; f <= kf+1; f++ { + if f < 0 || f > 7 { + continue + } + var mask uint64 + if white { + for rr := kr + 1; rr <= kr+2 && rr < 8; rr++ { + mask |= 1 << uint(rr*8+f) + } + } else { + for rr := kr - 1; rr >= kr-2 && rr >= 0; rr-- { + mask |= 1 << uint(rr*8+f) + } + } + if mask != 0 && pawns&mask == 0 { + pen += pawnShieldPen + } + } + return pen +} diff --git a/internal/engine/eval_test.go b/internal/engine/eval_test.go new file mode 100644 index 0000000..55eae84 --- /dev/null +++ b/internal/engine/eval_test.go @@ -0,0 +1,113 @@ +package engine + +import ( + "sort" + "strings" + "testing" + + "github.com/dylhunn/dragontoothmg" +) + +// mirrorFEN returns the colour-swapped vertical mirror of a position: White and +// Black exchange roles and every rank is reflected. A correct evaluation must +// score the mirror exactly opposite (and equal once the side-to-move sign is +// applied), so Evaluate(pos) must equal Evaluate(mirror(pos)). +func mirrorFEN(fen string) string { + f := strings.Fields(fen) + + ranks := strings.Split(f[0], "/") + for i, j := 0, len(ranks)-1; i < j; i, j = i+1, j-1 { + ranks[i], ranks[j] = ranks[j], ranks[i] + } + for i := range ranks { + ranks[i] = swapCase(ranks[i]) + } + f[0] = strings.Join(ranks, "/") + + if f[1] == "w" { + f[1] = "b" + } else { + f[1] = "w" + } + + if f[2] != "-" { + cr := []byte(swapCase(f[2])) + sort.Slice(cr, func(i, j int) bool { return castleOrder(cr[i]) < castleOrder(cr[j]) }) + f[2] = string(cr) + } + + if f[3] != "-" { + switch f[3][1] { + case '3': + f[3] = string(f[3][0]) + "6" + case '6': + f[3] = string(f[3][0]) + "3" + } + } + return strings.Join(f, " ") +} + +func swapCase(s string) string { + b := []byte(s) + for i, c := range b { + switch { + case c >= 'a' && c <= 'z': + b[i] = c - 32 + case c >= 'A' && c <= 'Z': + b[i] = c + 32 + } + } + return string(b) +} + +func castleOrder(c byte) int { + return strings.IndexByte("KQkq", c) +} + +func TestEvaluateIsColourSymmetric(t *testing.T) { + fens := []string{ + dragontoothmg.Startpos, + "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3", + "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10", + "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", + "8/5k2/8/8/3P4/8/5K2/8 w - - 0 1", + "6k1/5ppp/8/8/8/8/5PPP/6K1 b - - 0 1", + } + for _, fen := range fens { + orig := dragontoothmg.ParseFen(fen) + mir := dragontoothmg.ParseFen(mirrorFEN(fen)) + if got, want := Evaluate(&mir), Evaluate(&orig); got != want { + t.Errorf("asymmetric eval for %s: got %d, mirror %d", fen, want, got) + } + } +} + +func TestGamePhaseBounds(t *testing.T) { + start := dragontoothmg.ParseFen(dragontoothmg.Startpos) + if p := gamePhase(&start); p != maxPhase { + t.Errorf("start position phase = %d, want %d", p, maxPhase) + } + bare := dragontoothmg.ParseFen("4k3/pppppppp/8/8/8/8/PPPPPPPP/4K3 w - - 0 1") + if p := gamePhase(&bare); p != 0 { + t.Errorf("king-and-pawns phase = %d, want 0", p) + } +} + +func TestStartPositionIsRoughlyBalanced(t *testing.T) { + b := dragontoothmg.ParseFen(dragontoothmg.Startpos) + if got := Evaluate(&b); got < 0 || got > 2*tempo { + t.Errorf("start position eval = %d, want it within [0, %d]", got, 2*tempo) + } +} + +func TestPassedPawnBeatsBlockadedPawn(t *testing.T) { + // White pawn on e6, no black pawns able to stop it. + passed := dragontoothmg.ParseFen("4k3/8/4P3/8/8/8/8/4K3 w - - 0 1") + // Same pawn, but a black pawn on e7 sits in front of it. + blockaded := dragontoothmg.ParseFen("4k3/4p3/4P3/8/8/8/8/4K3 w - - 0 1") + if Evaluate(&passed) <= Evaluate(&blockaded) { + t.Errorf("passed pawn (%d) did not beat blockaded pawn (%d)", + Evaluate(&passed), Evaluate(&blockaded)) + } +} From 5d1bb51423c9bcb61e772720a2ffa29a1bf01730 Mon Sep 17 00:00:00 2001 From: Christopher Junius Date: Sun, 30 Aug 2026 20:54:57 -0400 Subject: [PATCH 06/10] feat(uci): asynchronous search, real stop, and pondering engine.Search grows Stop / Ponder / PonderHit / Info fields: it runs on a copy of the board, streams each completed iteration through the Info callback, and honours an external abort flag. Pondering ignores the clock until PonderHit is observed, then arms the budget from that moment. The UCI layer runs the search on a goroutine. 'go' returns immediately; a mutex serialises stdout; 'stop' aborts; 'ponderhit' converts a ponder search onto its budget; 'go ponder' holds the bestmove until ponderhit or stop. bestmove now carries a 'ponder' move from the PV. Added winc/binc to the time budget and the Ponder option. Co-Authored-By: Claude Sonnet 5 --- docs/architecture.md | 18 ++-- internal/engine/search.go | 126 ++++++++++++++++++++++---- internal/uci/uci.go | 186 +++++++++++++++++++++++++++++++++----- internal/uci/uci_test.go | 70 ++++++++++++++ 4 files changed, 354 insertions(+), 46 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index bd7f1a1..fb0bf3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,15 +64,21 @@ unapply closure), FEN parsing, an incrementally-updated Zobrist hash A line-oriented reader for `uci`, `isready`, `setoption`, `ucinewgame`, `position` (`startpos` / `fen`, with `moves`), `go` (`depth`, `movetime`, -`wtime`/`btime`), `stop`, `d`, and `quit`. It owns the persistent `engine.TT` -(sized by the `Hash` option, cleared on `ucinewgame`) and the `Threads` setting. -Search is synchronous, so `stop` is a no-op and `bestmove` is emitted as soon as -`go` returns. +`wtime`/`btime`/`winc`/`binc`, `infinite`, `ponder`), `stop`, `ponderhit`, `d`, +and `quit`. It owns the persistent `engine.TT` (sized by the `Hash` option, +cleared on `ucinewgame`) and the `Threads` / `Ponder` settings. + +Search runs on its own goroutine: `go` returns immediately, `engine.Search` +streams `info` lines through a callback and the goroutine emits `bestmove` +(with a `ponder` move from the PV) once the search ends. `stop` aborts it; +`ponderhit` converts a pondering search onto its time budget; a mutex serialises +all writes to stdout. `quit` / EOF wait for a bounded search a GUI is blocking on +and abort anything infinite. ## Deliberately not here yet -Aspiration windows, principal variation search, static exchange evaluation, -search extensions, opening book, tablebases, pondering, `SearchMoves`/`MultiPV`. +Static exchange evaluation, search extensions, opening book, tablebases, +`SearchMoves`/`MultiPV`. ## Key invariants diff --git a/internal/engine/search.go b/internal/engine/search.go index 94e97a1..25ddb5c 100644 --- a/internal/engine/search.go +++ b/internal/engine/search.go @@ -55,6 +55,28 @@ type SearchParams struct { // carried between moves — callers that want that (the UCI layer) pass a // table they own. TT *TT + + // Stop, when non-nil, lets the caller abort the search from another + // goroutine. It is checked alongside the internal time-limit flag. + Stop *atomic.Bool + // Ponder starts the search in pondering mode: the MoveTime budget is ignored + // until PonderHit is set (the opponent played the expected move) or Stop is + // raised. On the first observation of PonderHit the budget starts counting + // from that moment. + Ponder bool + PonderHit *atomic.Bool + // Info, when non-nil, is called once per completed iteration with the + // current best line, for streaming "info" output. + Info func(SearchInfo) +} + +// SearchInfo is one iteration's summary, passed to SearchParams.Info. +type SearchInfo struct { + Depth int + Score int + Nodes int64 + Elapsed time.Duration + PV []dragontoothmg.Move } // SearchResult is the outcome of a Search call. @@ -69,13 +91,33 @@ type SearchResult struct { PV []dragontoothmg.Move } +// searchOpts bundles the per-call runtime controls shared by every Lazy-SMP +// worker (all read-only during the search except the atomics). +type searchOpts struct { + tt *TT + stop *atomic.Bool // internal: set on time-up or a proven mate + extStop *atomic.Bool // caller's abort flag (UCI "stop"), or nil + deadline time.Time // zero until armed; unset while pondering + budget time.Duration + ponder bool + ponderHit *atomic.Bool + start time.Time + info func(SearchInfo) +} + type searcher struct { - tt *TT - stop *atomic.Bool // shared across Lazy-SMP workers; set once time is up - id int - nodes int64 - deadline time.Time - stopped bool + tt *TT + stop *atomic.Bool // shared across Lazy-SMP workers; set once time is up + extStop *atomic.Bool + id int + nodes int64 + deadline time.Time + budget time.Duration + ponder bool + ponderHit *atomic.Bool + start time.Time + info func(SearchInfo) // non-nil only for the reporting worker + stopped bool // killers holds, per ply, up to two quiet moves that most recently caused a // beta cut-off at that ply; history accumulates depth^2 for every quiet move @@ -107,6 +149,20 @@ func (s *searcher) timeUp() bool { if s.stop != nil && s.stop.Load() { return true } + if s.extStop != nil && s.extStop.Load() { + return true + } + if s.ponder { + // Pondering: ignore the clock until the opponent plays the expected + // move, then start the budget from now. + if s.ponderHit == nil || !s.ponderHit.Load() { + return false + } + s.ponder = false + if s.budget > 0 { + s.deadline = time.Now().Add(s.budget) + } + } if s.deadline.IsZero() { return false } @@ -137,29 +193,59 @@ func Search(b *dragontoothmg.Board, p SearchParams) SearchResult { return res } - var deadline time.Time - if p.MoveTime > 0 { - deadline = start.Add(p.MoveTime) + opts := searchOpts{ + tt: tt, + stop: new(atomic.Bool), + extStop: p.Stop, + budget: p.MoveTime, + ponder: p.Ponder, + ponderHit: p.PonderHit, + start: start, + info: p.Info, + } + if p.MoveTime > 0 && !p.Ponder { + opts.deadline = start.Add(p.MoveTime) } - stop := new(atomic.Bool) tt.NewSearch() if threads > 1 { - res = searchLazySMP(b, p.MaxDepth, deadline, stop, tt, threads) + res = searchLazySMP(b, p.MaxDepth, threads, opts) } else { - s := &searcher{tt: tt, stop: stop, deadline: deadline} - res = s.runIterativeDeepening(b, p.MaxDepth, 1) + // Search a copy so a concurrent caller (the UCI read loop) may keep + // using its own board while this search runs. + board := *b + res = newSearcher(0, opts, true).runIterativeDeepening(&board, p.MaxDepth, 1) } res.Elapsed = time.Since(start) return res } +// newSearcher builds a worker from the shared options. reporting is true for the +// single worker that streams Info output. +func newSearcher(id int, opts searchOpts, reporting bool) *searcher { + s := &searcher{ + tt: opts.tt, + stop: opts.stop, + extStop: opts.extStop, + deadline: opts.deadline, + budget: opts.budget, + ponder: opts.ponder, + ponderHit: opts.ponderHit, + start: opts.start, + id: id, + } + if reporting { + s.info = opts.info + } + return s +} + // searchLazySMP runs `threads` workers over one shared transposition table. Each // worker deepens independently on its own copy of the board; workers seeded with // a different start depth diverge into different subtrees, and the shared TT // lets every worker profit from what the others have already searched. The // deepest completed result wins. See https://www.chessprogramming.org/Lazy_SMP. -func searchLazySMP(b *dragontoothmg.Board, maxDepth int, deadline time.Time, stop *atomic.Bool, tt *TT, threads int) SearchResult { +func searchLazySMP(b *dragontoothmg.Board, maxDepth, threads int, opts searchOpts) SearchResult { results := make([]SearchResult, threads) var wg sync.WaitGroup for i := 0; i < threads; i++ { @@ -173,8 +259,7 @@ func searchLazySMP(b *dragontoothmg.Board, maxDepth int, deadline time.Time, sto if startDepth > maxDepth { startDepth = maxDepth } - s := &searcher{tt: tt, stop: stop, deadline: deadline, id: id} - results[id] = s.runIterativeDeepening(&board, maxDepth, startDepth) + results[id] = newSearcher(id, opts, id == 0).runIterativeDeepening(&board, maxDepth, startDepth) }(i) } wg.Wait() @@ -213,6 +298,15 @@ func (s *searcher) runIterativeDeepening(b *dragontoothmg.Board, maxDepth, start res.BestMove, res.Score, res.Depth = move, score, depth res.PV = append(res.PV[:0], s.pv[0][:s.pvLen[0]]...) prevScore = score + if s.info != nil { + s.info(SearchInfo{ + Depth: depth, + Score: score, + Nodes: s.nodes, + Elapsed: time.Since(s.start), + PV: res.PV, + }) + } if score >= mateThreshold || score <= -mateThreshold { if s.stop != nil { s.stop.Store(true) // forced mate: let the other workers stop too diff --git a/internal/uci/uci.go b/internal/uci/uci.go index 9801e27..e5113b6 100644 --- a/internal/uci/uci.go +++ b/internal/uci/uci.go @@ -10,6 +10,8 @@ import ( "runtime" "strconv" "strings" + "sync" + "sync/atomic" "time" "github.com/dylhunn/dragontoothmg" @@ -35,11 +37,28 @@ const ( type session struct { board dragontoothmg.Board out io.Writer + mu sync.Mutex // guards writes to out and the search field tt *engine.TT hashMB int threads int + ponder bool // the "Ponder" option; a GUI only sends "go ponder" when set + search *activeSearch } +// activeSearch tracks the goroutine running the current search so "stop", +// "ponderhit" and "quit" can reach it. +type activeSearch struct { + stop *atomic.Bool + ponderHit *atomic.Bool + bounded bool // has a depth or movetime limit and is not pondering + hasBudget bool // a movetime / clock budget was given + release chan struct{} // closed on ponderhit or stop; gates the bestmove + relOnce sync.Once + done chan struct{} // closed once bestmove has been emitted +} + +func (as *activeSearch) signalRelease() { as.relOnce.Do(func() { close(as.release) }) } + // Run reads UCI commands from r and writes responses to w until "quit" or EOF. // version is reported in the "id name" line. func Run(r io.Reader, w io.Writer, version string) error { @@ -59,36 +78,51 @@ func Run(r io.Reader, w io.Writer, version string) error { } switch fields[0] { case "uci": - fmt.Fprintf(w, "id name %s %s\n", engineName, version) - fmt.Fprintf(w, "id author %s\n", engineAuthor) - fmt.Fprintf(w, "option name Hash type spin default %d min %d max %d\n", defaultHashMB, minHashMB, maxHashMB) - fmt.Fprintf(w, "option name Threads type spin default %d min 1 max %d\n", defaultThreads, maxThreads) - fmt.Fprintln(w, "uciok") + s.emit("id name %s %s\n", engineName, version) + s.emit("id author %s\n", engineAuthor) + s.emit("option name Hash type spin default %d min %d max %d\n", defaultHashMB, minHashMB, maxHashMB) + s.emit("option name Threads type spin default %d min 1 max %d\n", defaultThreads, maxThreads) + s.emit("option name Ponder type check default false\n") + s.emit("uciok\n") case "isready": s.ensureTT() - fmt.Fprintln(w, "readyok") + s.emit("readyok\n") case "setoption": s.handleSetOption(fields[1:]) case "ucinewgame": + s.stopSearch() s.board = dragontoothmg.ParseFen(dragontoothmg.Startpos) if s.tt != nil { s.tt.Clear() } case "position": + s.stopSearch() s.handlePosition(fields[1:]) case "go": s.handleGo(fields[1:]) case "stop": - // Search is synchronous, so there is nothing to interrupt. + s.stopSearch() + case "ponderhit": + s.ponderHit() case "d": - fmt.Fprintln(w, s.board.ToFen()) + s.emit("%s\n", s.board.ToFen()) case "quit": + s.endSearch(true) return nil } } + s.endSearch(true) return sc.Err() } +// emit writes one formatted line to out under the mutex, so the search goroutine +// and the command loop never interleave output. +func (s *session) emit(format string, args ...any) { + s.mu.Lock() + defer s.mu.Unlock() + fmt.Fprintf(s.out, format, args...) +} + // ensureTT lazily allocates the transposition table at the configured size. func (s *session) ensureTT() { if s.tt == nil { @@ -96,6 +130,43 @@ func (s *session) ensureTT() { } } +// stopSearch aborts the running search (if any) and blocks until its bestmove +// has been emitted. +func (s *session) stopSearch() { s.endSearch(false) } + +// endSearch blocks until the running search (if any) has emitted its bestmove. +// It aborts the search unless graceful is set and the search is guaranteed to +// finish on its own soon (a fixed depth / movetime search a GUI is waiting on, +// or a pondering search that already got its ponderhit and has a budget). A +// "quit" or EOF must never hang on an infinite or still-pondering search. +func (s *session) endSearch(graceful bool) { + s.mu.Lock() + as := s.search + s.mu.Unlock() + if as == nil { + return + } + willFinish := as.bounded || (as.hasBudget && as.ponderHit.Load()) + if !graceful || !willFinish { + as.stop.Store(true) + } + as.signalRelease() + <-as.done +} + +// ponderhit tells a pondering search that the opponent played the expected move, +// so it should start spending its time budget and report as normal. +func (s *session) ponderHit() { + s.mu.Lock() + as := s.search + s.mu.Unlock() + if as == nil { + return + } + as.ponderHit.Store(true) + as.signalRelease() +} + // handleSetOption parses "setoption name value " for the options // advertised in the "uci" reply. Unknown options are ignored, as the protocol // requires. @@ -119,6 +190,7 @@ func (s *session) handleSetOption(args []string) { switch strings.ToLower(name) { case "hash": if n, err := strconv.Atoi(value); err == nil { + s.stopSearch() s.hashMB = clamp(n, minHashMB, maxHashMB) s.tt = engine.NewTT(s.hashMB) // resize now, before the next search } @@ -126,6 +198,8 @@ func (s *session) handleSetOption(args []string) { if n, err := strconv.Atoi(value); err == nil { s.threads = clamp(n, 1, maxThreads) } + case "ponder": + s.ponder = strings.EqualFold(value, "true") } } @@ -170,12 +244,22 @@ func (s *session) handlePosition(args []string) { } func (s *session) handleGo(args []string) { - params := engine.SearchParams{} - var wtime, btime, movetime time.Duration + s.stopSearch() - // Scan for the keywords we support, each followed by an integer argument. - // Unknown keywords (movestogo, winc, ponder, ...) are ignored. - for i := 0; i < len(args)-1; i++ { + var params engine.SearchParams + var wtime, btime, winc, binc, movetime time.Duration + ponder := false + + for i := 0; i < len(args); i++ { + switch args[i] { + case "ponder": + ponder = true + case "infinite": + params.MaxDepth = 0 + } + if i+1 >= len(args) { + break + } switch args[i] { case "depth": if d, err := strconv.Atoi(args[i+1]); err == nil { @@ -187,16 +271,20 @@ func (s *session) handleGo(args []string) { wtime = millis(args[i+1]) case "btime": btime = millis(args[i+1]) + case "winc": + winc = millis(args[i+1]) + case "binc": + binc = millis(args[i+1]) } } if movetime == 0 { - remaining := btime + remaining, inc := btime, binc if s.board.Wtomove { - remaining = wtime + remaining, inc = wtime, winc } if remaining > 0 { - movetime = remaining / clockDivisor + movetime = remaining/clockDivisor + inc*3/4 } } params.MoveTime = movetime @@ -208,17 +296,67 @@ func (s *session) handleGo(args []string) { params.Threads = n // never spawn more workers than the machine has cores } - res := engine.Search(&s.board, params) - if res.Depth > 0 { - fmt.Fprintf(s.out, "info depth %d score %s nodes %d time %d pv %s\n", - res.Depth, scoreString(res.Score), res.Nodes, res.Elapsed.Milliseconds(), res.BestMove.String()) + as := &activeSearch{ + stop: new(atomic.Bool), + ponderHit: new(atomic.Bool), + bounded: !ponder && (params.MaxDepth > 0 || params.MoveTime > 0), + hasBudget: params.MoveTime > 0, + release: make(chan struct{}), + done: make(chan struct{}), + } + if !ponder { + as.signalRelease() // no ponder handshake: emit the bestmove as soon as it's ready + } + params.Stop = as.stop + params.Ponder = ponder + params.PonderHit = as.ponderHit + params.Info = s.infoPrinter() + + s.mu.Lock() + s.search = as + s.mu.Unlock() + + board := s.board + go func() { + res := engine.Search(&board, params) + <-as.release // wait for ponderhit / stop before moving + s.emitBestMove(res) + s.mu.Lock() + s.search = nil + s.mu.Unlock() + close(as.done) + }() +} + +// infoPrinter returns the per-iteration callback that streams "info" lines. +func (s *session) infoPrinter() func(engine.SearchInfo) { + return func(in engine.SearchInfo) { + var pv strings.Builder + for i, m := range in.PV { + if i > 0 { + pv.WriteByte(' ') + } + pv.WriteString(m.String()) + } + var nps int64 + if in.Elapsed > 0 { + nps = in.Nodes * int64(time.Second) / int64(in.Elapsed) + } + s.emit("info depth %d score %s nodes %d nps %d time %d pv %s\n", + in.Depth, scoreString(in.Score), in.Nodes, nps, in.Elapsed.Milliseconds(), pv.String()) + } +} + +func (s *session) emitBestMove(res engine.SearchResult) { + if res.BestMove == 0 || res.BestMove.String() == "0000" { + s.emit("bestmove (none)\n") + return } - best := res.BestMove.String() - if best == "0000" { - fmt.Fprintln(s.out, "bestmove (none)") + if len(res.PV) >= 2 { + s.emit("bestmove %s ponder %s\n", res.BestMove.String(), res.PV[1].String()) return } - fmt.Fprintf(s.out, "bestmove %s\n", best) + s.emit("bestmove %s\n", res.BestMove.String()) } func millis(s string) time.Duration { diff --git a/internal/uci/uci_test.go b/internal/uci/uci_test.go index 1f50cbd..4b37f61 100644 --- a/internal/uci/uci_test.go +++ b/internal/uci/uci_test.go @@ -4,6 +4,7 @@ import ( "bytes" "strings" "testing" + "time" "github.com/cjunius/goChess/internal/uci" ) @@ -125,3 +126,72 @@ func TestStopAndUnknownGoArgsAreHarmless(t *testing.T) { t.Errorf("expected a bestmove:\n%s", out) } } + +func TestUciAdvertisesPonderOption(t *testing.T) { + out := run(t, "uci\nquit\n") + if !strings.Contains(out, "option name Ponder type check default false") { + t.Errorf("missing Ponder option:\n%s", out) + } +} + +func TestInfiniteSearchStopsOnStop(t *testing.T) { + start := time.Now() + out := run(t, "position startpos\ngo infinite\nstop\nquit\n") + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("infinite search took %s to stop", elapsed) + } + if n := strings.Count(out, "bestmove"); n != 1 { + t.Fatalf("want exactly one bestmove, got %d:\n%s", n, out) + } +} + +func TestPonderHitReleasesBestMove(t *testing.T) { + // White has mate in one (a1a8). Pondering, then ponderhit lets the search + // spend its budget, find the mate and report it. + out := run(t, strings.Join([]string{ + "position fen 6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1", + "go ponder movetime 2000", + "ponderhit", + "quit", + "", + }, "\n")) + if n := strings.Count(out, "bestmove"); n != 1 { + t.Fatalf("want exactly one bestmove, got %d:\n%s", n, out) + } + if !strings.Contains(out, "bestmove a1a8") { + t.Errorf("want 'bestmove a1a8' after ponderhit:\n%s", out) + } +} + +func TestPonderStopReleasesBestMoveWithoutHit(t *testing.T) { + start := time.Now() + out := run(t, strings.Join([]string{ + "position startpos moves e2e4", + "go ponder wtime 60000 btime 60000", + "stop", + "quit", + "", + }, "\n")) + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("pondering search took %s to stop after 'stop'", elapsed) + } + if n := strings.Count(out, "bestmove"); n != 1 { + t.Fatalf("want exactly one bestmove, got %d:\n%s", n, out) + } +} + +func TestBestMoveCarriesPonderMove(t *testing.T) { + out := run(t, "position startpos\ngo depth 6\nquit\n") + if !strings.Contains(out, "bestmove ") { + t.Fatalf("no bestmove:\n%s", out) + } + line := "" + for _, l := range strings.Split(out, "\n") { + if strings.HasPrefix(l, "bestmove ") { + line = l + } + } + if !strings.Contains(line, " ponder ") { + t.Errorf("bestmove line has no ponder move: %q", line) + } +} From 52d72bd2f8253ac44780eb5436bb6641a6ee60d1 Mon Sep 17 00:00:00 2001 From: Christopher Junius Date: Sun, 30 Aug 2026 20:58:40 -0400 Subject: [PATCH 07/10] feat(engine): Polyglot opening book Hand-rolled Polyglot support (no new dependency): PolyglotKey computes the book's Zobrist key (validated against the format's canonical key vectors), and OpenBook / Book.Probe read a .bin file and return the highest-weighted legal move for a position, translating the Polyglot castling and promotion move encoding. UCI gains OwnBook and BookFile options; when a book move is available the 'go' handler emits it immediately and skips the search (never while pondering). Co-Authored-By: Claude Sonnet 5 --- internal/engine/polyglot.go | 171 ++++++++++++++++++++++++ internal/engine/polyglot_random.go | 204 +++++++++++++++++++++++++++++ internal/engine/polyglot_test.go | 129 ++++++++++++++++++ internal/uci/uci.go | 34 +++++ internal/uci/uci_test.go | 42 ++++++ 5 files changed, 580 insertions(+) create mode 100644 internal/engine/polyglot.go create mode 100644 internal/engine/polyglot_random.go create mode 100644 internal/engine/polyglot_test.go diff --git a/internal/engine/polyglot.go b/internal/engine/polyglot.go new file mode 100644 index 0000000..d30b66e --- /dev/null +++ b/internal/engine/polyglot.go @@ -0,0 +1,171 @@ +package engine + +import ( + "encoding/binary" + "fmt" + "math/bits" + "os" + "sort" + "strings" + + "github.com/dylhunn/dragontoothmg" +) + +// Polyglot opening book support: the Zobrist key defined by the Polyglot ".bin" +// format (which differs from dragontoothmg's own hash) and a reader for the +// 16-byte-per-entry book files those keys index. + +// PolyglotKey returns the Polyglot Zobrist key for b. See +// http://hgm.nubati.net/book_format.html. +func PolyglotKey(b *dragontoothmg.Board) uint64 { + var key uint64 + + for pt := dragontoothmg.Pawn; pt <= dragontoothmg.King; pt++ { + white := pieceBoard(&b.White, pt) + black := pieceBoard(&b.Black, pt) + // Polyglot "kind_of_piece": black pawn 0, white pawn 1, black knight 2, … + wKind := 2*(pt-1) + 1 + bKind := 2 * (pt - 1) + for x := white; x != 0; x &= x - 1 { + key ^= polyglotRandom[64*wKind+bits.TrailingZeros64(x)] + } + for x := black; x != 0; x &= x - 1 { + key ^= polyglotRandom[64*bKind+bits.TrailingZeros64(x)] + } + } + + f := strings.Fields(b.ToFen()) + for _, c := range f[2] { + switch c { + case 'K': + key ^= polyglotRandom[768] + case 'Q': + key ^= polyglotRandom[769] + case 'k': + key ^= polyglotRandom[770] + case 'q': + key ^= polyglotRandom[771] + } + } + + // En passant only counts when a pawn of the side to move can actually make + // the capture — matching Polyglot, not FEN. + if f[3] != "-" { + if ts, err := dragontoothmg.AlgebraicToIndex(f[3]); err == nil { + file := int(ts) % 8 + var capSq int + var ourPawns uint64 + if b.Wtomove { + capSq, ourPawns = int(ts)-8, b.White.Pawns + } else { + capSq, ourPawns = int(ts)+8, b.Black.Pawns + } + canCapture := (file > 0 && ourPawns&(1<= key }) + + best := -1 + for ; i < len(bk.entries) && bk.entries[i].key == key; i++ { + if best < 0 || bk.entries[i].weight > bk.entries[best].weight { + best = i + } + } + if best < 0 { + return 0, false + } + return decodePolyglotMove(bk.entries[best].move, b) +} + +// decodePolyglotMove converts a Polyglot-encoded move to a dragontoothmg move, +// translating the "king onto its own rook" castling encoding, and only returns +// ok when the result is legal in b. +func decodePolyglotMove(pm uint16, b *dragontoothmg.Board) (dragontoothmg.Move, bool) { + from := int((pm>>9)&7)*8 + int((pm>>6)&7) + to := int((pm>>3)&7)*8 + int(pm&7) + + var mv dragontoothmg.Move + mv.Setfrom(dragontoothmg.Square(from)) //nolint:gosec // 0..63 + mv.Setto(dragontoothmg.Square(to)) //nolint:gosec // 0..63 + switch (pm >> 12) & 7 { + case 1: + mv.Setpromote(dragontoothmg.Knight) + case 2: + mv.Setpromote(dragontoothmg.Bishop) + case 3: + mv.Setpromote(dragontoothmg.Rook) + case 4: + mv.Setpromote(dragontoothmg.Queen) + } + + if (b.White.Kings|b.Black.Kings)&(1< g1f3 (from file 6 row 0, to file 5 row 2). + move := uint16(5 | 2<<3 | 6<<6 | 0<<9) + var rec [16]byte + binary.BigEndian.PutUint64(rec[0:], key) + binary.BigEndian.PutUint16(rec[8:], move) + binary.BigEndian.PutUint16(rec[10:], 1) + + path := filepath.Join(t.TempDir(), "book.bin") + if err := os.WriteFile(path, rec[:], 0o600); err != nil { + t.Fatal(err) + } + + start2 := time.Now() + out := run(t, strings.Join([]string{ + "setoption name BookFile value " + path, + "setoption name OwnBook value true", + "position startpos", + "go depth 20", + "quit", + "", + }, "\n")) + if elapsed := time.Since(start2); elapsed > time.Second { + t.Fatalf("book move took %s — it should skip the search", elapsed) + } + if !strings.Contains(out, "book loaded: 1 entries") { + t.Errorf("book was not loaded:\n%s", out) + } + if !strings.Contains(out, "bestmove g1f3") { + t.Errorf("want the book move g1f3:\n%s", out) + } +} + func TestBestMoveCarriesPonderMove(t *testing.T) { out := run(t, "position startpos\ngo depth 6\nquit\n") if !strings.Contains(out, "bestmove ") { From 2563bba8f8359c8ad55703783451104a4ee6f752 Mon Sep 17 00:00:00 2001 From: Christopher Junius Date: Sun, 30 Aug 2026 21:02:18 -0400 Subject: [PATCH 08/10] docs: record PVS, aspiration, tapered eval, pondering & book Move the delivered backlog items into the Implemented sections of design.md, architecture.md and engine-strength.md; refresh the README feature list, roadmap and UCI options table; note the pre-change performance numbers; add ADR 0003 recording why Syzygy tablebases are deferred. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 25 +++++++- README.md | 27 ++++++--- docs/adr/0003-defer-syzygy-tablebases.md | 48 ++++++++++++++++ docs/architecture.md | 34 ++++++----- docs/design.md | 48 ++++++++-------- docs/engine-strength.md | 72 ++++++++++++++---------- docs/performance.md | 28 +++++---- 7 files changed, 192 insertions(+), 90 deletions(-) create mode 100644 docs/adr/0003-defer-syzygy-tablebases.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c850be..22611dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,16 +16,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 a shared lock-free transposition table, Lazy SMP, and a hard time budget. - `internal/engine`: search heuristics — killer moves + a `[side][from][to]` history table in move ordering, null-move pruning, and late move reductions. +- `internal/engine`: principal variation search with a triangular PV table, + aspiration windows from depth 5, and a fail-soft alpha-beta. +- `internal/engine`: richer Lazy SMP — a 6-bit transposition-table generation + that ages out the previous search's entries, plus per-helper root-move and + aspiration-window skew. +- `internal/engine`: tapered PeSTO evaluation (middlegame/endgame material and + piece-square tables interpolated by game phase) with passed-pawn, mobility, + king-safety and tempo terms, guarded by a colour-mirror symmetry test. +- `internal/engine`: hand-rolled Polyglot opening book — `PolyglotKey`, + `OpenBook`, `Book.Probe`. - `internal/uci`: a UCI protocol loop (`uci`, `isready`, `ucinewgame`, - `position`, `go`, `stop`, `quit`) supporting `go depth`, `go movetime`, and - `go wtime/btime`. + `position`, `go`, `stop`, `ponderhit`, `quit`) supporting `go depth`, + `go movetime`, `go wtime/btime/winc/binc`, `go infinite`, and `go ponder`. + Search runs on a goroutine: `stop` and pondering work, `info … pv …` is + streamed per iteration, and `bestmove` carries a `ponder` move. +- `internal/uci`: `Ponder`, `OwnBook` and `BookFile` options. - `cmd/gochess`: CLI with `uci`, `perft`, `bench`, and `version` subcommands. - Repository scaffolding: CI, lint, CodeQL and release workflows; issue and PR templates; `CODEOWNERS`; Dependabot; `Makefile`; `Dockerfile`; GoReleaser. +- `docs/adr/0003`: record deferring Syzygy tablebases (no pure-Go prober; the + build is strictly `CGO_ENABLED=0`). ### Changed - Replaced the `notnil/chess` prototype (random mover + perft) with the `dragontoothmg`-based engine. +- Evaluation is now tapered (PeSTO) rather than a single Michniewski + piece-square table set; move choices and reported scores shift accordingly. + +### Removed + +- Retired the Go Report Card badge from the README. [Unreleased]: https://github.com/cjunius/goChess/commits/main diff --git a/README.md b/README.md index 0797f92..589fdce 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,16 @@ move generator. Early. The engine plays legal chess via a UCI loop with: - **Move generation** — delegated to `dragontoothmg` (verified with perft). -- **Evaluation** — material + piece-square tables + bishop pair. -- **Search** — iterative deepening, negamax alpha-beta, quiescence search, - a shared transposition table with hash-move ordering, MVV-LVA + killer-move + - history move ordering, null-move pruning, late move reductions, Lazy SMP - (multi-threaded search), hard time limits. - -Not yet implemented: aspiration windows / PVS, opening book, endgame tablebases. +- **Evaluation** — tapered PeSTO material + piece-square tables, bishop pair, + passed pawns, mobility, king safety, tempo. +- **Search** — iterative deepening with aspiration windows, principal variation + search, quiescence search, a shared aged transposition table with hash-move + ordering, MVV-LVA + killer-move + history move ordering, null-move pruning, + late move reductions, Lazy SMP (multi-threaded search), asynchronous search + with working `stop` and pondering, hard time limits. +- **Opening book** — optional Polyglot `.bin` book. + +Not yet implemented: Syzygy endgame tablebases, static exchange evaluation. See the [roadmap](#roadmap). ## Install @@ -64,6 +67,9 @@ go movetime 1000 |---|---|---|---| | `Hash` | 64 | 1–4096 | Transposition-table size in MiB. | | `Threads` | 1 | 1–256 | Lazy-SMP worker count (capped at the machine's core count at search time). | +| `Ponder` | false | check | Let a GUI drive `go ponder` / `ponderhit` so the engine thinks on the opponent's clock. | +| `OwnBook` | false | check | Play from the Polyglot book when the position is in it. | +| `BookFile` | — | string | Path to a Polyglot `.bin` book; loaded when set. | ## Development @@ -96,8 +102,11 @@ docs/ architecture notes and ADRs - [x] Lazy SMP — multi-threaded search over the shared TT (`Threads` UCI option) - [x] Killer moves + history heuristic - [x] Null-move pruning + late move reductions -- [ ] Aspiration windows / principal variation search -- [ ] Opening book (Polyglot) and Syzygy tablebase probing +- [x] Aspiration windows + principal variation search +- [x] Tapered PeSTO evaluation + pawn/mobility/king-safety terms +- [x] Asynchronous search — working `stop` and pondering +- [x] Opening book (Polyglot) +- [ ] Syzygy tablebase probing ([ADR 0003](docs/adr/0003-defer-syzygy-tablebases.md)) - [ ] Strength testing harness (SPRT via cutechess-cli) ## License diff --git a/docs/adr/0003-defer-syzygy-tablebases.md b/docs/adr/0003-defer-syzygy-tablebases.md new file mode 100644 index 0000000..8068acb --- /dev/null +++ b/docs/adr/0003-defer-syzygy-tablebases.md @@ -0,0 +1,48 @@ +# 3. Defer Syzygy endgame tablebases + +Date: 2026-08-30 + +## Status + +Accepted + +## Context + +Syzygy tablebases give perfect play for positions with few pieces: WDL +(win/draw/loss) tables probed inside the search and DTZ (distance-to-zero) tables +probed at the root to pick a move that makes progress under the fifty-move rule. +They are a meaningful strength gain in endgames and are on the design backlog. + +Adding them now runs into two problems: + +1. **No mature pure-Go prober.** The reference implementations (Fathom, + `syzygy1/probetool`) are C. The only Go options are thin CGO wrappers around + Fathom or unmaintained partial ports. +2. **The project is strictly `CGO_ENABLED=0`.** The Docker build, the release + pipeline (`.goreleaser.yaml`) and the cross-compilation matrix all assume a + static, cgo-free binary. Introducing CGO would fragment the build and the + release artifacts. + +A correct pure-Go WDL+DTZ prober is roughly two thousand lines of intricate code +(the RE-PAIR decompression and the Syzygy indexing scheme) that is hard to test +without tablebase files in CI. It deserves its own focused change, not a rider on +a large search/eval batch. + +## Decision + +Defer Syzygy tablebase support. Ship the rest of the backlog batch (PVS, +aspiration windows, richer Lazy SMP, tapered evaluation, pondering, Polyglot +book) without it. + +When it is picked up, the preferred approach is a **pure-Go prober** (keeping +`CGO_ENABLED=0`), starting with WDL-only probing in the search and adding the +DTZ root probe afterwards. A CGO/Fathom binding gated behind a build tag is the +fallback if the pure-Go effort proves too large. + +## Consequences + +- Endgame play stays at search + tapered-eval strength; no perfect play with + ≤ 7 pieces. +- `SearchParams` has no tablebase hook yet; adding one later is additive. +- The UCI layer will need `SyzygyPath` (and probably `SyzygyProbeDepth` / + `SyzygyProbeLimit`) options when the feature lands. diff --git a/docs/architecture.md b/docs/architecture.md index fb0bf3e..f4e0c47 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,31 +34,37 @@ unapply closure), FEN parsing, an incrementally-updated Zobrist hash - **perft.go** — thin wrappers over `dragontoothmg.Perft` plus timed series and divide helpers. Backed by regression tests against published node counts. -- **eval.go** — `Evaluate(*Board) int`. Material values + Michniewski - piece-square tables + a bishop-pair bonus. Returns a score relative to the - side to move (negamax convention). Piece-square lookups use little-endian - rank-file indexing: white reads `pst[sq]`, black reads `pst[sq^56]`. +- **eval.go / eval_terms.go** — `Evaluate(*Board) int`. Tapered PeSTO material + + piece-square tables interpolated by game phase, plus bishop-pair, passed-pawn, + mobility, king-safety and tempo terms (each an `(mg, eg)` pair). Returns a + score relative to the side to move (negamax convention). Piece-square tables + are stored a8-first: white reads `pst[sq^56]`, black reads `pst[sq]`. - **search.go** — `Search(*Board, SearchParams) SearchResult`. Iterative - deepening around a negamax alpha-beta core, with: + deepening around a fail-soft negamax alpha-beta core, with: + - principal variation search (full window for move 0, null-window scout + + re-search for the rest) and a triangular PV table, + - aspiration windows from depth 5, widening the failing side, - quiescence search at the horizon (captures and promotions only), - transposition-table probes/stores with hash-move ordering, - move ordering: TT move, promotions, MVV-LVA captures, two killer moves per ply, then quiet moves by a `[side][from][to]` history score, - - null-move pruning (skipped in check, at shallow depth, with only pawns - left, or right after another null move; the pass board is built through - FEN so the shared TT never sees a stale hash), - - late move reductions — late quiet moves are searched a ply or two - shallower and re-searched at full depth only if they beat alpha, + - null-move pruning and late move reductions, - mate-distance-aware scoring (`mateScore - ply`), ply-rebased through the TT, - Lazy SMP: `SearchParams.Threads` workers deepen independently on their own - board copy over one shared TT; the deepest completed result wins, - - a hard wall-clock budget checked every 2048 nodes; the last fully completed - depth is returned. + board copy over one shared TT, with per-helper root-move and aspiration + skew; the deepest completed result wins, + - `SearchParams.Stop` / `Ponder` / `PonderHit` / `Info` for asynchronous + driving: a hard wall-clock budget checked every 2048 nodes (ignored while + pondering), an external abort flag, and a per-iteration callback. - **transposition.go** — `TT`, a fixed-size power-of-two table keyed by `Board.Hash`. Each 16-byte slot is a pair of `atomic.Uint64` words accessed with Hyatt's lockless XOR trick (`word0 = key ^ data`), so the Lazy-SMP workers share it without a mutex; a write torn across goroutines reads as a - miss. `data` packs move, int32 score, depth and bound flag. + miss. `data` packs move, int32 score, depth, bound flag and a 6-bit + generation; `NewSearch` bumps the generation so `store` treats the previous + search's entries as replaceable. +- **polyglot.go** — `PolyglotKey`, `OpenBook`, `Book.Probe`: the Polyglot book + Zobrist key (distinct from `Board.Hash`) and a reader for `.bin` book files. ### `internal/uci` diff --git a/docs/design.md b/docs/design.md index a0e6240..e894c6d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -10,8 +10,9 @@ choices see the [ADRs](adr/). `engine.Search` is the search core. It is a plain function around a `searcher` value (node counter, deadline, per-ply killer moves, `[side][from][to]` history -table) with a few free collaborators rather than an injected object graph — -small enough that dependency injection would cost more than it buys. +table, triangular PV table) with a few free collaborators rather than an +injected object graph — small enough that dependency injection would cost more +than it buys. | collaborator | responsibility | |---|---| @@ -22,33 +23,37 @@ small enough that dependency injection would cost more than it buys. | `searcher` | carries the node count, TT handle, shared stop flag, wall-clock deadline, and the per-searcher killer/history tables; answers `timeUp` and flips `stopped` | `internal/uci` is the protocol layer and the only place that prints `info` / -`bestmove`. It owns the persistent `engine.TT` (`Hash` option) and the `Threads` -setting. Search is synchronous, so `stop` is a no-op and `bestmove` is emitted -as soon as `go` returns. `cmd/gochess` wires these together and adds the `perft` -and `bench` subcommands. +`bestmove`. It owns the persistent `engine.TT` (`Hash` option), the `Threads` / +`Ponder` settings, and the optional Polyglot book (`OwnBook` / `BookFile`). It +runs `Search` on a goroutine and serialises stdout with a mutex, so `stop`, +`ponderhit` and streamed `info` all work while the search runs. `cmd/gochess` +wires these together and adds the `perft` and `bench` subcommands. ## Implemented ### [Search](https://www.chessprogramming.org/Search) -- [Negamax](https://www.chessprogramming.org/Negamax) with [alpha-beta pruning](https://www.chessprogramming.org/Alpha-Beta) — fail-hard -- [Iterative Deepening](https://www.chessprogramming.org/Iterative_Deepening) — the last fully completed depth is the one returned -- [Transposition Table](https://www.chessprogramming.org/Transposition_Table) — power-of-two table keyed by `dragontoothmg.Board.Hash()`; stores EXACT / LOWER / UPPER bounds with the best move, mate scores rebased by ply on store and probe. Lock-free (Hyatt XOR) so Lazy-SMP workers share one table. The stored move seeds move ordering even when the entry is too shallow to cut -- [Lazy SMP](https://www.chessprogramming.org/Lazy_SMP) — `Threads` workers run iterative deepening in parallel on private board copies over the shared TT; workers start at staggered depths so they diverge, and the deepest completed result wins +- [Negamax](https://www.chessprogramming.org/Negamax) with [alpha-beta pruning](https://www.chessprogramming.org/Alpha-Beta), fail-soft, and [Principal Variation Search](https://www.chessprogramming.org/Principal_Variation_Search) — the first move at each node gets a full window, later moves a null-window scout re-searched only when it beats alpha (LMR is folded in as an extra reduction on that scout) +- [Iterative Deepening](https://www.chessprogramming.org/Iterative_Deepening) with [Aspiration Windows](https://www.chessprogramming.org/Aspiration_Windows) — from depth 5 each iteration first searches a ±25 cp window around the previous score, doubling the failing side until the score lands inside. The last fully completed depth is the one returned +- A real [principal variation](https://www.chessprogramming.org/Principal_Variation) — a triangular PV table produces the full line, streamed in the `info … pv …` output each iteration +- [Transposition Table](https://www.chessprogramming.org/Transposition_Table) — power-of-two table keyed by `dragontoothmg.Board.Hash()`; stores EXACT / LOWER / UPPER bounds with the best move, mate scores rebased by ply on store and probe. Lock-free (Hyatt XOR) so Lazy-SMP workers share one table. The stored move seeds move ordering even when the entry is too shallow to cut. A 6-bit generation ages entries: a new search (`NewSearch`) reclaims the previous search's slots regardless of their depth +- [Lazy SMP](https://www.chessprogramming.org/Lazy_SMP) — `Threads` workers run iterative deepening in parallel on private board copies over the shared TT; workers start at staggered depths, and helpers additionally skew their root move order and use a wider, asymmetric aspiration window so they diverge. The deepest completed result wins +- Asynchronous search — the UCI layer runs `Search` on a goroutine, so `stop` aborts immediately and pondering (`go ponder` / `ponderhit`) works; a pondering search ignores the clock until the ponder move is confirmed - [Quiescence Search](https://www.chessprogramming.org/Quiescence_Search) at the horizon — captures and promotions only, depth-bounded by `maxPly` - [Null Move Pruning](https://www.chessprogramming.org/Null_Move_Pruning) — `R = 2`, or `3` from depth 6; tried only when not in check, at depth ≥ 3, with a non-mate beta, with non-pawn material for the side to move, and not immediately after another null move. The pass position is built through FEN because `dragontoothmg` keeps the Zobrist hash and en-passant square in unexported fields - [Late Move Reductions](https://www.chessprogramming.org/Late_Move_Reductions) — from depth 3, quiet moves past the third in the ordered list are searched 1 ply shallower (2 from the seventh move at depth ≥ 5); a reduced search that beats alpha is repeated at full depth. Moves that give or evade check are never reduced - [Move Ordering](https://www.chessprogramming.org/Move_Ordering) — TT/hash move, then promotions, then [MVV-LVA](https://www.chessprogramming.org/MVV-LVA) captures, then the two [killer moves](https://www.chessprogramming.org/Killer_Heuristic) for the ply, then quiet moves by [history](https://www.chessprogramming.org/History_Heuristic) score (`depth²` per beta cut-off, per `[side][from][to]`, clamped). Killer and history tables are per-searcher, so Lazy-SMP workers keep independent copies - [Mate-distance scoring](https://www.chessprogramming.org/Mate_Distance_Pruning) — `mateScore - ply`, so the shortest mate is preferred; a proven mate ends iterative deepening early - Draw detection — the fifty-move rule (`Halfmoveclock >= 100`) is scored `0` inside the tree -- Time management — a hard wall-clock budget checked every 2048 nodes; the UCI layer spends `1/30` of the remaining clock when the GUI sends `wtime` / `btime` instead of `movetime` +- Time management — a hard wall-clock budget checked every 2048 nodes; the UCI layer spends `1/30` of the remaining clock plus `3/4` of the increment when the GUI sends `wtime` / `btime` (`winc` / `binc`) instead of `movetime` +- [Opening book](https://www.chessprogramming.org/Opening_Book) — optional [Polyglot](http://hgm.nubati.net/book_format.html) `.bin` book (`OwnBook` / `BookFile`); a book hit is played instantly with no search - King-capture guard — scores the illegal position `dragontoothmg` can hand back when the side not to move was already in check, instead of panicking on an empty king bitboard ### [Evaluation](https://www.chessprogramming.org/Evaluation) -- Material — standard centipawn values (`P 100 · N 320 · B 330 · R 500 · Q 900`) -- [Piece-square tables](https://www.chessprogramming.org/Piece-Square_Tables) — Michniewski's "simplified evaluation function", single (non-tapered) set; black reads the vertically mirrored square (`sq^56`) -- [Bishop pair](https://www.chessprogramming.org/Bishop_Pair) — `+30` for holding both bishops +- [Tapered eval](https://www.chessprogramming.org/Tapered_Eval) — [PeSTO](https://www.chessprogramming.org/PeSTO%27s_Evaluation_Function) middlegame/endgame material values and piece-square tables interpolated by game phase (`N/B = 1`, `R = 2`, `Q = 4`, clamped to 24); black reads the vertically mirrored square (`sq^56`) +- [Bishop pair](https://www.chessprogramming.org/Bishop_Pair), [passed pawns](https://www.chessprogramming.org/Passed_Pawn) (bonus by rank, endgame-weighted), [mobility](https://www.chessprogramming.org/Mobility) for N/B/R/Q, [king safety](https://www.chessprogramming.org/King_Safety) (attacker weight in the king ring + missing-pawn-shield penalty, middlegame only), and a [tempo](https://www.chessprogramming.org/Tempo) bonus — all accumulated as `(mg, eg)` pairs and folded into the taper +- A colour-mirror symmetry test guards the whole evaluation Evaluation is recomputed from scratch on every call; there is no incremental update and no pawn or evaluation hash. @@ -57,22 +62,17 @@ update and no pawn or evaluation hash. ### Search -- [Principal Variation Search](https://www.chessprogramming.org/Principal_Variation_Search) — null-window scout + re-search (LMR already does a null-window reduced search; PVS would extend that to every move past the first) -- [Aspiration Windows](https://www.chessprogramming.org/Aspiration_Windows) around the previous iteration's score -- Tuning the null-move and LMR formulas (verification search, adaptive `R`, reduction from the history score) against SPRT +- Tuning the aspiration / null-move / LMR formulas (adaptive `R`, reduction from the history score) against SPRT - [Check extensions](https://www.chessprogramming.org/Check_Extensions) and other search extensions - [Static Exchange Evaluation](https://www.chessprogramming.org/Static_Exchange_Evaluation) for capture ordering and bad-capture pruning in quiescence - Threefold-[repetition](https://www.chessprogramming.org/Repetitions) detection (needs a position history the current `Search` does not keep) -- A real principal variation in the `info` line (only `bestmove` is reported today) -- Richer Lazy SMP — per-worker root-move splitting, aspiration-window skew, TT ageing / bucketed replacement -- Asynchronous search so `stop` and pondering actually work -- [Opening book](https://www.chessprogramming.org/Opening_Book) (Polyglot) and [Syzygy endgame tablebases](https://www.chessprogramming.org/Endgame_Tablebases) +- [Syzygy endgame tablebases](https://www.chessprogramming.org/Endgame_Tablebases) — deferred; see [ADR 0003](adr/0003-defer-syzygy-tablebases.md) ### Evaluation -- [Tapered eval](https://www.chessprogramming.org/Tapered_Eval) — mid/endgame PST pairs interpolated by game phase ([PeSTO](https://www.chessprogramming.org/PeSTO%27s_Evaluation_Function)) -- Pawn structure — [passed](https://www.chessprogramming.org/Passed_Pawn) / [isolated](https://www.chessprogramming.org/Isolated_Pawn) / [doubled](https://www.chessprogramming.org/Doubled_Pawn) / [backward](https://www.chessprogramming.org/Backward_Pawn) pawns -- [Mobility](https://www.chessprogramming.org/Mobility), [rook on open file](https://www.chessprogramming.org/Rook_on_Open_File), [knight outposts](https://www.chessprogramming.org/Outpost), [king safety](https://www.chessprogramming.org/King_Safety), [tempo](https://www.chessprogramming.org/Tempo) +- Pawn structure — [isolated](https://www.chessprogramming.org/Isolated_Pawn) / [doubled](https://www.chessprogramming.org/Doubled_Pawn) / [backward](https://www.chessprogramming.org/Backward_Pawn) pawns +- [Rook on open file](https://www.chessprogramming.org/Rook_on_Open_File), [knight outposts](https://www.chessprogramming.org/Outpost) +- Evaluation-weight tuning (Texel / gradient) against a labelled position set - [Incremental updates](https://www.chessprogramming.org/Incremental_Updates) of material + PST on make/unmake - [Pawn](https://www.chessprogramming.org/Pawn_Hash_Table) and [evaluation](https://www.chessprogramming.org/Evaluation_Hash_Table) hash tables diff --git a/docs/engine-strength.md b/docs/engine-strength.md index 6e02a6b..3e900a2 100644 --- a/docs/engine-strength.md +++ b/docs/engine-strength.md @@ -25,9 +25,10 @@ until [the strength-testing harness](#measuring-it-properly) produces real data. valuable victim, least valuable attacker), then the two killer moves for the ply, then quiet moves by history score. Good ordering is what makes alpha-beta actually prune, and it is also what makes null-move pruning and LMR safe. -- **Evaluation** — material values + Michniewski piece-square tables + a - bishop-pair bonus. Crude, but it captures development, central control, king - placement and the two-bishop advantage. +- **Evaluation** — tapered PeSTO material and piece-square tables plus + bishop-pair, passed-pawn, mobility, king-safety and tempo terms. Untuned, but + it captures development, central control, king safety, the two-bishop + advantage, and the middlegame/endgame shift. - **Compiled and fast** — ~2.5M nodes/sec in a single thread on an Apple M4 (`gochess bench`), and Lazy SMP puts the other cores to work. That is ~50–60× the node rate of a typical Python engine, so effective search depth is @@ -55,6 +56,17 @@ until [the strength-testing harness](#measuring-it-properly) produces real data. nulls). - **Late move reductions.** Late quiet moves are searched 1–2 ply shallower and re-searched at full depth only when the reduced search beats alpha. +- **Principal variation search + aspiration windows.** Only the first move at a + node gets a full window; the rest are null-window scouts. From depth 5 each + iteration starts inside a ±25 cp window around the previous score. +- **Tapered PeSTO evaluation** with passed-pawn, mobility and king-safety terms, + replacing the single Michniewski PST set. +- **Asynchronous search** — `stop` aborts immediately and pondering + (`go ponder` / `ponderhit`) lets the engine think on the opponent's clock. +- **TT ageing + richer Lazy SMP** — a generation counter reclaims the previous + search's entries, and helper workers skew their root move order and aspiration + window. +- **Polyglot opening book** (`OwnBook` / `BookFile`). Together these cut a depth-8 search from the opening from ~5.9M nodes to ~167k and let the single-threaded search reach depth 12 in roughly the time depth 8 @@ -62,37 +74,34 @@ used to cost. See [performance.md](performance.md). ### Limiting factors -- **No aspiration windows or principal variation search.** Every move past the - first at a node is still searched with a full window (LMR aside), so the - alpha-beta tree is wider than a PVS engine's. - **No search extensions** (check extensions, singular extensions). Tactical lines that need one extra ply past the horizon are missed. -- **Hand-set evaluation weights, never tuned.** No Texel tuning, no - game-phase interpolation (a single PST set is used from opening to endgame), - no explicit terms for passed pawns, pawn structure, mobility, rook-on-open- - file, or king safety beyond the king PST. -- **Thin endgame play.** No tablebase probing, no KPK / KBNK knowledge, no +- **Hand-set evaluation weights, never tuned.** The tapered PeSTO tables and the + passed-pawn / mobility / king-safety coefficients are literature defaults, not + Texel- or gradient-tuned for this engine. No isolated/doubled/backward pawn, + rook-on-open-file or outpost terms yet. +- **Thin endgame play.** No tablebase probing (see + [ADR 0003](adr/0003-defer-syzygy-tablebases.md)), no KPK / KBNK knowledge, no contempt. The 50-move rule is honoured but threefold repetition is not detected inside the search. -- **Basic Lazy SMP only.** Workers share the TT and start at staggered depths, - but there is no root-move splitting, aspiration-window skew, or TT ageing, so - parallel scaling past a handful of threads is modest. -- **Synchronous search.** `stop` is a no-op and there is no pondering, so the - engine cannot think on the opponent's clock or bail out of a bad time - allocation. +- **Lazy SMP is still "lazy".** Helpers skew their root order and aspiration + window and the TT is aged, but there is no explicit root-move splitting or + shared-PV coordination, so scaling past a handful of threads is modest. +- **No SEE.** Captures are ordered by MVV-LVA only; losing captures are not + pruned in quiescence. ## Calibration against known engines | Reference engine | ~CCRL blitz | Relevant comparison | | ---------------- | ----------- | ------------------- | | TSCP 1.81 | ~1700 | Similar search shape but no null-move / LMR. goChess should now be clearly stronger. | -| Sungorus 1.4 | ~2000 | TT + null-move + PVS + killers — the closest match to goChess's current feature set. goChess should land near here, held back by the cruder evaluation and the missing PVS. | -| CT800 / Claudia class | ~2100+ | Full modern pruning set plus a tuned eval. Reachable once aspiration/PVS and a tapered, tuned evaluation land. | +| Sungorus 1.4 | ~2000 | TT + null-move + PVS + killers — a close match to goChess's search feature set. goChess should be around here or a little above. | +| CT800 / Claudia class | ~2100+ | Full modern pruning set plus a tuned eval. Reachable once the evaluation weights are tuned and a few structural terms are added. | -The feature set now resembles a "complete first-generation pruning engine" — -tactically sharp for its node rate, still positionally simplistic (one untuned -PST set, no pawn-structure or king-safety terms), so the evaluation is the main -thing left holding the rating down. +The feature set now resembles a "complete first-generation pruning engine" with +a tapered evaluation — tactically sharp for its node rate; the remaining +positional gaps (untuned weights, no pawn-structure or outpost terms) are the +main thing left holding the rating down. ## Time-control sensitivity @@ -118,16 +127,17 @@ assuming each is implemented competently and validated by SPRT: | Killer moves + history heuristic | +50 to +100 | done, pending SPRT | | Null-move pruning | +50 to +80 | done, pending SPRT | | Late move reductions | +50 to +100 | done, pending SPRT | -| Aspiration windows / PVS | +20 to +50 | | -| Game-phase eval interpolation (tapered eval) | +30 to +60 | | -| Passed pawns / king safety / mobility terms | +40 to +80 | | +| Aspiration windows / PVS | +20 to +50 | done, pending SPRT | +| Game-phase eval interpolation (tapered eval) | +30 to +60 | done, pending SPRT | +| Passed pawns / king safety / mobility terms | +40 to +80 | done, pending SPRT | | Texel-tuned evaluation weights | +40 to +80 | | -| Opening book (Polyglot) | +20 to +40 at short TC | | -| Syzygy tablebase probing | +10 to +20 | | +| Opening book (Polyglot) | +20 to +40 at short TC | done, pending SPRT | +| Syzygy tablebase probing | +10 to +20 | deferred ([ADR 0003](adr/0003-defer-syzygy-tablebases.md)) | -With the TT, Lazy SMP, killers/history, null-move pruning and LMR all in, -goChess plausibly sits in the ~1900–2150 range; a tapered, tuned evaluation with -pawn-structure and king-safety terms on top is what targets 2300+. +With the TT, Lazy SMP, killers/history, null-move pruning, LMR, PVS/aspiration +and a tapered evaluation all in, goChess plausibly sits in the ~2000–2250 range; +tuning the evaluation weights and adding a few structural terms is what targets +2300+. ## Measuring it properly diff --git a/docs/performance.md b/docs/performance.md index 95a24fd..04ae5f8 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,10 +1,17 @@ # Performance -Benchmark of the current engine (iterative deepening, negamax alpha-beta, -bounded quiescence, a shared transposition table, MVV-LVA + killer + history -move ordering, null-move pruning, late move reductions, Lazy SMP, hard time -limit) on one machine: Apple M4, 10 cores, Go 1.27, `darwin/arm64`. Search is -from the starting position unless noted. "Nodes" is the engine's node counter +> Note: the node counts and depths below were measured before principal +> variation search, aspiration windows and the tapered PeSTO evaluation landed. +> The shape of the story (narrow tree, modest SMP overlap) still holds; the +> exact figures will be lower on nodes / higher on depth. Re-benchmark before +> quoting. + +Benchmark of the engine (iterative deepening, fail-soft negamax alpha-beta with +principal variation search and aspiration windows, bounded quiescence, a shared +aged transposition table, MVV-LVA + killer + history move ordering, null-move +pruning, late move reductions, Lazy SMP, hard time limit) on one machine: Apple +M4, 10 cores, Go 1.27, `darwin/arm64`. Search is from the starting position +unless noted. "Nodes" is the engine's node counter (`negamax` + `quiesce` calls). Numbers are single-run and rounded; with a shared TT and multiple workers the search is no longer bit-for-bit deterministic, so node counts wobble a few percent between runs. @@ -47,11 +54,12 @@ pruning search a tree much narrower than full-width minimax. ## Lazy SMP scaling -Basic Lazy SMP: workers share the TT and start at staggered depths, but there is -no root-move splitting or aspiration-window skew yet, so on TT-friendly -positions the workers largely re-explore the same tree. The value shows up as -extra breadth and tactical robustness, and as a modest depth gain at fixed time. -Approximate, `movetime 2000` from the start position: +Lazy SMP: workers share the TT (aged per search) and start at staggered depths; +helpers also skew their root move order and use a wider, asymmetric aspiration +window. There is still no explicit root-move splitting or shared-PV coordination, +so on TT-friendly positions the workers overlap substantially. The value shows +up as extra breadth and tactical robustness, and as a modest depth gain at fixed +time. Approximate, `movetime 2000` from the start position: | threads | depth reached | nodes | |--------:|--------------:|------:| From 9cd7824d0ed87cd6007ee141b4606789d1d54385 Mon Sep 17 00:00:00 2001 From: Christopher Junius Date: Sun, 30 Aug 2026 21:04:55 -0400 Subject: [PATCH 09/10] chore: satisfy golangci-lint (gocyclo, nolintlint) Extract go-argument parsing into parseGoArgs to bring handleGo under the cyclomatic-complexity limit; drop two nolint:gosec directives gosec does not need on provably-bounded square conversions. Co-Authored-By: Claude Sonnet 5 --- internal/engine/polyglot.go | 4 ++-- internal/uci/uci.go | 41 +++++++++++++++++++++++++------------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/internal/engine/polyglot.go b/internal/engine/polyglot.go index d30b66e..5efc369 100644 --- a/internal/engine/polyglot.go +++ b/internal/engine/polyglot.go @@ -136,8 +136,8 @@ func decodePolyglotMove(pm uint16, b *dragontoothmg.Board) (dragontoothmg.Move, to := int((pm>>3)&7)*8 + int(pm&7) var mv dragontoothmg.Move - mv.Setfrom(dragontoothmg.Square(from)) //nolint:gosec // 0..63 - mv.Setto(dragontoothmg.Square(to)) //nolint:gosec // 0..63 + mv.Setfrom(dragontoothmg.Square(from)) + mv.Setto(dragontoothmg.Square(to)) switch (pm >> 12) & 7 { case 1: mv.Setpromote(dragontoothmg.Knight) diff --git a/internal/uci/uci.go b/internal/uci/uci.go index 89756e1..6cb8dfe 100644 --- a/internal/uci/uci.go +++ b/internal/uci/uci.go @@ -267,19 +267,26 @@ func (s *session) handlePosition(args []string) { } } -func (s *session) handleGo(args []string) { - s.stopSearch() +// goLimits is the parsed subset of a "go" command's arguments. +type goLimits struct { + maxDepth int + movetime time.Duration + ponder bool +} - var params engine.SearchParams - var wtime, btime, winc, binc, movetime time.Duration - ponder := false +// parseGoArgs reads the "go" keywords goChess supports. Unknown keywords +// (movestogo, mate, searchmoves, …) are ignored. whiteToMove selects which +// side's clock feeds the time budget. +func parseGoArgs(args []string, whiteToMove bool) goLimits { + var g goLimits + var wtime, btime, winc, binc time.Duration for i := 0; i < len(args); i++ { switch args[i] { case "ponder": - ponder = true + g.ponder = true case "infinite": - params.MaxDepth = 0 + g.maxDepth = 0 } if i+1 >= len(args) { break @@ -287,10 +294,10 @@ func (s *session) handleGo(args []string) { switch args[i] { case "depth": if d, err := strconv.Atoi(args[i+1]); err == nil { - params.MaxDepth = d + g.maxDepth = d } case "movetime": - movetime = millis(args[i+1]) + g.movetime = millis(args[i+1]) case "wtime": wtime = millis(args[i+1]) case "btime": @@ -302,16 +309,24 @@ func (s *session) handleGo(args []string) { } } - if movetime == 0 { + if g.movetime == 0 { remaining, inc := btime, binc - if s.board.Wtomove { + if whiteToMove { remaining, inc = wtime, winc } if remaining > 0 { - movetime = remaining/clockDivisor + inc*3/4 + g.movetime = remaining/clockDivisor + inc*3/4 } } - params.MoveTime = movetime + return g +} + +func (s *session) handleGo(args []string) { + s.stopSearch() + + g := parseGoArgs(args, s.board.Wtomove) + ponder := g.ponder + params := engine.SearchParams{MaxDepth: g.maxDepth, MoveTime: g.movetime} // An in-book move short-circuits the search entirely (but never while // pondering — there is nothing to ponder on a book move). From 640b2dd56fda846507c93890da28a476fe103e32 Mon Sep 17 00:00:00 2001 From: Christopher Junius Date: Sun, 30 Aug 2026 21:08:15 -0400 Subject: [PATCH 10/10] test: stop the aspiration parity test timing out on slow CI Drop the 5s deadline (both searches are depth-bounded, so they always finish) and lower the fixed depth from 6 to 5. On a -race CI runner the depth-6 search was exceeding the deadline, leaving the full-window baseline with no result to compare against. --- internal/engine/search_internal_test.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/engine/search_internal_test.go b/internal/engine/search_internal_test.go index 4f0cc68..b501711 100644 --- a/internal/engine/search_internal_test.go +++ b/internal/engine/search_internal_test.go @@ -230,19 +230,24 @@ func TestAspirationWindowMatchesFullWindow(t *testing.T) { "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3", "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10", } - const depth = 6 + const depth = 5 for _, fen := range fens { b := dragontoothmg.ParseFen(fen) - full := &searcher{tt: NewTT(8), deadline: time.Now().Add(5 * time.Second)} - wantScore, wantMove, _ := full.searchRoot(&b, depth, -infinity, infinity) + // No deadline: both searches are depth-bounded, so they always finish — + // this keeps the comparison meaningful on a slow -race CI runner. + full := &searcher{tt: NewTT(8)} + wantScore, wantMove, ok := full.searchRoot(&b, depth, -infinity, infinity) + if !ok { + t.Fatalf("%s: full-window searchRoot did not complete", fen) + } // Feed searchDepth the true score as the previous iteration's guess so the // aspiration window is tight — the hardest case for it to get right. - asp := &searcher{tt: NewTT(8), deadline: time.Now().Add(5 * time.Second)} + asp := &searcher{tt: NewTT(8)} gotScore, gotMove, ok := asp.searchDepth(&b, depth, wantScore) if !ok { - t.Fatalf("%s: searchDepth timed out", fen) + t.Fatalf("%s: searchDepth did not complete", fen) } if gotScore != wantScore { t.Errorf("%s: aspiration score %d, full-window score %d", fen, gotScore, wantScore)