Skip to content

Commit 441403b

Browse files
committed
Add codeclash/tournaments/ladder.py, break up original impl to separate cli from tourney logic
1 parent 6321837 commit 441403b

2 files changed

Lines changed: 308 additions & 219 deletions

File tree

codeclash/cli/ladder.py

Lines changed: 22 additions & 219 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,17 @@
1-
"""`codeclash ladder` subcommands: build a ladder (make) and climb it (run)."""
1+
"""`codeclash ladder` subcommands: build a ladder (make) and climb it (run).
2+
3+
Thin CLI adapter over :mod:`codeclash.tournaments.ladder`, which holds all ladder logic.
4+
"""
25

3-
import copy
4-
import getpass
5-
import json
6-
import time
7-
from concurrent.futures import ThreadPoolExecutor, as_completed
86
from pathlib import Path
97

108
import typer
119
import yaml
1210

1311
from codeclash import CONFIG_DIR
14-
from codeclash.constants import LOCAL_LOG_DIR
15-
from codeclash.tournaments.pvp import PvpTournament
16-
from codeclash.utils.log import get_logger
12+
from codeclash.tournaments.ladder import LadderTournament, build_ladder, resolve_ladder_rules # noqa: F401
1713
from codeclash.utils.yaml_utils import resolve_includes
1814

19-
logger = get_logger("ladder")
20-
21-
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)``.
24-
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``).
34-
"""
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)
51-
52-
# win_last_k: number of trailing rounds the player must win (1 == just the final round, 0 == disabled).
53-
if isinstance(win_last_k, bool) or not isinstance(win_last_k, int):
54-
typer.echo(f"ladder_rules.win_last_k must be an integer, got {win_last_k!r}.")
55-
raise typer.Exit(1)
56-
if win_last_k < 0:
57-
typer.echo(
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."
60-
)
61-
raise typer.Exit(1)
62-
if win_last_k > min_round_wins:
63-
typer.echo(
64-
f"ladder_rules.win_last_k ({win_last_k}) cannot exceed ladder_rules.min_round_wins ({min_round_wins})."
65-
)
66-
raise typer.Exit(1)
67-
68-
return min_round_wins, win_last_k
69-
70-
7115
ladder_app = typer.Typer(
7216
no_args_is_help=True,
7317
add_completion=False,
@@ -76,6 +20,12 @@ def _resolve_ladder_rules(ladder_rules: dict, rounds: int) -> tuple[int, int]:
7620
)
7721

7822

23+
def _load_config(config_path: Path) -> dict:
24+
yaml_content = config_path.read_text()
25+
preprocessed_yaml = resolve_includes(yaml_content, base_dir=CONFIG_DIR)
26+
return yaml.safe_load(preprocessed_yaml)
27+
28+
7929
@ladder_app.command("make")
8030
def make(
8131
config_path: Path = typer.Argument(..., help="Path to the ladder (round-robin) config file."),
@@ -87,46 +37,7 @@ def make(
8737
8838
[dim]• codeclash ladder make configs/ladder/make_battlesnake.yaml[/dim]
8939
"""
90-
yaml_content = config_path.read_text()
91-
preprocessed_yaml = resolve_includes(yaml_content, base_dir=CONFIG_DIR)
92-
config = yaml.safe_load(preprocessed_yaml)
93-
94-
players = config["players"]
95-
num_players = len(players)
96-
97-
# Build one fully independent (deep-copied) config per pair up front so concurrent runs
98-
# never share or mutate the same player/config dicts.
99-
jobs: list[tuple[dict, Path]] = []
100-
for i in range(num_players):
101-
for j in range(i + 1, num_players):
102-
player1 = copy.deepcopy(players[i])
103-
player1["name"] = player1["branch_init"]
104-
player2 = copy.deepcopy(players[j])
105-
player2["name"] = player2["branch_init"]
106-
pvp_config = {**copy.deepcopy(config), "players": [player1, player2]}
107-
vs = f"PvpTournament.{player1['name']}_vs_{player2['name']}".replace("/", "_")
108-
output_dir = LOCAL_LOG_DIR / "ladder" / config["game"]["name"] / vs
109-
jobs.append((pvp_config, output_dir))
110-
111-
def run_pair(pvp_config: dict, output_dir: Path) -> None:
112-
try:
113-
tournament = PvpTournament(pvp_config, output_dir=output_dir)
114-
except FileExistsError:
115-
return # already completed by a previous invocation
116-
# A single failing pair must not abort the rest of a long round-robin.
117-
try:
118-
tournament.run()
119-
except Exception:
120-
logger.exception(f"Pair failed, skipping: {output_dir.name}")
121-
122-
if workers <= 1:
123-
for pvp_config, output_dir in jobs:
124-
run_pair(pvp_config, output_dir)
125-
else:
126-
with ThreadPoolExecutor(max_workers=workers) as executor:
127-
futures = [executor.submit(run_pair, c, d) for c, d in jobs]
128-
for f in as_completed(futures):
129-
f.result()
40+
build_ladder(_load_config(config_path), workers=workers)
13041

13142

13243
@ladder_app.command("run")
@@ -143,124 +54,16 @@ def run(
14354
14455
[dim]• codeclash ladder run path/to/ladder_config.yaml -c # clean up after each rung[/dim]
14556
"""
146-
yaml_content = config_path.read_text()
147-
preprocessed_yaml = resolve_includes(yaml_content, base_dir=CONFIG_DIR)
148-
config = yaml.safe_load(preprocessed_yaml)
149-
ladder, player, rounds, sims = (
150-
config["ladder"],
151-
config["player"],
152-
config["tournament"]["rounds"],
153-
config["game"]["sims_per_round"],
154-
)
155-
min_round_wins, win_last_k = _resolve_ladder_rules(config.get("ladder_rules", {}), rounds)
156-
timestamp = time.strftime("%y%m%d%H%M%S")
157-
del config["player"]
158-
del config["ladder"]
159-
config.pop("ladder_rules", None)
160-
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}."
165-
)
166-
print(advancement_rule)
167-
logger.info(advancement_rule)
168-
ladder_folder = f"LadderTournament.{config['game']['name']}.r{rounds}.s{sims}.{player['name']}.{timestamp}"
169-
player["branch"] = ladder_folder
170-
parent_dir = LOCAL_LOG_DIR / getpass.getuser() / ladder_folder
171-
172-
rungs_cleared = 0
173-
advanced = False
174-
for idx, opponent in enumerate(ladder):
175-
opponent_rank = len(ladder) - idx
176-
opponent["name"] = opponent["branch_init"].replace("human/", "").replace("/", "_")
177-
if "branch_init" in player and idx > 0:
178-
# After first opponent, remove branch_init so that player continues from previous tournament's codebase
179-
del player["branch_init"]
180-
c = {
181-
**config,
182-
"players": [
183-
player,
184-
opponent,
185-
],
186-
}
187-
188-
players = [p["name"] for p in c["players"]]
189-
p_num = len(players)
190-
p_list = ".".join(players)
191-
suffix_part = f".{suffix}" if suffix else ""
192-
folder_name = f"PvpTournament.{c['game']['name']}.r{rounds}.s{sims}.p{p_num}.{p_list}{suffix_part}"
193-
194-
tournament_dir = parent_dir / folder_name if output_dir is None else output_dir / folder_name
195-
tournament = PvpTournament(
196-
c,
197-
output_dir=tournament_dir,
57+
config = _load_config(config_path)
58+
try:
59+
tournament = LadderTournament(
60+
config,
61+
output_dir=output_dir,
62+
suffix=suffix,
19863
cleanup=cleanup,
19964
keep_containers=keep_containers,
20065
)
201-
tournament.run()
202-
203-
# Get results
204-
metadata_path = tournament_dir / "metadata.json"
205-
with open(metadata_path) as f:
206-
metadata = yaml.safe_load(f)
207-
round_winners = [r["winner"] for k, r in metadata["round_stats"].items() if int(k) != 0]
208-
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.
212-
player_wins = sum(1 for w in round_winners if w == player["name"])
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
216-
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:
228-
# Player failed the advancement rule; the ladder challenge ends here.
229-
print("=" * 10)
230-
print(
231-
f"{player['name']} did not clear {opponent['name']} "
232-
f"(rank {opponent_rank}/{len(ladder)}): won {player_wins}/{len(round_winners)} agent rounds "
233-
f"(needed >= {min_round_wins}), last {win_last_k} round(s) won: {won_last_k}.\n"
234-
"Ladder challenge ends."
235-
)
236-
print("=" * 10)
237-
break
238-
239-
rungs_cleared += 1
240-
print("=" * 10)
241-
print(
242-
f"{player['name']} successfully beat {opponent['name']} (rank {opponent_rank}/{len(ladder)}) "
243-
f"in {player_wins}/{len(round_winners)} rounds.\n"
244-
"Ladder challenge continuing"
245-
)
246-
print("=" * 10)
247-
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-
265-
print(f"Ladder tournament complete. Logs saved to {parent_dir}")
266-
print(f"Final opponent faced: {opponent['name']} (rank {opponent_rank}/{len(ladder)} in ladder)")
66+
except ValueError as e:
67+
typer.echo(str(e))
68+
raise typer.Exit(1)
69+
tournament.run()

0 commit comments

Comments
 (0)