Skip to content

Commit 1557e6b

Browse files
authored
Add fast_forward mode to CC:Ladder (#120)
* Add fast_forward mode to CC:Ladder (skip rungs the bot already dominates) * Add `early_clinch` flag for ladder (skips remaining rounds if # of rounds to win reached before last round)
1 parent 8b12c1a commit 1557e6b

8 files changed

Lines changed: 328 additions & 3 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: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,50 @@ def resolve_ladder_rules(ladder_rules: dict, rounds: int) -> tuple[int, int]:
8383
return min_round_wins, win_last_k
8484

8585

86+
def resolve_fast_forward(ladder_rules: dict) -> tuple[bool, float]:
87+
"""Validate the optional ``ladder_rules.fast_forward`` sub-block and return
88+
``(enabled, min_sim_win_rate)``.
89+
90+
Fast-forward lets a climber *skip playing* a rung whose carried-over codebase already dominates:
91+
if the climber wins at least ``min_sim_win_rate`` of the rung's round-0 simulations (ties count
92+
as non-wins), the rung is cleared without spending edit rounds. Absent or ``enabled: false`` ->
93+
``(False, 0.0)`` = today's full-play behavior.
94+
"""
95+
ff = ladder_rules.get("fast_forward")
96+
if ff is None:
97+
return False, 0.0
98+
if "enabled" not in ff:
99+
raise ValueError("ladder_rules.fast_forward.enabled is required when a fast_forward block is present.")
100+
enabled = ff["enabled"]
101+
if not isinstance(enabled, bool):
102+
raise ValueError(f"ladder_rules.fast_forward.enabled must be a bool, got {enabled!r}.")
103+
if not enabled:
104+
return False, 0.0
105+
if "min_sim_win_rate" not in ff:
106+
raise ValueError("ladder_rules.fast_forward.min_sim_win_rate is required when fast_forward is enabled.")
107+
rate = ff["min_sim_win_rate"]
108+
if isinstance(rate, bool) or not isinstance(rate, (int, float)):
109+
raise ValueError(f"ladder_rules.fast_forward.min_sim_win_rate must be a number, got {rate!r}.")
110+
if not 0.5 < rate <= 1.0:
111+
raise ValueError(f"ladder_rules.fast_forward.min_sim_win_rate must be in (0.5, 1.0], got {rate}.")
112+
return True, float(rate)
113+
114+
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+
86130
def build_ladder(config: dict, workers: int = 1) -> None:
87131
"""Build a ladder: run PvP tournaments across all pairs of players (for ranking).
88132
@@ -154,6 +198,8 @@ def __init__(
154198
self.rounds = config["tournament"]["rounds"]
155199
self.sims = config["game"]["sims_per_round"]
156200
self.min_round_wins, self.win_last_k = resolve_ladder_rules(config.get("ladder_rules", {}), self.rounds)
201+
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)
157203

158204
del config["player"]
159205
del config["ladder"]
@@ -254,9 +300,41 @@ def _scan_resume(self, resume_dir: Path) -> tuple[int, str | None]:
254300
return idx, resume_tag # rung ran but never finished → resume here
255301
if not advancement.get("cleared"):
256302
raise ValueError(f"Run already ended: climber lost at rung {idx + 1}. Nothing to resume.")
257-
resume_tag = self._climber_final_tag(meta)
303+
# A fast-forwarded rung made no code changes (no round tags), so it isn't a new carry-over
304+
# point — keep the tag from the last *played* rung.
305+
if not advancement.get("fast_forwarded"):
306+
resume_tag = self._climber_final_tag(meta)
258307
raise ValueError("Run already cleared the entire ladder. Nothing to resume.")
259308

309+
def _fast_forward_probe(self, rung_config: dict, rung_dir: Path) -> float:
310+
"""Run round 0 only in a throwaway probe dir and return the climber's share of the round-0
311+
simulations (ties count as non-wins). Used to decide whether to skip playing the rung."""
312+
probe_config = copy.deepcopy(rung_config)
313+
probe_config["tournament"] = {**probe_config["tournament"], "rounds": 0}
314+
probe_dir = rung_dir.parent / f".ff-probe.{rung_dir.name}"
315+
if probe_dir.exists():
316+
shutil.rmtree(probe_dir)
317+
try:
318+
probe = PvpTournament(
319+
probe_config, output_dir=probe_dir, cleanup=self.cleanup, keep_containers=self.keep_containers
320+
)
321+
probe.run()
322+
meta = json.loads((probe_dir / "metadata.json").read_text())
323+
round_stats = meta.get("round_stats", {})
324+
r0 = round_stats.get("0") or round_stats.get(0) or {}
325+
wins = (r0.get("scores") or {}).get(self.player["name"], 0)
326+
return wins / self.sims if self.sims else 0.0
327+
finally:
328+
if probe_dir.exists():
329+
shutil.rmtree(probe_dir)
330+
331+
def _record_fast_forward(self, rung_dir: Path, win_rate: float) -> None:
332+
"""Write a minimal rung metadata.json marking it cleared-by-fast-forward (no rounds played),
333+
so resume and the ladder summary can account for it like any other cleared rung."""
334+
rung_dir.mkdir(parents=True, exist_ok=True)
335+
meta = {"ladder_advancement": {"cleared": True, "fast_forwarded": True, "round0_win_rate": round(win_rate, 4)}}
336+
(rung_dir / "metadata.json").write_text(json.dumps(meta, indent=2))
337+
260338
def _evaluate_advancement(self, round_winners: list[str], player_name: str) -> tuple[int, bool, bool]:
261339
"""Apply the advancement rule to a rung's round winners.
262340
@@ -274,6 +352,7 @@ def run(self) -> dict:
274352
logger.info(advancement_rule)
275353

276354
advanced = False
355+
fast_forwarded = 0
277356
opponent: dict = {}
278357
opponent_rank = 0
279358
rung = 0
@@ -307,11 +386,35 @@ def run(self) -> dict:
307386
# PvpTournament (which refuses a pre-existing metadata.json) can re-run it fresh.
308387
if self._resuming and idx == self._start_idx and tournament_dir.exists():
309388
shutil.rmtree(tournament_dir)
389+
390+
# Fast-forward gate: if enabled and the carried-over bot already wins round 0 by a large
391+
# enough margin, clear this rung without playing the edit rounds (see resolve_fast_forward).
392+
if self.ff_enabled:
393+
ff_rate = self._fast_forward_probe(c, tournament_dir)
394+
if ff_rate >= self.ff_min_win_rate:
395+
self._record_fast_forward(tournament_dir, ff_rate)
396+
advanced = True
397+
fast_forwarded += 1
398+
print("=" * 10)
399+
print(
400+
f"{self.player['name']} fast-forwarded rung {rung}/{total} ({opponent['name']}, "
401+
f"elo #{opponent_rank}) — won {ff_rate:.0%} of round-0 sims "
402+
f"(>= {self.ff_min_win_rate:.0%}).\nLadder challenge continuing"
403+
)
404+
print("=" * 10)
405+
continue
406+
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
310412
tournament = PvpTournament(
311413
c,
312414
output_dir=tournament_dir,
313415
cleanup=self.cleanup,
314416
keep_containers=self.keep_containers,
417+
early_stop=early_stop,
315418
)
316419
tournament.run()
317420

@@ -366,6 +469,7 @@ def run(self) -> dict:
366469
"win_last_k": self.win_last_k,
367470
"ladder_size": len(self.ladder),
368471
"rungs_cleared": rungs_cleared,
472+
"rungs_fast_forwarded": fast_forwarded,
369473
"final_opponent": opponent["name"],
370474
"final_opponent_rank": opponent_rank,
371475
"cleared_ladder": rungs_cleared == len(self.ladder),

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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,37 @@ 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`.
6062
- `win_last_k` must be an integer with `0 <= win_last_k <= min_round_wins`. `0` **disables** the trailing-rounds requirement; `1` means "just win the final round".
63+
64+
### Fast-forward (optional)
65+
66+
Since the north-star metric is *the highest rung a bot can reach*, you can skip the (usually redundant) grind of playing edit rounds against opponents your carried-over bot already crushes:
67+
68+
```yaml
69+
ladder_rules:
70+
min_round_wins: 2
71+
win_last_k: 0
72+
fast_forward:
73+
enabled: true
74+
min_sim_win_rate: 0.9 # skip a rung if the bot wins >= 90% of that rung's round-0 sims
75+
```
76+
77+
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,10 @@
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
6+
# Optional: skip playing a rung whose carried-over bot already dominates round 0 (faster eval,
7+
# same "highest rung reached" metric). Off by default. Uncomment to enable:
8+
# fast_forward:
9+
# enabled: true
10+
# min_sim_win_rate: 0.9 # clear the rung without editing if the bot wins >= 90% of round-0 sims

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"

0 commit comments

Comments
 (0)