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
86from pathlib import Path
97
108import typer
119import yaml
1210
1311from 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
1713from 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-
7115ladder_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" )
8030def 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