Skip to content

Commit d45abfe

Browse files
committed
Add early_clinch flag for ladder (skips remaining rounds if # of rounds to win reached before last round)
1 parent 05a9580 commit d45abfe

8 files changed

Lines changed: 225 additions & 2 deletions

File tree

codeclash/cli/app.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ def run(
5757
preprocessed_yaml = resolve_includes(yaml_content, base_dir=CONFIG_DIR)
5858
config = yaml.safe_load(preprocessed_yaml)
5959

60+
if "ladder_rules" in config:
61+
raise typer.BadParameter(
62+
"ladder_rules (min_round_wins, win_last_k, fast_forward, early_clinch) applies only to "
63+
"`codeclash ladder run`, not `codeclash run`."
64+
)
65+
6066
def get_output_path() -> Path:
6167
if is_running_in_aws_batch():
6268
# Offset timestamp by random seconds to avoid collisions

codeclash/tournaments/ladder.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,21 @@ def resolve_fast_forward(ladder_rules: dict) -> tuple[bool, float]:
112112
return True, float(rate)
113113

114114

115+
def resolve_early_clinch(ladder_rules: dict, win_last_k: int) -> bool:
116+
"""Validate the optional ``ladder_rules.early_clinch`` flag (default ``False``).
117+
118+
When true, a rung stops as soon as the climber has won ``min_round_wins`` agent rounds rather than
119+
always playing all ``rounds``. Requires ``win_last_k == 0``: a trailing-rounds requirement can't be
120+
decided before the final round, so early-stopping would be unsound.
121+
"""
122+
ec = ladder_rules.get("early_clinch", False)
123+
if not isinstance(ec, bool):
124+
raise ValueError(f"ladder_rules.early_clinch must be a bool, got {ec!r}.")
125+
if ec and win_last_k != 0:
126+
raise ValueError("ladder_rules.early_clinch requires ladder_rules.win_last_k == 0.")
127+
return ec
128+
129+
115130
def build_ladder(config: dict, workers: int = 1) -> None:
116131
"""Build a ladder: run PvP tournaments across all pairs of players (for ranking).
117132
@@ -184,6 +199,7 @@ def __init__(
184199
self.sims = config["game"]["sims_per_round"]
185200
self.min_round_wins, self.win_last_k = resolve_ladder_rules(config.get("ladder_rules", {}), self.rounds)
186201
self.ff_enabled, self.ff_min_win_rate = resolve_fast_forward(config.get("ladder_rules", {}))
202+
self.early_clinch = resolve_early_clinch(config.get("ladder_rules", {}), self.win_last_k)
187203

188204
del config["player"]
189205
del config["ladder"]
@@ -388,11 +404,17 @@ def run(self) -> dict:
388404
print("=" * 10)
389405
continue
390406

407+
# When early_clinch is on (win_last_k == 0 enforced), stop the rung once the climber has
408+
# locked in `min_round_wins` rather than playing out the remaining rounds.
409+
early_stop = None
410+
if self.early_clinch:
411+
early_stop = lambda winners: self._evaluate_advancement(winners, self.player["name"])[2] # noqa: E731
391412
tournament = PvpTournament(
392413
c,
393414
output_dir=tournament_dir,
394415
cleanup=self.cleanup,
395416
keep_containers=self.keep_containers,
417+
early_stop=early_stop,
396418
)
397419
tournament.run()
398420

codeclash/tournaments/pvp.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import shutil
77
import subprocess
8+
from collections.abc import Callable
89
from concurrent.futures import ThreadPoolExecutor
910
from pathlib import Path
1011

@@ -28,13 +29,16 @@ def __init__(
2829
output_dir: Path,
2930
cleanup: bool = False,
3031
keep_containers: bool = False,
32+
early_stop: Callable[[list[str]], bool] | None = None,
3133
):
3234
metadata_file = output_dir / "metadata.json"
3335
if metadata_file.exists():
3436
raise FileExistsError(f"Metadata file already exists: {metadata_file}")
3537

3638
super().__init__(config, name="PvpTournament", output_dir=output_dir)
3739
self.cleanup_on_end = cleanup
40+
# Given the agent-round winners so far (rounds 1..n), return True to stop before `rounds`.
41+
self.early_stop = early_stop
3842
self.game: CodeArena = get_arena(
3943
self.config,
4044
tournament_id=self.tournament_id,
@@ -91,12 +95,18 @@ def run(self) -> None:
9195
"""Main execution function that runs all rounds."""
9296
try:
9397
self.run_competition_phase(0) # Initial round with identical codebases
98+
last_round = self.rounds
9499
for round_num in range(1, self.rounds + 1):
95100
self.run_edit_phase(round_num)
96101
self.run_competition_phase(round_num)
97-
# Need to separately compress the last round, because
102+
if self.early_stop is not None:
103+
winners = [self._metadata["round_stats"][r]["winner"] for r in range(1, round_num + 1)]
104+
if self.early_stop(winners):
105+
last_round = round_num
106+
break
107+
# Need to separately compress the last round played, because
98108
# in run_edit_phase we always only compress the previous round
99-
self._compress_round_folder(self.rounds)
109+
self._compress_round_folder(last_round)
100110
finally:
101111
self.end()
102112

configs/ladder/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ ladder_rules:
5454
win_last_k: 0 # ...and must win the last K rounds (1 == just the final round, 0 == disabled)
5555
```
5656
57+
These keys are read only by `codeclash ladder run`; a plain PvP `codeclash run` rejects any config that carries a `ladder_rules` block (so `early_clinch`/`fast_forward` can't silently no-op there).
58+
5759
Both keys are **required** — there are no defaults; a config that omits either one errors out. `min_round_wins` is a whole number: the player advances when `player_wins >= min_round_wins`. Round 0 (before any edits against the opponent — identical codebases at the first rung, carried-over at later rungs) is excluded, so with `tournament.rounds: 5` there are 5 scored agent rounds (rounds 1–5). Validation:
5860

5961
- `min_round_wins` must be an integer with `1 <= min_round_wins <= tournament.rounds`.
@@ -73,3 +75,16 @@ ladder_rules:
7375
```
7476

7577
At each rung, a **round-0-only probe** runs the carried-over bot against the opponent. If the climber wins at least `min_sim_win_rate` of the simulations (ties count as non-wins), the rung is **cleared without playing edit rounds** and recorded with `ladder_advancement.fast_forwarded: true`. Any rung *not* cleanly won still plays in full under the normal `ladder_rules`, so this is safe under non-transitive matchups and never skips a rung it would actually lose. Rung 1 is identical-code (~coin flip at round 0), so it never fast-forwards — the climber always has to earn the first rung. Omit the block (or `enabled: false`) for today's full-play behavior. Validation: `enabled` is a bool (required if the block is present); `min_sim_win_rate` is a number in `(0.5, 1.0]` (required when enabled).
78+
79+
### Early clinch (optional)
80+
81+
Where fast-forward skips a rung entirely, `early_clinch` stops a rung mid-way once the outcome is decided — the climber has already won `min_round_wins` rounds, so the remaining rounds can't change whether it advances:
82+
83+
```yaml
84+
ladder_rules:
85+
min_round_wins: 2
86+
win_last_k: 0
87+
early_clinch: true
88+
```
89+
90+
The rung plays rounds normally and breaks the moment `player_wins >= min_round_wins`; advancement is recorded from the rounds actually played. Requires `win_last_k == 0` — a trailing-rounds requirement can't be satisfied before the final round, so the two can't be combined (validation errors otherwise). Composes with `fast_forward` (probe first, then early-clinch the rounds that do get played). Off by default.

configs/ladder/ladder_rules.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
min_round_wins: 2
22
win_last_k: 0
3+
# Optional: stop a rung as soon as the climber has won `min_round_wins` rounds instead of playing
4+
# them all. Requires win_last_k == 0. Off by default. Uncomment to enable:
5+
# early_clinch: true
36
# Optional: skip playing a rung whose carried-over bot already dominates round 0 (faster eval,
47
# same "highest rung reached" metric). Off by default. Uncomment to enable:
58
# fast_forward:

docs/usage/ladder.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Ladder Tournament
2+
3+
A ladder is a ranked list of opponents, weakest first.
4+
One model (the "climber") plays them one rung at a time and keeps going until it fails to advance.
5+
Its score is how high it climbs.
6+
7+
## Two commands
8+
9+
Build the ladder once — ranks the human bots by round-robin:
10+
11+
```bash
12+
uv run codeclash ladder make configs/ladder/make_battlesnake.yaml
13+
```
14+
15+
Then send a model up it:
16+
17+
```bash
18+
uv run codeclash ladder run configs/ladder/battlesnake__opus_4_8.yaml
19+
```
20+
21+
Resume an interrupted climb from its log dir (needs `push: True`):
22+
23+
```bash
24+
uv run codeclash ladder run configs/ladder/battlesnake__opus_4_8.yaml -r logs/<user>/LadderTournament...
25+
```
26+
27+
Options: `-c/--cleanup`, `-o/--output-dir`, `-s/--suffix`, `-k/--keep-containers`, `-r/--resume`.
28+
29+
## Minimal run config
30+
31+
```yaml
32+
tournament:
33+
rounds: 5
34+
ladder_rules: !include ladder/ladder_rules.yaml
35+
game:
36+
name: RobotRumble
37+
sims_per_round: 250
38+
player:
39+
agent: mini
40+
name: claude-sonnet-4-5
41+
branch_init: human/anton/anton3000
42+
config:
43+
agent: !include mini/default.yaml
44+
model:
45+
model_name: anthropic/claude-sonnet-4-5-20250929
46+
push: True
47+
prompts: !include ladder/ladder_prompt.yaml
48+
ladder: !include ladder/rungs/robotrumble.yaml
49+
```
50+
51+
`ladder` is the ranked opponent list; `player` is the climber.
52+
53+
## Advancement settings (`ladder_rules`)
54+
55+
These live in `configs/ladder/ladder_rules.yaml` and decide what it takes to clear a rung.
56+
57+
**`min_round_wins`** (required)
58+
How many rounds the climber must win to move up a rung.
59+
Round 0, before any edits, doesn't count.
60+
61+
**`win_last_k`** (required)
62+
Also require winning the last `k` rounds — `1` means just the final round.
63+
Set to `0` to turn this off.
64+
65+
**`early_clinch`** (optional)
66+
Stop a rung early the moment the climber has already won `min_round_wins` rounds.
67+
Saves time, and the outcome is identical to playing every round.
68+
Only allowed when `win_last_k` is `0`.
69+
70+
**`fast_forward`** (optional)
71+
Skip a rung entirely if the climber already crushes it before making any edits.
72+
It plays only round 0; if the climber wins at least `min_sim_win_rate` of the sims, the rung clears.
73+
74+
## Copy-paste examples
75+
76+
Win 2+ of `n` rounds:
77+
78+
```yaml
79+
min_round_wins: 2
80+
win_last_k: 0
81+
```
82+
83+
Win 2+ rounds, and the final round must be one of them:
84+
85+
```yaml
86+
min_round_wins: 2
87+
win_last_k: 1
88+
```
89+
90+
Fastest eval:
91+
92+
```yaml
93+
min_round_wins: 2
94+
win_last_k: 0
95+
early_clinch: true
96+
fast_forward:
97+
enabled: true
98+
min_sim_win_rate: 0.9
99+
```
100+
101+
* Win 2+ of `n` rounds
102+
* `early_clinch`: If the model wins 2 rounds before playing all `n`, skip remaining rounds
103+
* `fast_forward`: If the model's current solution beats the next opponent, skip playing that opponent all together.
104+
105+
--8<-- "docs/_footer.md"

docs/usage/pvp.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# PvP Tournament
2+
3+
A PvP tournament pits two or more agents against each other over several edit-and-compete rounds.
4+
Each round, every agent edits its own codebase, then all agents play the game.
5+
The winner is whoever takes the most rounds.
6+
7+
## Run one
8+
9+
```bash
10+
uv run codeclash run <config.yaml>
11+
```
12+
13+
```bash
14+
# Basic run
15+
uv run codeclash run configs/pvp/BattleSnake__claude-sonnet-4-5-20250929__o3__r15__s1000.yaml
16+
17+
# Keep containers around for debugging
18+
uv run codeclash run configs/test/battlesnake.yaml -k
19+
20+
# Custom output dir + suffix
21+
uv run codeclash run configs/my_config.yaml -o ./experiments -s trial1
22+
```
23+
24+
Options: `-c/--cleanup`, `-o/--output-dir`, `-s/--suffix`, `-k/--keep-containers`.
25+
26+
## Minimal config
27+
28+
```yaml
29+
tournament:
30+
rounds: 5
31+
game:
32+
name: BattleSnake
33+
sims_per_round: 1000
34+
players:
35+
- agent: mini
36+
name: claude
37+
config:
38+
agent: !include mini/default.yaml
39+
model:
40+
model_name: '@anthropic/claude-sonnet-4-5-20250929'
41+
- agent: mini
42+
name: o3
43+
config:
44+
agent: !include mini/default.yaml
45+
model:
46+
model_name: '@openai/o3'
47+
prompts:
48+
game_description: |
49+
You are competing in BattleSnake...
50+
```
51+
52+
For every config field, arena args, env vars, and the output layout, see [Running Tournaments](tournaments.md).
53+
54+
## `ladder_rules` doesn't apply here
55+
56+
`ladder_rules` (`min_round_wins`, `win_last_k`, `early_clinch`, `fast_forward`) is only read by `codeclash ladder run`.
57+
`codeclash run` rejects any config that includes it, so those settings can't silently do nothing in a PvP run.
58+
See [Ladder Tournament](ladder.md).
59+
60+
--8<-- "docs/_footer.md"

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ nav:
6666
- Docs:
6767
- "About": index.md
6868
- "Quick Start": quickstart.md
69+
- "PvP Tournament": usage/pvp.md
70+
- "Ladder Tournament": usage/ladder.md
6971
- API Reference:
7072
- "reference/index.md"
7173
- Arenas:

0 commit comments

Comments
 (0)