Skip to content

Commit 05a9580

Browse files
committed
Add fast_forward mode to CC:Ladder (skip rungs the bot already dominates)
1 parent fc25764 commit 05a9580

3 files changed

Lines changed: 103 additions & 1 deletion

File tree

codeclash/tournaments/ladder.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,35 @@ 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+
86115
def build_ladder(config: dict, workers: int = 1) -> None:
87116
"""Build a ladder: run PvP tournaments across all pairs of players (for ranking).
88117
@@ -154,6 +183,7 @@ def __init__(
154183
self.rounds = config["tournament"]["rounds"]
155184
self.sims = config["game"]["sims_per_round"]
156185
self.min_round_wins, self.win_last_k = resolve_ladder_rules(config.get("ladder_rules", {}), self.rounds)
186+
self.ff_enabled, self.ff_min_win_rate = resolve_fast_forward(config.get("ladder_rules", {}))
157187

158188
del config["player"]
159189
del config["ladder"]
@@ -254,9 +284,41 @@ def _scan_resume(self, resume_dir: Path) -> tuple[int, str | None]:
254284
return idx, resume_tag # rung ran but never finished → resume here
255285
if not advancement.get("cleared"):
256286
raise ValueError(f"Run already ended: climber lost at rung {idx + 1}. Nothing to resume.")
257-
resume_tag = self._climber_final_tag(meta)
287+
# A fast-forwarded rung made no code changes (no round tags), so it isn't a new carry-over
288+
# point — keep the tag from the last *played* rung.
289+
if not advancement.get("fast_forwarded"):
290+
resume_tag = self._climber_final_tag(meta)
258291
raise ValueError("Run already cleared the entire ladder. Nothing to resume.")
259292

293+
def _fast_forward_probe(self, rung_config: dict, rung_dir: Path) -> float:
294+
"""Run round 0 only in a throwaway probe dir and return the climber's share of the round-0
295+
simulations (ties count as non-wins). Used to decide whether to skip playing the rung."""
296+
probe_config = copy.deepcopy(rung_config)
297+
probe_config["tournament"] = {**probe_config["tournament"], "rounds": 0}
298+
probe_dir = rung_dir.parent / f".ff-probe.{rung_dir.name}"
299+
if probe_dir.exists():
300+
shutil.rmtree(probe_dir)
301+
try:
302+
probe = PvpTournament(
303+
probe_config, output_dir=probe_dir, cleanup=self.cleanup, keep_containers=self.keep_containers
304+
)
305+
probe.run()
306+
meta = json.loads((probe_dir / "metadata.json").read_text())
307+
round_stats = meta.get("round_stats", {})
308+
r0 = round_stats.get("0") or round_stats.get(0) or {}
309+
wins = (r0.get("scores") or {}).get(self.player["name"], 0)
310+
return wins / self.sims if self.sims else 0.0
311+
finally:
312+
if probe_dir.exists():
313+
shutil.rmtree(probe_dir)
314+
315+
def _record_fast_forward(self, rung_dir: Path, win_rate: float) -> None:
316+
"""Write a minimal rung metadata.json marking it cleared-by-fast-forward (no rounds played),
317+
so resume and the ladder summary can account for it like any other cleared rung."""
318+
rung_dir.mkdir(parents=True, exist_ok=True)
319+
meta = {"ladder_advancement": {"cleared": True, "fast_forwarded": True, "round0_win_rate": round(win_rate, 4)}}
320+
(rung_dir / "metadata.json").write_text(json.dumps(meta, indent=2))
321+
260322
def _evaluate_advancement(self, round_winners: list[str], player_name: str) -> tuple[int, bool, bool]:
261323
"""Apply the advancement rule to a rung's round winners.
262324
@@ -274,6 +336,7 @@ def run(self) -> dict:
274336
logger.info(advancement_rule)
275337

276338
advanced = False
339+
fast_forwarded = 0
277340
opponent: dict = {}
278341
opponent_rank = 0
279342
rung = 0
@@ -307,6 +370,24 @@ def run(self) -> dict:
307370
# PvpTournament (which refuses a pre-existing metadata.json) can re-run it fresh.
308371
if self._resuming and idx == self._start_idx and tournament_dir.exists():
309372
shutil.rmtree(tournament_dir)
373+
374+
# Fast-forward gate: if enabled and the carried-over bot already wins round 0 by a large
375+
# enough margin, clear this rung without playing the edit rounds (see resolve_fast_forward).
376+
if self.ff_enabled:
377+
ff_rate = self._fast_forward_probe(c, tournament_dir)
378+
if ff_rate >= self.ff_min_win_rate:
379+
self._record_fast_forward(tournament_dir, ff_rate)
380+
advanced = True
381+
fast_forwarded += 1
382+
print("=" * 10)
383+
print(
384+
f"{self.player['name']} fast-forwarded rung {rung}/{total} ({opponent['name']}, "
385+
f"elo #{opponent_rank}) — won {ff_rate:.0%} of round-0 sims "
386+
f"(>= {self.ff_min_win_rate:.0%}).\nLadder challenge continuing"
387+
)
388+
print("=" * 10)
389+
continue
390+
310391
tournament = PvpTournament(
311392
c,
312393
output_dir=tournament_dir,
@@ -366,6 +447,7 @@ def run(self) -> dict:
366447
"win_last_k": self.win_last_k,
367448
"ladder_size": len(self.ladder),
368449
"rungs_cleared": rungs_cleared,
450+
"rungs_fast_forwarded": fast_forwarded,
369451
"final_opponent": opponent["name"],
370452
"final_opponent_rank": opponent_rank,
371453
"cleared_ladder": rungs_cleared == len(self.ladder),

configs/ladder/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,18 @@ Both keys are **required** — there are no defaults; a config that omits either
5858

5959
- `min_round_wins` must be an integer with `1 <= min_round_wins <= tournament.rounds`.
6060
- `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".
61+
62+
### Fast-forward (optional)
63+
64+
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:
65+
66+
```yaml
67+
ladder_rules:
68+
min_round_wins: 2
69+
win_last_k: 0
70+
fast_forward:
71+
enabled: true
72+
min_sim_win_rate: 0.9 # skip a rung if the bot wins >= 90% of that rung's round-0 sims
73+
```
74+
75+
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).

configs/ladder/ladder_rules.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
min_round_wins: 2
22
win_last_k: 0
3+
# Optional: skip playing a rung whose carried-over bot already dominates round 0 (faster eval,
4+
# same "highest rung reached" metric). Off by default. Uncomment to enable:
5+
# fast_forward:
6+
# enabled: true
7+
# min_sim_win_rate: 0.9 # clear the rung without editing if the bot wins >= 90% of round-0 sims

0 commit comments

Comments
 (0)