Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ codeclash run configs/test/battlesnake.yaml
Once this works, you should be set up to run a real tournament!
To run *Claude Sonnet 4.5* against *o3* in a *BattleSnake* tournament with *5 rounds* and *1000 competition simulations* per round, run:
```bash
uv run codeclash run configs/examples/BattleSnake__claude-sonnet-4-5-20250929__o3__r5__s1000.yaml
uv run codeclash run configs/pvp/BattleSnake__claude-sonnet-4-5-20250929__o3__r15__s1000.yaml
```

## ⚔️ How It Works
Expand Down
2 changes: 1 addition & 1 deletion codeclash/agents/mini_anthropic_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
`model_class: codeclash.agents.mini_anthropic_model.AnthropicModel` and provide the API key,
base URL, and model name (see the `*_env` config fields, which keep endpoint-specific values in
the environment rather than in committed configs). Requires the optional `anthropic` dependency
(`uv pip install -e '.[llama]'`). See configs/ablations/ladder/robotrumble_llama.yaml.
(`uv pip install -e '.[llama]'`). See configs/ladder/robotrumble_llama.yaml.
"""

import json
Expand Down
1 change: 1 addition & 0 deletions codeclash/agents/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class GameContext(BaseModel):
round: int
rounds: int
working_dir: str
arena_description: str = ""

def _render_prompt_templates(self) -> dict:
context = self.model_dump()
Expand Down
2 changes: 1 addition & 1 deletion codeclash/analysis/code_evolve/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from codeclash.arenas import ARENAS
from codeclash.constants import LOCAL_LOG_DIR

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


Expand Down
63 changes: 35 additions & 28 deletions codeclash/arenas/arena.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import random
import subprocess
import threading
import time
from abc import ABC, abstractmethod
from pathlib import Path
Expand Down Expand Up @@ -75,6 +76,10 @@ class CodeArena(ABC):
default_args: dict = {}
submission: str

# Serializes image builds across concurrent pairs (e.g. `ladder make --workers N`), so
# worker threads don't all race `docker build` on a cold start and fail with "already exists".
_build_lock = threading.Lock()

def __init__(self, config: dict, *, tournament_id: str, local_output_dir: Path, keep_containers: bool = False):
"""The CodeArena class is responsible for running games, i.e., taking a list of code
from different agents/players and running them against each other.
Expand Down Expand Up @@ -127,36 +132,38 @@ def build_image(self):
if is_running_in_aws_batch():
pull_game_container_aws_ecr(game_name=self.name, image_name=self.image_name, logger=self.logger)

# Check if container exists using subprocess
self.logger.debug(f"Checking if container {self.image_name} exists")
result = subprocess.run(
f"docker images -q {self.image_name}",
shell=True,
capture_output=True,
text=True,
)
if result.stdout.strip():
self.logger.debug(f"Container {self.image_name} exists")
return
# Hold the lock across check-and-build so concurrent pairs don't race: the first thread
# builds while the rest wait, then find the image already present and skip.
with CodeArena._build_lock:
self.logger.debug(f"Checking if container {self.image_name} exists")
result = subprocess.run(
f"docker images -q {self.image_name}",
shell=True,
capture_output=True,
text=True,
)
if result.stdout.strip():
self.logger.debug(f"Container {self.image_name} exists")
return

self.logger.info(
f"Building Docker image {self.image_name}. This may take 1-5 minutes and only work on Linux for some games."
)
self.logger.info(
f"Building Docker image {self.image_name}. This may take 1-5 minutes and only work on Linux for some games."
)

# NOTE: Assuming Dockerfile is declared in same directory as the arena.
arena_file = Path(inspect.getfile(self.__class__))
folder_path = arena_file.parent
result = subprocess.run(
f"docker build --no-cache -t {self.image_name} -f {folder_path}/{self.name}.Dockerfile .",
shell=True,
capture_output=True,
text=True,
)
if result.returncode == 0:
self.logger.info(f"✅ Built Docker image {self.image_name}")
else:
self.logger.error(f"❌ Failed to build Docker image: {result.stderr}\n{result.stdout}{result.stderr}")
raise RuntimeError(f"Failed to build Docker image: {result.stderr}")
# NOTE: Assuming Dockerfile is declared in same directory as the arena.
arena_file = Path(inspect.getfile(self.__class__))
folder_path = arena_file.parent
result = subprocess.run(
f"docker build --no-cache -t {self.image_name} -f {folder_path}/{self.name}.Dockerfile .",
shell=True,
capture_output=True,
text=True,
)
if result.returncode == 0:
self.logger.info(f"✅ Built Docker image {self.image_name}")
else:
self.logger.error(f"❌ Failed to build Docker image: {result.stderr}\n{result.stdout}{result.stderr}")
raise RuntimeError(f"Failed to build Docker image: {result.stderr}")

def copy_logs_from_env(self, round_num: int) -> None:
"""Copy logs from the game's environment to the local machine."""
Expand Down
5 changes: 5 additions & 0 deletions codeclash/arenas/battlesnake/battlesnake.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,9 @@ def validate_code(self, agent: Player) -> tuple[bool, str | None]:
error_msg.append(f"There should be a `{func}` function implemented in `{self.submission}`")
if len(error_msg) > 0:
return False, "\n".join(error_msg + ["Don't change the function signatures!"])
if "__main__" not in bot_content:
return False, (
f'`{self.submission}` must keep its `if __name__ == "__main__"` block that starts '
"the server, or the bot fails to launch."
)
return True, None
116 changes: 81 additions & 35 deletions codeclash/cli/ladder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import copy
import getpass
import json
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
Expand All @@ -18,43 +19,53 @@
logger = get_logger("ladder")


def _resolve_ladder_rules(ladder_rules: dict, rounds: int) -> tuple[float, int]:
"""Validate the optional ``ladder_rules`` block and return ``(min_round_win_fraction, win_last_k)``.
def _resolve_ladder_rules(ladder_rules: dict, rounds: int) -> tuple[int, int]:
"""Validate the required ``ladder_rules`` block and return ``(min_round_wins, win_last_k)``.

Defaults: win at least ``min_round_win_fraction`` (0.4) of the *agent* rounds AND win the last
``win_last_k`` (1) round(s). The baseline round 0 (identical, un-edited codebases) is excluded
from this count — it reflects game variance, not the agent — so the fraction is taken over the
``rounds`` rounds the agent actually edits.
Both keys must be specified explicitly in the config (no defaults):
- ``min_round_wins``: the whole number of *agent* rounds the player must win to advance
(a ``>=`` threshold). Must be ``1 <= min_round_wins <= rounds``.
- ``win_last_k``: the player must win the last ``win_last_k`` round(s). ``1`` means just the final
round; ``0`` disables the trailing-rounds requirement entirely. Must be ``<= min_round_wins``.

The baseline round 0 (identical, un-edited codebases) is excluded from the count — it reflects
game variance, not the agent — so wins are counted over the ``rounds`` rounds the agent actually
edits (rounds 1..``rounds``).
"""
min_round_win_fraction = ladder_rules.get("min_round_win_fraction", 0.4)
win_last_k = ladder_rules.get("win_last_k", 1)
if "min_round_wins" not in ladder_rules:
typer.echo("ladder_rules.min_round_wins is required; specify it explicitly in the config.")
raise typer.Exit(1)
if "win_last_k" not in ladder_rules:
typer.echo("ladder_rules.win_last_k is required; specify it explicitly in the config.")
raise typer.Exit(1)
min_round_wins = ladder_rules["min_round_wins"]
win_last_k = ladder_rules["win_last_k"]

# min_round_wins: whole number of agent rounds the player must win (round 0 excluded).
if isinstance(min_round_wins, bool) or not isinstance(min_round_wins, int):
typer.echo(f"ladder_rules.min_round_wins must be an integer, got {min_round_wins!r}.")
raise typer.Exit(1)
if not 1 <= min_round_wins <= rounds:
typer.echo(f"ladder_rules.min_round_wins must be in [1, {rounds}] (tournament.rounds), got {min_round_wins}.")
raise typer.Exit(1)

# win_last_k: number of trailing rounds the player must win (1 == just the final round).
# win_last_k: number of trailing rounds the player must win (1 == just the final round, 0 == disabled).
if isinstance(win_last_k, bool) or not isinstance(win_last_k, int):
typer.echo(f"ladder_rules.win_last_k must be an integer, got {win_last_k!r}.")
raise typer.Exit(1)
if win_last_k < 1:
if win_last_k < 0:
typer.echo(
f"ladder_rules.win_last_k must be >= 1, got {win_last_k}. Use 1 to require winning only the final round."
f"ladder_rules.win_last_k must be >= 0, got {win_last_k}. "
"Use 0 to disable the trailing-rounds requirement, or 1 to require winning only the final round."
)
raise typer.Exit(1)
if win_last_k > rounds:
typer.echo(f"ladder_rules.win_last_k ({win_last_k}) cannot exceed tournament.rounds ({rounds}).")
raise typer.Exit(1)

# min_round_win_fraction: player must win >= this fraction of the agent rounds (round 0 excluded).
if isinstance(min_round_win_fraction, bool) or not isinstance(min_round_win_fraction, (int, float)):
typer.echo(f"ladder_rules.min_round_win_fraction must be a number, got {min_round_win_fraction!r}.")
raise typer.Exit(1)
if not 0 <= min_round_win_fraction <= 1:
if win_last_k > min_round_wins:
typer.echo(
f"ladder_rules.min_round_win_fraction must be in [0, 1], got {min_round_win_fraction}. "
"The player must win >= this fraction of the agent rounds; 1 requires winning all of them, "
"0 drops the fraction requirement."
f"ladder_rules.win_last_k ({win_last_k}) cannot exceed ladder_rules.min_round_wins ({min_round_wins})."
)
raise typer.Exit(1)

return float(min_round_win_fraction), win_last_k
return min_round_wins, win_last_k


ladder_app = typer.Typer(
Expand All @@ -74,7 +85,7 @@ def make(
):
"""Build a ladder: run PvP tournaments across all pairs of players (for ranking).

[dim]• codeclash ladder make configs/ablations/ladder/make_battlesnake.yaml[/dim]
[dim]• codeclash ladder make configs/ladder/make_battlesnake.yaml[/dim]
"""
yaml_content = config_path.read_text()
preprocessed_yaml = resolve_includes(yaml_content, base_dir=CONFIG_DIR)
Expand Down Expand Up @@ -141,20 +152,25 @@ def run(
config["tournament"]["rounds"],
config["game"]["sims_per_round"],
)
min_round_win_fraction, win_last_k = _resolve_ladder_rules(config.get("ladder_rules", {}), rounds)
min_round_wins, win_last_k = _resolve_ladder_rules(config.get("ladder_rules", {}), rounds)
timestamp = time.strftime("%y%m%d%H%M%S")
del config["player"]
del config["ladder"]
config.pop("ladder_rules", None)

print(
f"Ladder advancement rule: win >= {min_round_win_fraction:.0%} of {rounds} agent rounds "
f"(baseline round 0 excluded) and win the last {win_last_k} round(s)."
last_k_rule = "disabled" if win_last_k == 0 else f"win the last {win_last_k} round(s)"
advancement_rule = (
f"Ladder advancement rule: win >= {min_round_wins} of {rounds} agent rounds "
f"(baseline round 0 excluded) and {last_k_rule}."
)
print(advancement_rule)
logger.info(advancement_rule)
ladder_folder = f"LadderTournament.{config['game']['name']}.r{rounds}.s{sims}.{player['name']}.{timestamp}"
player["branch"] = ladder_folder
parent_dir = LOCAL_LOG_DIR / getpass.getuser() / ladder_folder

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

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

if not won_majority or not won_last_k:
# Record this rung's outcome in its metadata.json (durable gameplay log). The rule itself
# (min_round_wins, win_last_k) is constant across the run and lives in the ladder summary.
metadata["ladder_advancement"] = {
"player_wins": player_wins,
"won_last_k": won_last_k,
"cleared": advanced,
}
with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2)

if not advanced:
# Player failed the advancement rule; the ladder challenge ends here.
print("=" * 10)
print(
f"{player['name']} did not clear {opponent['name']} "
f"(rank {opponent_rank}/{len(ladder)}): won {player_wins}/{len(round_winners)} agent rounds "
f"(needed >= {min_round_win_fraction:.0%}), last {win_last_k} round(s) won: {won_last_k}.\n"
f"(needed >= {min_round_wins}), last {win_last_k} round(s) won: {won_last_k}.\n"
"Ladder challenge ends."
)
print("=" * 10)
break

rungs_cleared += 1
print("=" * 10)
print(
f"{player['name']} successfully beat {opponent['name']} (rank {opponent_rank}/{len(ladder)}) "
Expand All @@ -216,5 +245,22 @@ def run(
)
print("=" * 10)

# Persist the overall climb result to a ladder-level metadata.json in the run's parent dir.
ladder_summary = {
"player": player["name"],
"game": config["game"]["name"],
"rounds": rounds,
"min_round_wins": min_round_wins,
"win_last_k": win_last_k,
"ladder_size": len(ladder),
"rungs_cleared": rungs_cleared,
"final_opponent": opponent["name"],
"final_opponent_rank": opponent_rank,
"cleared_ladder": rungs_cleared == len(ladder),
}
parent_dir.mkdir(parents=True, exist_ok=True)
with open(parent_dir / "metadata.json", "w") as f:
json.dump(ladder_summary, f, indent=2)

print(f"Ladder tournament complete. Logs saved to {parent_dir}")
print(f"Final opponent faced: {opponent['name']} (rank {opponent_rank}/{len(ladder)} in ladder)")
1 change: 1 addition & 0 deletions codeclash/tournaments/pvp.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def get_agent(self, agent_config: dict, prompts: dict) -> Player:
round=1,
rounds=self.rounds,
working_dir=str(DIR_WORK),
arena_description=self.game.description,
)

return get_agent(agent_config, game_context, environment)
Expand Down
8 changes: 4 additions & 4 deletions codeclash/utils/generate_confs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@

Each configuration file specifies a tournament between two models in a given arena,
including the number of rounds and simulations per round. The configurations are saved
as YAML files in the specified output directory (default: configs/main/).
as YAML files in the specified output directory (default: configs/pvp/).

Also generates a tracking JSON file at configs/tracker.json to keep track of
the number of tournaments and rounds played for each pair of models in each arena.

Usage:

python codeclash/utils/generate_confs.py -m configs/models.yaml -r 15 -s 1000
python codeclash/utils/generate_confs.py -m configs/mini/model_roster.yaml -r 15 -s 1000
"""

import argparse
Expand Down Expand Up @@ -184,7 +184,7 @@ def main(models, arenas, rounds: int, simulations: int, record_ratio: float, out
"-m",
"--models",
type=str,
default="configs/models.yaml",
default="configs/mini/model_roster.yaml",
help="Path to model configurations.",
)
parser.add_argument(
Expand Down Expand Up @@ -218,7 +218,7 @@ def main(models, arenas, rounds: int, simulations: int, record_ratio: float, out
"-o",
"--output",
type=Path,
default=Path("configs/main/"),
default=Path("configs/pvp/"),
help="Output directory for configuration files (default: main/).",
)
args = parser.parse_args()
Expand Down
2 changes: 1 addition & 1 deletion codeclash/viewer/static/js/picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ async function fillTextareaWithAWSSubmitCommands() {
// Format output as AWS submit commands
const commands = successfulResults.map(
(result) =>
`aws/run_job.py -- aws/docker_and_sync.sh codeclash run configs/main/${result.config_name}`,
`aws/run_job.py -- aws/docker_and_sync.sh codeclash run configs/pvp/${result.config_name}`,
);
textarea.value = commands.join("\n");

Expand Down
22 changes: 0 additions & 22 deletions configs/ablations/ladder/corewar__gemini_3_5_flash.yaml

This file was deleted.

Loading
Loading