Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions ChessKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,20 @@ let package = Package(
.target(name: "ChessKit"),
.target(name: "ChessProtocol", dependencies: ["ChessKit"]),
.target(name: "ChessOnline"),
// Engine measurement harness (bench + self-play). Deliberately NOT a
// library `product`, so it is never linked into the iOS app; it only
// *calls* the engine, preserving its determinism guarantee. Covered by
// `swift test` via EngineLabTests, and driven ad hoc by the `engine-lab`
// executable.
.target(
name: "EngineLab",
dependencies: ["ChessProtocol", "ChessKit"],
exclude: ["README.md"]
),
.executableTarget(name: "engine-lab", dependencies: ["EngineLab"]),
.testTarget(name: "ChessKitTests", dependencies: ["ChessKit"]),
.testTarget(name: "ChessProtocolTests", dependencies: ["ChessProtocol", "ChessKit"]),
.testTarget(name: "ChessOnlineTests", dependencies: ["ChessOnline"]),
.testTarget(name: "EngineLabTests", dependencies: ["EngineLab", "ChessProtocol", "ChessKit"]),
]
)
137 changes: 137 additions & 0 deletions ChessKit/Sources/EngineLab/Bench.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import ChessKit
import ChessProtocol
import Foundation

/// A 64-bit rolling checksum. Deterministic and order-sensitive, so it detects
/// any change in the per-position results (nodes, depth, score, best move) even
/// when — as under a hard node cap — the total node count is pinned to the
/// budget and would not move on its own.
struct Signature {
private(set) var value: UInt64 = 0xCBF2_9CE4_8422_2325 // FNV offset basis

mutating func fold(_ v: UInt64) {
value ^= v
value = value &* 0x9E37_79B9_7F4A_7C15 // fibonacci hashing multiplier
value ^= value >> 29
}

mutating func fold(_ v: Int) {
fold(UInt64(bitPattern: Int64(v)))
}
}

/// Per-position bench measurement.
public struct BenchPositionResult: Sendable {
public let name: String
public let nodes: Int
public let depth: Int
public let scoreCentipawns: Int
public let bestMove: String
}

/// The result of a whole bench run.
public struct BenchResult: Sendable {
public let limit: SearchLimit
public let perPosition: [BenchPositionResult]
public let totalNodes: Int
public let elapsedSeconds: Double
/// Behavioral fingerprint of the run — stable across runs, moves only when
/// search behavior changes. The determinism regression guard.
public let signature: UInt64

public var nodesPerSecond: Double {
elapsedSeconds > 0 ? Double(totalNodes) / elapsedSeconds : 0
}
}

/// Fixed-workload bench: search each suite position under one reproducible
/// limit and summarize. Reproducibility comes from fixed node/depth limits (no
/// wall-clock) and no opening book, so `signature` and `totalNodes` are
/// identical on every machine and every run.
public enum Bench {
/// A fixed per-position node budget for the executable's default run.
/// Sized so the whole suite finishes in ~20s in a release build while still
/// reaching non-trivial depths — the engine is deliberately simple and only
/// searches a few hundred thousand nodes/sec.
public static let defaultNodeBudget = 200_000

/// A limit that caps each search at `nodes` visited (with a high depth
/// ceiling so the node budget, not depth, is the binding constraint).
public static func nodeLimit(_ nodes: Int) -> SearchLimit {
SearchLimit(depth: 64, maxNodes: nodes)
}

public static func run(
limit: SearchLimit = Bench.nodeLimit(defaultNodeBudget),
positions: [BenchPosition] = BenchSuite.positions
) -> BenchResult {
// A fresh, book-less engine: the deterministic configuration.
let engine = NegamaxEngine()
var perPosition: [BenchPositionResult] = []
perPosition.reserveCapacity(positions.count)
var totalNodes = 0
var signature = Signature()

let clock = ContinuousClock()
let start = clock.now
for (index, position) in positions.enumerated() {
let board = parseFEN(position.fen)
let result = engine.search(board, limit: limit)
totalNodes += result.nodes

// Fold every observable of the search into the signature, keyed by
// position index so a reordering is also detected.
signature.fold(index)
signature.fold(result.nodes)
signature.fold(result.depth)
signature.fold(result.scoreCentipawns)
signature.fold(moveCode(result.bestMove))

perPosition.append(BenchPositionResult(
name: position.name,
nodes: result.nodes,
depth: result.depth,
scoreCentipawns: result.scoreCentipawns,
bestMove: result.bestMove?.uci ?? "(none)"
))
}
let elapsed = clock.now - start

return BenchResult(
limit: limit,
perPosition: perPosition,
totalNodes: totalNodes,
elapsedSeconds: elapsed.seconds,
signature: signature.value
)
}

/// Encodes a move as a small integer for the signature (0 = no move).
private static func moveCode(_ move: Move?) -> Int {
guard let move else { return 0 }
let promo = move.promotion.map { $0.rawValueForSignature } ?? 0
return 1 + move.from * 64 * 8 + move.to * 8 + promo
}
}

private extension PieceKind {
/// A stable small index for folding a promotion choice into the signature.
var rawValueForSignature: Int {
switch self {
case .pawn: return 1
case .knight: return 2
case .bishop: return 3
case .rook: return 4
case .queen: return 5
case .king: return 6
}
}
}

extension Duration {
/// Whole-plus-fractional seconds as a `Double`.
var seconds: Double {
let (secs, atto) = components
return Double(secs) + Double(atto) / 1e18
}
}
55 changes: 55 additions & 0 deletions ChessKit/Sources/EngineLab/BenchSuite.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import ChessKit

/// A named position in the bench suite.
public struct BenchPosition: Sendable {
public let name: String
public let fen: String

public init(name: String, fen: String) {
self.name = name
self.fen = fen
}
}

/// Parses a FEN known to be valid (the built-in suites are hardcoded). Traps on
/// a malformed constant rather than force-unwrapping, which keeps the production
/// target free of `!` and turns a typo into a clear message.
func parseFEN(_ fen: String) -> Board {
guard let board = Board(fen: fen) else {
preconditionFailure("EngineLab: invalid FEN in built-in suite: \(fen)")
}
return board
}

/// The fixed bench suite: 20 positions spanning openings, tactical middlegames,
/// and endgames, chosen for a mix of branching factors so the total node count
/// is a broad, stable fingerprint of search behavior. Order is fixed — the
/// bench signature folds in each position's index, so reordering changes it.
public enum BenchSuite {
public static let positions: [BenchPosition] = [
// Openings / early middlegame
.init(name: "startpos", fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"),
.init(name: "italian", fen: "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4"),
.init(name: "ruy-lopez", fen: "r1bqkbnr/1ppp1ppp/p1n5/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 0 4"),
.init(name: "najdorf", fen: "rnbqkb1r/1p2pppp/p2p1n2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 0 6"),
.init(name: "qgd", fen: "rnbqkb1r/ppp1pppp/5n2/3p4/2PP4/8/PP2PPPP/RNBQKBNR w KQkq - 0 3"),
.init(name: "french", fen: "rnbqkb1r/ppp2ppp/4pn2/3p4/2PP4/2N5/PP2PPPP/R1BQKBNR w KQkq - 0 4"),
// Rich middlegames (high branching factor)
.init(name: "kid-middlegame", fen: "r1bq1rk1/pp2bppp/2n1pn2/3p4/2PP4/2N1PN2/PP2BPPP/R1BQ1RK1 w - - 0 8"),
.init(name: "kiwipete", fen: "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"),
.init(name: "perft-pos4", fen: "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1"),
.init(name: "perft-pos5", fen: "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8"),
.init(name: "perft-pos6", fen: "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10"),
// Tactical (Win At Chess) test positions
.init(name: "wac-001", fen: "2rr3k/pp3pp1/1nnqbN1p/3pN3/2pP4/2P3Q1/PPB4P/R4RK1 w - - 0 1"),
.init(name: "wac-002", fen: "8/7p/5k2/5p2/p1p2P2/Pr1pPK2/1P1R3P/8 b - - 0 1"),
.init(name: "wac-010", fen: "3r1r1k/1p3pp1/p2q1n1p/8/2p1B3/P1P3Q1/1P3PPP/2K1R2R w - - 0 1"),
// Endgames (low branching, deep tactics)
.init(name: "rook-mate", fen: "6k1/5ppp/8/8/8/8/8/R3K3 w - - 0 1"),
.init(name: "kq-vs-k", fen: "8/8/8/4k3/8/8/8/3QK3 w - - 0 1"),
.init(name: "kr-vs-k", fen: "8/8/8/4k3/8/8/8/R3K3 w - - 0 1"),
.init(name: "pawn-endgame", fen: "8/5p2/5k2/8/5K2/8/5P2/8 w - - 0 1"),
.init(name: "rook-endgame", fen: "8/8/5k2/8/8/2K5/4R3/6r1 w - - 0 1"),
.init(name: "opposite-bishops", fen: "8/2k5/3b4/8/8/4B3/2K5/8 w - - 0 1"),
]
}
Loading
Loading