From b17e74d05654559850a912a78f68f1972e541d88 Mon Sep 17 00:00:00 2001 From: testtest126 <44771568+testtest126@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:53:48 +0200 Subject: [PATCH] Engine: pawn structure, king safety, and tapered king PST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested strengthening of the built-in engine, staying MIT (no Stockfish/GPL). Turned out the search side of that ask was already largely built: alpha-beta + iterative deepening + aspiration windows, quiescence search, a Zobrist-keyed transposition table, null-move pruning, and MVV-LVA + killer + history move ordering are all already in NegamaxEngine. The real gap was the evaluation function `evaluateFast()` uses (the one every search leaf calls): material + PST only, no pawn structure, no king safety, no notion of game phase. This PR adds those, in one pass over the board (no second scan, no move generation): - Pawn structure: doubled/isolated pawn penalties, passed-pawn bonus by rank that scales up in the endgame (an unstoppable passer is worth far more once there's no piece play left to compensate). - King safety: pawn-shield bonus (friendly pawns on the squares directly ahead of the king) and an open/semi-open king-file penalty. Faded out as material is traded off — shield safety isn't a meaningful concept on a near-empty board. - Tapered king PST: the existing king table (correctly favors tucking into a corner) is now blended with a new endgame table (favors centralizing) by a phase estimate from remaining non-pawn material. The king's own PST bonus is deferred until after the per-square pass, once the phase is known. Deliberately left out of the hot path: full legal-move mobility. It's already computed in the slower `evaluate()` (used for game review, not search) via `pseudoLegalMoves().count`; wiring that into the every-leaf `evaluateFast()` would roughly double search cost for a comparatively small eval signal, on an engine that's already the search's bottleneck (~160k nps release). Cheap, single-pass terms only. Tests: `EvaluationTests.swift` unit-tests `Eval.pawnStructureScore` and `PST.kingBonus` directly (pure functions, exact values, confound-free) plus a few `Board.evaluateFast()` integration checks (sheltered beats exposed king, centralized beats cornered king in a bare endgame, connected beats doubled pawns). `BenchTests.signatureAtCIBudget` updated to the new deterministic value — the search is still bit-for-bit reproducible (verified: both intra-run determinism assertions passed before I updated the pinned constant, only the snapshot moved, exactly as expected for an intentional eval change). Everything else (perft, move generation, TT, quiescence, opening book, self-play, pondering) is untouched. `swift test`: 122/122 pass. Co-Authored-By: Claude Sonnet 5 --- ChessKit/Sources/ChessKit/Board.swift | 208 +++++++++++++++++- ChessKit/Sources/ChessKit/Core.swift | 16 ++ .../Tests/ChessKitTests/EvaluationTests.swift | 167 ++++++++++++++ .../Tests/EngineLabTests/BenchTests.swift | 2 +- 4 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 ChessKit/Tests/ChessKitTests/EvaluationTests.swift diff --git a/ChessKit/Sources/ChessKit/Board.swift b/ChessKit/Sources/ChessKit/Board.swift index 4d343ad..b1a70d3 100644 --- a/ChessKit/Sources/ChessKit/Board.swift +++ b/ChessKit/Sources/ChessKit/Board.swift @@ -515,20 +515,126 @@ public struct Board: Equatable, Hashable, Sendable { return score } - /// Material + piece-square tables only: the cheap core of ``evaluate()``, - /// with no move generation at all. Callers are responsible for handling - /// terminal positions (checkmate/stalemate/draws) themselves. + /// Material + piece-square tables, pawn structure, and king safety: the + /// cheap core of ``evaluate()``, with no move generation at all (aside + /// from the fixed-radius king-shield lookups, which are direct square + /// indexing, not movegen). Callers are responsible for handling terminal + /// positions (checkmate/stalemate/draws) themselves. + /// + /// One pass over the board collects material/PST plus the per-file pawn + /// bookkeeping that both the pawn-structure and king-safety terms need, + /// so those additions cost a handful of per-pawn/per-king checks, not a + /// second board scan. The king's own PST bonus is deferred until after + /// the pass, once the game phase (needed to blend the middlegame/endgame + /// king tables) is known. public func evaluateFast() -> Int { var score = 0 + var whitePawnFiles = [Int](repeating: 0, count: 8) + var blackPawnFiles = [Int](repeating: 0, count: 8) + // Per file, the least-advanced white pawn's rank / most-advanced + // black pawn's rank — exactly what a passed-pawn check on the other + // color needs. Sentinels (8 / -1) mean "no pawn on this file" and + // naturally fall out of every passed-pawn comparison as "no blocker". + var whiteMinRankOnFile = [Int](repeating: 8, count: 8) + var blackMaxRankOnFile = [Int](repeating: -1, count: 8) + var whitePawnSquares: [Int] = [] + var blackPawnSquares: [Int] = [] + var whiteKingSquare: Int? + var blackKingSquare: Int? + var phaseWeight = 0 + for (i, piece) in squares.enumerated() { guard let piece else { continue } - var value = piece.kind.centipawnValue - value += PST.bonus(for: piece, at: i) + switch piece.kind { + case .king: + // Deferred: the tapered king PST needs `endgameFactor`, which + // isn't known until every piece has been seen. + if piece.color == .white { whiteKingSquare = i } else { blackKingSquare = i } + continue + case .pawn: + let f = Sq.file(i), r = Sq.rank(i) + if piece.color == .white { + whitePawnFiles[f] += 1 + whiteMinRankOnFile[f] = min(whiteMinRankOnFile[f], r) + whitePawnSquares.append(i) + } else { + blackPawnFiles[f] += 1 + blackMaxRankOnFile[f] = max(blackMaxRankOnFile[f], r) + blackPawnSquares.append(i) + } + default: + phaseWeight += piece.kind.phaseWeight + } + + let value = piece.kind.centipawnValue + PST.bonus(for: piece, at: i) score += piece.color == .white ? value : -value } + + let endgameFactor = 1 - min(1, Double(phaseWeight) / Double(PieceKind.maxPhaseWeight)) + + if let whiteKingSquare { + score += PST.kingBonus(at: whiteKingSquare, color: .white, endgameFactor: endgameFactor) + } + if let blackKingSquare { + score -= PST.kingBonus(at: blackKingSquare, color: .black, endgameFactor: endgameFactor) + } + + score += Eval.pawnStructureScore( + whitePawnFiles: whitePawnFiles, blackPawnFiles: blackPawnFiles, + whiteMinRankOnFile: whiteMinRankOnFile, blackMaxRankOnFile: blackMaxRankOnFile, + whitePawnSquares: whitePawnSquares, blackPawnSquares: blackPawnSquares, + endgameFactor: endgameFactor + ) + + // King safety (pawn shield, open/semi-open files) fades out as the + // endgame approaches — it stops being a meaningful concept on a + // near-empty board, and by then the tapered PST above is already + // pulling the king toward the center instead of the corner. + let kingSafetyFactor = 1 - endgameFactor + if let whiteKingSquare { + score += kingSafetyScore( + kingSquare: whiteKingSquare, color: .white, + ownPawnFiles: whitePawnFiles, enemyPawnFiles: blackPawnFiles, + factor: kingSafetyFactor + ) + } + if let blackKingSquare { + score -= kingSafetyScore( + kingSquare: blackKingSquare, color: .black, + ownPawnFiles: blackPawnFiles, enemyPawnFiles: whitePawnFiles, + factor: kingSafetyFactor + ) + } + return score } + /// Pawn shield + open/semi-open file exposure for one king, from that + /// king's own color's perspective (positive is good for `color`). + private func kingSafetyScore( + kingSquare: Int, color: PieceColor, + ownPawnFiles: [Int], enemyPawnFiles: [Int], factor: Double + ) -> Int { + let f = Sq.file(kingSquare), r = Sq.rank(kingSquare) + let shieldRank = color == .white ? r + 1 : r - 1 + var raw = 0 + + if (0...7).contains(shieldRank) { + for shieldFile in max(0, f - 1)...min(7, f + 1) { + if let piece = self[Sq.index(file: shieldFile, rank: shieldRank)], + piece.color == color, piece.kind == .pawn { + raw += Eval.kingShieldBonus + } + } + } + + if ownPawnFiles[f] == 0 { + raw -= enemyPawnFiles[f] == 0 ? Eval.openFileKingPenalty : Eval.semiOpenFileKingPenalty + } + + return Int(Double(raw) * factor) + } + /// One-ply-deep "best reply aware" evaluation: minimax over legal moves using static eval. /// From White's perspective. Used for review accuracy classification. public func evaluateWithLookahead() -> Int { @@ -599,7 +705,9 @@ enum PST { -10, 0, 0, 0, 0, 0, 0, -10, -20, -10, -10, -5, -5, -10, -10, -20, ] - static let king: [Int] = [ + /// Middlegame king safety: reward tucking into a corner behind the pawn + /// shield, punish the center where the king is exposed to open lines. + static let kingMidgame: [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, @@ -609,6 +717,19 @@ enum PST { -30, -40, -40, -50, -50, -40, -40, -30, -30, -40, -40, -50, -50, -40, -40, -30, ] + /// Endgame king activity: with queens and most minor/major pieces off the + /// board, the king is an attacking piece — reward centralization, the + /// opposite of the middlegame table above. + static let kingEndgame: [Int] = [ + -50, -30, -30, -30, -30, -30, -30, -50, + -30, -30, 0, 0, 0, 0, -30, -30, + -30, -10, 20, 30, 30, 20, -10, -30, + -30, -10, 30, 40, 40, 30, -10, -30, + -30, -10, 30, 40, 40, 30, -10, -30, + -30, -10, 20, 30, 30, 20, -10, -30, + -30, -20, -10, 0, 0, -10, -20, -30, + -50, -40, -30, -20, -20, -30, -40, -50, + ] static func bonus(for piece: Piece, at square: Int) -> Int { // Mirror vertically for black. @@ -619,7 +740,80 @@ enum PST { case .bishop: return bishop[idx] case .rook: return rook[idx] case .queen: return queen[idx] - case .king: return king[idx] + case .king: return kingMidgame[idx] } } + + /// Tapered king bonus: blends the middlegame (safety-seeking) and + /// endgame (centralizing) tables by how much material has come off the + /// board. Kept separate from `bonus(for:at:)` because it needs the game + /// phase, which isn't known until the whole board has been scanned. + static func kingBonus(at square: Int, color: PieceColor, endgameFactor: Double) -> Int { + let idx = color == .white ? square : Sq.index(file: Sq.file(square), rank: 7 - Sq.rank(square)) + let mg = Double(kingMidgame[idx]) + let eg = Double(kingEndgame[idx]) + return Int((mg * (1 - endgameFactor) + eg * endgameFactor).rounded()) + } +} + +// MARK: - Pawn structure + +enum Eval { + /// Penalty per pawn beyond the first on a file. + static let doubledPawnPenalty = 12 + /// Penalty for a pawn with no friendly pawn on either adjacent file. + static let isolatedPawnPenalty = 14 + /// Passed-pawn bonus by rank, White's perspective (a black passed pawn + /// looks up the mirrored rank, `7 - rank`). Scaled by `1 + endgameFactor` + /// in `pawnStructureScore` — an unstoppable passer is worth far more once + /// there's no piece play left on the board to compensate for losing it. + static let passedPawnByRank: [Int] = [0, 5, 10, 20, 35, 55, 80, 0] + static let kingShieldBonus = 9 + static let semiOpenFileKingPenalty = 10 + static let openFileKingPenalty = 22 + + /// Doubled/isolated/passed pawns. Reuses the per-file bookkeeping + /// `evaluateFast()` already collects in its one pass over the board, so + /// this adds no further board scan beyond a handful of per-file/per-pawn + /// checks. + static func pawnStructureScore( + whitePawnFiles: [Int], blackPawnFiles: [Int], + whiteMinRankOnFile: [Int], blackMaxRankOnFile: [Int], + whitePawnSquares: [Int], blackPawnSquares: [Int], + endgameFactor: Double + ) -> Int { + var score = 0 + + for f in 0..<8 { + if whitePawnFiles[f] > 1 { score -= doubledPawnPenalty * (whitePawnFiles[f] - 1) } + if blackPawnFiles[f] > 1 { score += doubledPawnPenalty * (blackPawnFiles[f] - 1) } + + if whitePawnFiles[f] > 0 { + let hasNeighbor = (f > 0 && whitePawnFiles[f - 1] > 0) || (f < 7 && whitePawnFiles[f + 1] > 0) + if !hasNeighbor { score -= isolatedPawnPenalty * whitePawnFiles[f] } + } + if blackPawnFiles[f] > 0 { + let hasNeighbor = (f > 0 && blackPawnFiles[f - 1] > 0) || (f < 7 && blackPawnFiles[f + 1] > 0) + if !hasNeighbor { score += isolatedPawnPenalty * blackPawnFiles[f] } + } + } + + let passedScale = 1 + endgameFactor + for square in whitePawnSquares { + let f = Sq.file(square), r = Sq.rank(square) + let blocked = (max(0, f - 1)...min(7, f + 1)).contains { blackMaxRankOnFile[$0] > r } + if !blocked { + score += Int(Double(passedPawnByRank[r]) * passedScale) + } + } + for square in blackPawnSquares { + let f = Sq.file(square), r = Sq.rank(square) + let blocked = (max(0, f - 1)...min(7, f + 1)).contains { whiteMinRankOnFile[$0] < r } + if !blocked { + score -= Int(Double(passedPawnByRank[7 - r]) * passedScale) + } + } + + return score + } } diff --git a/ChessKit/Sources/ChessKit/Core.swift b/ChessKit/Sources/ChessKit/Core.swift index e0fff69..e783f8f 100644 --- a/ChessKit/Sources/ChessKit/Core.swift +++ b/ChessKit/Sources/ChessKit/Core.swift @@ -33,6 +33,22 @@ public enum PieceKind: String, Codable, Sendable, CaseIterable, Hashable { case .king: return 0 } } + + /// Contribution to the tapered-evaluation game-phase estimate. Pawns and + /// kings don't count — the phase tracks how much *piece* play is left on + /// the board, which is what the king-safety/activity tables key off. + var phaseWeight: Int { + switch self { + case .knight, .bishop: return 1 + case .rook: return 2 + case .queen: return 4 + case .pawn, .king: return 0 + } + } + + /// Both sides' starting non-pawn material in `phaseWeight` units: + /// (2 knights + 2 bishops + 2 rooks×2 + 1 queen×4) × 2 sides = 24. + static let maxPhaseWeight = 24 } public struct Piece: Equatable, Hashable, Codable, Sendable { diff --git a/ChessKit/Tests/ChessKitTests/EvaluationTests.swift b/ChessKit/Tests/ChessKitTests/EvaluationTests.swift new file mode 100644 index 0000000..7953152 --- /dev/null +++ b/ChessKit/Tests/ChessKitTests/EvaluationTests.swift @@ -0,0 +1,167 @@ +import XCTest +@testable import ChessKit + +/// Coverage for the evaluation upgrade (material + PST was already there): +/// pawn structure (doubled/isolated/passed), king safety (shield, open +/// files), and the tapered king PST (safety-seeking midgame, centralizing +/// endgame). The pure-function tests exercise `Eval`/`PST` directly with +/// controlled inputs so results are exact and confound-free; the +/// `Board.evaluateFast()` tests are integration-level smoke checks that the +/// pieces are actually wired together on a real position. +final class EvaluationTests: XCTestCase { + // MARK: - Eval.pawnStructureScore (pure function, exact values) + + func testDoubledPawnsArePenalized() { + // Two white pawns on the a-file (a2, a3), nothing else — also + // isolated (no b-file pawn) and passed (no black pawns anywhere). + // No black pawns means there's no cross-side term to confound the + // arithmetic, so this locks in the exact sum of all three terms. + let score = Eval.pawnStructureScore( + whitePawnFiles: [2, 0, 0, 0, 0, 0, 0, 0], + blackPawnFiles: [0, 0, 0, 0, 0, 0, 0, 0], + whiteMinRankOnFile: [1, 8, 8, 8, 8, 8, 8, 8], + blackMaxRankOnFile: [-1, -1, -1, -1, -1, -1, -1, -1], + whitePawnSquares: [Sq.index(file: 0, rank: 1), Sq.index(file: 0, rank: 2)], + blackPawnSquares: [], + endgameFactor: 0 + ) + // doubled: -12×1. isolated: -14×2 (both a-file pawns, no b-file + // neighbor). passed: rank1 (+5) + rank2 (+10), scale 1. + XCTAssertEqual(score, -12 - 28 + 15) + } + + func testIsolatedPawnsScoreWorseThanConnectedPawnsAtEqualMaterial() { + // Same two pawns, same ranks, same PST cells either way (a4/c4 vs + // a4/b4 land on identical piece-square values) — only file adjacency + // differs, isolating the isolated-pawn term from doubled/PST noise. + let isolated = Eval.pawnStructureScore( + whitePawnFiles: [1, 0, 1, 0, 0, 0, 0, 0], + blackPawnFiles: [0, 0, 0, 0, 0, 0, 0, 0], + whiteMinRankOnFile: [3, 8, 3, 8, 8, 8, 8, 8], + blackMaxRankOnFile: [-1, -1, -1, -1, -1, -1, -1, -1], + whitePawnSquares: [Sq.index(file: 0, rank: 3), Sq.index(file: 2, rank: 3)], + blackPawnSquares: [], + endgameFactor: 0 + ) + let connected = Eval.pawnStructureScore( + whitePawnFiles: [1, 1, 0, 0, 0, 0, 0, 0], + blackPawnFiles: [0, 0, 0, 0, 0, 0, 0, 0], + whiteMinRankOnFile: [3, 3, 8, 8, 8, 8, 8, 8], + blackMaxRankOnFile: [-1, -1, -1, -1, -1, -1, -1, -1], + whitePawnSquares: [Sq.index(file: 0, rank: 3), Sq.index(file: 1, rank: 3)], + blackPawnSquares: [], + endgameFactor: 0 + ) + // Both pawns are passed in both scenarios (identical bonus either + // way — no black pawns to block them), so the whole gap is the + // isolated penalty applying to both a4 and c4 versus neither. + XCTAssertEqual(connected - isolated, 2 * Eval.isolatedPawnPenalty) + } + + func testPassedPawnBonusScalesUpInTheEndgame() { + // Two connected, unopposed pawns (c4/d4 — neither isolated, both + // passed) so the isolated-pawn term is zero in both calls and the + // whole score is passed-pawn bonus, scaled only by `endgameFactor`. + func score(endgameFactor: Double) -> Int { + Eval.pawnStructureScore( + whitePawnFiles: [0, 0, 1, 1, 0, 0, 0, 0], + blackPawnFiles: [0, 0, 0, 0, 0, 0, 0, 0], + whiteMinRankOnFile: [8, 8, 3, 3, 8, 8, 8, 8], + blackMaxRankOnFile: [-1, -1, -1, -1, -1, -1, -1, -1], + whitePawnSquares: [Sq.index(file: 2, rank: 3), Sq.index(file: 3, rank: 3)], + blackPawnSquares: [], + endgameFactor: endgameFactor + ) + } + let midgame = score(endgameFactor: 0) + let endgame = score(endgameFactor: 1) + XCTAssertGreaterThan(midgame, 0, "two unopposed connected pawns are passed even in the midgame") + XCTAssertEqual(endgame, 2 * midgame, "passedScale is exactly 1 + endgameFactor") + } + + func testBlockedPawnGetsNoPassedBonus() { + // Same white d4 pawn (isolated identically either way — the + // isolated-pawn term cancels in the difference), only whether a + // pawn blocks its file ahead differs. No actual black pawn is + // scored (`blackPawnSquares` stays empty), so nothing on the black + // side of the ledger can confound the comparison. + func score(blockerAheadOnDFile: Bool) -> Int { + Eval.pawnStructureScore( + whitePawnFiles: [0, 0, 0, 1, 0, 0, 0, 0], + blackPawnFiles: [0, 0, 0, 0, 0, 0, 0, 0], + whiteMinRankOnFile: [8, 8, 8, 3, 8, 8, 8, 8], + blackMaxRankOnFile: blockerAheadOnDFile + ? [-1, -1, -1, 5, -1, -1, -1, -1] + : [-1, -1, -1, -1, -1, -1, -1, -1], + whitePawnSquares: [Sq.index(file: 3, rank: 3)], + blackPawnSquares: [], + endgameFactor: 1 + ) + } + let unblocked = score(blockerAheadOnDFile: false) + let blocked = score(blockerAheadOnDFile: true) + XCTAssertEqual(unblocked - blocked, Int(Double(Eval.passedPawnByRank[3]) * 2)) + } + + // MARK: - PST.kingBonus (tapering) + + func testKingBonusIsPureMidgameTableAtEndgameFactorZero() { + let square = Sq.index(file: 6, rank: 0) // g1 + XCTAssertEqual( + PST.kingBonus(at: square, color: .white, endgameFactor: 0), + PST.kingMidgame[square] + ) + } + + func testKingBonusIsPureEndgameTableAtEndgameFactorOne() { + let square = Sq.index(file: 4, rank: 3) // e4 + XCTAssertEqual( + PST.kingBonus(at: square, color: .white, endgameFactor: 1), + PST.kingEndgame[square] + ) + } + + func testKingBonusRewardsCentralizationOnlyAsPhaseIncreases() { + // e4 is punished by the midgame table (exposed center) but rewarded + // by the endgame one (active king) — the blended bonus must cross + // from worse-than-corner to better-than-corner as the phase shifts. + let corner = Sq.index(file: 6, rank: 0) // g1 + let center = Sq.index(file: 4, rank: 3) // e4 + let midgameGap = PST.kingBonus(at: center, color: .white, endgameFactor: 0) + - PST.kingBonus(at: corner, color: .white, endgameFactor: 0) + let endgameGap = PST.kingBonus(at: center, color: .white, endgameFactor: 1) + - PST.kingBonus(at: corner, color: .white, endgameFactor: 1) + XCTAssertLessThan(midgameGap, 0, "center is worse than the corner with pieces still on") + XCTAssertGreaterThan(endgameGap, 0, "center is better than the corner once material is off") + } + + // MARK: - Board.evaluateFast() integration + + func testShelteredKingScoresBetterThanExposedKingAtEqualMaterial() { + // Same material (Q+2R+8P per side) and the same king square (g1) in + // both positions; the only difference is whether White's f/g/h pawns + // are still on the second rank (an intact shield) or pushed to the + // fourth (none of them shielding anymore). Enough non-pawn material + // stays on the board that endgameFactor is well short of 1, so the + // king-safety term isn't faded all the way out. + let sheltered = Board(fen: "r2q1rk1/pppppppp/8/8/8/8/PPPPPPPP/R2Q1RK1 w - - 0 1")! + let exposed = Board(fen: "r2q1rk1/pppppppp/8/8/5PPP/8/PPPPP3/R2Q1RK1 w - - 0 1")! + XCTAssertGreaterThan(sheltered.evaluateFast(), exposed.evaluateFast()) + } + + func testCentralizedKingBeatsCorneredKingInABarePawnEndgame() { + // Bare king-and-pawn endgame: no other material at all, so + // endgameFactor is exactly 1 and the tapered king table alone + // decides which king placement scores better. Kings are kept well + // apart (not adjacent) so the position is a sane one to evaluate. + let centralized = Board(fen: "k7/8/8/8/3K4/8/4P3/8 w - - 0 1")! + let cornered = Board(fen: "k7/8/8/8/8/8/4P3/7K w - - 0 1")! + XCTAssertGreaterThan(centralized.evaluateFast(), cornered.evaluateFast()) + } + + func testConnectedPawnsBeatDoubledPawnsOnARealBoard() { + let connected = Board(fen: "4k3/8/8/8/8/8/PP6/4K3 w - - 0 1")! + let doubled = Board(fen: "4k3/8/8/8/8/P7/P7/4K3 w - - 0 1")! + XCTAssertGreaterThan(connected.evaluateFast(), doubled.evaluateFast()) + } +} diff --git a/ChessKit/Tests/EngineLabTests/BenchTests.swift b/ChessKit/Tests/EngineLabTests/BenchTests.swift index a558357..52d5e0d 100644 --- a/ChessKit/Tests/EngineLabTests/BenchTests.swift +++ b/ChessKit/Tests/EngineLabTests/BenchTests.swift @@ -14,7 +14,7 @@ final class BenchTests: XCTestCase { /// engine — that diff is the review signal. Engine evaluation is pure /// integer, so the value is byte-identical across machines and across /// debug/release builds (verified). - static let signatureAtCIBudget: UInt64 = 0xcd7f_a918_c21e_afc2 + static let signatureAtCIBudget: UInt64 = 0x55b1_10b1_c3c9_c16a /// Every suite FEN must parse and be a real, non-terminal position — a /// typo'd or already-checkmated FEN would silently search nothing.