From 10f17c5120bb58dfacf2304532bbc63aef86fabe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:55:35 +0000 Subject: [PATCH] feat(action): Give play-shard an optional clock for the candidate alone A time odds match plays the same build on both sides with one given more of the clock, which prices the time rather than a change. play-shard had one time control and put it in -each, which reaches both engines. With candidate_time_control set, each side's tc goes on its own -engine line and none goes in -each, since a tc in -each takes precedence over an engine's own. That was the fault a hand-built time odds match once hit: both sides played the shorter clock and it read -3 for a clock later measured at +66. Unset, the fastchess command line is unchanged, and a test holds it to that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013vnTSb5N6JEKfqkQHAgevq --- actions/play-shard/action.yml | 10 +++++++- actions/play-shard/play.sh | 20 ++++++++++++--- tests/test_action_output.py | 47 ++++++++++++++++++++++++++++++++++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/actions/play-shard/action.yml b/actions/play-shard/action.yml index 10fb9d2..76f652c 100644 --- a/actions/play-shard/action.yml +++ b/actions/play-shard/action.yml @@ -58,8 +58,15 @@ inputs: every batch replays the first one's openings. default: "0" time_control: - description: The time control, as fastchess reads it. + description: > + The time control, as fastchess reads it. With `candidate_time_control` + set it is the opponent's alone. required: true + candidate_time_control: + description: > + A clock for the candidate alone, for a time odds match. Empty gives both + sides `time_control`, which is every other match. + default: "" hash_mb: description: > The hash each engine is asked for. Asked rather than given: an engine @@ -143,6 +150,7 @@ runs: START: ${{ steps.slice.outputs.start }} PAIRS: ${{ inputs.pairs }} TIME_CONTROL: ${{ inputs.time_control }} + CANDIDATE_TIME_CONTROL: ${{ inputs.candidate_time_control }} HASH: ${{ inputs.hash_mb }} CONCURRENCY: ${{ inputs.concurrency }} STARTUP_MS: ${{ inputs.startup_ms }} diff --git a/actions/play-shard/play.sh b/actions/play-shard/play.sh index 4a77efb..c615e63 100755 --- a/actions/play-shard/play.sh +++ b/actions/play-shard/play.sh @@ -17,6 +17,7 @@ # START the opening this shard starts at # PAIRS how many rounds it plays # TIME_CONTROL, HASH, CONCURRENCY, STARTUP_MS +# CANDIDATE_TIME_CONTROL optional, the candidate's own clock # MAX_MATCH_MINUTES the wall clock cap # # stdout is fastchess's own, so the progress and the result block reach the @@ -31,6 +32,19 @@ outputs=${1:?usage: play.sh } # find the last attempt's here. rm -f games.pgn result.txt config.json +# A clock for the candidate alone is a time odds match: the same build both +# sides, one given more time, which prices the time rather than a change. Each +# side's tc then goes on its own -engine line and none in -each, because a tc +# in -each reaches both engines and takes precedence over theirs. +candidate_tc=() +opponent_tc=() +each_tc=("tc=$TIME_CONTROL") +if [ -n "${CANDIDATE_TIME_CONTROL:-}" ]; then + candidate_tc=("tc=$CANDIDATE_TIME_CONTROL") + opponent_tc=("tc=$TIME_CONTROL") + each_tc=() +fi + # No -sprt, sharded or not. A shard that stopped itself when its own games # settled the question would be one look per shard at a bound meant for one # look, and its games a slice chosen by what they said. The summary runs the @@ -42,9 +56,9 @@ rm -f games.pgn result.txt config.json # timeout says it sent it. status=0 timeout -k 60 -s INT "${MAX_MATCH_MINUTES}m" ./fastchess \ - -engine "name=$CANDIDATE" "cmd=$CANDIDATE_BINARY" \ - -engine "name=$OPPONENT" "cmd=$OPPONENT_BINARY" \ - -each proto=uci "tc=$TIME_CONTROL" "option.Hash=$HASH" \ + -engine "name=$CANDIDATE" "cmd=$CANDIDATE_BINARY" ${candidate_tc[@]+"${candidate_tc[@]}"} \ + -engine "name=$OPPONENT" "cmd=$OPPONENT_BINARY" ${opponent_tc[@]+"${opponent_tc[@]}"} \ + -each proto=uci ${each_tc[@]+"${each_tc[@]}"} "option.Hash=$HASH" \ -startup-ms "$STARTUP_MS" \ -openings "file=$BOOK_FILE" "format=$BOOK_FORMAT" order=sequential "start=$START" \ -rounds "$PAIRS" -repeat -concurrency "$CONCURRENCY" -recover \ diff --git a/tests/test_action_output.py b/tests/test_action_output.py index ed668ff..2fa92cd 100644 --- a/tests/test_action_output.py +++ b/tests/test_action_output.py @@ -59,7 +59,7 @@ def match(tmp_path): return tools -def play(tools, outputs): +def play(tools, outputs, **extra): return subprocess.run( [str(PLAY), str(outputs)], cwd=tools, @@ -81,6 +81,7 @@ def play(tools, outputs): "CONCURRENCY": "2", "STARTUP_MS": "20000", "MAX_MATCH_MINUTES": "150", + **extra, }, ) @@ -123,6 +124,50 @@ def test_a_harness_that_died_fails_the_shard(self, match, tmp_path): assert play(match, tmp_path / "outputs").returncode == 3 +def arguments(tools, outputs, **extra): + """What fastchess was given, one argument a line, the `-engine` and + `-each` groups split apart.""" + executable(tools / "fastchess", 'printf "%s\\n" "$@" > args.txt') + result = play(tools, outputs, **extra) + assert result.returncode == 0, result.stderr + groups, current = [], None + for argument in (tools / "args.txt").read_text().splitlines(): + if argument.startswith("-"): + current = [argument] + groups.append(current) + else: + current.append(argument) + return groups + + +class TestTheClocks: + # A tc in -each reaches both engines and wins over an -engine one, which + # is how a time odds match once played both sides at the shorter clock + # and read -3 for a clock that was worth +66. + + def test_one_clock_is_given_to_both_sides_in_each(self, match, tmp_path): + groups = arguments(match, tmp_path / "outputs") + engines = [g for g in groups if g[0] == "-engine"] + each = next(g for g in groups if g[0] == "-each") + assert "tc=10+0.1" in each + assert not any(a.startswith("tc=") for g in engines for a in g) + + def test_a_candidate_clock_goes_on_each_engine_line_and_not_in_each( + self, match, tmp_path + ): + groups = arguments(match, tmp_path / "outputs", CANDIDATE_TIME_CONTROL="20+0.2") + candidate, opponent = [g for g in groups if g[0] == "-engine"] + each = next(g for g in groups if g[0] == "-each") + assert "name=new" in candidate and "tc=20+0.2" in candidate + assert "name=old" in opponent and "tc=10+0.1" in opponent + assert not any(a.startswith("tc=") for a in each) + + def test_an_empty_candidate_clock_is_the_symmetric_match(self, match, tmp_path): + assert arguments( + match, tmp_path / "outputs", CANDIDATE_TIME_CONTROL="" + ) == arguments(match, tmp_path / "outputs") + + @pytest.fixture def games(tmp_path): """One shard's pgn, and an estimator that reports a remark the way the