Skip to content

Commit 6a51a2b

Browse files
committed
Add code evolution analysis (Ofirs rec)
1 parent b51ad57 commit 6a51a2b

2 files changed

Lines changed: 380 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
\ofir{ofir's 4 questions:
2+
\begin{itemize}
3+
\item (1a) when we run say GPT-5 vs Claude, lets say we do just round 1, repeated 20 times, do we get different solutions from claude or gpt-5? or do they keep on doing the same stuff?
4+
\item (1b) when we run say GPT-5 vs Claude multiple times, all the way to the last round, how different are the last round solutions that GPT-5 generates in every last round? how different are the last round solutions that Claude generates? [and do this for each model in all pairs]
5+
\item (2a) when we run GPT-5 vs Claude (just one round) and GPT-5 vs Gemini, does gpt-5 program the same initial solution in each?
6+
\item (2b) how different are the last-round solutions gpt-5 generates when playing against Claude than when playing against Gemini?
7+
\end{itemize}
8+
9+
in case there's high variance, we can talk about it being maybe straightforward that self-play would lead to interesting results that could be used for training?
10+
}
Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
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

Comments
 (0)