From f1cb86def1b8edafcc85b656caaa8866efcfc2d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:49:04 +0000 Subject: [PATCH 1/2] feat(action): Add an action at the root that pools games already on the runner GitHub Marketplace lists the action whose metadata is at a repository's root, and there was none. action.yml there now reads fastchess pgns found by paths or glob patterns and pools them into one estimate or one sequential test. The report goes to the job summary, and line, trailer, verdict and carried come back as outputs. It suits a repository that plays its own matches and only wants them read, which the actions under actions/ do not serve on their own: summarise-match expects the shard layout the workflows download. It runs the package from its own checkout, so the version a caller names is the version that reads the games and there is no pin inside it to move. The work is in bin/estimate.sh, so that shellcheck sees it and the tests run it. It writes nothing in the caller's working directory. The tests run it on a fixture pgn with the real estimator, and check the metadata against what the Marketplace accepts. A job in the Action workflow calls it with `uses: ./` on two shards found by a `**` pattern. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ccJFsmZ4jAKYmFNgNBh9u --- .github/workflows/action.yml | 43 ++++++ action.yml | 112 +++++++++++++++ bin/estimate.sh | 98 +++++++++++++ tests/fixtures/pairs.pgn | 259 +++++++++++++++++++++++++++++++++++ tests/test_root_action.py | 229 +++++++++++++++++++++++++++++++ 5 files changed, 741 insertions(+) create mode 100644 action.yml create mode 100755 bin/estimate.sh create mode 100644 tests/fixtures/pairs.pgn create mode 100644 tests/test_root_action.py diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml index eeefc25..5182650 100644 --- a/.github/workflows/action.yml +++ b/.github/workflows/action.yml @@ -140,6 +140,49 @@ jobs: python3 -c 'from mache import match_estimate, rating_estimate' test "$REPORTED" = "$(python3 -c 'import mache; print(mache.__version__)')" + root: + # The action at the root, which the Marketplace lists, called the way a + # caller calls it: games already on the runner, found by a pattern, and + # nothing set up first. The runner's own python3 is all it needs. + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Lay the games out as two downloaded shards + run: | + for shard in 0 1; do + mkdir -p "downloaded/shard-${shard}" + cp tests/fixtures/pairs.pgn "downloaded/shard-${shard}/games.pgn" + done + + - uses: ./ # zizmor: ignore[self-repository] + id: estimate + with: + pgn: downloaded/**/games.pgn + candidate: new + baseline: old + time_control: 10+0.1 + sprt: true + + - name: It read both shards and judged the test + env: + LINE: ${{ steps.estimate.outputs.line }} + VERDICT: ${{ steps.estimate.outputs.verdict }} + CARRIED: ${{ steps.estimate.outputs.carried }} + run: | + case "$LINE" in + *"(52 games), SPRT [0, 10] "*) ;; + *) echo "::error::the line reads ${LINE}"; exit 1 ;; + esac + test "$VERDICT" = inconclusive + test "$CARRIED" = 2,4,12,6,2 + orchestration: # The actions a caller runs before anything is built. Each is checked # through its outputs, because outputs are the part a workflow author diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..c51263b --- /dev/null +++ b/action.yml @@ -0,0 +1,112 @@ +# The action GitHub Marketplace lists. It reads fastchess games that are +# already on the runner and pools them into one estimate or one sequential +# test, so it suits a caller that plays its own matches. A caller that wants +# the match played as well calls the reusable workflows or the actions under +# actions/, which this does not replace. +# +# It runs the package from this action's own checkout rather than from a pin, +# so the version a caller names is the version that reads the games. + +name: Chess engine elo and SPRT (mache) +description: > + Pool fastchess games into one elo estimate or one sequential test (SPRT), + and put the report in the job summary. +author: Andrew Wright +branding: + icon: bar-chart-2 + color: blue + +inputs: + pgn: + description: > + The pgn files to read, as paths or glob patterns separated by spaces or + new lines. Each file is one shard, named in the report after the + directory it is in. `**` reaches into directories. + required: true + candidate: + description: > + The engine being measured, as the pgn names it. Only games it played are + read. + required: true + baseline: + description: What it played against. Recorded in the report and the trailer. + required: true + time_control: + description: The time control played, recorded in the report and the trailer. + default: "" + sprt: + description: Whether to judge a sequential test over the pooled pairs. + default: "false" + elo0: + description: The null hypothesis the test is against. + default: "0" + elo1: + description: The alternative hypothesis the test is against. + default: "10" + sprt_model: + description: > + What elo0 and elo1 are differences in: `logistic` elo, or `normalized` + elo. Every batch of one test is judged under the same one. + default: logistic + alpha: + description: The chance of a wrong pass the test accepts. + default: "0.05" + beta: + description: The chance of a wrong fail the test accepts. + default: "0.05" + prior_pairs: + description: > + The pair counts of the batches already played, from an earlier run's + `carried` output. Empty to start a test. + default: "" + +outputs: + line: + description: The estimate and any verdict as one line, for release notes. + value: ${{ steps.estimate.outputs.line }} + trailer: + description: The same as a commit trailer. + value: ${{ steps.estimate.outputs.trailer }} + verdict: + description: > + What the sequential test came to: `passed`, `failed` or `inconclusive`. + Empty when there was no test. + value: ${{ steps.estimate.outputs.verdict }} + carried: + description: > + The pair counts of the whole test so far, to hand the next run as + `prior_pairs`. Empty when there was no test. + value: ${{ steps.estimate.outputs.carried }} + +runs: + using: composite + steps: + - name: Pool the games + id: estimate + shell: bash + env: + PGN: ${{ inputs.pgn }} + CANDIDATE: ${{ inputs.candidate }} + BASELINE: ${{ inputs.baseline }} + TIME_CONTROL: ${{ inputs.time_control }} + SPRT: ${{ inputs.sprt }} + ELO0: ${{ inputs.elo0 }} + ELO1: ${{ inputs.elo1 }} + SPRT_MODEL: ${{ inputs.sprt_model }} + SPRT_ALPHA: ${{ inputs.alpha }} + SPRT_BETA: ${{ inputs.beta }} + PRIOR_PAIRS: ${{ inputs.prior_pairs }} + run: | + # the package from this checkout of the action, and nothing installed + export PYTHONPATH="${GITHUB_ACTION_PATH}${PYTHONPATH:+:${PYTHONPATH}}" + read -ra patterns <<< "$(tr '\n' ' ' <<< "$PGN")" + outputs="${RUNNER_TEMP}/mache-estimate-outputs.txt" + remarks="${RUNNER_TEMP}/mache-estimate-remarks.txt" + "${GITHUB_ACTION_PATH}/bin/estimate.sh" "$outputs" "${patterns[@]}" \ + 2> "$remarks" \ + || { echo "::error::$(head -1 "$remarks")"; cat "$remarks"; exit 1; } + while read -r remark; do + echo "::warning::${remark}" + done < "$remarks" + cat "$outputs" >> "$GITHUB_OUTPUT" + echo "::notice::$(sed -n 's/^line=//p' "$outputs")" diff --git a/bin/estimate.sh b/bin/estimate.sh new file mode 100755 index 0000000..018ff52 --- /dev/null +++ b/bin/estimate.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Copyright (C) 2022-2026 Andrew Wright + +# Pool fastchess pgns into one estimate, for the action at the root of this +# repository: +# +# estimate.sh ... +# +# Each pattern is a path or a glob, and every file they match is read as one +# shard. The caller sets CANDIDATE and BASELINE, and may set TIME_CONTROL, +# SPRT, ELO0, ELO1, PRIOR_PAIRS, SPRT_MODEL, SPRT_ALPHA and SPRT_BETA. An unset +# model is the logistic one and an unset rate is five percent, as they are for +# match-estimate itself. +# +# The report goes to stdout, and to the step summary when there is one. The +# `key=value` lines the caller wants go to , and the remarks to +# stderr, one to a line. Nothing is written in the working directory, which is +# the caller's. +set -euo pipefail + +usage="usage: estimate.sh ..." +outputs=${1:?$usage} +shift +[ "$#" -gt 0 ] || { echo "$usage" >&2; exit 1; } + +# globstar so that `**/*.pgn` reaches into directories, and nullglob so that a +# pattern matching nothing expands to nothing rather than to itself +shopt -s nullglob globstar +games=() +for pattern in "$@"; do + # unquoted on purpose: the glob is what is being expanded + # shellcheck disable=SC2206 + matched=($pattern) + if [ "${#matched[@]}" -eq 0 ]; then + echo "estimate.sh: nothing matches ${pattern}" >&2 + exit 1 + fi + games+=("${matched[@]}") +done + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +estimate=(python3 -m mache.match_estimate "${games[@]}" + --candidate "${CANDIDATE:?estimate.sh: CANDIDATE is the engine to measure}" + --baseline "${BASELINE:?estimate.sh: BASELINE is what it played}" + --tc "${TIME_CONTROL-}") +if [ "${SPRT-false}" = "true" ]; then + estimate+=(--elo0 "${ELO0:-0}" --elo1 "${ELO1:-10}" + --prior-pairs "${PRIOR_PAIRS-}" + --model "${SPRT_MODEL:-logistic}" + --alpha "${SPRT_ALPHA:-0.05}" --beta "${SPRT_BETA:-0.05}") +fi + +"${estimate[@]}" > "${work}/report.md" 2> "${work}/remarks.txt" \ + || { echo "estimate.sh: the games could not be read" >&2 + cat "${work}/remarks.txt" >&2 + exit 1; } + +cat "${work}/report.md" + +# the same estimate asked the other ways. Their stderr is the same remarks +# again, and the run above is the one that reports them +trailer=$("${estimate[@]}" --trailer 2> /dev/null) +line=$("${estimate[@]}" --line 2> /dev/null) +# read out of the json rather than parsed back out of the report, so the two +# cannot disagree. Both are empty when this was not a sequential test +"${estimate[@]}" --json 2> /dev/null | python3 -c ' +import json +import sys + +sequential = json.load(sys.stdin)["sprt"] or {} +for key in ("verdict", "carried"): + print("%s=%s" % (key, sequential.get(key, ""))) +' > "${work}/decided.txt" + +if [ -n "${GITHUB_STEP_SUMMARY-}" ]; then + { + echo "${CANDIDATE} against ${BASELINE}, over ${#games[@]} pgn files${TIME_CONTROL:+ at ${TIME_CONTROL}}." + echo + cat "${work}/report.md" + echo + echo "As the trailer for the commit that made the difference:" + echo + echo '```' + echo "$trailer" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" +fi + +cat "${work}/remarks.txt" >&2 + +{ + echo "line=${line}" + echo "trailer=${trailer}" + cat "${work}/decided.txt" +} > "$outputs" diff --git a/tests/fixtures/pairs.pgn b/tests/fixtures/pairs.pgn new file mode 100644 index 0000000..ec7e69b --- /dev/null +++ b/tests/fixtures/pairs.pgn @@ -0,0 +1,259 @@ +[Event "Fastchess Tournament"] +[Site "?"] +[Round "1"] +[White "new"] +[Black "old"] +[Result "0-1"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 0-1 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "1"] +[White "old"] +[Black "new"] +[Result "1-0"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1-0 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "2"] +[White "new"] +[Black "old"] +[Result "0-1"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 0-1 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "2"] +[White "old"] +[Black "new"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "3"] +[White "new"] +[Black "old"] +[Result "0-1"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 0-1 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "3"] +[White "old"] +[Black "new"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "4"] +[White "new"] +[Black "old"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "4"] +[White "old"] +[Black "new"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "5"] +[White "new"] +[Black "old"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "5"] +[White "old"] +[Black "new"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "6"] +[White "new"] +[Black "old"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "6"] +[White "old"] +[Black "new"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "7"] +[White "new"] +[Black "old"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "7"] +[White "old"] +[Black "new"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "8"] +[White "new"] +[Black "old"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "8"] +[White "old"] +[Black "new"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "9"] +[White "new"] +[Black "old"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "9"] +[White "old"] +[Black "new"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "10"] +[White "new"] +[Black "old"] +[Result "1-0"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1-0 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "10"] +[White "old"] +[Black "new"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "11"] +[White "new"] +[Black "old"] +[Result "1-0"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1-0 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "11"] +[White "old"] +[Black "new"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "12"] +[White "new"] +[Black "old"] +[Result "1-0"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1-0 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "12"] +[White "old"] +[Black "new"] +[Result "1/2-1/2"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1/2-1/2 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "13"] +[White "new"] +[Black "old"] +[Result "1-0"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 1-0 + +[Event "Fastchess Tournament"] +[Site "?"] +[Round "13"] +[White "old"] +[Black "new"] +[Result "0-1"] +[Termination "normal"] + +1. e4 {book} e5 {book} 2. Nf3 {+0.15/5 0.010s, tl=2.054s, latency=0.000s, n=83485, sd=20, nps=8348500, hashfull=0, pv="e1g1 e8g8 c3e2", White mates} 0-1 diff --git a/tests/test_root_action.py b/tests/test_root_action.py new file mode 100644 index 0000000..e2725da --- /dev/null +++ b/tests/test_root_action.py @@ -0,0 +1,229 @@ +# SPDX-License-Identifier: MIT +# Copyright (C) 2022-2026 Andrew Wright + +"""The action at the root of the repository, which is the one GitHub +Marketplace lists. + +It reads games already on the runner, so its script is run here on a real pgn +with the real estimator rather than a stub. What is pinned is what a caller +depends on: which files a pattern reaches, what goes on each stream, that the +caller's working directory is left alone, and that the metadata is what the +Marketplace accepts. +""" + +import os +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +ACTION = ROOT / "action.yml" +ESTIMATE = ROOT / "bin" / "estimate.sh" +GAMES = ROOT / "tests" / "fixtures" / "pairs.pgn" + +# the pair counts the fixture was written with, in score order +COUNTS = "1,2,6,3,1" + +# the badge colours the Marketplace accepts +COLOURS = { + "white", + "black", + "yellow", + "blue", + "green", + "orange", + "red", + "purple", + "gray-dark", +} + + +def estimate(cwd, outputs, *patterns, **environment): + return subprocess.run( + [str(ESTIMATE), str(outputs), *patterns], + cwd=cwd, + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "PYTHONPATH": str(ROOT), + "CANDIDATE": "new", + "BASELINE": "old", + "TIME_CONTROL": "10+0.1", + **environment, + }, + ) + + +def outputs_of(path): + return dict(line.split("=", 1) for line in path.read_text().splitlines()) + + +@pytest.fixture +def shards(tmp_path): + """Two shards laid out as a download of two artifacts would leave them.""" + for index in (0, 1): + shard = tmp_path / "games" / f"shard-{index}" + shard.mkdir(parents=True) + (shard / "games.pgn").write_text(GAMES.read_text()) + work = tmp_path / "work" + work.mkdir() + return tmp_path, work + + +class TestTheScript: + def test_an_estimate_reports_and_hands_back_its_line(self, shards): + root, work = shards + outputs = root / "outputs" + result = estimate(work, outputs, str(root / "games" / "shard-0" / "games.pgn")) + assert result.returncode == 0, result.stderr + assert "Elo (26 games)" in result.stdout + written = outputs_of(outputs) + assert "Elo (26 games)" in written["line"] + assert written["trailer"].startswith("Elo: ") + # no test, so nothing to chain + assert written["verdict"] == "" + assert written["carried"] == "" + + def test_a_glob_reaches_every_shard(self, shards): + root, work = shards + outputs = root / "outputs" + result = estimate(work, outputs, str(root / "games" / "**" / "*.pgn")) + assert result.returncode == 0, result.stderr + assert "Elo (52 games)" in outputs_of(outputs)["line"] + assert "shard-0" in result.stdout and "shard-1" in result.stdout + + def test_several_patterns_are_pooled(self, shards): + root, work = shards + outputs = root / "outputs" + patterns = [str(root / "games" / f"shard-{i}" / "games.pgn") for i in (0, 1)] + result = estimate(work, outputs, *patterns) + assert result.returncode == 0, result.stderr + assert "Elo (52 games)" in outputs_of(outputs)["line"] + + def test_a_sequential_test_hands_back_its_verdict_and_counts(self, shards): + root, work = shards + outputs = root / "outputs" + result = estimate( + work, + outputs, + str(GAMES), + SPRT="true", + ELO0="0", + ELO1="10", + SPRT_ALPHA="0.01", + SPRT_BETA="0.01", + ) + assert result.returncode == 0, result.stderr + written = outputs_of(outputs) + assert written["verdict"] == "inconclusive" + assert written["carried"] == COUNTS + # the rates reached the estimate, since the line names them + assert "alpha=0.01 beta=0.01" in written["line"] + + def test_a_pattern_that_matches_nothing_is_an_error(self, shards): + root, work = shards + result = estimate(work, root / "outputs", str(root / "nowhere" / "*.pgn")) + assert result.returncode != 0 + assert "nothing matches" in result.stderr + + def test_the_callers_working_directory_is_left_alone(self, shards): + root, work = shards + estimate(work, root / "outputs", str(GAMES)) + assert list(work.iterdir()) == [] + + def test_the_summary_gets_the_report_and_the_trailer(self, shards): + root, work = shards + summary = root / "summary.md" + result = estimate( + work, root / "outputs", str(GAMES), GITHUB_STEP_SUMMARY=str(summary) + ) + assert result.returncode == 0, result.stderr + written = summary.read_text() + assert written.startswith("new against old, over 1 pgn files at 10+0.1.") + assert "Elo (26 games)" in written + assert "As the trailer for the commit" in written + + def test_the_outputs_file_holds_only_key_equals_value(self, shards): + root, work = shards + outputs = root / "outputs" + estimate(work, outputs, str(GAMES)) + for line in outputs.read_text().splitlines(): + assert re.fullmatch(r"[a-z]+=.*", line), line + + def test_it_is_executable_in_git(self): + staged = subprocess.run( + ["git", "ls-files", "--stage", "--", "bin/estimate.sh"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ).stdout + if not staged: + pytest.skip("estimate.sh is not in the index yet") + assert staged.startswith("100755"), staged + + +def metadata(): + return ACTION.read_text(encoding="utf-8") + + +def block(text, key): + """The keys directly under a top-level mapping.""" + lines = text.splitlines() + start = lines.index(f"{key}:") + found = [] + for line in lines[start + 1 :]: + if line and not line.startswith(" "): + break + if match := re.fullmatch(r" ([a-z0-9_]+):.*", line): + found.append(match.group(1)) + return found + + +class TestTheMetadata: + def test_it_is_at_the_root_where_the_marketplace_looks(self): + assert ACTION.exists() + assert not (ROOT / "action.yaml").exists() + + def test_it_names_itself_as_mache(self): + name = re.search(r"^name: (.+)$", metadata(), re.MULTILINE).group(1) + assert "mache" in name + + def test_the_description_fits_the_marketplace(self): + # the listing takes 125 characters + found = re.search( + r"^description: >\n((?: .*\n)+)", metadata(), re.MULTILINE + ).group(1) + description = " ".join(line.strip() for line in found.splitlines()) + assert len(description) <= 125, len(description) + + def test_the_badge_is_one_the_marketplace_draws(self): + text = metadata() + colour = re.search(r"^ color: (\S+)$", text, re.MULTILINE).group(1) + assert colour in COLOURS + assert re.search(r"^ icon: (\S+)$", text, re.MULTILINE) + + def test_every_input_reaches_the_script(self): + text = metadata() + for name in block(text, "inputs"): + assert f"${{{{ inputs.{name} }}}}" in text, f"{name} is taken and unused" + + def test_every_output_is_one_the_script_writes(self, tmp_path): + outputs = tmp_path / "outputs" + work = tmp_path / "work" + work.mkdir() + estimate(work, outputs, str(GAMES)) + written = set(outputs_of(outputs)) + for name in block(metadata(), "outputs"): + assert name in written, f"{name} is declared and never written" + + def test_it_runs_the_package_from_its_own_checkout(self): + # so the version a caller names is the version that reads the games, + # with no pin inside to fall behind it + text = metadata() + assert "aywrite/mache/" not in text + assert 'PYTHONPATH="${GITHUB_ACTION_PATH}' in text From 9a81896a0fd84e2d8201bf39576061b75d73d36b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:49:04 +0000 Subject: [PATCH 2/2] docs(docs): Publish the documentation as a GitHub Pages site docs/ is now a site that GitHub Pages builds with Jekyll and the just-the-docs theme, pinned at v0.12.0, with no workflow. Each page's address and place in the menu are set in docs/_config.yml rather than in front matter, which GitHub would show as a table when a page is read in the repository. The long sections of the README move into pages of their own: using the tools, running a match without CI, what hosted runners can measure, how it works, and the statistics. Their text is unchanged apart from the heading levels. The README keeps the overview, gains a section on the root action and a list of the pages, and links to the site where it linked to its own anchors. Links out of docs/ are full URLs, since the site has nothing above it. tests/test_docs.py checks that no link climbs out of docs/, that links between pages find their page and heading, that every page has an address and a place in the menu, and that the README and pyproject.toml link to addresses the site has. The README's call of the root action is held to the current release like the other pins. PyPI's Documentation link now points at the site. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ccJFsmZ4jAKYmFNgNBh9u --- AGENTS.md | 19 +- README.md | 468 ++++------------------------------------- docs/QUICKSTART.md | 22 +- docs/_config.yml | 45 ++++ docs/how-it-works.md | 76 +++++++ docs/index.md | 31 +++ docs/limits.md | 52 +++++ docs/statistics.md | 57 +++++ docs/tools.md | 191 +++++++++++++++++ docs/without-ci.md | 48 +++++ pyproject.toml | 2 +- tests/test_docs.py | 112 ++++++++++ tests/test_examples.py | 9 + 13 files changed, 689 insertions(+), 443 deletions(-) create mode 100644 docs/_config.yml create mode 100644 docs/how-it-works.md create mode 100644 docs/index.md create mode 100644 docs/limits.md create mode 100644 docs/statistics.md create mode 100644 docs/tools.md create mode 100644 docs/without-ci.md create mode 100644 tests/test_docs.py diff --git a/AGENTS.md b/AGENTS.md index 93bae24..ceab3e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,9 +39,10 @@ on it: the reusable workflows pin the actions beside them, those pins have to name the version being released, and `GITHUB_TOKEN` may not write under `.github/workflows/`, so the workflow cannot move them itself. `.github/workflows/README.md` says what the pins are. -The calls shown in `examples/`, `docs/QUICKSTART.md` and -`.github/workflows/README.md` name the same version and move in the same -commit. `tests/test_examples.py` holds them to it. +The calls shown in `examples/`, `docs/QUICKSTART.md`, +`.github/workflows/README.md` and the README's call of the root action name the +same version and move in the same commit. `tests/test_examples.py` holds them to +it. Merging that pull request tags `vX.Y.Z` on the commit that carries the version and starts the release, which refuses a tag that does not match `__version__`, builds the sdist and the wheel, publishes @@ -61,6 +62,18 @@ The `--json` shape is a contract from `0.1.0` on. `JSON_FORMAT` says which shape, fields are added and not removed, and a change that cannot be made that way raises the number. +## The action at the root and the site + +`action.yml` at the root is the one GitHub Marketplace lists. It runs the +package from its own checkout, so it has no pin to move. Whether a release is +listed is set on the GitHub release itself, by its Marketplace checkbox. + +`docs/` is the site GitHub Pages builds, with Jekyll and the theme named in +`docs/_config.yml`. Each page's address and place in the menu are set there +rather than in front matter, which GitHub would show as a table when the file +is read in the repository. A link out of `docs/` is written as a full URL, +since the site has nothing above it. `tests/test_docs.py` checks both. + ## What stays out of this repository Planning documents, unless one has been asked for. Keep them in a scratch diff --git a/README.md b/README.md index 210e8ff..851cccd 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,16 @@ on its pull requests or by hand, and the result goes in the run's summary. fastchess plays the games. To set one up in your engine's repository, start with -[the quickstart](docs/QUICKSTART.md). +[the quickstart](https://aywrite.github.io/mache/quickstart/). It suits an engine whose changes are still large enough to show within a few thousand games. A small change can take many runs to settle, and -[What hosted runners can measure](https://github.com/aywrite/mache#what-hosted-runners-can-measure) +[What hosted runners can measure](https://aywrite.github.io/mache/limits/) says how many. The tools that read the games also work without GitHub Actions, on pgn files fastchess wrote anywhere. -[Running a match without CI](https://github.com/aywrite/mache#running-a-match-without-ci) +[Running a match without CI](https://aywrite.github.io/mache/without-ci/) shows how. The name is from mache (μάχη), Greek for battle, and reads as Measure A CHess @@ -52,7 +52,8 @@ Hosted runners are the point of mache and also what make it awkward. Their timing varies from one job to the next, and a job is stopped at a time limit, often long before a match worth reading has finished. So a match is split across jobs that run at once and pooled afterwards, which is most of what the -tools below are for, and why they take the care described further down. +tools below are for, and why they take the care +[How it works](https://aywrite.github.io/mache/how-it-works/) describes. ## What is here @@ -100,430 +101,41 @@ pass a table of its own. There is no build script among them, and `docs/BUILDING-A-REF.md` says why, along with the one trap a build step written for this has to avoid. -## What hosted runners can measure - -A run of `strength.yml` with its defaults plays 500 games over five shards, -which is 250 pairs. A sequential test given `batches: 4` plays up to four such -batches in one run, 1,000 pairs. It stops early if a batch settles the test, -and otherwise hands back the counts for the next run to carry on from. - -For an estimate on its own, the 95% margin in normalized elo, as mache works -it out, depends only on the number of pairs: - -| pairs | margin | -| ---: | ---: | -| 250 | ±30 | -| 1,000 | ±15 | -| 4,000 | ±8 | - -The margin in logistic elo also depends on how the pairs scored, and so on -how many games were drawn. - -A sequential test runs until the pairs settle it, and the closer its bounds, -the longer that takes. In the simulation below, halving the gap between the -bounds took between three and four times the pairs. It ran 300 tests a row of -the normalized test with mache's own code, at the default error rates of five -percent each, judged every 250 pairs as with the workflow's default batch. The pair scores were drawn from a distribution in -which three pairs in five score one point, shifted to each true difference: - -| bounds (normalized elo) | true difference at a bound | true difference halfway between | -| --- | ---: | ---: | -| [0, 10] | about 4,000 pairs | about 7,000 pairs | -| [0, 5] | about 13,000 to 14,000 pairs | about 26,000 pairs | - -Those are averages, and the spread around them is wide: about one test in ten -ran to nearly twice the average or longer. At 1,000 pairs a run, the averages -come to about four to seven runs for [0, 10] and about thirteen to twenty-six -for [0, 5]. The normalized model is what `match-estimate --model normalized` -uses, and what `strength.yml` and `actions/summarise-match` use when given -`sprt_model: normalized`. Both default to logistic elo, so `strength.yml`'s -default bounds of [0, 10] are not the first row above, and how long a logistic -test runs depends on the draw rate as well as the bounds. - -How long a batch takes depends on the time control, the engines and the -runner, so it is worth timing one before planning a test around it. A shard -is stopped at `max_match_minutes`, 150 by default, and reports the games it -finished by then. - -Both engines of a shard share one runner, so a slow runner slows both. An -engine whose strength changes a lot with the time it is given loses more to a -slow runner than one whose strength does not. - -GitHub does not charge for its standard hosted runners on public repositories. -A private repository uses the Actions minutes its plan includes. GitHub's -billing documentation has the current terms. - -## Using the tools - -`pip install mache` gives the four command names used below. Under the -composite action nothing is installed, and the same tools are -`python3 -m mache.` with underscores where the command name has hyphens. - -Each tool prints its report on stdout. Anything worth an alert goes to stderr -instead, a fault or an unfinished game, so a workflow can raise it from there -rather than reading it back out of the report. - -### `match-estimate` - -A shard is one pgn, written by one job of the run. fastchess plays it with -`-repeat`, so a round is two games on one opening with the colours reversed, -and `--candidate` and `--baseline` name the engines as fastchess named them. - -``` -match-estimate strength-1-1-shard-*/games.pgn \ - --candidate new --baseline ce8b662 --tc 10+0.1 -``` - -``` -+56 ±37 Elo (150 games) - -75 pairs from 150 games. The 95% interval is +19 to +93 elo, and the likelihood of superiority is 99.9%. In normalized elo the difference is +85 ±56, which is the figure to compare across books and time controls. - -| pair score | 0 | 0.5 | 1 | 1.5 | 2 | -| --- | --- | --- | --- | --- | --- | -| pairs | 2 | 9 | 36 | 19 | 9 | - -| shard | games | score | faults | -| --- | --- | --- | --- | -| strength-1-1-shard-0 | 50 | 57.0% | 0 | -| strength-1-1-shard-1 | 50 | 59.0% | 1 | -| strength-1-1-shard-2 | 50 | 58.0% | 0 | -| pooled | 150 | 58.0% | 1 | -``` - -The block `match-terminations` prints follows that, and the last line of the -report names the version that read the games. `--tc` and `--baseline` are -recorded rather than read, so a baseline that is not a release tag can go in as -its sha. - -`--elo0` and `--elo1` read the same pairs a second way, as a sequential test: - -``` -match-estimate strength-1-1-shard-*/games.pgn \ - --candidate new --baseline ce8b662 --tc 10+0.1 --elo0 0 --elo1 10 -``` - -which puts this paragraph in the report: - -``` -SPRT [0, 10] inconclusive. The log likelihood ratio over the 75 pairs of the test (75 from this batch and 0 from the batches before it) is 1.32 against bounds of (-2.94, 2.94). The games so far settle it neither way. Launch another batch with prior_pairs set to 2,9,36,19,9. -``` - -The counts at the end of it are what the next batch carries in, so that the -runs accumulate into one test: - -``` -match-estimate strength-1-1-shard-*/games.pgn \ - --candidate new --baseline ce8b662 --tc 10+0.1 \ - --elo0 0 --elo1 10 --prior-pairs 2,9,36,19,9 -``` - -``` -SPRT [0, 10] inconclusive. The log likelihood ratio over the 150 pairs of the test (75 from this batch and 75 from the batches before it) is 2.64 against bounds of (-2.94, 2.94). Over all of them the difference is +56 ±26 elo, which is the figure the trailer carries. The games so far settle it neither way. Launch another batch with prior_pairs set to 4,18,72,38,18. -``` - -`--alpha` and `--beta` are the chances of a wrong pass and a wrong fail the test -accepts, five percent each unless given. They set Wald's bounds, which are -(-2.94, 2.94) at the defaults and (-4.60, 4.60) at one percent each, so -stricter rates take more pairs to settle a test. A verdict reached at other -rates names them wherever it is quoted, as in -`SPRT [0, 10] alpha=0.01 beta=0.01 passed`. Give every batch of one test the -same rates, since they are what the bounds were drawn at. -`actions/summarise-match` takes them as `alpha` and `beta`. - -Three flags each replace the whole report. `--line` prints what release notes -carry: - -``` -+56 ±37 Elo (150 games), SPRT [0, 10] inconclusive, LLR 1.32 (-2.94, 2.94) -``` - -`--trailer` prints what a commit carries: - -``` -Elo: +56 ±37 (sprt [0, 10] inconclusive, 150 games, 10+0.1, vs ce8b662) -``` - -`--json` prints all of it as data, for a reader that is not a person: - -``` -{ - "format": 1, - "tool": { - "name": "mache", - "version": "0.1.0", - "command": "match_estimate" - }, - "candidate": "new", - "baseline": "ce8b662", - "tc": "10+0.1", - "games": 150, - "pairs": 75, -``` - -`format` is which shape the object is in, and shape 1 is a contract from -`0.1.0` on. Fields are added to it. None is removed and none is given a new -meaning under the name it has, and a change that cannot be made that way -raises the number. The rest of the object holds the pentanomial counts, the -sequential test, a row per shard, the terminations, and the `line` and -`trailer` strings above. - -### `rating-estimate` - -The ladder is one argument, `name:rating` per opponent separated by commas, -with the names as the pgn spells them: - -``` -rating-estimate gauntlet.pgn arche-0.5 \ - 'stash-v33:1876,cheng-4.39:1932,supernova-2.1:1801,winter-0.7:1978' -``` - -``` -| opponent | ccrl | w-d-l | score | implies | -| --- | --- | --- | --- | --- | -| stash-v33 | 1876 | 12-6-12 | 50.0% | 1876 | -| cheng-4.39 | 1932 | 9-7-14 | 41.7% | 1874 | -| supernova-2.1 | 1801 | 16-5-9 | 61.7% | 1884 | -| winter-0.7 | 1978 | 7-6-17 | 33.3% | 1858 | - -1873 ±56 (95%) on the ccrl blitz scale (120 games) - -Read by mache 0.1.0. -``` - -`--line` prints the estimate and nothing else. `--json` prints the fit, the -ladder it was given and a record per opponent, in the same format 1. - -The figure is on the scale of the list the ratings were read off, and the -line names it. That is `ccrl blitz` unless `--scale` says otherwise, so a -ladder read off the 40/15 list is fitted with `--scale 'ccrl 40/15'`. The -`summarise-gauntlet` action takes the same thing as its `scale` input. - -### `match-terminations` - -``` -match-terminations strength-1-1-shard-1/games.pgn -``` - -``` -games: 50 -normal: 49 -adjudication: 0 -time forfeit: 1 (new 1) -disconnect: 0 -stall: 0 -abandoned: 0 -illegal move: 0 -unterminated: 0 -``` - -Every ending is printed, zero included. The line about the one that ended by a -fault goes to stderr beside the block. `--json` prints the same counts, with -the engines an ending fell on as a mapping rather than as the sentence. - -### `book-slice` - -``` -book-slice --openings 34700 --pairs 250 --shards 5 --shard 3 --seed 7 -``` - -``` -758 -``` - -That number is one based, which is what fastchess's `start=` takes. Every -shard of a run asks with the same `--openings`, `--pairs`, `--shards` and -`--seed`, and its own `--shard`, so the slices are worked out from the run's -own numbers and no two of them hold an opening in common. - -A sequential test pools its batches the same way it pools its shards, so the -rule holds across them too, and `--batches` with `--batch` is how a caller -says so. What is then reserved is the whole test rather than one batch of it, -and a batch takes the slice after the batch before it. This matters where the -batches are chained inside one run, because they share a seed: each reserving -its own games alone would start every batch where the first one started, and -the pooled estimate would count those positions twice with nothing failing. -A caller that passes neither plays what an unbatched run plays. - -## Running a match without CI - -None of the four tools needs GitHub Actions. They read pgn files, or in -`book-slice`'s case a few numbers, so they work on games played anywhere: on -one machine, or on a few machines that each play part of a match. -`pip install mache` gives the command names. fastchess and an opening book are -yours to provide. In a clone of this repository, -`bin/book_table.sh fetch 8moves_v3 .` downloads one of the two books the -actions use and checks it against its recorded hash. - -Each machine plays a shard. `book-slice` says which opening a shard starts at, -so that no two shards play the same one. Every shard asks with the same -numbers and its own `--shard`: - -``` -openings=$(grep -c '^\[Event ' 8moves_v3.pgn) -start=$(book-slice --openings "$openings" --pairs 100 --shards 2 --shard 0 --seed 7) -``` - -Then fastchess plays the shard. `-repeat` pairs the games as `match-estimate` -expects. `-rounds` is the number of pairs and has to match `--pairs` above. -`nodes=true` is what the report's table of clocks and nodes counts moves by, -and `timeleft=true` gives its last column. Without them the table reads as -zeros. - -``` -fastchess -engine name=new cmd=./new -engine name=old cmd=./old \ - -each proto=uci tc=10+0.1 \ - -openings file=8moves_v3.pgn format=pgn order=sequential start="$start" \ - -rounds 100 -repeat -concurrency 2 \ - -pgnout file=games.pgn nodes=true timeleft=true -``` - -fastchess adds to a `games.pgn` that is already there, so start each shard in -an empty directory. A shard played twice into one file would be counted twice. - -With each shard's games in a directory of its own, one command pools them. The -directory names become the rows of the shard table: - -``` -match-estimate shard-0/games.pgn shard-1/games.pgn \ - --candidate new --baseline old --tc 10+0.1 -``` - -`--elo0`, `--elo1`, `--prior-pairs` and `--model` run the sequential test here -the same way they do in a workflow. A test played in several batches passes -`--batches` and `--batch` to `book-slice` as well, so that a later batch does -not replay an earlier one's openings. - -## The part that is not obvious - -A sharded match is not a long match cut up. Three things have to hold or the -number it produces is wrong. - -**The estimate is over the pool.** fastchess prints one, but only for the -games its own process played. With five shards that is a fifth of the -evidence, and averaging five such figures is a different calculation. -`match-estimate` reads the games themselves. - -**The error bar is over pairs, not games.** Under `-repeat` the two games of a -round are one opening with the colours reversed, so they are one observation. -Counting them as two understates the spread. - -**A sequential test looks only at batch boundaries.** A per-shard SPRT that -stopped when its own games settled the question would be one look per shard at -a bound priced for one, on a sample chosen by what it said. Here the shards -play their slices out with nothing watching and the test is judged once over -all of them. A run is one batch, and `--prior-pairs` carries its pairs into -the next, so repeated runs accumulate into one test rather than several. - -Openings follow from a seed rather than a shuffle, so a schedule can be played -again from what the run recorded. - -## The clocks and the search are in the report - -A match report carries what each side's clock and search did: moves thought -about, nodes, time, nodes a second, and the tightest its clock ever got. The -figures are per engine rather than per colour, since `-repeat` plays every -opening both ways, and they pool across shards the way the estimate does. - -They are there because an elo figure does not say why. A result that is really -one side being handed more time, or more nodes for the time, shows as a ratio -away from one here and nowhere else in the report. A registration that says a -surprising number is re-read against the clocks and the node counts is -answered from this table. - -**It is in the report, and the report goes to the log and to the run's -summary.** That is the point of putting it there rather than leaving it in the -games: a later session reading back a run can reach a log, and may not be able -to reach the artifacts. Book moves are left out of the counts, since the engine -did not think about them, and still hold their place so the moves after them -are attributed to the side that made them. - -## The test keeps no state - -mache stores nothing between runs. The pairs the earlier batches of a -sequential test played are an argument: a run prints them at the end of its -verdict and the next run is handed them back with `--prior-pairs`. That is a -decision and not an omission. - -Carrying five numbers is the price. A caller that loses them has lost the test -and has to start it again. `actions/summarise-match` hands them back as -`carried`, beside the `verdict` that says whether another batch is wanted at -all, so a caller writing its own job graph passes them on rather than a person -retyping them between runs. What it buys is that a run says on its face -what it was judged over, so a reader checks the count against the batches that -were played rather than trusting a file nobody looked at. Stored state would -also have to be one thing per test, and a tool that cannot see which test a run -belongs to would be guessing at that. - -An accumulator that keeps the counts in an artifact is a later addition if -anyone wants one. It is not missing by accident. - -## The version is in the output - -A change to the estimator can price the same games differently. So a report -names the version that read them, `--json` carries it in its `tool` object, and -the composite action hands the version on the path back as an output, for a -caller to write into whatever it records about a run. A figure kept without it -cannot be checked against the code that produced it. - -`--line` and `--trailer` are one line each and carry no version. They are -quoted beside a report or a manifest that does. - -## Logistic and normalized elo - -The headline figure is logistic elo, read off the score with -`-400 log10(1/p - 1)`. How far a given improvement moves the score depends on -how often the games are drawn. The same change reads as fewer elo on a balanced -book than on an unbalanced one, and fewer at a long time control than a short -one, so two runs that differ in either are not comparable in it. - -The report also gives the difference in normalized elo, which is what fastchess -prints as nElo. It is the score's distance from a half divided by the spread of -the pairs, scaled so that a small difference in a match with no draws reads -about the same in both. Because it measures how clearly the games separate the -two sides, it compares across books and time controls better than logistic elo -does, and its margin depends on the number of pairs alone. For the same pairs -the figure here is the one fastchess prints. `--json` carries it as `nelo` and -`nelo_margin`. - -The sequential test takes its bounds in either. `--model normalized` reads -`--elo0` and `--elo1` as normalized elo, and the default, `--model logistic`, -reads them as logistic elo as before. Under the normalized model the number of -games a test needs to settle depends on the bounds, and much less than under -the logistic model on the book or the time control, so the same bounds cost -roughly the same whichever is played. A normalized test names itself as -`SPRT [0, 5] nElo` in the report, the line and the trailer, and `--json` -carries the model in its `sprt` object. - -Every batch of one test is judged under the model it started with. The pair -counts carried between batches are the same under either, so nothing stops a -caller changing it part way, but the error rates only hold for a test that did -not. `strength.yml` and `actions/summarise-match` take it as `sprt_model`. - -The ratio under the normalized model fits, for each hypothesis, the -distribution over the five pair scores that is likeliest to have produced the -pairs while being that many of its own standard deviations from a half. It -does that as a convex fit at each spread and a search over the spread, rather -than by the fixed point iteration fastchess uses, which does not converge from -every set of counts. Where both converge they agree. Hypotheses are limited to -100 normalized elo either side of nought, which is wider than the bounds a test -normally uses. - -## Reading a rating estimate - -`rating-estimate` holds every opponent at its published figure and fits the one -free parameter, so the figure is the rating at which the expected score equals -the score actually made. - -The `±` is a 95% interval and it describes the games and nothing else. Whether -one rating can describe the results at all is asked separately: when the -opponents disagree with each other by more than chance allows, a note says so, -and the interval is an understatement rather than an estimate. - -**A placement against a published list carries a systematic error no number of -games reduces.** The opponents earned their ratings on other hardware at -slower time controls. Treat the figure as a placement worth about a hundred -points either way, not as a rating. +## As one step + +The action at the root of this repository reads games that are already on the +runner. It suits a repository that plays its own matches and only wants them +pooled: + +```yaml +- uses: aywrite/mache@v0.6.0 + with: + pgn: shards/**/games.pgn + candidate: new + baseline: old + sprt: true +``` + +Every file the pattern matches is one shard. The report goes in the job +summary, and `line`, `trailer`, `verdict` and `carried` come back as outputs. +It needs `python3` 3.10 or newer, which GitHub's hosted Ubuntu runners have, +and installs nothing. [`action.yml`](https://github.com/aywrite/mache/blob/main/action.yml) +describes each input. + +## Documentation + +The documentation is at [aywrite.github.io/mache](https://aywrite.github.io/mache/): + +- [Quickstart](https://aywrite.github.io/mache/quickstart/), a match in your engine's + repository from the build script to a first run +- [Using the tools](https://aywrite.github.io/mache/tools/), what each command prints +- [Running a match without CI](https://aywrite.github.io/mache/without-ci/) +- [What hosted runners can measure](https://aywrite.github.io/mache/limits/) +- [How it works](https://aywrite.github.io/mache/how-it-works/), and why a sharded match is + pooled the way it is +- [The statistics](https://aywrite.github.io/mache/statistics/), logistic and normalized elo + and reading a rating estimate +- [Building an engine at a commit](https://aywrite.github.io/mache/building-a-ref/) ## Install diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 8192fc2..d2224e5 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -15,7 +15,7 @@ runners and puts the result in the run summary. So far mache has only been run from public repositories. In a private one, stick to tags, commits and the branch you run from for now: -[actions/resolve-ref](../actions/resolve-ref/README.md#in-a-private-repository) +[actions/resolve-ref](https://github.com/aywrite/mache/blob/main/actions/resolve-ref/README.md#in-a-private-repository) has why. ## 1. Build script @@ -30,7 +30,7 @@ scripts/build_at.sh The script builds the engine as it was at `` and leaves a runnable file at ``. It's run from the root of your repository's checkout. -Copy [`examples/build_at.sh`](../examples/build_at.sh) to +Copy [`examples/build_at.sh`](https://github.com/aywrite/mache/blob/main/examples/build_at.sh) to `scripts/build_at.sh`. It's written for a Rust engine built with cargo, so you'll probably need to change the two lines at the bottom (the build command, and where the binary ends up). For make they might look like: @@ -73,7 +73,7 @@ printf 'uci\nquit\n' | /tmp/engine | grep uciok ## 2. Workflow -Copy [`examples/strength.yml`](../examples/strength.yml) to +Copy [`examples/strength.yml`](https://github.com/aywrite/mache/blob/main/examples/strength.yml) to `.github/workflows/strength.yml`: ```yaml @@ -147,14 +147,14 @@ run): ``` and then the pair counts, a row per shard, how the games ended and each -engine's clock and node counts. [The README](../README.md#match-estimate) goes +engine's clock and node counts. [Using the tools](tools.md#match-estimate) goes through each part. Each shard also uploads its games, fastchess's own output and a manifest as an artifact called `strength---shard-`. Don't expect much from 500 games. The margin is about ±30 normalized elo, which is enough to see a big change and not a lot else. (The headline figure is logistic elo, so the two aren't directly comparable.) -[What hosted runners can measure](../README.md#what-hosted-runners-can-measure) +[What hosted runners can measure](limits.md) has numbers for bigger matches. Time your first run too, since how long a match takes depends on your engine, the time control and the runner. @@ -162,7 +162,7 @@ takes depends on your engine, the time control and the runner. For smaller changes you'll want a sequential test (SPRT) instead. It keeps playing until it can say whether the change is at least a given amount better. -[`examples/sprt.yml`](../examples/sprt.yml) turns it on: +[`examples/sprt.yml`](https://github.com/aywrite/mache/blob/main/examples/sprt.yml) turns it on: ```yaml with: @@ -214,7 +214,7 @@ A `pull_request` workflow can't pass a candidate or a baseline. mache won't resolve a ref you name unless the workflow was started by `workflow_dispatch`, `push`, `schedule` or `release`, where whoever picked the ref can already push to the repository. -[actions/resolve-ref](../actions/resolve-ref/README.md#the-refusal-and-what-it-does-not-cover) +[actions/resolve-ref](https://github.com/aywrite/mache/blob/main/actions/resolve-ref/README.md#the-refusal-and-what-it-does-not-cover) has the details. ## When it goes wrong @@ -231,7 +231,7 @@ has the details. ## Options The ones you're most likely to want. The rest are described in -[strength.yml](../.github/workflows/strength.yml). +[strength.yml](https://github.com/aywrite/mache/blob/main/.github/workflows/strength.yml). | input | default | what it does | | --- | --- | --- | @@ -239,17 +239,17 @@ The ones you're most likely to want. The rest are described in | `shards` | 5 | how many jobs to split them across | | `time_control` | 10+0.1 | passed to fastchess as `tc` | | `book` | `8moves_v3` | the opening book; `UHO_4060_v2` is the other one mache knows | -| `book_table` | empty | your own book table, for any other book (the contract is in [actions/setup](../actions/setup/README.md#the-book-table-contract)) | +| `book_table` | empty | your own book table, for any other book (the contract is in [actions/setup](https://github.com/aywrite/mache/blob/main/actions/setup/README.md#the-book-table-contract)) | | `cache_paths`, `cache_key` | empty | paths kept between runs with `actions/cache`; with the example script that's `~/.cargo/registry` and `~/.cargo/git`, since it builds in a fresh directory each time | | `hash_mb` | 256 | the hash size each engine is asked for | | `concurrency` | 2 | games a shard plays at once | ## Next -- [calibrate.yml](../.github/workflows/README.md#the-other-two-contracts) places +- [calibrate.yml](https://github.com/aywrite/mache/blob/main/.github/workflows/README.md#the-other-two-contracts) places your engine on a rating list by playing a gauntlet against rated opponents. It needs a table of opponents as well as the build script. - if you want your own cache action, build steps or manifest, call the composite actions from your own workflow instead. - [.github/workflows/README.md](../.github/workflows/README.md#what-you-give-up-by-calling-these) + [.github/workflows/README.md](https://github.com/aywrite/mache/blob/main/.github/workflows/README.md#what-you-give-up-by-calling-these) has the trade-off. diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..9222eac --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,45 @@ +# The documentation site. GitHub Pages builds it from this directory on main: +# Settings, Pages, deploy from a branch, main and /docs. There is no workflow +# and nothing to pin but the theme below. +title: mache +description: Chess engine testing on GitHub Actions, with no server to run. +url: https://aywrite.github.io +baseurl: /mache + +remote_theme: just-the-docs/just-the-docs@v0.12.0 +plugins: + - jekyll-remote-theme + - jekyll-relative-links + - jekyll-titles-from-headings + +aux_links: + mache on GitHub: https://github.com/aywrite/mache +search_enabled: true +# the pages are served once, at their own address, and not also as markdown +optional_front_matter: + remove_originals: true +heading_anchors: true + +# Where each page is published and where it sits in the menu. Kept here rather +# than as front matter, which GitHub would show as a table at the top of each +# page when the files are read in the repository. A page's title is its first +# heading, except the home page's. +defaults: + - scope: {path: ""} + values: {layout: default} + - scope: {path: index.md} + values: {title: Home, nav_order: 1} + - scope: {path: QUICKSTART.md} + values: {permalink: /quickstart/, nav_order: 2} + - scope: {path: tools.md} + values: {permalink: /tools/, nav_order: 3} + - scope: {path: without-ci.md} + values: {permalink: /without-ci/, nav_order: 4} + - scope: {path: limits.md} + values: {permalink: /limits/, nav_order: 5} + - scope: {path: how-it-works.md} + values: {permalink: /how-it-works/, nav_order: 6} + - scope: {path: statistics.md} + values: {permalink: /statistics/, nav_order: 7} + - scope: {path: BUILDING-A-REF.md} + values: {permalink: /building-a-ref/, nav_order: 8} diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..be26fb1 --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,76 @@ +# How it works + +## The part that is not obvious + +A sharded match is not a long match cut up. Three things have to hold or the +number it produces is wrong. + +**The estimate is over the pool.** fastchess prints one, but only for the +games its own process played. With five shards that is a fifth of the +evidence, and averaging five such figures is a different calculation. +`match-estimate` reads the games themselves. + +**The error bar is over pairs, not games.** Under `-repeat` the two games of a +round are one opening with the colours reversed, so they are one observation. +Counting them as two understates the spread. + +**A sequential test looks only at batch boundaries.** A per-shard SPRT that +stopped when its own games settled the question would be one look per shard at +a bound priced for one, on a sample chosen by what it said. Here the shards +play their slices out with nothing watching and the test is judged once over +all of them. A run is one batch, and `--prior-pairs` carries its pairs into +the next, so repeated runs accumulate into one test rather than several. + +Openings follow from a seed rather than a shuffle, so a schedule can be played +again from what the run recorded. + +## The clocks and the search are in the report + +A match report carries what each side's clock and search did: moves thought +about, nodes, time, nodes a second, and the tightest its clock ever got. The +figures are per engine rather than per colour, since `-repeat` plays every +opening both ways, and they pool across shards the way the estimate does. + +They are there because an elo figure does not say why. A result that is really +one side being handed more time, or more nodes for the time, shows as a ratio +away from one here and nowhere else in the report. A registration that says a +surprising number is re-read against the clocks and the node counts is +answered from this table. + +**It is in the report, and the report goes to the log and to the run's +summary.** That is the point of putting it there rather than leaving it in the +games: a later session reading back a run can reach a log, and may not be able +to reach the artifacts. Book moves are left out of the counts, since the engine +did not think about them, and still hold their place so the moves after them +are attributed to the side that made them. + +## The test keeps no state + +mache stores nothing between runs. The pairs the earlier batches of a +sequential test played are an argument: a run prints them at the end of its +verdict and the next run is handed them back with `--prior-pairs`. That is a +decision and not an omission. + +Carrying five numbers is the price. A caller that loses them has lost the test +and has to start it again. `actions/summarise-match` hands them back as +`carried`, beside the `verdict` that says whether another batch is wanted at +all, so a caller writing its own job graph passes them on rather than a person +retyping them between runs. What it buys is that a run says on its face +what it was judged over, so a reader checks the count against the batches that +were played rather than trusting a file nobody looked at. Stored state would +also have to be one thing per test, and a tool that cannot see which test a run +belongs to would be guessing at that. + +An accumulator that keeps the counts in an artifact is a later addition if +anyone wants one. It is not missing by accident. + +## The version is in the output + +A change to the estimator can price the same games differently. So a report +names the version that read them, `--json` carries it in its `tool` object, and +the composite action hands the version on the path back as an output, for a +caller to write into whatever it records about a run. A figure kept without it +cannot be checked against the code that produced it. + +`--line` and `--trailer` are one line each and carry no version. They are +quoted beside a report or a manifest that does. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..6c8aa3a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,31 @@ +# mache + +Chess engine testing on GitHub Actions, with no server to run. + +mache runs a match between two versions of an engine on the GitHub-hosted +runners a repository already has. The match is split across jobs that run at +the same time, and the games are pooled afterwards into one elo estimate or +one sequential test (SPRT). fastchess plays the games, and the result goes in +the run's summary. + +It suits an engine whose changes are still large enough to show within a few +thousand games. [What hosted runners can measure](limits.md) says how many a +smaller change needs. + +## Where to start + +- [Quickstart](QUICKSTART.md), a match in your engine's repository from the + build script to a first run +- [Using the tools](tools.md), what each command prints +- [Running a match without CI](without-ci.md), on pgn files fastchess wrote + anywhere +- [How it works](how-it-works.md), and why a sharded match is pooled the way it + is +- [The statistics](statistics.md), logistic and normalized elo and reading a + rating estimate +- [Building an engine at a commit](BUILDING-A-REF.md), and the one mistake that + is silent + +The code, the composite actions and the reusable workflows are in +[the repository](https://github.com/aywrite/mache), and +`pip install mache` gives the command-line tools. diff --git a/docs/limits.md b/docs/limits.md new file mode 100644 index 0000000..d2c7260 --- /dev/null +++ b/docs/limits.md @@ -0,0 +1,52 @@ +# What hosted runners can measure + +A run of `strength.yml` with its defaults plays 500 games over five shards, +which is 250 pairs. A sequential test given `batches: 4` plays up to four such +batches in one run, 1,000 pairs. It stops early if a batch settles the test, +and otherwise hands back the counts for the next run to carry on from. + +For an estimate on its own, the 95% margin in normalized elo, as mache works +it out, depends only on the number of pairs: + +| pairs | margin | +| ---: | ---: | +| 250 | ±30 | +| 1,000 | ±15 | +| 4,000 | ±8 | + +The margin in logistic elo also depends on how the pairs scored, and so on +how many games were drawn. + +A sequential test runs until the pairs settle it, and the closer its bounds, +the longer that takes. In the simulation below, halving the gap between the +bounds took between three and four times the pairs. It ran 300 tests a row of +the normalized test with mache's own code, at the default error rates of five +percent each, judged every 250 pairs as with the workflow's default batch. The pair scores were drawn from a distribution in +which three pairs in five score one point, shifted to each true difference: + +| bounds (normalized elo) | true difference at a bound | true difference halfway between | +| --- | ---: | ---: | +| [0, 10] | about 4,000 pairs | about 7,000 pairs | +| [0, 5] | about 13,000 to 14,000 pairs | about 26,000 pairs | + +Those are averages, and the spread around them is wide: about one test in ten +ran to nearly twice the average or longer. At 1,000 pairs a run, the averages +come to about four to seven runs for [0, 10] and about thirteen to twenty-six +for [0, 5]. The normalized model is what `match-estimate --model normalized` +uses, and what `strength.yml` and `actions/summarise-match` use when given +`sprt_model: normalized`. Both default to logistic elo, so `strength.yml`'s +default bounds of [0, 10] are not the first row above, and how long a logistic +test runs depends on the draw rate as well as the bounds. + +How long a batch takes depends on the time control, the engines and the +runner, so it is worth timing one before planning a test around it. A shard +is stopped at `max_match_minutes`, 150 by default, and reports the games it +finished by then. + +Both engines of a shard share one runner, so a slow runner slows both. An +engine whose strength changes a lot with the time it is given loses more to a +slow runner than one whose strength does not. + +GitHub does not charge for its standard hosted runners on public repositories. +A private repository uses the Actions minutes its plan includes. GitHub's +billing documentation has the current terms. diff --git a/docs/statistics.md b/docs/statistics.md new file mode 100644 index 0000000..02a2e14 --- /dev/null +++ b/docs/statistics.md @@ -0,0 +1,57 @@ +# The statistics + +## Logistic and normalized elo + +The headline figure is logistic elo, read off the score with +`-400 log10(1/p - 1)`. How far a given improvement moves the score depends on +how often the games are drawn. The same change reads as fewer elo on a balanced +book than on an unbalanced one, and fewer at a long time control than a short +one, so two runs that differ in either are not comparable in it. + +The report also gives the difference in normalized elo, which is what fastchess +prints as nElo. It is the score's distance from a half divided by the spread of +the pairs, scaled so that a small difference in a match with no draws reads +about the same in both. Because it measures how clearly the games separate the +two sides, it compares across books and time controls better than logistic elo +does, and its margin depends on the number of pairs alone. For the same pairs +the figure here is the one fastchess prints. `--json` carries it as `nelo` and +`nelo_margin`. + +The sequential test takes its bounds in either. `--model normalized` reads +`--elo0` and `--elo1` as normalized elo, and the default, `--model logistic`, +reads them as logistic elo as before. Under the normalized model the number of +games a test needs to settle depends on the bounds, and much less than under +the logistic model on the book or the time control, so the same bounds cost +roughly the same whichever is played. A normalized test names itself as +`SPRT [0, 5] nElo` in the report, the line and the trailer, and `--json` +carries the model in its `sprt` object. + +Every batch of one test is judged under the model it started with. The pair +counts carried between batches are the same under either, so nothing stops a +caller changing it part way, but the error rates only hold for a test that did +not. `strength.yml` and `actions/summarise-match` take it as `sprt_model`. + +The ratio under the normalized model fits, for each hypothesis, the +distribution over the five pair scores that is likeliest to have produced the +pairs while being that many of its own standard deviations from a half. It +does that as a convex fit at each spread and a search over the spread, rather +than by the fixed point iteration fastchess uses, which does not converge from +every set of counts. Where both converge they agree. Hypotheses are limited to +100 normalized elo either side of nought, which is wider than the bounds a test +normally uses. + +## Reading a rating estimate + +`rating-estimate` holds every opponent at its published figure and fits the one +free parameter, so the figure is the rating at which the expected score equals +the score actually made. + +The `±` is a 95% interval and it describes the games and nothing else. Whether +one rating can describe the results at all is asked separately: when the +opponents disagree with each other by more than chance allows, a note says so, +and the interval is an understatement rather than an estimate. + +**A placement against a published list carries a systematic error no number of +games reduces.** The opponents earned their ratings on other hardware at +slower time controls. Treat the figure as a placement worth about a hundred +points either way, not as a rating. diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..cd9fba1 --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,191 @@ +# Using the tools + +`pip install mache` gives the four command names used below. Under the +composite action nothing is installed, and the same tools are +`python3 -m mache.` with underscores where the command name has hyphens. + +Each tool prints its report on stdout. Anything worth an alert goes to stderr +instead, a fault or an unfinished game, so a workflow can raise it from there +rather than reading it back out of the report. + +## `match-estimate` + +A shard is one pgn, written by one job of the run. fastchess plays it with +`-repeat`, so a round is two games on one opening with the colours reversed, +and `--candidate` and `--baseline` name the engines as fastchess named them. + +``` +match-estimate strength-1-1-shard-*/games.pgn \ + --candidate new --baseline ce8b662 --tc 10+0.1 +``` + +``` ++56 ±37 Elo (150 games) + +75 pairs from 150 games. The 95% interval is +19 to +93 elo, and the likelihood of superiority is 99.9%. In normalized elo the difference is +85 ±56, which is the figure to compare across books and time controls. + +| pair score | 0 | 0.5 | 1 | 1.5 | 2 | +| --- | --- | --- | --- | --- | --- | +| pairs | 2 | 9 | 36 | 19 | 9 | + +| shard | games | score | faults | +| --- | --- | --- | --- | +| strength-1-1-shard-0 | 50 | 57.0% | 0 | +| strength-1-1-shard-1 | 50 | 59.0% | 1 | +| strength-1-1-shard-2 | 50 | 58.0% | 0 | +| pooled | 150 | 58.0% | 1 | +``` + +The block `match-terminations` prints follows that, and the last line of the +report names the version that read the games. `--tc` and `--baseline` are +recorded rather than read, so a baseline that is not a release tag can go in as +its sha. + +`--elo0` and `--elo1` read the same pairs a second way, as a sequential test: + +``` +match-estimate strength-1-1-shard-*/games.pgn \ + --candidate new --baseline ce8b662 --tc 10+0.1 --elo0 0 --elo1 10 +``` + +which puts this paragraph in the report: + +``` +SPRT [0, 10] inconclusive. The log likelihood ratio over the 75 pairs of the test (75 from this batch and 0 from the batches before it) is 1.32 against bounds of (-2.94, 2.94). The games so far settle it neither way. Launch another batch with prior_pairs set to 2,9,36,19,9. +``` + +The counts at the end of it are what the next batch carries in, so that the +runs accumulate into one test: + +``` +match-estimate strength-1-1-shard-*/games.pgn \ + --candidate new --baseline ce8b662 --tc 10+0.1 \ + --elo0 0 --elo1 10 --prior-pairs 2,9,36,19,9 +``` + +``` +SPRT [0, 10] inconclusive. The log likelihood ratio over the 150 pairs of the test (75 from this batch and 75 from the batches before it) is 2.64 against bounds of (-2.94, 2.94). Over all of them the difference is +56 ±26 elo, which is the figure the trailer carries. The games so far settle it neither way. Launch another batch with prior_pairs set to 4,18,72,38,18. +``` + +`--alpha` and `--beta` are the chances of a wrong pass and a wrong fail the test +accepts, five percent each unless given. They set Wald's bounds, which are +(-2.94, 2.94) at the defaults and (-4.60, 4.60) at one percent each, so +stricter rates take more pairs to settle a test. A verdict reached at other +rates names them wherever it is quoted, as in +`SPRT [0, 10] alpha=0.01 beta=0.01 passed`. Give every batch of one test the +same rates, since they are what the bounds were drawn at. +`actions/summarise-match` takes them as `alpha` and `beta`. + +Three flags each replace the whole report. `--line` prints what release notes +carry: + +``` ++56 ±37 Elo (150 games), SPRT [0, 10] inconclusive, LLR 1.32 (-2.94, 2.94) +``` + +`--trailer` prints what a commit carries: + +``` +Elo: +56 ±37 (sprt [0, 10] inconclusive, 150 games, 10+0.1, vs ce8b662) +``` + +`--json` prints all of it as data, for a reader that is not a person: + +``` +{ + "format": 1, + "tool": { + "name": "mache", + "version": "0.1.0", + "command": "match_estimate" + }, + "candidate": "new", + "baseline": "ce8b662", + "tc": "10+0.1", + "games": 150, + "pairs": 75, +``` + +`format` is which shape the object is in, and shape 1 is a contract from +`0.1.0` on. Fields are added to it. None is removed and none is given a new +meaning under the name it has, and a change that cannot be made that way +raises the number. The rest of the object holds the pentanomial counts, the +sequential test, a row per shard, the terminations, and the `line` and +`trailer` strings above. + +## `rating-estimate` + +The ladder is one argument, `name:rating` per opponent separated by commas, +with the names as the pgn spells them: + +``` +rating-estimate gauntlet.pgn arche-0.5 \ + 'stash-v33:1876,cheng-4.39:1932,supernova-2.1:1801,winter-0.7:1978' +``` + +``` +| opponent | ccrl | w-d-l | score | implies | +| --- | --- | --- | --- | --- | +| stash-v33 | 1876 | 12-6-12 | 50.0% | 1876 | +| cheng-4.39 | 1932 | 9-7-14 | 41.7% | 1874 | +| supernova-2.1 | 1801 | 16-5-9 | 61.7% | 1884 | +| winter-0.7 | 1978 | 7-6-17 | 33.3% | 1858 | + +1873 ±56 (95%) on the ccrl blitz scale (120 games) + +Read by mache 0.1.0. +``` + +`--line` prints the estimate and nothing else. `--json` prints the fit, the +ladder it was given and a record per opponent, in the same format 1. + +The figure is on the scale of the list the ratings were read off, and the +line names it. That is `ccrl blitz` unless `--scale` says otherwise, so a +ladder read off the 40/15 list is fitted with `--scale 'ccrl 40/15'`. The +`summarise-gauntlet` action takes the same thing as its `scale` input. + +## `match-terminations` + +``` +match-terminations strength-1-1-shard-1/games.pgn +``` + +``` +games: 50 +normal: 49 +adjudication: 0 +time forfeit: 1 (new 1) +disconnect: 0 +stall: 0 +abandoned: 0 +illegal move: 0 +unterminated: 0 +``` + +Every ending is printed, zero included. The line about the one that ended by a +fault goes to stderr beside the block. `--json` prints the same counts, with +the engines an ending fell on as a mapping rather than as the sentence. + +## `book-slice` + +``` +book-slice --openings 34700 --pairs 250 --shards 5 --shard 3 --seed 7 +``` + +``` +758 +``` + +That number is one based, which is what fastchess's `start=` takes. Every +shard of a run asks with the same `--openings`, `--pairs`, `--shards` and +`--seed`, and its own `--shard`, so the slices are worked out from the run's +own numbers and no two of them hold an opening in common. + +A sequential test pools its batches the same way it pools its shards, so the +rule holds across them too, and `--batches` with `--batch` is how a caller +says so. What is then reserved is the whole test rather than one batch of it, +and a batch takes the slice after the batch before it. This matters where the +batches are chained inside one run, because they share a seed: each reserving +its own games alone would start every batch where the first one started, and +the pooled estimate would count those positions twice with nothing failing. +A caller that passes neither plays what an unbatched run plays. diff --git a/docs/without-ci.md b/docs/without-ci.md new file mode 100644 index 0000000..e6d1dde --- /dev/null +++ b/docs/without-ci.md @@ -0,0 +1,48 @@ +# Running a match without CI + +None of the four tools needs GitHub Actions. They read pgn files, or in +`book-slice`'s case a few numbers, so they work on games played anywhere: on +one machine, or on a few machines that each play part of a match. +`pip install mache` gives the command names. fastchess and an opening book are +yours to provide. In a clone of this repository, +`bin/book_table.sh fetch 8moves_v3 .` downloads one of the two books the +actions use and checks it against its recorded hash. + +Each machine plays a shard. `book-slice` says which opening a shard starts at, +so that no two shards play the same one. Every shard asks with the same +numbers and its own `--shard`: + +``` +openings=$(grep -c '^\[Event ' 8moves_v3.pgn) +start=$(book-slice --openings "$openings" --pairs 100 --shards 2 --shard 0 --seed 7) +``` + +Then fastchess plays the shard. `-repeat` pairs the games as `match-estimate` +expects. `-rounds` is the number of pairs and has to match `--pairs` above. +`nodes=true` is what the report's table of clocks and nodes counts moves by, +and `timeleft=true` gives its last column. Without them the table reads as +zeros. + +``` +fastchess -engine name=new cmd=./new -engine name=old cmd=./old \ + -each proto=uci tc=10+0.1 \ + -openings file=8moves_v3.pgn format=pgn order=sequential start="$start" \ + -rounds 100 -repeat -concurrency 2 \ + -pgnout file=games.pgn nodes=true timeleft=true +``` + +fastchess adds to a `games.pgn` that is already there, so start each shard in +an empty directory. A shard played twice into one file would be counted twice. + +With each shard's games in a directory of its own, one command pools them. The +directory names become the rows of the shard table: + +``` +match-estimate shard-0/games.pgn shard-1/games.pgn \ + --candidate new --baseline old --tc 10+0.1 +``` + +`--elo0`, `--elo1`, `--prior-pairs` and `--model` run the sequential test here +the same way they do in a workflow. A test played in several batches passes +`--batches` and `--batch` to `book-slice` as well, so that a later batch does +not replay an earlier one's openings. diff --git a/pyproject.toml b/pyproject.toml index ec44be6..5dc75f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ classifiers = [ [project.urls] Homepage = "https://github.com/aywrite/mache" -Documentation = "https://github.com/aywrite/mache/blob/main/docs/QUICKSTART.md" +Documentation = "https://aywrite.github.io/mache/" Issues = "https://github.com/aywrite/mache/issues" Changelog = "https://github.com/aywrite/mache/blob/main/CHANGELOG.md" diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..8c8caf3 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: MIT +# Copyright (C) 2022-2026 Andrew Wright + +"""The documentation site, which GitHub Pages builds from docs/. + +The pages are read in two places: on the site, and as files in the repository. +A link that works in one can break in the other, and nothing fails when it +does. What is pinned here is what keeps both working: no link climbs out of +docs/, since the site has nothing above it; every link between pages names a +page and a heading that exist; every page has an address and a place in the +menu; and the links into the site from the README and from PyPI name +addresses the site has. +""" + +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +DOCS = ROOT / "docs" +CONFIG = DOCS / "_config.yml" +SITE = "https://aywrite.github.io/mache/" +PAGES = sorted(DOCS.glob("*.md")) + +# a markdown link, and where it goes +LINK = re.compile(r"\]\(([^)\s]+)\)") + + +def slug(heading): + """The anchor GitHub gives a heading. Kramdown, which builds the site, + gives the same one for the headings these pages have.""" + text = re.sub(r"[`*_]", "", heading.strip().lower()) + text = re.sub(r"[^\w\- ]", "", text) + return text.replace(" ", "-") + + +def anchors(path): + text = path.read_text(encoding="utf-8") + # headings inside code blocks are not headings + text = re.sub(r"```.*?```", "", text, flags=re.DOTALL) + return { + slug(line.lstrip("#")) for line in text.splitlines() if line.startswith("#") + } + + +def configured(): + """Each page the config places, with the permalink it gives it.""" + text = CONFIG.read_text(encoding="utf-8") + found = {} + for path, values in re.findall(r"path: ([^}]*)}\n\s+values: {([^}]*)}", text): + if not path.strip().endswith(".md"): + # the default every page takes, which is not a page + continue + permalink = re.search(r"permalink: (\S+?),", values) + found[path.strip()] = ( + permalink.group(1) if permalink else "/", + "nav_order" in values, + ) + return found + + +def test_there_are_pages(): + assert len(PAGES) >= 8, [page.name for page in PAGES] + + +@pytest.mark.parametrize("page", PAGES, ids=lambda page: page.name) +def test_no_link_climbs_out_of_the_site(page): + for target in LINK.findall(page.read_text(encoding="utf-8")): + assert not target.startswith("../"), f"{page.name} links to {target}" + + +@pytest.mark.parametrize("page", PAGES, ids=lambda page: page.name) +def test_every_link_between_pages_finds_its_page_and_heading(page): + for target in LINK.findall(page.read_text(encoding="utf-8")): + if re.match(r"[a-z]+:", target): + continue + name, _, anchor = target.partition("#") + linked = DOCS / name if name else page + assert linked.exists(), f"{page.name} links to {target}, which is not a page" + if anchor: + assert anchor in anchors(linked), f"{page.name} links to {target}" + + +@pytest.mark.parametrize("page", PAGES, ids=lambda page: page.name) +def test_every_page_has_an_address_and_a_place_in_the_menu(page): + placed = configured() + assert page.name in placed, f"{page.name} is not in docs/_config.yml" + assert placed[page.name][1], f"{page.name} has no nav_order" + + +def test_no_two_pages_share_an_address(): + permalinks = [permalink for permalink, _ in configured().values()] + assert len(permalinks) == len(set(permalinks)) + + +@pytest.mark.parametrize( + "path", [ROOT / "README.md", ROOT / "pyproject.toml"], ids=lambda p: p.name +) +def test_links_into_the_site_name_pages_it_has(path): + addresses = {SITE + permalink.lstrip("/") for permalink, _ in configured().values()} + found = re.findall(re.escape(SITE) + r"[^)\s\"]*", path.read_text("utf-8")) + assert found, f"{path.name} does not link to the site" + for url in found: + assert url.split("#")[0] in addresses, f"{path.name} links to {url}" + + +def test_the_readme_links_to_every_page(): + # the README is what GitHub and PyPI show, so it is the way in + readme = (ROOT / "README.md").read_text(encoding="utf-8") + for permalink, _ in configured().values(): + assert SITE + permalink.lstrip("/") in readme, permalink diff --git a/tests/test_examples.py b/tests/test_examples.py index 305f37b..87fd21a 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -160,6 +160,15 @@ def test_every_call_names_the_current_release(path): assert ref == f"v{version_here()}", f"{path.name} calls {workflow}@{ref}" +def test_the_readme_calls_the_root_action_at_the_current_release(): + # the action GitHub Marketplace lists, shown in the README the listing + # displays, so it moves with the other pins + text = (ROOT / "README.md").read_text("utf-8") + found = re.findall(r"uses: +aywrite/mache@(\S+)", text) + assert found, "the README shows no call of the root action" + assert set(found) == {f"v{version_here()}"}, found + + def test_the_quickstart_names_no_other_release(): # the prose as well as the calls, so a sentence about what a release # takes is looked at again when the pins move. A tag with a suffix, such