Skip to content

Commit efa49c9

Browse files
committed
Merge branch 'main' of https://github.com/CodeClash-ai/CodeClash into john/gomoku-ladder
# Conflicts: # configs/ladder/make_gomoku.yaml
2 parents 52c3c3b + 9ec20b9 commit efa49c9

256 files changed

Lines changed: 1154 additions & 1505 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.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
---
2+
name: create-arena-ladder
3+
description: >-
4+
Build a CC:Ladder for any CodeClash arena: import human-written solutions as git
5+
branches, rank them via round-robin PvP + Elo, and assemble the ladder configs.
6+
Use when asked to "create a ladder", "import human solutions", "push human bots as
7+
branches", or "make a CC:<arena> ladder" for arenas like BattleSnake, RobotRumble,
8+
CoreWar, Gomoku, RoboCode, SCML, etc.
9+
---
10+
11+
# Create an Arena Ladder (CC:Ladder)
12+
13+
A **ladder** turns a curated set of human-written bots into a ranked gauntlet, then
14+
measures how far up a model can climb. Two phases: **make the ladder** (rank the humans
15+
via round-robin), then **run the ladder** (a model climbs rung by rung until it loses).
16+
17+
This skill produces, for arena `<A>`: `human/<author>/<name>` branches on `CodeClash-ai/<A>`,
18+
a `make_<a>.yaml` (round-robin), a ranked `rungs/<a>.yaml`, and a run config `<a>.yaml`.
19+
20+
Work in the `repo/` clone. Reference implementations to mirror in
21+
`configs/ablations/ladder/`: `make_battlesnake.yaml` (round-robin), `battlesnake.yaml`
22+
(run), `rungs/battlesnake.yaml` (ranked opponents), and its `README.md`. The arena's own
23+
`codeclash/arenas/<a>/<a>.py` is the source of truth for the submission contract.
24+
25+
---
26+
27+
## Mechanics you must respect (arena-agnostic)
28+
29+
- **Where human code lives:** each arena has its **own** repo under `CodeClash-ai`
30+
(hardcoded `GH_ORG` in `codeclash/constants.py`). The arena Dockerfile `git clone`s it
31+
into `/workspace`. Human bots are **branches of that per-arena repo**, NOT of this
32+
monorepo — so `git branch -a` here shows no `human/*`.
33+
- **How a branch becomes a player:** a player with `branch_init: human/foo/bar` makes
34+
`Player.__init__` (`codeclash/agents/player.py`) run `git fetch && git checkout` in the
35+
clone; that branch's files overlay `/workspace`. `agent: dummy` = static opponent.
36+
`push: True` (the climbing player) needs `GITHUB_TOKEN`.
37+
- **What a branch must contain:** the arena's **submission file(s)** at the path its
38+
`validate_code` expects; everything else (engine, assets) comes from the base clone.
39+
**The single source of truth is `codeclash/arenas/<a>/<a>.py` — read its `submission`
40+
attribute and `validate_code` method.** Those two define the contract. Examples:
41+
BattleSnake → `main.py` HTTP server (`info/start/end/move`); Gomoku → `main.py` with
42+
`get_move(board,color)`; SCML → agent with `decide(observation)`; RoboCode → Java class
43+
under `robots/custom/`; CoreWar → `warrior.red`.
44+
45+
---
46+
47+
## Phase 0 — Prerequisites
48+
49+
- Arena class `codeclash/arenas/<a>/<a>.py` exists; record its `submission` path + exact
50+
`validate_code` requirements.
51+
- Arena repo `github.com/CodeClash-ai/<A>` exists and its Dockerfile clones it.
52+
- Docker running; `GITHUB_TOKEN` set (public repos → `gh auth token` works for push + run).
53+
54+
## Phase 1 — Source many human solutions
55+
56+
The hard part is finding a large set. Best sources: official leaderboards and
57+
`awesome-<arena>` repos (BattleSnake used `awesome-battlesnake`; RobotRumble crawled
58+
`robotrumble.org/boards/2`; RoboCode drew from RoboWiki/GitHub). Capture author + bot name
59+
per candidate → these become the branch slugs. Keep a provenance record (source URL,
60+
author, license) as you go — a table plus a header comment in each imported file.
61+
62+
## Phase 2 — Adapt each solution to the arena's contract
63+
64+
Every bot must end up matching the one submission contract. Do NOT discard a bot merely for
65+
being in another language — pick the cheapest import shape:
66+
67+
- **Copy-in** — source is already in the arena's language/framework: drop the files in and
68+
rename/repackage (e.g. RoboCode: main class → `MyTank`, `package custom;`). Mechanical.
69+
- **Function-contract port** — reimplement the core "given state, choose a move" logic as
70+
the arena's single entry function (Gomoku `get_move`, SCML `decide`, BattleSnake `move`).
71+
Ignore the source's GUI/protocol/CLI wrapper; keep its evaluation + search faithful.
72+
73+
Porting hard rules (adapt per arena; worth writing up as a guide for any porting agents):
74+
- **Runtime-only deps** — match the arena image (often stdlib-only Python 3.10; re-express
75+
array math in pure Python). No trained weights / NN unless you can obtain the binary.
76+
- **One entry point, never raise** — a crash or illegal move = a forfeit; wrap the body and
77+
fall back to a safe legal move.
78+
- **Fast enough** — a round-robin plays many games; cap search depth / rollouts.
79+
- Only skip a bot if it truly can't run. **Log every skip with a reason — never silently
80+
drop bots.**
81+
82+
## Phase 3 — Validate (two-stage), then push
83+
84+
Local validation is necessary but NOT sufficient — a local shim skips Docker, the repo's
85+
`server.py`, and real payloads. Gate every bot in two stages before it earns a branch:
86+
87+
1. **Stage 1 (local):** syntax/import + the arena's `validate_code` legality check. Cheap;
88+
catches most breakage without Docker. Fix or drop failures.
89+
2. **Stage 2 (arena, REQUIRED):** play each stage-1 pass through the real arena image and
90+
confirm it completes a full game without erroring. This is the step that actually gates.
91+
Requires Docker. (Scripting both stages over a folder of candidates is worth it at scale.)
92+
3. **Push** each stage-2-healthy bot as `human/<author>/<name>` (dedupe identical content).
93+
Use consistent kebab/lowercase slugs; branches must be pushed before any arena run, since
94+
`branch_init` fetches from the remote.
95+
96+
## Phase 4 — Make the ladder (round-robin + Elo)
97+
98+
1. Write `configs/ablations/ladder/make_<a>.yaml` (mirror `make_battlesnake.yaml`):
99+
`tournament.rounds: 0`, a `game` block, and `players:` = every `human/*` branch as
100+
`{agent: dummy, branch_init: ...}`.
101+
2. Pre-build the image **once** to avoid a build stampede under many workers:
102+
`docker build -t codeclash/<a> -f codeclash/arenas/<a>/<A>.Dockerfile .`
103+
3. Run all-pairs PvP (resumable — skips pairs already logged; `--workers ≈ cores-2`):
104+
`GITHUB_TOKEN=$(gh auth token) uv run codeclash ladder make configs/ablations/ladder/make_<a>.yaml --workers N`
105+
→ logs land under `logs/ladder/<A>/`. Fast arenas run on a laptop; big ones (e.g. SCML's
106+
~1275 pairs) want an AWS box under `tmux`/`nohup`.
107+
4. Rank: `uv run python -m codeclash.analysis.metrics.elo -d logs/ladder/<A> --include-round-0 --output-dir assets/<a>_elo`
108+
→ prints the Bradley-Terry/Elo order (weakest → strongest); that ordering IS the ladder.
109+
- **`--include-round-0` is REQUIRED for ladder construction.** `ladder make` uses
110+
`tournament.rounds: 0`, so round 0 IS the match; without the flag every tournament is
111+
dropped (round 0 is normally the excluded identical-codebases baseline) and the fit
112+
crashes on an empty matrix. Do NOT pass it for normal multi-round PvP/climbing Elo.
113+
- Use `uv run python`, not bare `python` — the analysis deps (matplotlib) live in the uv venv.
114+
- Tip: run a cheap low-`sims` pilot first and eyeball that baselines sit near the bottom.
115+
116+
## Phase 5 — Assemble the ranked configs
117+
118+
1. `configs/ablations/ladder/rungs/<a>.yaml` — the ranked opponents, **weakest first,
119+
strongest last** (each `{agent: dummy, branch_init: human/...}`), in Elo order.
120+
2. `configs/ablations/ladder/<a>.yaml` — the climber `player` (starting at the weakest
121+
rung, `push: True`) + `ladder: !include ablations/ladder/rungs/<a>.yaml` + a
122+
`ladder_rules` block. Model on `battlesnake.yaml`.
123+
3. Optional `<a>__<model>.yaml` per-model variants (swap `model: !include mini/models/...`);
124+
they share the same `rungs/<a>.yaml` include.
125+
126+
`ladder_rules` (optional; defaults reproduce historical behavior):
127+
```yaml
128+
ladder_rules:
129+
min_round_win_fraction: 0.5 # must win strictly more than this fraction of rounds
130+
win_last_k: 1 # ...and must win the last K rounds
131+
```
132+
133+
## Phase 6 — Run the ladder
134+
135+
`uv run codeclash ladder run configs/ablations/ladder/<a>.yaml`
136+
→ prints the highest rung reached; logs under `LOCAL_LOG_DIR/<user>/LadderTournament.*`.
137+
138+
---
139+
140+
## Deliverables checklist
141+
- [ ] N validated `human/<author>/<name>` branches pushed to `CodeClash-ai/<A>`.
142+
- [ ] **Stage-2 arena smoke passed** (each bot plays a real game in Docker), skips logged.
143+
- [ ] `make_<a>.yaml` + round-robin run + Elo ranking (`assets/<a>_elo`).
144+
- [ ] `rungs/<a>.yaml` (weakest→strongest) + `<a>.yaml` (climber + `ladder_rules`).
145+
- [ ] `ladder run` executes end-to-end against a sample model.
146+
- [ ] Provenance recorded (source/author/license per bot); note in `ladder/README.md` if
147+
the arena is new.
148+
149+
## Gotchas
150+
- Human branches go to the **per-arena** repo, not the monorepo; `branch_init` fetches from
151+
the remote, so **push before any run** and keep Docker up.
152+
- A bot that fails `validate_code` silently forfeits. A local shim can pass yet fail in the
153+
arena — the **stage-2 Docker smoke** is the real gate, not stage 1.
154+
- Pre-build the arena image once before a `--workers N` run, or workers stampede the build.
155+
- Port aggressively into the single submission contract; skip only un-runnable bots, and
156+
log every skip. A port must reproduce the original's behavior.

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/analysis/metrics/elo.py

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def __init__(
4040
score_type: SCORING_TYPES = "per_round_tertiary",
4141
max_round: int = 15,
4242
only_specific_round: bool = False,
43+
include_round_0: bool = False,
4344
):
4445
"""This class builds a win matrix from a log directory, it doesn't fit anything yet.
4546
It also adds a "ALL" game to the win matrix, which is the sum of all games.
@@ -62,6 +63,9 @@ def __init__(
6263
6364
The `max_round` parameter controls the maximum number of rounds to include in the score calculation (default: 15).
6465
The `only_specific_round` parameter controls whether to only include the specific round (True) or all rounds up to max_round (False).
66+
The `include_round_0` parameter controls whether round 0 is counted. In normal PvP/climbing
67+
tournaments round 0 is the identical-codebases baseline and is excluded. For ladder
68+
construction (`ladder make`, `tournament.rounds: 0`) round 0 IS the match, so set this True.
6569
"""
6670
self.win_matrix: dict[str, dict[tuple[str, str], list[float]]] = defaultdict(
6771
lambda: defaultdict(lambda: [0.0, 0.0])
@@ -71,6 +75,7 @@ def __init__(
7175
self.score_type = score_type
7276
self.max_round = max_round
7377
self.only_specific_round = only_specific_round
78+
self.include_round_0 = include_round_0
7479
self._samples: dict[str, dict[tuple[str, str], list[tuple[float, float]]]] = defaultdict(
7580
lambda: defaultdict(list)
7681
)
@@ -154,13 +159,19 @@ def _process_tournament(self, metadata_path: Path) -> None:
154159
return
155160

156161
player_names = [p["name"] for p in players]
157-
models = [p["config"]["model"]["model_name"].strip("@") for p in players]
162+
models = []
163+
for p in players:
164+
try:
165+
models.append(p["config"]["model"]["model_name"].strip("@"))
166+
except KeyError:
167+
# Ladder bots have no model config; identify by branch (flatten "/" to keep years distinct).
168+
models.append(p["name"].removeprefix("human/").replace("/", "__"))
158169

159170
# Aggregate scores for each round
160171
p1_round_scores = []
161172
p2_round_scores = []
162173
for idx, stats in metadata["round_stats"].items():
163-
if idx == "0":
174+
if idx == "0" and not self.include_round_0:
164175
continue
165176

166177
round_num = int(idx)
@@ -1547,6 +1558,12 @@ def write_latex_table_plain(results: dict[str, dict], output_dir: Path) -> None:
15471558
parser = argparse.ArgumentParser(description="Build win matrix and fit Bradley-Terry model")
15481559
parser.add_argument("-d", "--log_dir", type=Path, default=LOCAL_LOG_DIR)
15491560
parser.add_argument("--print-matrix", action="store_true", help="Print win matrix")
1561+
parser.add_argument(
1562+
"--include-round-0",
1563+
action="store_true",
1564+
help="Count round 0 (normally the excluded identical-codebases baseline). REQUIRED for "
1565+
"ladder construction (`ladder make` uses tournament.rounds: 0, so round 0 IS the match).",
1566+
)
15501567
parser.add_argument(
15511568
"-s",
15521569
"--score-type",
@@ -1578,7 +1595,9 @@ def write_latex_table_plain(results: dict[str, dict], output_dir: Path) -> None:
15781595
args = parser.parse_args()
15791596

15801597
builder = ScoreMatrixBuilder(
1581-
all_games_normalization_scheme=args.all_normalization_scheme, score_type=args.score_type
1598+
all_games_normalization_scheme=args.all_normalization_scheme,
1599+
score_type=args.score_type,
1600+
include_round_0=args.include_round_0,
15821601
)
15831602
builder.build(args.log_dir)
15841603

@@ -1632,22 +1651,26 @@ def write_latex_table_plain(results: dict[str, dict], output_dir: Path) -> None:
16321651
).run()
16331652
write_bootstrap_metrics_table(bootstrap_results, args.output_dir, game="ALL")
16341653

1635-
logger.info("Running EloVsMaxRounds analysis")
1636-
EloVsMaxRounds(
1637-
log_dir=args.log_dir,
1638-
max_rounds=15,
1639-
all_games_normalization_scheme=args.all_normalization_scheme,
1640-
score_type=args.score_type,
1641-
regularization=args.regularization,
1642-
output_dir=args.output_dir,
1643-
).run()
1644-
1645-
logger.info("Running EloOnlyAtRound analysis")
1646-
EloOnlyAtRound(
1647-
log_dir=args.log_dir,
1648-
max_rounds=15,
1649-
all_games_normalization_scheme=args.all_normalization_scheme,
1650-
score_type=args.score_type,
1651-
regularization=args.regularization,
1652-
output_dir=args.output_dir,
1653-
).run()
1654+
# Max-round analyses are multi-round-only; skip them for single-round ladder round-robins.
1655+
if not args.include_round_0:
1656+
logger.info("Running EloVsMaxRounds analysis")
1657+
EloVsMaxRounds(
1658+
log_dir=args.log_dir,
1659+
max_rounds=15,
1660+
all_games_normalization_scheme=args.all_normalization_scheme,
1661+
score_type=args.score_type,
1662+
regularization=args.regularization,
1663+
output_dir=args.output_dir,
1664+
).run()
1665+
1666+
logger.info("Running EloOnlyAtRound analysis")
1667+
EloOnlyAtRound(
1668+
log_dir=args.log_dir,
1669+
max_rounds=15,
1670+
all_games_normalization_scheme=args.all_normalization_scheme,
1671+
score_type=args.score_type,
1672+
regularization=args.regularization,
1673+
output_dir=args.output_dir,
1674+
).run()
1675+
else:
1676+
logger.info("Skipping EloVsMaxRounds / EloOnlyAtRound (ladder mode: single round-0 round-robin)")

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."""

0 commit comments

Comments
 (0)