diff --git a/.github/workflows/tournament-tools.yml b/.github/workflows/tournament-tools.yml new file mode 100644 index 0000000..f059a9c --- /dev/null +++ b/.github/workflows/tournament-tools.yml @@ -0,0 +1,80 @@ +name: Validate Elo tournament tools + +on: + push: + branches: [tools/elo-tournament] + paths: + - 'tournament/**' + - '.github/workflows/tournament-tools.yml' + pull_request: + paths: + - 'tournament/**' + - '.github/workflows/tournament-tools.yml' + +jobs: + estimator: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Compile estimator + run: python -m py_compile tournament/estimate_elo.py + - name: Smoke-test Elo fit + run: | + cat > /tmp/synthetic.pgn <<'PGN' + [Event "Synthetic"] + [White "Pickle_D11"] + [Black "Stockfish_2400"] + [Result "1-0"] + + [Event "Synthetic"] + [White "Stockfish_2400"] + [Black "Pickle_D11"] + [Result "0-1"] + + [Event "Synthetic"] + [White "Pickle_D11"] + [Black "Stockfish_2400"] + [Result "0-1"] + + [Event "Synthetic"] + [White "Stockfish_2400"] + [Black "Pickle_D11"] + [Result "1-0"] + + [Event "Synthetic"] + [White "Pickle_D11"] + [Black "Stockfish_2400"] + [Result "1/2-1/2"] + + [Event "Synthetic"] + [White "Stockfish_2400"] + [Black "Pickle_D11"] + [Result "1/2-1/2"] + + [Event "Synthetic"] + [White "Pickle_D11"] + [Black "Stockfish_2400"] + [Result "1/2-1/2"] + + [Event "Synthetic"] + [White "Stockfish_2400"] + [Black "Pickle_D11"] + [Result "1/2-1/2"] + PGN + python tournament/estimate_elo.py /tmp/synthetic.pgn --bootstrap 300 | tee /tmp/elo.txt + grep -q 'Estimated Pickle D11 Elo: 2400' /tmp/elo.txt + + powershell-syntax: + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - name: Parse PowerShell launchers + shell: pwsh + run: | + Get-ChildItem tournament -Filter *.ps1 | ForEach-Object { + Write-Host "Parsing $($_.Name)" + [void][ScriptBlock]::Create((Get-Content $_.FullName -Raw)) + } diff --git a/README.md b/README.md index e86da7a..f1ac45e 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The engine is compiled to WebAssembly and runs inside a Web Worker, so the brows The UI includes: - play as White or Black -- multiple search-strength presets +- fixed depth-11 engine play - live engine evaluation during normal search - search depth and node count - move history @@ -138,6 +138,12 @@ npm run build The repository includes a root `vercel.json`. The production Vercel project is linked to this repository and deploys from `main`, so new pushes to `main` trigger the production deployment pipeline. +## Local Elo tournament + +`tournament/` contains a Windows FastChess harness for estimating Pickle's fixed-D11 strength against official Stockfish `UCI_Elo` anchors. It builds/downloads the required binaries, uses the neutral `UHO_Lichess_4852_v1.epd` opening suite with paired colors, runs a quick rating bracket, then supports a longer 120+1 calibration run with a fitted Elo estimate and bootstrap confidence interval. + +See [`tournament/README.md`](tournament/README.md) for the complete workflow. + ## Project layout ```text @@ -156,6 +162,7 @@ Pickle/ ├── zobrist.* position hashing ├── wasm_api.cpp browser-facing C++ API ├── scripts/ build/book-generation helpers +├── tournament/ local FastChess Elo harness └── web/ playable React interface ``` diff --git a/tournament/.gitignore b/tournament/.gitignore new file mode 100644 index 0000000..32e5ad7 --- /dev/null +++ b/tournament/.gitignore @@ -0,0 +1,7 @@ +bin/ +books/ +results/ +.downloads/ +environment.txt +config.json +*.zip diff --git a/tournament/README.md b/tournament/README.md new file mode 100644 index 0000000..6985aee --- /dev/null +++ b/tournament/README.md @@ -0,0 +1,155 @@ +# Pickle local Elo tournament + +This directory turns Pickle into a reproducible local UCI engine tournament using [FastChess](https://github.com/Disservin/fastchess), official Stockfish `UCI_Elo` anchors, and the Stockfish project's neutral `UHO_Lichess_4852_v1.epd` opening suite. + +The goal is an **approximate engine Elo for Pickle at fixed depth 11**, not a Chess.com bot rating and not an official CCRL submission. + +## What is being measured + +The tournament profile is deliberately simple: + +- Pickle searches to **depth 11** on every normal move. +- `OwnBook=false` for Pickle so every engine receives the same neutral starting positions. +- Stockfish uses one thread, 32 MB hash, `UCI_LimitStrength=true`, and named `UCI_Elo` anchors. +- Openings come from `UHO_Lichess_4852_v1.epd` and are repeated with colors reversed. +- FastChess uses standard paired-game reporting and common engine-test adjudication thresholds. +- The final calibration uses Stockfish at **120+1.0**, matching the current time-control basis used for Stockfish's `UCI_Elo` calibration. + +This makes the number much more defensible than trying to infer Elo from one or two games against website bots. + +It is intentionally a **core-engine D11 rating**. The browser's Lichess Syzygy network probe is not used in local native tournaments, and PickleBook is disabled for the rating run because a common opening suite is the fair comparison method used by engine-testing communities. + +## 1. Prepare the Windows tournament environment + +Open PowerShell in this directory and run: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\setup_windows.ps1 +``` + +The setup script will: + +1. build `pickle.exe` in Release mode; +2. download the latest Windows x86-64 FastChess release; +3. download the latest official Windows x64 Stockfish release; +4. download a pinned copy of `UHO_Lichess_4852_v1.epd` from `official-stockfish/books`; +5. place the runnable files under `tournament/bin` and `tournament/books`; +6. write exact source/release information to `environment.txt`. + +Requirements: CMake and a Windows C++ compiler/toolchain. Python is only required for the automatic Elo estimator after the games finish. + +If you already placed a freshly built `pickle.exe` at `tournament/bin/pickle.exe`, setup can skip the Pickle build: + +```powershell +.\setup_windows.ps1 -SkipBuild +``` + +## 2. Verify UCI compatibility + +```powershell +.\run_compliance.ps1 +``` + +FastChess will exercise Pickle's UCI interface before you spend time on tournament games. + +## 3. Find Pickle's rough rating band + +Run the quick bracket: + +```powershell +.\run_bracket.ps1 +``` + +Defaults: + +- Stockfish anchors: 1600, 1800, 2000, 2200, 2400, 2600, 2800, 3000 +- 12 paired openings per anchor +- Stockfish time control: 10+0.1 +- Pickle: fixed D11 + +The short time control makes this a **locator**, not the final number. At the end, `estimate_elo.py` reads the PGN and prints an approximate center. + +Examples: + +```powershell +# More games per anchor +.\run_bracket.ps1 -PairsPerAnchor 25 + +# Restrict the first pass to a plausible band +.\run_bracket.ps1 -Anchors 2000,2200,2400,2600,2800 + +# Reduce CPU load +.\run_bracket.ps1 -Concurrency 2 +``` + +Every `PairsPerAnchor=12` means 24 games against each anchor because each opening is played with colors reversed. + +## 4. Run the final calibration + +Once the quick bracket tells you the neighborhood, center the long run there. For example, if the bracket points near 2500: + +```powershell +.\run_calibration.ps1 -Center 2500 +``` + +By default this uses five anchors at `Center - 200`, `Center - 100`, `Center`, `Center + 100`, and `Center + 200`, with 20 paired openings per anchor. That is 200 games total. + +For a tighter estimate, increase the pairs: + +```powershell +.\run_calibration.ps1 -Center 2500 -PairsPerAnchor 50 +``` + +That produces 500 games. For a serious long run, 100 pairs per anchor gives 1000 games. + +The final mode uses Stockfish `120+1.0`, because Stockfish's limited-strength Elo values are calibrated around that time-control regime. Pickle itself remains capped at depth 11, which is the strength profile we are trying to measure. + +## 5. Read the result + +Each run creates its own timestamped directory under: + +```text +results/ +``` + +A completed calibration contains: + +```text +games.pgn +fastchess.log +elo-summary.txt +``` + +The estimator reports results per anchor, total W/D/L, Pickle's fitted rating, and a stratified bootstrap 95% interval. For example: + +```text +Estimated Pickle D11 Elo: 2470 +95% bootstrap interval: 2415 to 2524 +``` + +That should be reported as something like: + +> Pickle D11: approximately 2470 Stockfish-UCI-Elo-calibrated, 95% local bootstrap interval 2415-2524, 500 games. + +Do **not** call it an official CCRL rating. Elo depends on the opponent pool, time control, hardware/search limits, opening suite, and rating anchor. + +## Reproducibility + +`setup_windows.ps1` writes the exact Pickle commit, FastChess release, Stockfish release/asset, and opening-suite revision to `environment.txt`. Keep that file with any result you publish. + +The opening positions are paired and color-reversed so a favorable opening is not credited to only one engine. The final rating script fits one Pickle rating simultaneously against every Stockfish anchor instead of simply averaging a handful of individual performance ratings. + +## Files + +```text +tournament/ +├── setup_windows.ps1 build/download the tournament environment +├── run_compliance.ps1 FastChess UCI protocol check +├── run_bracket.ps1 quick broad Elo locator +├── run_calibration.ps1 longer 120+1 Stockfish-anchor gauntlet +├── estimate_elo.py PGN -> fitted Elo + bootstrap interval +├── bin/ generated local executables (ignored by git) +├── books/ downloaded opening suite (ignored by git) +└── results/ local PGNs/logs/summaries (ignored by git) +``` diff --git a/tournament/estimate_elo.py b/tournament/estimate_elo.py new file mode 100644 index 0000000..0e33f66 --- /dev/null +++ b/tournament/estimate_elo.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Estimate Pickle's Elo from a FastChess PGN against Stockfish UCI_Elo anchors. + +The estimate solves the standard logistic Elo score equation across all anchors. +The confidence interval is a stratified non-parametric bootstrap over game results. +No third-party Python packages are required. +""" + +from __future__ import annotations + +import argparse +import math +import random +import re +from collections import defaultdict +from pathlib import Path + +TAG_RE = re.compile(r'^\[([A-Za-z0-9_]+)\s+"(.*)"\]$') +ANCHOR_RE = re.compile(r'^Stockfish_(\d+)$') +PICKLE_NAME = 'Pickle_D11' + + +def read_games(path: Path): + games = [] + headers = {} + + def flush(): + nonlocal headers + if {'White', 'Black', 'Result'} <= headers.keys(): + games.append(headers) + headers = {} + + with path.open('r', encoding='utf-8-sig', errors='replace') as handle: + for raw in handle: + line = raw.strip() + if line.startswith('['): + match = TAG_RE.match(line) + if match: + key, value = match.groups() + if key == 'Event' and headers.get('Result'): + flush() + headers[key] = value + elif not line and headers.get('Result'): + flush() + if headers.get('Result'): + flush() + + return games + + +def pickle_score(game): + white = game['White'] + black = game['Black'] + result = game['Result'] + + if white == PICKLE_NAME: + opponent = black + if result == '1-0': + score = 1.0 + elif result == '0-1': + score = 0.0 + elif result == '1/2-1/2': + score = 0.5 + else: + return None + elif black == PICKLE_NAME: + opponent = white + if result == '0-1': + score = 1.0 + elif result == '1-0': + score = 0.0 + elif result == '1/2-1/2': + score = 0.5 + else: + return None + else: + return None + + match = ANCHOR_RE.match(opponent) + if not match: + return None + return int(match.group(1)), score + + +def expectation(rating: float, opponent: float) -> float: + exponent = (opponent - rating) / 400.0 + if exponent > 20: + return 0.0 + if exponent < -20: + return 1.0 + return 1.0 / (1.0 + 10.0 ** exponent) + + +def solve_rating(samples, lo=400.0, hi=4000.0): + if not samples: + raise ValueError('No usable games were found.') + + def residual(rating): + return sum(score - expectation(rating, opponent) for opponent, score in samples) + + if residual(lo) <= 0: + return lo + if residual(hi) >= 0: + return hi + + for _ in range(80): + mid = (lo + hi) / 2.0 + if residual(mid) > 0: + lo = mid + else: + hi = mid + return (lo + hi) / 2.0 + + +def percentile(sorted_values, p): + if not sorted_values: + return float('nan') + position = (len(sorted_values) - 1) * p + lower = int(math.floor(position)) + upper = int(math.ceil(position)) + if lower == upper: + return sorted_values[lower] + fraction = position - lower + return sorted_values[lower] * (1.0 - fraction) + sorted_values[upper] * fraction + + +def bootstrap_interval(grouped_scores, iterations=5000, seed=0x5049434B4C45): + rng = random.Random(seed) + ratings = [] + groups = sorted(grouped_scores.items()) + + for _ in range(iterations): + sample = [] + for anchor, scores in groups: + for _ in range(len(scores)): + sample.append((anchor, rng.choice(scores))) + ratings.append(solve_rating(sample)) + + ratings.sort() + return percentile(ratings, 0.025), percentile(ratings, 0.975) + + +def performance_rating(anchor, scores): + # Half-game smoothing avoids infinite performance ratings after a sweep. + points = sum(scores) + smoothed = (points + 0.5) / (len(scores) + 1.0) + return anchor + 400.0 * math.log10(smoothed / (1.0 - smoothed)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('pgn', type=Path) + parser.add_argument('--label', default='Pickle Elo calibration') + parser.add_argument('--bootstrap', type=int, default=5000) + args = parser.parse_args() + + games = read_games(args.pgn) + samples = [] + grouped = defaultdict(list) + + for game in games: + item = pickle_score(game) + if item is None: + continue + anchor, score = item + samples.append((anchor, score)) + grouped[anchor].append(score) + + if not samples: + raise SystemExit( + 'No Pickle_D11 vs Stockfish_ games were found in the PGN. ' + 'Use the supplied FastChess scripts so engine names match the estimator.' + ) + + print(args.label) + print('=' * len(args.label)) + print(f'PGN: {args.pgn}') + print() + print(f'{"Anchor":>8} {"Games":>7} {"W":>5} {"D":>5} {"L":>5} {"Score":>8} {"Perf Elo":>10}') + + total_wins = total_draws = total_losses = 0 + for anchor in sorted(grouped): + scores = grouped[anchor] + wins = sum(score == 1.0 for score in scores) + draws = sum(score == 0.5 for score in scores) + losses = sum(score == 0.0 for score in scores) + pct = 100.0 * sum(scores) / len(scores) + perf = performance_rating(anchor, scores) + total_wins += wins + total_draws += draws + total_losses += losses + print(f'{anchor:8d} {len(scores):7d} {wins:5d} {draws:5d} {losses:5d} {pct:7.2f}% {perf:10.0f}') + + rating = solve_rating(samples) + low, high = bootstrap_interval(grouped, iterations=max(200, args.bootstrap)) + total = len(samples) + total_score = 100.0 * sum(score for _, score in samples) / total + + print() + print(f'Total games: {total} W/D/L: {total_wins}/{total_draws}/{total_losses} Score: {total_score:.2f}%') + print(f'Estimated Pickle D11 Elo: {rating:.0f}') + print(f'95% bootstrap interval: {low:.0f} to {high:.0f}') + print() + print('Interpretation: this is a local Stockfish-UCI_Elo-calibrated estimate, not an official CCRL rating.') + print('The interval measures sampling uncertainty in these games; it does not include uncertainty in Stockfish\'s Elo calibration.') + + +if __name__ == '__main__': + main() diff --git a/tournament/run_bracket.ps1 b/tournament/run_bracket.ps1 new file mode 100644 index 0000000..02c793e --- /dev/null +++ b/tournament/run_bracket.ps1 @@ -0,0 +1,85 @@ +param( + [int]$PairsPerAnchor = 12, + [int]$Concurrency = 0, + [string]$StockfishTC = '10+0.1', + [int[]]$Anchors = @(1600, 1800, 2000, 2200, 2400, 2600, 2800, 3000), + [long]$Seed = 20260917 +) + +$ErrorActionPreference = 'Stop' +$TournamentDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$FastChess = Join-Path $TournamentDir 'bin\fastchess.exe' +$Pickle = Join-Path $TournamentDir 'bin\pickle.exe' +$Stockfish = Join-Path $TournamentDir 'bin\stockfish.exe' +$Book = Join-Path $TournamentDir 'books\UHO_Lichess_4852_v1.epd' + +foreach ($path in @($FastChess, $Pickle, $Stockfish, $Book)) { + if (-not (Test-Path $path)) { + throw "Missing tournament dependency: $path`nRun .\setup_windows.ps1 first." + } +} + +if ($Concurrency -le 0) { + $Concurrency = [Math]::Max(1, [Math]::Min(6, [int]([Environment]::ProcessorCount / 2))) +} + +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$ResultDir = Join-Path $TournamentDir "results\bracket-$timestamp" +New-Item -ItemType Directory -Force -Path $ResultDir | Out-Null +$Pgn = Join-Path $ResultDir 'games.pgn' +$Log = Join-Path $ResultDir 'fastchess.log' + +$args = @( + '-tournament', 'gauntlet', + '-seeds', '1', + '-engine', "cmd=$Pickle", 'name=Pickle_D11', 'depth=11', 'option.Hash=32', 'option.OwnBook=false' +) + +foreach ($elo in $Anchors) { + if ($elo -lt 1320 -or $elo -gt 3190) { + throw "Stockfish UCI_Elo anchor $elo is outside the supported calibration range used by this harness (1320-3190)." + } + $args += @( + '-engine', "cmd=$Stockfish", "name=Stockfish_$elo", "tc=$StockfishTC", + 'option.Threads=1', 'option.Hash=32', 'option.UCI_LimitStrength=true', "option.UCI_Elo=$elo" + ) +} + +$args += @( + '-openings', "file=$Book", 'format=epd', 'order=random', + '-srand', "$Seed", + '-rounds', "$PairsPerAnchor", + '-repeat', + '-concurrency', "$Concurrency", + '-recover', + '-ratinginterval', '20', + '-report', 'penta=true', + '-resign', 'movecount=3', 'score=600', 'twosided=true', + '-draw', 'movenumber=34', 'movecount=8', 'score=20', + '-pgnout', "file=$Pgn", 'notation=san', 'nodes=true', 'nps=true', 'append=false', + '-log', "file=$Log", 'level=info', 'append=false', 'engine=false', + '-event', 'Pickle D11 Elo bracket', + '-site', 'local' +) + +Write-Host "Quick bracket: $($Anchors -join ', ')" -ForegroundColor Cyan +Write-Host "Pairs per anchor: $PairsPerAnchor | Stockfish TC: $StockfishTC | Concurrency: $Concurrency" +Write-Host 'Pickle: fixed depth 11, OwnBook=false. All games use the same neutral UHO opening suite.' +Write-Host "Results: $ResultDir" +Write-Host '' + +& $FastChess @args +$exitCode = $LASTEXITCODE +if ($exitCode -ne 0) { + throw "FastChess exited with code $exitCode. See $Log" +} + +$python = Get-Command python -ErrorAction SilentlyContinue +if ($python) { + & python (Join-Path $TournamentDir 'estimate_elo.py') $Pgn --label 'Quick bracket (non-final TC)' | Tee-Object -FilePath (Join-Path $ResultDir 'elo-summary.txt') +} else { + Write-Warning 'Python was not found, so the Elo summary was not generated automatically.' +} + +Write-Host '' +Write-Host 'Bracket complete. Use the result to choose the center for run_calibration.ps1.' -ForegroundColor Green diff --git a/tournament/run_calibration.ps1 b/tournament/run_calibration.ps1 new file mode 100644 index 0000000..62afaf0 --- /dev/null +++ b/tournament/run_calibration.ps1 @@ -0,0 +1,97 @@ +param( + [int]$Center = 2400, + [int]$Step = 100, + [int]$Radius = 2, + [int]$PairsPerAnchor = 20, + [int]$Concurrency = 0, + [string]$StockfishTC = '120+1.0', + [long]$Seed = 20260918 +) + +$ErrorActionPreference = 'Stop' +$TournamentDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$FastChess = Join-Path $TournamentDir 'bin\fastchess.exe' +$Pickle = Join-Path $TournamentDir 'bin\pickle.exe' +$Stockfish = Join-Path $TournamentDir 'bin\stockfish.exe' +$Book = Join-Path $TournamentDir 'books\UHO_Lichess_4852_v1.epd' + +foreach ($path in @($FastChess, $Pickle, $Stockfish, $Book)) { + if (-not (Test-Path $path)) { + throw "Missing tournament dependency: $path`nRun .\setup_windows.ps1 first." + } +} + +if ($Concurrency -le 0) { + $Concurrency = [Math]::Max(1, [Math]::Min(4, [int]([Environment]::ProcessorCount / 2))) +} + +$Anchors = @() +for ($i = -$Radius; $i -le $Radius; $i++) { + $rating = $Center + ($i * $Step) + if ($rating -ge 1320 -and $rating -le 3190) { + $Anchors += $rating + } +} +$Anchors = @($Anchors | Sort-Object -Unique) +if ($Anchors.Count -lt 2) { + throw 'Calibration needs at least two valid Stockfish Elo anchors in the 1320-3190 range.' +} + +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$ResultDir = Join-Path $TournamentDir "results\calibration-$timestamp" +New-Item -ItemType Directory -Force -Path $ResultDir | Out-Null +$Pgn = Join-Path $ResultDir 'games.pgn' +$Log = Join-Path $ResultDir 'fastchess.log' + +$args = @( + '-tournament', 'gauntlet', + '-seeds', '1', + '-engine', "cmd=$Pickle", 'name=Pickle_D11', 'depth=11', 'option.Hash=32', 'option.OwnBook=false' +) + +foreach ($elo in $Anchors) { + $args += @( + '-engine', "cmd=$Stockfish", "name=Stockfish_$elo", "tc=$StockfishTC", + 'option.Threads=1', 'option.Hash=32', 'option.UCI_LimitStrength=true', "option.UCI_Elo=$elo" + ) +} + +$args += @( + '-openings', "file=$Book", 'format=epd', 'order=random', + '-srand', "$Seed", + '-rounds', "$PairsPerAnchor", + '-repeat', + '-concurrency', "$Concurrency", + '-recover', + '-ratinginterval', '20', + '-report', 'penta=true', + '-resign', 'movecount=3', 'score=600', 'twosided=true', + '-draw', 'movenumber=34', 'movecount=8', 'score=20', + '-pgnout', "file=$Pgn", 'notation=san', 'nodes=true', 'nps=true', 'append=false', + '-log', "file=$Log", 'level=info', 'append=false', 'engine=false', + '-event', 'Pickle D11 Stockfish-UCI-Elo calibration', + '-site', 'local' +) + +Write-Host "Calibration anchors: $($Anchors -join ', ')" -ForegroundColor Cyan +Write-Host "Pairs per anchor: $PairsPerAnchor | Stockfish TC: $StockfishTC | Concurrency: $Concurrency" +Write-Host 'Pickle: fixed depth 11, OwnBook=false. Each opening is repeated with colors reversed.' +Write-Host 'This is the final calibration mode; it intentionally uses Stockfish current UCI_Elo calibration time control.' +Write-Host "Results: $ResultDir" +Write-Host '' + +& $FastChess @args +$exitCode = $LASTEXITCODE +if ($exitCode -ne 0) { + throw "FastChess exited with code $exitCode. See $Log" +} + +$python = Get-Command python -ErrorAction SilentlyContinue +if (-not $python) { + throw 'Python is required to calculate the calibrated Elo estimate from the completed PGN.' +} + +& python (Join-Path $TournamentDir 'estimate_elo.py') $Pgn --label 'Stockfish UCI_Elo calibration' | Tee-Object -FilePath (Join-Path $ResultDir 'elo-summary.txt') + +Write-Host '' +Write-Host 'Calibration complete. The estimate and 95% bootstrap interval are in elo-summary.txt.' -ForegroundColor Green diff --git a/tournament/run_compliance.ps1 b/tournament/run_compliance.ps1 new file mode 100644 index 0000000..69dad55 --- /dev/null +++ b/tournament/run_compliance.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference = 'Stop' +$TournamentDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$FastChess = Join-Path $TournamentDir 'bin\fastchess.exe' +$Pickle = Join-Path $TournamentDir 'bin\pickle.exe' + +if (-not (Test-Path $FastChess) -or -not (Test-Path $Pickle)) { + throw 'Tournament binaries are missing. Run .\setup_windows.ps1 first.' +} + +Write-Host 'Running FastChess UCI compliance check for Pickle...' +& $FastChess --compliance $Pickle +if ($LASTEXITCODE -ne 0) { + throw "FastChess compliance check failed with exit code $LASTEXITCODE." +} + +Write-Host 'Pickle passed the FastChess UCI compliance check.' -ForegroundColor Green diff --git a/tournament/setup_windows.ps1 b/tournament/setup_windows.ps1 new file mode 100644 index 0000000..2cece8f --- /dev/null +++ b/tournament/setup_windows.ps1 @@ -0,0 +1,145 @@ +param( + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' +$TournamentDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent $TournamentDir +$BinDir = Join-Path $TournamentDir 'bin' +$BookDir = Join-Path $TournamentDir 'books' +$ResultsDir = Join-Path $TournamentDir 'results' +$DownloadDir = Join-Path $TournamentDir '.downloads' + +foreach ($dir in @($BinDir, $BookDir, $ResultsDir, $DownloadDir)) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null +} + +function Invoke-DownloadFile { + param([string]$Url, [string]$Destination) + Write-Host "Downloading $Url" + Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing +} + +function Get-LatestRelease { + param([string]$Repository) + $headers = @{ 'User-Agent' = 'Pickle-Elo-Harness' } + Invoke-RestMethod -Uri "https://api.github.com/repos/$Repository/releases/latest" -Headers $headers +} + +function Select-ReleaseAsset { + param($Release, [scriptblock]$Predicate, [string]$Description) + $asset = $Release.assets | Where-Object $Predicate | Select-Object -First 1 + if (-not $asset) { + throw "Could not find $Description in release $($Release.tag_name)." + } + return $asset +} + +if (-not $SkipBuild) { + if (-not (Get-Command cmake -ErrorAction SilentlyContinue)) { + throw 'CMake is required to build Pickle. Install CMake and a Windows C++ toolchain, then rerun this script.' + } + + $BuildDir = Join-Path $RepoRoot 'build-tournament' + Write-Host 'Building Pickle in Release mode...' + & cmake -S $RepoRoot -B $BuildDir -DCMAKE_BUILD_TYPE=Release + if ($LASTEXITCODE -ne 0) { throw 'CMake configure failed.' } + & cmake --build $BuildDir --config Release --parallel + if ($LASTEXITCODE -ne 0) { throw 'Pickle build failed.' } + + $pickleCandidates = @( + (Join-Path $BuildDir 'Release\pickle.exe'), + (Join-Path $BuildDir 'pickle.exe') + ) + $pickle = $pickleCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $pickle) { + throw 'Build completed but pickle.exe was not found.' + } + Copy-Item $pickle (Join-Path $BinDir 'pickle.exe') -Force +} +elseif (-not (Test-Path (Join-Path $BinDir 'pickle.exe'))) { + throw '-SkipBuild was requested but tournament\bin\pickle.exe does not exist.' +} + +Write-Host 'Fetching latest FastChess Windows x86-64 release...' +$fastRelease = Get-LatestRelease 'Disservin/fastchess' +$fastAsset = Select-ReleaseAsset $fastRelease { + $_.name -match '(?i)windows' -and $_.name -match '(?i)(x86[-_]?64|x64)' -and $_.name -match '\.zip$' +} 'a Windows x86-64 FastChess ZIP' +$fastZip = Join-Path $DownloadDir $fastAsset.name +Invoke-DownloadFile $fastAsset.browser_download_url $fastZip +$fastExtract = Join-Path $DownloadDir 'fastchess' +Remove-Item $fastExtract -Recurse -Force -ErrorAction SilentlyContinue +Expand-Archive -Path $fastZip -DestinationPath $fastExtract -Force +$fastExe = Get-ChildItem $fastExtract -Recurse -File | Where-Object { $_.Name -match '(?i)^fastchess.*\.exe$' } | Select-Object -First 1 +if (-not $fastExe) { throw 'FastChess ZIP did not contain a Windows executable.' } +Copy-Item $fastExe.FullName (Join-Path $BinDir 'fastchess.exe') -Force + +Write-Host 'Fetching latest official Stockfish release...' +$sfRelease = Get-LatestRelease 'official-stockfish/Stockfish' +$sfAssets = @($sfRelease.assets) +$sfAsset = $sfAssets | Where-Object { $_.name -match '(?i)windows.*x64.*sse41.*\.zip$' } | Select-Object -First 1 +if (-not $sfAsset) { + $sfAsset = $sfAssets | Where-Object { $_.name -match '(?i)windows.*x64.*avx2.*\.zip$' } | Select-Object -First 1 +} +if (-not $sfAsset) { + $sfAsset = $sfAssets | Where-Object { $_.name -match '(?i)windows.*x64.*\.zip$' } | Select-Object -First 1 +} +if (-not $sfAsset) { throw "Could not find a compatible Windows x64 Stockfish ZIP in release $($sfRelease.tag_name)." } +$sfZip = Join-Path $DownloadDir $sfAsset.name +Invoke-DownloadFile $sfAsset.browser_download_url $sfZip +$sfExtract = Join-Path $DownloadDir 'stockfish' +Remove-Item $sfExtract -Recurse -Force -ErrorAction SilentlyContinue +Expand-Archive -Path $sfZip -DestinationPath $sfExtract -Force +$sfExe = Get-ChildItem $sfExtract -Recurse -File | Where-Object { $_.Name -match '(?i)^stockfish.*\.exe$' } | Select-Object -First 1 +if (-not $sfExe) { throw 'Stockfish ZIP did not contain a Windows executable.' } +Copy-Item $sfExe.FullName (Join-Path $BinDir 'stockfish.exe') -Force + +# Pin the opening suite so runs can be reproduced later. +$bookCommit = '65815ccdbc7727cd4f6aee252ba8f67fb740e92f' +$bookZip = Join-Path $DownloadDir 'UHO_Lichess_4852_v1.epd.zip' +$bookUrl = "https://raw.githubusercontent.com/official-stockfish/books/$bookCommit/UHO_Lichess_4852_v1.epd.zip" +Invoke-DownloadFile $bookUrl $bookZip +$bookExtract = Join-Path $DownloadDir 'book' +Remove-Item $bookExtract -Recurse -Force -ErrorAction SilentlyContinue +Expand-Archive -Path $bookZip -DestinationPath $bookExtract -Force +$bookFile = Get-ChildItem $bookExtract -Recurse -File | Where-Object { $_.Name -eq 'UHO_Lichess_4852_v1.epd' } | Select-Object -First 1 +if (-not $bookFile) { throw 'Opening suite ZIP did not contain UHO_Lichess_4852_v1.epd.' } +Copy-Item $bookFile.FullName (Join-Path $BookDir 'UHO_Lichess_4852_v1.epd') -Force + +$picklePath = Join-Path $BinDir 'pickle.exe' +$fastPath = Join-Path $BinDir 'fastchess.exe' +$sfPath = Join-Path $BinDir 'stockfish.exe' +$bookPath = Join-Path $BookDir 'UHO_Lichess_4852_v1.epd' +$pickleCommit = 'unknown (repository was not available through git)' +if (Get-Command git -ErrorAction SilentlyContinue) { + try { + $resolvedCommit = (& git -C $RepoRoot rev-parse HEAD 2>$null) + if ($LASTEXITCODE -eq 0 -and $resolvedCommit) { $pickleCommit = $resolvedCommit.Trim() } + } catch { } +} + +$metadata = @( + "Created UTC: $([DateTime]::UtcNow.ToString('o'))", + "CPU: $env:PROCESSOR_IDENTIFIER", + "Pickle commit: $pickleCommit", + "Pickle SHA256: $((Get-FileHash $picklePath -Algorithm SHA256).Hash)", + "FastChess release: $($fastRelease.tag_name)", + "FastChess asset: $($fastAsset.name)", + "FastChess SHA256: $((Get-FileHash $fastPath -Algorithm SHA256).Hash)", + "Stockfish release: $($sfRelease.tag_name)", + "Stockfish asset: $($sfAsset.name)", + "Stockfish SHA256: $((Get-FileHash $sfPath -Algorithm SHA256).Hash)", + "Opening suite: official-stockfish/books@$bookCommit UHO_Lichess_4852_v1.epd", + "Opening suite SHA256: $((Get-FileHash $bookPath -Algorithm SHA256).Hash)" +) +$metadata | Set-Content -Path (Join-Path $TournamentDir 'environment.txt') -Encoding utf8 + +Write-Host '' +Write-Host 'Tournament environment is ready:' -ForegroundColor Green +Write-Host " Pickle: $picklePath" +Write-Host " FastChess: $fastPath" +Write-Host " Stockfish: $sfPath" +Write-Host " Openings: $bookPath" +Write-Host '' +Write-Host 'Next: .\run_compliance.ps1, then .\run_bracket.ps1'