Skip to content

Commit 623d945

Browse files
author
yolo h8cker 93
committed
Improve stats object (ensure all player present); viewer for matrix
1 parent 2264265 commit 623d945

10 files changed

Lines changed: 810 additions & 7 deletions

File tree

.cursor/rules/style.mdc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ alwaysApply: true
2020
12. Don't catch exceptions just to re-raise them slightly differently. This doesn't add any value.
2121
13. Write concise code and thing of elegant solution.
2222
14. When logging exceptions, always include `exc_info=True` to capture the full traceback.
23+
15. Command line arguments should be parsed with `argparse`.
2324

2425
## Test style
2526

@@ -39,3 +40,7 @@ assert result == b
3940
# good
4041
assert func() == b
4142
```
43+
44+
## Chat style
45+
46+
* Do not summarize your changes at the end of the chat.

.cursor/rules/viewer.mdc

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,9 +385,61 @@ The application has two main pages:
385385
- File selector dropdown to choose which file to analyze
386386
- Chart.js-powered line charts showing changes across rounds
387387
- Data visualization for understanding code development patterns
388+
* **Matrix Analysis**: Show "self-play" matrix analysis
388389
* **Consistent Styling**: Uses same section formatting as Overview and Rounds sections
389390
* **Foldout Structure**: Analysis content is contained in collapsible foldouts for space efficiency
390391

392+
##### Matrix Analysis
393+
394+
If a file `matrix.json` exists, load it. It looks like this:
395+
396+
```
397+
{
398+
"name": "MatrixEvaluator",
399+
"pvp_output_dir": "logs/klieret/real_games/PvpTournament.BattleSnake.250918193642.sonnet_vs_o3",
400+
"p1_name": "sonnet-4",
401+
"p2_name": "o3",
402+
"rounds": 11,
403+
"n_repetitions": 1,
404+
"created_timestamp": 1758572722,
405+
"matrices": {
406+
"sonnet-4_vs_sonnet-4": {
407+
"0": {
408+
"0": {
409+
"round_num": "e2513f264ac04a019c25982d2b03b3bb",
410+
"winner": "sonnet-4_2",
411+
"scores": {
412+
"sonnet-4_2": 1,
413+
"sonnet-4_1": 0.0
414+
},
415+
"player_stats": {
416+
"sonnet-4_2": {
417+
"name": "sonnet-4_2",
418+
"invalid_reason": "",
419+
"score": 1,
420+
"valid_submit": true
421+
},
422+
"sonnet-4_1": {
423+
"name": "sonnet-4_1",
424+
"invalid_reason": "",
425+
"score": 0.0,
426+
"valid_submit": true
427+
}
428+
}
429+
}
430+
},
431+
432+
...
433+
}
434+
```
435+
436+
For this, you want to now show a dropdown with all the matrix names, e.g., "sonnet-4_vs_sonnet-4".
437+
Visualize the matrix by always showing the scores dictionary. You always want to show it relative to the player 1 perspective,
438+
i.e., here `sonnet-4_1`, so you would put a 0% for the 0-0 cell.
439+
Other than that, follow the logic with the % value similar to the overall results table, i.e., you calculate
440+
wins + 0.5 * ties / total games * 100 for the % value. All this calculation should be done in the python code.
441+
Note that the table might be incomplete.
442+
391443
#### Rounds
392444

393445
First, show a foldout of `round_1.log`.

codeclash/analysis/__init__.py

Whitespace-only changes.

codeclash/analysis/matrix.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import argparse
2+
import json
3+
import time
4+
import uuid
5+
from pathlib import Path
6+
7+
from codeclash.agents.dummy_agent import Dummy
8+
from codeclash.agents.utils import GameContext
9+
from codeclash.constants import DIR_WORK
10+
from codeclash.games import get_game
11+
from codeclash.tournaments.utils.git_utils import filter_git_diff
12+
from codeclash.utils.log import add_file_handler, get_logger
13+
14+
# todo: add visualization code
15+
# todo: Should start from initial commit set in metadata rather than last commit
16+
17+
18+
class PvPMatrixEvaluator:
19+
def __init__(self, pvp_output_dir: Path, n_repetitions: int = 3):
20+
self.pvp_output_dir = Path(pvp_output_dir)
21+
self.n_repetitions = n_repetitions
22+
self.metadata = json.loads((self.pvp_output_dir / "metadata.json").read_text())
23+
24+
assert len(self.players) == 2, f"Expected exactly 2 players, got {len(self.players)}"
25+
26+
# Set up logging
27+
self.logger = get_logger("MatrixEvaluator", log_path=self.pvp_output_dir / "matrix_eval.log", emoji="📊")
28+
add_file_handler(get_logger("."), self.pvp_output_dir / "matrix_eval.log")
29+
30+
# Initialize metadata similar to tournament class
31+
self._metadata = {
32+
"name": "MatrixEvaluator",
33+
"pvp_output_dir": str(self.pvp_output_dir),
34+
"p1_name": self.players[0],
35+
"p2_name": self.players[1],
36+
"rounds": self.rounds,
37+
"n_repetitions": n_repetitions,
38+
"created_timestamp": int(time.time()),
39+
"matrices": {},
40+
}
41+
42+
# Load existing progress if available
43+
self._load_existing_progress()
44+
45+
# Create game instance for evaluation
46+
tournament_id = f"MatrixEval.{self.metadata['name']}.{time.strftime('%y%m%d%H%M%S')}"
47+
48+
self.config["game"]["sims_per_round"] = n_repetitions
49+
50+
self.game = get_game(
51+
self.config,
52+
tournament_id=tournament_id,
53+
local_output_dir=self.pvp_output_dir / "matrix_eval",
54+
keep_containers=False,
55+
)
56+
57+
self.logger.info(f"Initialized matrix evaluator for {self.players[0]} vs {self.players[1]}")
58+
self.logger.info(f"Will evaluate {self.rounds + 1} rounds with {n_repetitions} repetitions each")
59+
60+
# Quick access properties
61+
# -----------------------
62+
63+
@property
64+
def config(self) -> dict:
65+
"""Game configuration from PvP metadata."""
66+
return self.metadata["config"]
67+
68+
@property
69+
def rounds(self) -> int:
70+
"""Number of rounds actually evaluated, determined from round_stats in metadata."""
71+
return len(self.metadata["round_stats"])
72+
73+
@property
74+
def players(self) -> list[str]:
75+
"""List of player names from metadata."""
76+
return [agent["name"] for agent in self.metadata["agents"]]
77+
78+
@property
79+
def output_file(self) -> Path:
80+
"""Path to the matrix.json output file."""
81+
return self.pvp_output_dir / "matrix.json"
82+
83+
@property
84+
def matrices(self) -> dict:
85+
"""Direct access to the matrices in metadata."""
86+
return self._metadata["matrices"]
87+
88+
# -----------------------
89+
90+
def _load_existing_progress(self):
91+
"""Load existing progress from matrix.json if it exists."""
92+
if not self.output_file.exists():
93+
return
94+
existing_data = json.loads(self.output_file.read_text())
95+
if "matrices" in existing_data:
96+
self._metadata["matrices"] = existing_data["matrices"]
97+
98+
def _save_progress(self):
99+
"""Save current progress to matrix.json."""
100+
self.output_file.write_text(json.dumps(self._metadata, indent=2))
101+
self.logger.debug("Progress saved to matrix.json")
102+
103+
def _get_round_diff(self, player_name: str, round_num: int) -> str:
104+
"""Read diff data from changes_r{round}.json file."""
105+
if round_num == 0:
106+
return ""
107+
changes_file = self.pvp_output_dir / "players" / player_name / f"changes_r{round_num}.json"
108+
changes_data = json.loads(changes_file.read_text())
109+
return changes_data.get("full_diff", "")
110+
111+
def _create_dummy_agent(self, player_name: str, agent_suffix: str = "") -> Dummy:
112+
"""Create a dummy agent for matrix evaluation."""
113+
# Find the original player config
114+
original_config = None
115+
for player_config in self.config["players"]:
116+
if player_config["name"] == player_name:
117+
original_config = player_config.copy()
118+
break
119+
120+
if original_config is None:
121+
raise ValueError(f"Player {player_name} not found in config")
122+
123+
# Create unique name for this agent instance
124+
original_config["name"] = f"{player_name}{agent_suffix}"
125+
126+
environment = self.game.get_environment(f"{self.game.game_id}.{original_config['name']}")
127+
game_context = GameContext(
128+
id=self.game.game_id,
129+
log_env=self.game.log_env,
130+
log_local=self.game.log_local,
131+
name=self.game.name,
132+
player_id=original_config["name"],
133+
prompts=self.config["prompts"],
134+
round=0,
135+
rounds=self.rounds,
136+
working_dir=str(DIR_WORK),
137+
)
138+
139+
return Dummy(original_config, environment, game_context)
140+
141+
def _evaluate_matrix_cell(
142+
self, agent1: Dummy, agent2: Dummy, player1_name: str, player2_name: str, i: int, j: int, matrix_id: str
143+
) -> dict:
144+
"""Evaluate a single matrix cell and return the stats object."""
145+
# Return existing result if already completed
146+
try:
147+
existing_result = self.matrices[matrix_id][str(i)][str(j)]
148+
except KeyError:
149+
existing_result = None
150+
if existing_result:
151+
self.logger.debug(f"Skipping {player1_name} round {i} vs {player2_name} round {j} - already completed")
152+
return existing_result
153+
154+
patch1 = self._get_round_diff(player1_name, i)
155+
patch2 = self._get_round_diff(player2_name, j)
156+
157+
agent1.reset_and_apply_patch(filter_git_diff(patch1))
158+
agent2.reset_and_apply_patch(filter_git_diff(patch2))
159+
160+
self.logger.info(f"Evaluating {player1_name} round {i} vs {player2_name} round {j}")
161+
162+
round_id = str(uuid.uuid4().hex)
163+
stats = self.game.run_round([agent1, agent2], round_id)
164+
self.logger.debug(f"Result: {stats.to_dict()}")
165+
166+
return stats.to_dict()
167+
168+
def _evaluate_matrix(self, player1_name: str, player2_name: str):
169+
"""Generic method to evaluate a matrix between two players (or same player)."""
170+
symmetric = player1_name == player2_name
171+
matrix_id = f"{player1_name}_vs_{player2_name}"
172+
self.logger.info(f"Evaluating {matrix_id} matrix: {player1_name} vs {player2_name}")
173+
174+
agent1 = self._create_dummy_agent(player1_name, "_1" if player1_name == player2_name else "")
175+
agent2 = self._create_dummy_agent(player2_name, "_2" if player1_name == player2_name else "")
176+
177+
self.matrices.setdefault(matrix_id, {})
178+
for i in range(self.rounds + 1):
179+
self.matrices[matrix_id].setdefault(str(i), {})
180+
j_range = range(i + 1) if symmetric else range(self.rounds + 1)
181+
for j in j_range:
182+
self.matrices[matrix_id][str(i)][str(j)] = self._evaluate_matrix_cell(
183+
agent1, agent2, player1_name, player2_name, i, j, matrix_id
184+
)
185+
self._save_progress()
186+
187+
def evaluate_all_matrices(self) -> dict:
188+
"""Evaluate vs matrix between the two players."""
189+
self.logger.info("Starting matrix evaluation")
190+
self._evaluate_matrix(self.players[0], self.players[0])
191+
self._evaluate_matrix(self.players[1], self.players[1])
192+
self._metadata["evaluation_completed_timestamp"] = int(time.time())
193+
self.end()
194+
return self.matrices
195+
196+
def end(self):
197+
"""Save metadata and clean up resources."""
198+
self.output_file.write_text(json.dumps(self._metadata, indent=2))
199+
self.logger.info(f"Matrix evaluation results saved to {self.output_file}")
200+
self.game.end(cleanup=True)
201+
202+
203+
def main(pvp_output_dir: Path, n_repetitions: int = 3):
204+
"""Main function to evaluate PvP tournament matrices."""
205+
evaluator = PvPMatrixEvaluator(pvp_output_dir, n_repetitions)
206+
return evaluator.evaluate_all_matrices()
207+
208+
209+
if __name__ == "__main__":
210+
parser = argparse.ArgumentParser(description="Evaluate PvP tournament matrices")
211+
parser.add_argument("pvp_output_dir", type=Path, help="Path to PvP tournament output directory")
212+
parser.add_argument(
213+
"--repetitions", "-r", type=int, default=3, help="Number of repetitions per matrix cell (default: 3)"
214+
)
215+
216+
args = parser.parse_args()
217+
main(args.pvp_output_dir, args.repetitions)

codeclash/games/game.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
class PlayerStats:
1818
def __init__(self, name: str):
1919
self.name = name
20-
self.invalid_reason: str | None = None
21-
self.score: float | None = None
20+
self.invalid_reason: str = ""
21+
self.score: float = 0.0
2222
self.valid_submit = False
2323

2424
def to_dict(self) -> dict[str, Any]:
@@ -48,14 +48,14 @@ def __str__(self) -> str:
4848
return "\n".join(rv)
4949

5050
def to_dict(self) -> dict[str, Any]:
51-
result = {
51+
# Going through some pain to ensure that the scores dict is always complete
52+
player_names = set(self.player_stats.keys()) | set(self.scores.keys())
53+
return {
5254
"round_num": self.round_num,
5355
"winner": self.winner,
54-
"scores": self.scores,
56+
"scores": {name: self.scores.get(name, 0.0) for name in player_names},
57+
"player_stats": {name: stats.to_dict() for name, stats in self.player_stats.items()},
5558
}
56-
if self.player_stats:
57-
result["player_stats"] = {name: stats.to_dict() for name, stats in self.player_stats.items()}
58-
return result
5959

6060

6161
class CodeGame(ABC):

0 commit comments

Comments
 (0)