|
| 1 | +"""Code evolution and consistency analysis across tournament games.""" |
| 2 | + |
| 3 | +import difflib |
| 4 | +import json |
| 5 | +from concurrent.futures import ProcessPoolExecutor, as_completed |
| 6 | +from pathlib import Path |
| 7 | + |
| 8 | +import matplotlib.colors as mcolors |
| 9 | +import matplotlib.pyplot as plt |
| 10 | +import numpy as np |
| 11 | +import yaml |
| 12 | +from tqdm.auto import tqdm |
| 13 | +from unidiff import PatchSet |
| 14 | + |
| 15 | +from codeclash.analysis.viz.utils import FONT_BOLD, MODEL_TO_COLOR, MODEL_TO_DISPLAY_NAME |
| 16 | +from codeclash.constants import LOCAL_LOG_DIR |
| 17 | +from codeclash.games import ARENAS |
| 18 | + |
| 19 | +DATA_CACHE = Path("assets/code_evolve_cache.jsonl") |
| 20 | +MODELS_PATH = Path("configs/models.yaml") |
| 21 | +TARGET_ROUNDS = [1, 5, 10, 15] |
| 22 | + |
| 23 | + |
| 24 | +def get_model_arena_logs(model: str | list[str], arena: str | list[str]) -> list[Path]: |
| 25 | + """Get all log folders matching the specified model(s) and arena(s).""" |
| 26 | + arena = arena if isinstance(arena, list) else [arena] |
| 27 | + model = model if isinstance(model, list) else [model] |
| 28 | + return [ |
| 29 | + x.parent |
| 30 | + for x in Path(LOCAL_LOG_DIR).rglob("metadata.json") |
| 31 | + if x.parent.name.split(".")[1] in arena and all(f".{m}." in x.parent.name for m in model) |
| 32 | + ] |
| 33 | + |
| 34 | + |
| 35 | +def get_submission_diff_at_round(log_folder: Path, player_name: str, round_num: int) -> PatchSet: |
| 36 | + """Extract submission-relevant code diff for a player at a specific round.""" |
| 37 | + arena = [a for a in ARENAS if a.name == log_folder.name.split(".")[1]][0] |
| 38 | + |
| 39 | + changes_file = log_folder / "players" / player_name / f"changes_r{round_num}.json" |
| 40 | + if not changes_file.exists(): |
| 41 | + raise FileNotFoundError(f"Changes file not found: {changes_file}") |
| 42 | + |
| 43 | + with open(changes_file) as f: |
| 44 | + changes_data = json.load(f) |
| 45 | + |
| 46 | + # Filter to submission-relevant files only |
| 47 | + patch = PatchSet(changes_data["full_diff"]) |
| 48 | + relevant = [] |
| 49 | + for file in patch: |
| 50 | + if file.path == arena.submission or file.path.startswith(arena.submission): |
| 51 | + relevant.append(file) |
| 52 | + |
| 53 | + return PatchSet("\n".join(str(f) for f in relevant)) |
| 54 | + |
| 55 | + |
| 56 | +def get_submission_diffs_at_round(log_folders: list[Path], player_name: str, round_num: int) -> dict[Path, PatchSet]: |
| 57 | + """Extract submission diffs for a player at a specific round across multiple tournaments.""" |
| 58 | + diffs = {} |
| 59 | + for folder in tqdm(log_folders, desc=f"Loading diffs for round {round_num}"): |
| 60 | + try: |
| 61 | + diffs[folder] = get_submission_diff_at_round(folder, player_name, round_num) |
| 62 | + except FileNotFoundError as e: |
| 63 | + print(e) |
| 64 | + continue |
| 65 | + return diffs |
| 66 | + |
| 67 | + |
| 68 | +def find_max_round_for_player(log_folder: Path, player_name: str) -> int: |
| 69 | + """Find the highest round number that exists for a player.""" |
| 70 | + player_dir = log_folder / "players" / player_name |
| 71 | + changes_files = list(player_dir.glob("changes_r*.json")) |
| 72 | + rounds = [int(f.stem.split("_r")[1]) for f in changes_files] |
| 73 | + return max(rounds) if rounds else 0 |
| 74 | + |
| 75 | + |
| 76 | +def compute_code_similarity(diff1: PatchSet, diff2: PatchSet) -> float: |
| 77 | + """Compute similarity score between two diffs using edit distance (0.0 = different, 1.0 = identical).""" |
| 78 | + diff1_str = "\n".join(str(f) for f in diff1) |
| 79 | + diff2_str = "\n".join(str(f) for f in diff2) |
| 80 | + seq_matcher = difflib.SequenceMatcher(None, diff1_str, diff2_str, autojunk=False) |
| 81 | + return seq_matcher.ratio() |
| 82 | + |
| 83 | + |
| 84 | +def _compute_similarity_row(args): |
| 85 | + """Helper for parallel similarity computation.""" |
| 86 | + i, patch_i, patches = args |
| 87 | + row = np.zeros(len(patches)) |
| 88 | + for j, patch_j in enumerate(patches): |
| 89 | + if i != j: |
| 90 | + row[j] = compute_code_similarity(patch_i, patch_j) |
| 91 | + else: |
| 92 | + row[j] = 1.0 |
| 93 | + return i, row |
| 94 | + |
| 95 | + |
| 96 | +def compute_round_consistency( |
| 97 | + model: str, opponent: str, arena: str, round_num: int, n_workers: int = 4 |
| 98 | +) -> tuple[np.ndarray, np.ndarray]: |
| 99 | + """ |
| 100 | + Compute pairwise similarity between a model's solutions at a specific round across multiple games. |
| 101 | + Use this for both questions 1a (early rounds) and 1b (final round). |
| 102 | + """ |
| 103 | + folders = get_model_arena_logs([model, opponent], arena) |
| 104 | + patches = get_submission_diffs_at_round(folders, model, round_num) |
| 105 | + print(f"Found {len(patches)} patches for {model} vs {opponent} in {arena} at round {round_num}") |
| 106 | + |
| 107 | + # Compute similarity matrix in parallel |
| 108 | + patch_list = list(patches.values()) |
| 109 | + n = len(patch_list) |
| 110 | + similarity_matrix = np.zeros((n, n)) |
| 111 | + |
| 112 | + with ProcessPoolExecutor(max_workers=n_workers) as executor: |
| 113 | + tasks = [(i, patch_list[i], patch_list) for i in range(n)] |
| 114 | + futures = {executor.submit(_compute_similarity_row, task): task for task in tasks} |
| 115 | + |
| 116 | + for future in tqdm(as_completed(futures), total=n, desc="Computing similarities"): |
| 117 | + i, row = future.result() |
| 118 | + similarity_matrix[i, :] = row |
| 119 | + |
| 120 | + # Extract upper triangle for statistics |
| 121 | + upper_triangle = similarity_matrix[np.triu_indices(n, k=1)] |
| 122 | + |
| 123 | + return similarity_matrix, upper_triangle |
| 124 | + |
| 125 | + |
| 126 | +def tag_to_str(tag: dict) -> str: |
| 127 | + return f"{tag['model_a']}__vs__{tag['model_b']}__in__{tag['arena']}__r{tag['round']}" |
| 128 | + |
| 129 | + |
| 130 | +def collect_data(): |
| 131 | + """Run code evolution analyses.""" |
| 132 | + if not DATA_CACHE.parent.exists(): |
| 133 | + DATA_CACHE.parent.mkdir(parents=True, exist_ok=True) |
| 134 | + mode, to_skip = "w", [] |
| 135 | + if DATA_CACHE.exists(): |
| 136 | + mode = "a" |
| 137 | + with open(DATA_CACHE) as f: |
| 138 | + for line in f: |
| 139 | + entry = json.loads(line) |
| 140 | + to_skip.append( |
| 141 | + tag_to_str( |
| 142 | + { |
| 143 | + "model_a": entry["model_a"], |
| 144 | + "model_b": entry["model_b"], |
| 145 | + "arena": entry["arena"], |
| 146 | + "round": entry["round"], |
| 147 | + } |
| 148 | + ) |
| 149 | + ) |
| 150 | + |
| 151 | + with open(MODELS_PATH) as f: |
| 152 | + models = [x["model_name"].rsplit("/")[-1] for x in yaml.safe_load(f)] |
| 153 | + arena = "BattleSnake" |
| 154 | + with open(DATA_CACHE, mode) as f: |
| 155 | + for i in range(0, len(models)): |
| 156 | + for j in range(0, len(models)): |
| 157 | + if i == j: |
| 158 | + continue |
| 159 | + for round in TARGET_ROUNDS: |
| 160 | + if ( |
| 161 | + tag_to_str( |
| 162 | + { |
| 163 | + "model_a": models[i], |
| 164 | + "model_b": models[j], |
| 165 | + "arena": arena, |
| 166 | + "round": round, |
| 167 | + } |
| 168 | + ) |
| 169 | + in to_skip |
| 170 | + ): |
| 171 | + continue |
| 172 | + try: |
| 173 | + sim_matrix, _ = compute_round_consistency(models[i], models[j], arena, round) |
| 174 | + except Exception as e: |
| 175 | + print( |
| 176 | + f"Error computing consistency for {models[i]} vs {models[j]} in {arena} at round {round}: {e}" |
| 177 | + ) |
| 178 | + continue |
| 179 | + f.write( |
| 180 | + json.dumps( |
| 181 | + { |
| 182 | + "model_a": models[i], |
| 183 | + "model_b": models[j], |
| 184 | + "arena": arena, |
| 185 | + "round": round, |
| 186 | + "similarity_matrix": sim_matrix.tolist(), |
| 187 | + } |
| 188 | + ) |
| 189 | + + "\n" |
| 190 | + ) |
| 191 | + f.flush() |
| 192 | + |
| 193 | + |
| 194 | +# ============================================= |
| 195 | +# MARK: Visualizations / Statistics below |
| 196 | +# =============================================c |
| 197 | + |
| 198 | + |
| 199 | +def load_cached_results() -> list[dict]: |
| 200 | + """Load all cached results from the data file.""" |
| 201 | + results = [] |
| 202 | + with open(DATA_CACHE) as f: |
| 203 | + for line in f: |
| 204 | + results.append(json.loads(line)) |
| 205 | + return results |
| 206 | + |
| 207 | + |
| 208 | +def compute_model_consistency_over_rounds(results: list[dict]) -> dict: |
| 209 | + """ |
| 210 | + Aggregate consistency data by model and round. |
| 211 | + For each model at each round, compute mean similarity across all matchups. |
| 212 | + """ |
| 213 | + from collections import defaultdict |
| 214 | + |
| 215 | + # Group by model and round |
| 216 | + model_round_similarities = defaultdict(lambda: defaultdict(list)) |
| 217 | + |
| 218 | + for entry in results: |
| 219 | + sim_matrix = np.array(entry["similarity_matrix"]) |
| 220 | + n = sim_matrix.shape[0] |
| 221 | + upper_tri = sim_matrix[np.triu_indices(n, k=1)] |
| 222 | + mean_sim = upper_tri.mean() |
| 223 | + |
| 224 | + # Similarity matrix reflects model_a's consistency (model_b is just the opponent filter) |
| 225 | + model_round_similarities[entry["model_a"]][entry["round"]].append(mean_sim) |
| 226 | + |
| 227 | + # Average across all matchups for each model/round |
| 228 | + model_consistency = {} |
| 229 | + for model, round_data in model_round_similarities.items(): |
| 230 | + model_consistency[model] = {round_num: np.mean(sims) for round_num, sims in round_data.items()} |
| 231 | + |
| 232 | + return model_consistency |
| 233 | + |
| 234 | + |
| 235 | +def compute_opponent_effect_matrix(results: list[dict], target_round: int) -> tuple[list[str], dict]: |
| 236 | + """ |
| 237 | + Build matrix showing how each model's consistency varies by opponent. |
| 238 | + Returns (model_list, opponent_matrix) where opponent_matrix[model][opponent] = mean_similarity. |
| 239 | + """ |
| 240 | + from collections import defaultdict |
| 241 | + |
| 242 | + # Group by model and opponent at target round |
| 243 | + model_opponent_similarities = defaultdict(lambda: defaultdict(list)) |
| 244 | + |
| 245 | + for entry in results: |
| 246 | + if entry["round"] != target_round: |
| 247 | + continue |
| 248 | + |
| 249 | + sim_matrix = np.array(entry["similarity_matrix"]) |
| 250 | + n = sim_matrix.shape[0] |
| 251 | + upper_tri = sim_matrix[np.triu_indices(n, k=1)] |
| 252 | + mean_sim = upper_tri.mean() |
| 253 | + |
| 254 | + # entry has model_a playing against model_b |
| 255 | + model_a = entry["model_a"] |
| 256 | + model_b = entry["model_b"] |
| 257 | + model_opponent_similarities[model_a][model_b].append(mean_sim) |
| 258 | + |
| 259 | + # Average across repeated games |
| 260 | + opponent_matrix = {} |
| 261 | + for model, opponent_data in model_opponent_similarities.items(): |
| 262 | + opponent_matrix[model] = {opponent: np.mean(sims) for opponent, sims in opponent_data.items()} |
| 263 | + |
| 264 | + return sorted(opponent_matrix.keys()), opponent_matrix |
| 265 | + |
| 266 | + |
| 267 | +def plot_opponent_effect_heatmap(target_round: int, output_path: str = None): |
| 268 | + """ |
| 269 | + Plot heatmap showing how model consistency varies by opponent. |
| 270 | + Answers questions 2a (round 1) and 2b (round 15). |
| 271 | + """ |
| 272 | + if output_path is None: |
| 273 | + output_path = f"assets/heatmap_code_evolution_per_opponent_r{target_round}.png" |
| 274 | + |
| 275 | + results = load_cached_results() |
| 276 | + models, opponent_matrix = compute_opponent_effect_matrix(results, target_round) |
| 277 | + |
| 278 | + # Get all unique opponents |
| 279 | + all_opponents = set() |
| 280 | + for model_data in opponent_matrix.values(): |
| 281 | + all_opponents.update(model_data.keys()) |
| 282 | + opponents = sorted(all_opponents) |
| 283 | + |
| 284 | + # Build matrix: rows=models, cols=opponents |
| 285 | + n_models = len(models) |
| 286 | + n_opponents = len(opponents) |
| 287 | + matrix = np.full((n_models, n_opponents), np.nan) |
| 288 | + for i, model in enumerate(models): |
| 289 | + for j, opponent in enumerate(opponents): |
| 290 | + if opponent in opponent_matrix[model]: |
| 291 | + matrix[i, j] = opponent_matrix[model][opponent] |
| 292 | + |
| 293 | + # Create heatmap with blue-white-red colormap like win_rates |
| 294 | + FONT_BOLD.set_size(16) |
| 295 | + _, ax = plt.subplots(figsize=(6, 6)) |
| 296 | + cmap = mcolors.LinearSegmentedColormap.from_list("br", ["#3498db", "#ffffff", "#e74c3c"]) |
| 297 | + masked = np.ma.masked_where(np.isnan(matrix), matrix) |
| 298 | + ax.imshow(masked, cmap=cmap, vmin=0, vmax=1, aspect="auto") |
| 299 | + |
| 300 | + # Add text values in each cell |
| 301 | + for i in range(n_models): |
| 302 | + for j in range(n_opponents): |
| 303 | + if not np.isnan(matrix[i, j]): |
| 304 | + # Choose text color based on background intensity |
| 305 | + color = "white" if abs(matrix[i, j] - 0.5) > 0.25 else "black" |
| 306 | + ax.text( |
| 307 | + j, |
| 308 | + i, |
| 309 | + f"{matrix[i, j]:.2f}", |
| 310 | + ha="center", |
| 311 | + va="center", |
| 312 | + color=color, |
| 313 | + fontweight="bold", |
| 314 | + fontproperties=FONT_BOLD, |
| 315 | + ) |
| 316 | + |
| 317 | + # Set ticks and labels |
| 318 | + clean_model_names = [MODEL_TO_DISPLAY_NAME.get(m, m) for m in models] |
| 319 | + clean_opponent_names = [MODEL_TO_DISPLAY_NAME.get(o, o) for o in opponents] |
| 320 | + |
| 321 | + ax.set_xticks(range(n_opponents)) |
| 322 | + ax.set_yticks(range(n_models)) |
| 323 | + ax.set_xticklabels(clean_opponent_names, rotation=45, ha="right", fontproperties=FONT_BOLD) |
| 324 | + ax.set_yticklabels(clean_model_names, fontproperties=FONT_BOLD) |
| 325 | + |
| 326 | + plt.tight_layout() |
| 327 | + plt.savefig(output_path, dpi=300, bbox_inches="tight") |
| 328 | + print(f"Saved heatmap to {output_path}") |
| 329 | + |
| 330 | + |
| 331 | +def plot_consistency_over_rounds(output_path: str = "assets/line_chart_code_evolution.png"): |
| 332 | + """ |
| 333 | + Plot line graph: x-axis = round, y-axis = code similarity, one line per model. |
| 334 | + Answers questions 1a (early round consistency) and 1b (evolution over time). |
| 335 | + """ |
| 336 | + results = load_cached_results() |
| 337 | + model_consistency = compute_model_consistency_over_rounds(results) |
| 338 | + |
| 339 | + plt.figure(figsize=(6, 6)) |
| 340 | + |
| 341 | + # Plot one line per model |
| 342 | + for model, round_data in sorted(model_consistency.items()): |
| 343 | + rounds = sorted(round_data.keys()) |
| 344 | + similarities = [round_data[r] for r in rounds] |
| 345 | + display = MODEL_TO_DISPLAY_NAME.get(model, model) |
| 346 | + color = MODEL_TO_COLOR.get(model, None) |
| 347 | + plt.plot(rounds, similarities, marker="o", label=display, linewidth=1.5, markersize=6, color=color) |
| 348 | + |
| 349 | + plt.xlabel("Round", fontsize=18, fontproperties=FONT_BOLD) |
| 350 | + plt.ylabel("Mean Code Similarity", fontsize=18, fontproperties=FONT_BOLD) |
| 351 | + plt.xticks(TARGET_ROUNDS, fontproperties=FONT_BOLD, fontsize=16) |
| 352 | + plt.yticks(fontproperties=FONT_BOLD, fontsize=16) |
| 353 | + FONT_BOLD.set_size(16) |
| 354 | + plt.legend(bbox_to_anchor=(1, 1), loc="upper right", prop=FONT_BOLD) |
| 355 | + plt.grid(True, alpha=0.3) |
| 356 | + plt.tight_layout() |
| 357 | + plt.savefig(output_path, dpi=300, bbox_inches="tight") |
| 358 | + print(f"Saved plot to {output_path}") |
| 359 | + |
| 360 | + |
| 361 | +if __name__ == "__main__": |
| 362 | + # Run data collection |
| 363 | + # collect_data() |
| 364 | + |
| 365 | + # Questions 1a/1b: Consistency over rounds |
| 366 | + plot_consistency_over_rounds() # Questions 1a and 1b |
| 367 | + |
| 368 | + # Questions 2a/2b: Opponent effect |
| 369 | + plot_opponent_effect_heatmap(target_round=1) # Question 2a |
| 370 | + plot_opponent_effect_heatmap(target_round=15) # Question 2b |
0 commit comments