Skip to content

Commit 9d36a75

Browse files
john-b-yangclaude
andauthored
Clean up ladder configs, agent observations, and BattleSnake validation (#116)
* Add lock for image construction * Update win conditions * Surface per-step budget in agent observations Add a global observation_template in configs/mini/default.yaml with a [Step n/limit used - k left] banner, and thread it onto the model via a ClashAgentConfig field pushed in ClashAgent.__init__. Applies to every model backend, tournament type, and arena; models otherwise learn the step limit only once at round start with no running counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Reject BattleSnake submissions missing a server entrypoint validate_code was a pure function-def check, so a main.py that drops the 'if __name__ == "__main__": run_server(...)' block passed validation but failed to start at runtime (forfeit). Require the entrypoint statically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Centralize ladder win condition in shared ladder_rules.yaml Every ladder run config duplicated the same ladder_rules block. Extract it to configs/ablations/ladder/ladder_rules.yaml (min_round_wins: 2, win_last_k: 0) and include it from all run configs so the win condition lives in one place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add matplotlib to dev dependencies Required by codeclash.analysis (elo plots + ranking). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Print ladder advancement rule to console Unify with scml-ladder's behavior: echo the advancement rule to stdout (in addition to logging it) at the start of a ladder run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * reduce ocmments * nit * reduce comments * Give ladder climbers an adversarial task prompt Ladder run configs only set game_description to e.g. 'BattleSnake ladder', so a climbing model was never told it's writing code to defeat an opponent (the shared instance template even frames rounds as a cooperative relay). Add a shared ladder_prompt.yaml with an arena-agnostic prompt that states the adversarial task and pulls in arena specifics via a new {{arena_description}} template var (exposed from the arena's static description through GameContext). All 19 run configs include it; make_* build configs are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Strip explanatory comments from ladder/agent config additions * Fix pre-commit * Move observation_template from agent config into model configs * Reorganize configs: main->pvp, ladder out of ablations, models.yaml->mini/model_roster.yaml * Remove unreferenced example configs (stale Bridge/haiku, duplicate HuskyBench) * Point docs at pvp configs; drop r5 example duplicates and vs_human ablation * Fix battlesnake entrypoint check to accept any server start; fix single_player doc ref * Remove unused ClashAgentConfig now that observation_template lives in model configs --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6337837 commit 9d36a75

241 files changed

Lines changed: 808 additions & 1012 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ codeclash run configs/test/battlesnake.yaml
7373
Once this works, you should be set up to run a real tournament!
7474
To run *Claude Sonnet 4.5* against *o3* in a *BattleSnake* tournament with *5 rounds* and *1000 competition simulations* per round, run:
7575
```bash
76-
uv run codeclash run configs/examples/BattleSnake__claude-sonnet-4-5-20250929__o3__r5__s1000.yaml
76+
uv run codeclash run configs/pvp/BattleSnake__claude-sonnet-4-5-20250929__o3__r15__s1000.yaml
7777
```
7878

7979
## ⚔️ How It Works

codeclash/agents/mini_anthropic_model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
`model_class: codeclash.agents.mini_anthropic_model.AnthropicModel` and provide the API key,
1212
base URL, and model name (see the `*_env` config fields, which keep endpoint-specific values in
1313
the environment rather than in committed configs). Requires the optional `anthropic` dependency
14-
(`uv pip install -e '.[llama]'`). See configs/ablations/ladder/robotrumble_llama.yaml.
14+
(`uv pip install -e '.[llama]'`). See configs/ladder/robotrumble_llama.yaml.
1515
"""
1616

1717
import json

codeclash/agents/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class GameContext(BaseModel):
2626
round: int
2727
rounds: int
2828
working_dir: str
29+
arena_description: str = ""
2930

3031
def _render_prompt_templates(self) -> dict:
3132
context = self.model_dump()

codeclash/analysis/code_evolve/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from codeclash.arenas import ARENAS
1919
from codeclash.constants import LOCAL_LOG_DIR
2020

21-
MODELS_PATH = Path("configs/models.yaml")
21+
MODELS_PATH = Path("configs/mini/model_roster.yaml")
2222
TARGET_ROUNDS = [1, 15, 5, 10]
2323

2424

codeclash/arenas/arena.py

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import random
44
import subprocess
5+
import threading
56
import time
67
from abc import ABC, abstractmethod
78
from pathlib import Path
@@ -75,6 +76,10 @@ class CodeArena(ABC):
7576
default_args: dict = {}
7677
submission: str
7778

79+
# Serializes image builds across concurrent pairs (e.g. `ladder make --workers N`), so
80+
# worker threads don't all race `docker build` on a cold start and fail with "already exists".
81+
_build_lock = threading.Lock()
82+
7883
def __init__(self, config: dict, *, tournament_id: str, local_output_dir: Path, keep_containers: bool = False):
7984
"""The CodeArena class is responsible for running games, i.e., taking a list of code
8085
from different agents/players and running them against each other.
@@ -127,36 +132,38 @@ def build_image(self):
127132
if is_running_in_aws_batch():
128133
pull_game_container_aws_ecr(game_name=self.name, image_name=self.image_name, logger=self.logger)
129134

130-
# Check if container exists using subprocess
131-
self.logger.debug(f"Checking if container {self.image_name} exists")
132-
result = subprocess.run(
133-
f"docker images -q {self.image_name}",
134-
shell=True,
135-
capture_output=True,
136-
text=True,
137-
)
138-
if result.stdout.strip():
139-
self.logger.debug(f"Container {self.image_name} exists")
140-
return
135+
# Hold the lock across check-and-build so concurrent pairs don't race: the first thread
136+
# builds while the rest wait, then find the image already present and skip.
137+
with CodeArena._build_lock:
138+
self.logger.debug(f"Checking if container {self.image_name} exists")
139+
result = subprocess.run(
140+
f"docker images -q {self.image_name}",
141+
shell=True,
142+
capture_output=True,
143+
text=True,
144+
)
145+
if result.stdout.strip():
146+
self.logger.debug(f"Container {self.image_name} exists")
147+
return
141148

142-
self.logger.info(
143-
f"Building Docker image {self.image_name}. This may take 1-5 minutes and only work on Linux for some games."
144-
)
149+
self.logger.info(
150+
f"Building Docker image {self.image_name}. This may take 1-5 minutes and only work on Linux for some games."
151+
)
145152

146-
# NOTE: Assuming Dockerfile is declared in same directory as the arena.
147-
arena_file = Path(inspect.getfile(self.__class__))
148-
folder_path = arena_file.parent
149-
result = subprocess.run(
150-
f"docker build --no-cache -t {self.image_name} -f {folder_path}/{self.name}.Dockerfile .",
151-
shell=True,
152-
capture_output=True,
153-
text=True,
154-
)
155-
if result.returncode == 0:
156-
self.logger.info(f"✅ Built Docker image {self.image_name}")
157-
else:
158-
self.logger.error(f"❌ Failed to build Docker image: {result.stderr}\n{result.stdout}{result.stderr}")
159-
raise RuntimeError(f"Failed to build Docker image: {result.stderr}")
153+
# NOTE: Assuming Dockerfile is declared in same directory as the arena.
154+
arena_file = Path(inspect.getfile(self.__class__))
155+
folder_path = arena_file.parent
156+
result = subprocess.run(
157+
f"docker build --no-cache -t {self.image_name} -f {folder_path}/{self.name}.Dockerfile .",
158+
shell=True,
159+
capture_output=True,
160+
text=True,
161+
)
162+
if result.returncode == 0:
163+
self.logger.info(f"✅ Built Docker image {self.image_name}")
164+
else:
165+
self.logger.error(f"❌ Failed to build Docker image: {result.stderr}\n{result.stdout}{result.stderr}")
166+
raise RuntimeError(f"Failed to build Docker image: {result.stderr}")
160167

161168
def copy_logs_from_env(self, round_num: int) -> None:
162169
"""Copy logs from the game's environment to the local machine."""

codeclash/arenas/battlesnake/battlesnake.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,9 @@ def validate_code(self, agent: Player) -> tuple[bool, str | None]:
203203
error_msg.append(f"There should be a `{func}` function implemented in `{self.submission}`")
204204
if len(error_msg) > 0:
205205
return False, "\n".join(error_msg + ["Don't change the function signatures!"])
206+
if "__main__" not in bot_content:
207+
return False, (
208+
f'`{self.submission}` must keep its `if __name__ == "__main__"` block that starts '
209+
"the server, or the bot fails to launch."
210+
)
206211
return True, None

codeclash/cli/ladder.py

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import copy
44
import getpass
5+
import json
56
import time
67
from concurrent.futures import ThreadPoolExecutor, as_completed
78
from pathlib import Path
@@ -18,43 +19,53 @@
1819
logger = get_logger("ladder")
1920

2021

21-
def _resolve_ladder_rules(ladder_rules: dict, rounds: int) -> tuple[float, int]:
22-
"""Validate the optional ``ladder_rules`` block and return ``(min_round_win_fraction, win_last_k)``.
22+
def _resolve_ladder_rules(ladder_rules: dict, rounds: int) -> tuple[int, int]:
23+
"""Validate the required ``ladder_rules`` block and return ``(min_round_wins, win_last_k)``.
2324
24-
Defaults: win at least ``min_round_win_fraction`` (0.4) of the *agent* rounds AND win the last
25-
``win_last_k`` (1) round(s). The baseline round 0 (identical, un-edited codebases) is excluded
26-
from this count — it reflects game variance, not the agent — so the fraction is taken over the
27-
``rounds`` rounds the agent actually edits.
25+
Both keys must be specified explicitly in the config (no defaults):
26+
- ``min_round_wins``: the whole number of *agent* rounds the player must win to advance
27+
(a ``>=`` threshold). Must be ``1 <= min_round_wins <= rounds``.
28+
- ``win_last_k``: the player must win the last ``win_last_k`` round(s). ``1`` means just the final
29+
round; ``0`` disables the trailing-rounds requirement entirely. Must be ``<= min_round_wins``.
30+
31+
The baseline round 0 (identical, un-edited codebases) is excluded from the count — it reflects
32+
game variance, not the agent — so wins are counted over the ``rounds`` rounds the agent actually
33+
edits (rounds 1..``rounds``).
2834
"""
29-
min_round_win_fraction = ladder_rules.get("min_round_win_fraction", 0.4)
30-
win_last_k = ladder_rules.get("win_last_k", 1)
35+
if "min_round_wins" not in ladder_rules:
36+
typer.echo("ladder_rules.min_round_wins is required; specify it explicitly in the config.")
37+
raise typer.Exit(1)
38+
if "win_last_k" not in ladder_rules:
39+
typer.echo("ladder_rules.win_last_k is required; specify it explicitly in the config.")
40+
raise typer.Exit(1)
41+
min_round_wins = ladder_rules["min_round_wins"]
42+
win_last_k = ladder_rules["win_last_k"]
43+
44+
# min_round_wins: whole number of agent rounds the player must win (round 0 excluded).
45+
if isinstance(min_round_wins, bool) or not isinstance(min_round_wins, int):
46+
typer.echo(f"ladder_rules.min_round_wins must be an integer, got {min_round_wins!r}.")
47+
raise typer.Exit(1)
48+
if not 1 <= min_round_wins <= rounds:
49+
typer.echo(f"ladder_rules.min_round_wins must be in [1, {rounds}] (tournament.rounds), got {min_round_wins}.")
50+
raise typer.Exit(1)
3151

32-
# win_last_k: number of trailing rounds the player must win (1 == just the final round).
52+
# win_last_k: number of trailing rounds the player must win (1 == just the final round, 0 == disabled).
3353
if isinstance(win_last_k, bool) or not isinstance(win_last_k, int):
3454
typer.echo(f"ladder_rules.win_last_k must be an integer, got {win_last_k!r}.")
3555
raise typer.Exit(1)
36-
if win_last_k < 1:
56+
if win_last_k < 0:
3757
typer.echo(
38-
f"ladder_rules.win_last_k must be >= 1, got {win_last_k}. Use 1 to require winning only the final round."
58+
f"ladder_rules.win_last_k must be >= 0, got {win_last_k}. "
59+
"Use 0 to disable the trailing-rounds requirement, or 1 to require winning only the final round."
3960
)
4061
raise typer.Exit(1)
41-
if win_last_k > rounds:
42-
typer.echo(f"ladder_rules.win_last_k ({win_last_k}) cannot exceed tournament.rounds ({rounds}).")
43-
raise typer.Exit(1)
44-
45-
# min_round_win_fraction: player must win >= this fraction of the agent rounds (round 0 excluded).
46-
if isinstance(min_round_win_fraction, bool) or not isinstance(min_round_win_fraction, (int, float)):
47-
typer.echo(f"ladder_rules.min_round_win_fraction must be a number, got {min_round_win_fraction!r}.")
48-
raise typer.Exit(1)
49-
if not 0 <= min_round_win_fraction <= 1:
62+
if win_last_k > min_round_wins:
5063
typer.echo(
51-
f"ladder_rules.min_round_win_fraction must be in [0, 1], got {min_round_win_fraction}. "
52-
"The player must win >= this fraction of the agent rounds; 1 requires winning all of them, "
53-
"0 drops the fraction requirement."
64+
f"ladder_rules.win_last_k ({win_last_k}) cannot exceed ladder_rules.min_round_wins ({min_round_wins})."
5465
)
5566
raise typer.Exit(1)
5667

57-
return float(min_round_win_fraction), win_last_k
68+
return min_round_wins, win_last_k
5869

5970

6071
ladder_app = typer.Typer(
@@ -74,7 +85,7 @@ def make(
7485
):
7586
"""Build a ladder: run PvP tournaments across all pairs of players (for ranking).
7687
77-
[dim]• codeclash ladder make configs/ablations/ladder/make_battlesnake.yaml[/dim]
88+
[dim]• codeclash ladder make configs/ladder/make_battlesnake.yaml[/dim]
7889
"""
7990
yaml_content = config_path.read_text()
8091
preprocessed_yaml = resolve_includes(yaml_content, base_dir=CONFIG_DIR)
@@ -141,20 +152,25 @@ def run(
141152
config["tournament"]["rounds"],
142153
config["game"]["sims_per_round"],
143154
)
144-
min_round_win_fraction, win_last_k = _resolve_ladder_rules(config.get("ladder_rules", {}), rounds)
155+
min_round_wins, win_last_k = _resolve_ladder_rules(config.get("ladder_rules", {}), rounds)
145156
timestamp = time.strftime("%y%m%d%H%M%S")
146157
del config["player"]
147158
del config["ladder"]
148159
config.pop("ladder_rules", None)
149160

150-
print(
151-
f"Ladder advancement rule: win >= {min_round_win_fraction:.0%} of {rounds} agent rounds "
152-
f"(baseline round 0 excluded) and win the last {win_last_k} round(s)."
161+
last_k_rule = "disabled" if win_last_k == 0 else f"win the last {win_last_k} round(s)"
162+
advancement_rule = (
163+
f"Ladder advancement rule: win >= {min_round_wins} of {rounds} agent rounds "
164+
f"(baseline round 0 excluded) and {last_k_rule}."
153165
)
166+
print(advancement_rule)
167+
logger.info(advancement_rule)
154168
ladder_folder = f"LadderTournament.{config['game']['name']}.r{rounds}.s{sims}.{player['name']}.{timestamp}"
155169
player["branch"] = ladder_folder
156170
parent_dir = LOCAL_LOG_DIR / getpass.getuser() / ladder_folder
157171

172+
rungs_cleared = 0
173+
advanced = False
158174
for idx, opponent in enumerate(ladder):
159175
opponent_rank = len(ladder) - idx
160176
opponent["name"] = opponent["branch_init"].replace("human/", "").replace("/", "_")
@@ -190,24 +206,37 @@ def run(
190206
metadata = yaml.safe_load(f)
191207
round_winners = [r["winner"] for k, r in metadata["round_stats"].items() if int(k) != 0]
192208

193-
# Advancement rule (configurable via `ladder_rules`): win at least
194-
# `min_round_win_fraction` of the agent rounds AND win the last `win_last_k` rounds.
209+
# Advancement rule (required via `ladder_rules`): win at least `min_round_wins` of the
210+
# agent rounds AND win the last `win_last_k` rounds. win_last_k == 0 disables the
211+
# trailing-rounds requirement.
195212
player_wins = sum(1 for w in round_winners if w == player["name"])
196-
won_majority = player_wins >= len(round_winners) * min_round_win_fraction
197-
won_last_k = all(w == player["name"] for w in round_winners[-win_last_k:])
213+
won_majority = player_wins >= min_round_wins
214+
won_last_k = win_last_k == 0 or all(w == player["name"] for w in round_winners[-win_last_k:])
215+
advanced = won_majority and won_last_k
198216

199-
if not won_majority or not won_last_k:
217+
# Record this rung's outcome in its metadata.json (durable gameplay log). The rule itself
218+
# (min_round_wins, win_last_k) is constant across the run and lives in the ladder summary.
219+
metadata["ladder_advancement"] = {
220+
"player_wins": player_wins,
221+
"won_last_k": won_last_k,
222+
"cleared": advanced,
223+
}
224+
with open(metadata_path, "w") as f:
225+
json.dump(metadata, f, indent=2)
226+
227+
if not advanced:
200228
# Player failed the advancement rule; the ladder challenge ends here.
201229
print("=" * 10)
202230
print(
203231
f"{player['name']} did not clear {opponent['name']} "
204232
f"(rank {opponent_rank}/{len(ladder)}): won {player_wins}/{len(round_winners)} agent rounds "
205-
f"(needed >= {min_round_win_fraction:.0%}), last {win_last_k} round(s) won: {won_last_k}.\n"
233+
f"(needed >= {min_round_wins}), last {win_last_k} round(s) won: {won_last_k}.\n"
206234
"Ladder challenge ends."
207235
)
208236
print("=" * 10)
209237
break
210238

239+
rungs_cleared += 1
211240
print("=" * 10)
212241
print(
213242
f"{player['name']} successfully beat {opponent['name']} (rank {opponent_rank}/{len(ladder)}) "
@@ -216,5 +245,22 @@ def run(
216245
)
217246
print("=" * 10)
218247

248+
# Persist the overall climb result to a ladder-level metadata.json in the run's parent dir.
249+
ladder_summary = {
250+
"player": player["name"],
251+
"game": config["game"]["name"],
252+
"rounds": rounds,
253+
"min_round_wins": min_round_wins,
254+
"win_last_k": win_last_k,
255+
"ladder_size": len(ladder),
256+
"rungs_cleared": rungs_cleared,
257+
"final_opponent": opponent["name"],
258+
"final_opponent_rank": opponent_rank,
259+
"cleared_ladder": rungs_cleared == len(ladder),
260+
}
261+
parent_dir.mkdir(parents=True, exist_ok=True)
262+
with open(parent_dir / "metadata.json", "w") as f:
263+
json.dump(ladder_summary, f, indent=2)
264+
219265
print(f"Ladder tournament complete. Logs saved to {parent_dir}")
220266
print(f"Final opponent faced: {opponent['name']} (rank {opponent_rank}/{len(ladder)} in ladder)")

codeclash/tournaments/pvp.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def get_agent(self, agent_config: dict, prompts: dict) -> Player:
7979
round=1,
8080
rounds=self.rounds,
8181
working_dir=str(DIR_WORK),
82+
arena_description=self.game.description,
8283
)
8384

8485
return get_agent(agent_config, game_context, environment)

codeclash/utils/generate_confs.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
44
Each configuration file specifies a tournament between two models in a given arena,
55
including the number of rounds and simulations per round. The configurations are saved
6-
as YAML files in the specified output directory (default: configs/main/).
6+
as YAML files in the specified output directory (default: configs/pvp/).
77
88
Also generates a tracking JSON file at configs/tracker.json to keep track of
99
the number of tournaments and rounds played for each pair of models in each arena.
1010
1111
Usage:
1212
13-
python codeclash/utils/generate_confs.py -m configs/models.yaml -r 15 -s 1000
13+
python codeclash/utils/generate_confs.py -m configs/mini/model_roster.yaml -r 15 -s 1000
1414
"""
1515

1616
import argparse
@@ -184,7 +184,7 @@ def main(models, arenas, rounds: int, simulations: int, record_ratio: float, out
184184
"-m",
185185
"--models",
186186
type=str,
187-
default="configs/models.yaml",
187+
default="configs/mini/model_roster.yaml",
188188
help="Path to model configurations.",
189189
)
190190
parser.add_argument(
@@ -218,7 +218,7 @@ def main(models, arenas, rounds: int, simulations: int, record_ratio: float, out
218218
"-o",
219219
"--output",
220220
type=Path,
221-
default=Path("configs/main/"),
221+
default=Path("configs/pvp/"),
222222
help="Output directory for configuration files (default: main/).",
223223
)
224224
args = parser.parse_args()

codeclash/viewer/static/js/picker.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ async function fillTextareaWithAWSSubmitCommands() {
503503
// Format output as AWS submit commands
504504
const commands = successfulResults.map(
505505
(result) =>
506-
`aws/run_job.py -- aws/docker_and_sync.sh codeclash run configs/main/${result.config_name}`,
506+
`aws/run_job.py -- aws/docker_and_sync.sh codeclash run configs/pvp/${result.config_name}`,
507507
);
508508
textarea.value = commands.join("\n");
509509

0 commit comments

Comments
 (0)