Skip to content

Commit 802e61b

Browse files
committed
Feat(analysis): llm as judge
1 parent 967af0c commit 802e61b

6 files changed

Lines changed: 354 additions & 0 deletions

File tree

codeclash/analysis/llm_as_judge/__init__.py

Whitespace-only changes.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import json
4+
import re
5+
from pathlib import Path
6+
from typing import Literal
7+
8+
import jinja2
9+
import yaml
10+
from minisweagent.models import GLOBAL_MODEL_STATS, get_model
11+
from pydantic import BaseModel
12+
from typing_extensions import Any
13+
14+
from codeclash.analysis.llm_as_judge.utils import FileLock, Instance, get_instances
15+
from codeclash.utils.log import get_logger
16+
17+
logger = get_logger("BigQuestionsEvaluator", emoji="🤖")
18+
19+
config_path = Path(__file__).parent / "big_questions.yaml"
20+
21+
22+
class BigQuestionsModelResponseSchema(BaseModel):
23+
are_edits_motivated_by_logs: bool
24+
are_edits_motivated: bool
25+
are_edits_tested_with_simulations: bool
26+
are_edits_validated_with_unittests: bool
27+
edit_category: Literal["tweak", "fix", "feature", "change", "none"]
28+
reasoning: str
29+
30+
31+
class ModelConfig(BaseModel):
32+
model_name: str
33+
model_kwargs: dict[str, Any]
34+
35+
36+
class BigQuestionsConfig(BaseModel):
37+
version: int
38+
system_prompt: str
39+
instance_prompt: str
40+
model: ModelConfig
41+
42+
43+
class BigQuestionsData(BaseModel):
44+
instance: Instance
45+
big_questions: BigQuestionsModelResponseSchema
46+
config_version: int
47+
48+
49+
def extract_triple_backticks(text: str) -> str:
50+
actions = re.findall(r"```bash\s*\n(.*?)\n```", text, re.DOTALL)
51+
return actions[0] if actions else ""
52+
53+
54+
def evaluate(instance: Instance, config: BigQuestionsConfig) -> None:
55+
target_path = instance.trajectory_path.parent.parent.parent / "llm_as_judge.json"
56+
data_id = f"big_questions_v{config.version}_{config.model.model_name}_{instance.instance_id}"
57+
if target_path.exists():
58+
content = target_path.read_text()
59+
if content.strip():
60+
data = json.loads(content)
61+
if data.get("data_id") == data_id:
62+
logger.info(
63+
f"Skipping instance {instance.instance_id} because it already exists in {target_path} with key {data_id}"
64+
)
65+
return
66+
model = get_model(config.model.model_name, config={"model_kwargs": config.model.model_kwargs})
67+
trajectory_messages = json.loads(instance.trajectory_path.read_text())["messages"]
68+
system_message = config.system_prompt
69+
trajectory_message_str = ""
70+
for message in trajectory_messages:
71+
content = message["content"]
72+
if isinstance(message["content"], list):
73+
assert len(message["content"]) == 1
74+
content = message["content"][0]["text"]
75+
if message["role"] == "assistant":
76+
trajectory_message_str += "\n<action>\n" + extract_triple_backticks(content) + "\n</action>\n"
77+
elif message["role"] == "user":
78+
trajectory_message_str += content # already enclosed in <output>
79+
instance_message = jinja2.Template(config.instance_prompt).render(trajectory_message_str=trajectory_message_str)
80+
# print(instance_message)
81+
response = model.query(
82+
messages=[
83+
{"role": "system", "content": system_message},
84+
{"role": "user", "content": instance_message},
85+
],
86+
response_format=BigQuestionsModelResponseSchema,
87+
)
88+
# atomic write with file lock in case other analyses are also writing
89+
response_data = BigQuestionsModelResponseSchema.model_validate_json(response["content"]).model_dump()
90+
with FileLock(target_path):
91+
# read again if changed in the meantime
92+
data = {}
93+
if target_path.exists():
94+
content = target_path.read_text()
95+
if content.strip():
96+
data = json.loads(content)
97+
data[data_id] = response_data
98+
target_path.write_text(json.dumps(data))
99+
logger.info(
100+
f"Evaluated instance {instance.instance_id}: {response_data}. Saved to {target_path} with key {data_id}"
101+
)
102+
103+
104+
if __name__ == "__main__":
105+
parser = argparse.ArgumentParser()
106+
parser.add_argument("input_dir", type=Path, help="Path to the input dir")
107+
args = parser.parse_args()
108+
109+
config = BigQuestionsConfig.model_validate(yaml.safe_load(config_path.read_text()))
110+
instances = get_instances(args.input_dir)
111+
for instance in instances:
112+
evaluate(instance, config)
113+
print(GLOBAL_MODEL_STATS.cost)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Incrementing this version will cause previous submissions to be re-evaluated
2+
version: 4
3+
system_prompt: |
4+
You need to answer three question
5+
6+
## Are edits motivated by logs?
7+
8+
Are the edits of the main player file of the player directly motivated by problems discovered by reading the logs?
9+
10+
We want to check if the edits of the main player file (`main.py`, `player.py`, `robot.js`, `warrior.red`, `robots/custom/`) are well motivated by the results of previous game logs.
11+
12+
Special case: If there are no edits to the main player file, answer `True`.
13+
14+
Reasons to answer `False` if there are edits to the main player file, but
15+
16+
1. Player does not look at logs.
17+
2. Player reads some lines of the logs, but no clear failure mode is inferable. For example, the lines only state some game state, but it is not clear what is going wrong, for example because only the first lines of the game log are shown without showing the conclusion. Or the logs only show which player won but without much of a reason.
18+
3. Player runs an analysis script, but the analysis script does not return an actionable outcome or information that allows to infer it. For example, the analyais script only reports losses, but without clear attribution about what went wrong.
19+
4. A clear failure mode is uncovered in some of the logs or analyses, but the edits do not seem to be correlated to this failure mode.
20+
21+
Answer `True` if ALL (!) of the following is true:
22+
23+
1. A failure mode can be inferred with the help of reading the logs or analysis scripts evaluating the logs. Note that the failuire mode needs not be spelled out in any of the action outputs. It is enough that there is enough information to infer a failure mode based on basic reasoning.
24+
2. The edit is directly related to this failure mode. It is ok if some minor parts of the edit are unrelated.
25+
26+
The logs can be either from a game that the player simulates it self, or from the previous round, but it must be a meaningful game log.
27+
28+
Here are some example of real failure modes:
29+
30+
- The snake that the player is controlling runs out of food (so we need to more aggressively search for food)
31+
- Our bot runs against a wall (so we change that)
32+
- Our race car does not move for several turns (so we fix some movement related bug)
33+
- Our warrior's missiles do not hit the enemy (so we improve something about the aim)
34+
- Our code times out (so we improve efficiency)
35+
36+
Here are some example of not real failure modes:
37+
38+
- Player 1 won 99% of the rounds (why?)
39+
- Player 2 is better most of the time (why?)
40+
- Player 1 is the last bot standing and is therefore the winner (does not explain why player 2 lost)
41+
42+
## Are edits motivated
43+
44+
Can the goal of the specific edits on the main player file be motivated by any output of previous actions?
45+
If you answered True to the previous question, answer True here as well.
46+
However, you can also answer True here, if one or more of the following is true:
47+
48+
1. The player wrote a meaningful test that revealed a problem (or a way to improve) and then performed the corresponding edit
49+
2. The player wrote a meaningful analysis script that revealed a problem (or a way to improve) and then performed the corresponding edit
50+
3. The player ran some test games that revealed a problem (or a way to improve) and then performed the corresponding edit
51+
4. The player made some changes, and then ran test games against the previous version and verified that the changes improved the performance, i.e., had a higher win rate.
52+
53+
However, if for 1. and 2. the test or analysis script gives a recommendation that's not
54+
corroborated by the actual code of the analysis or test file, or by its respective output,
55+
answer False. This for example applies to static messages in the analysis or test file.
56+
If you do not see the output or the code of the analysis or test file, answer False as well.
57+
58+
Just stating a low win rate is not a sufficient motivation.
59+
Remember: This is about the _specific_ edits being motivated, not just any edits.
60+
61+
Here problem can mean anything from a bug in the code, to something that can be improved otherwise.
62+
63+
You should answer False, if the edits seemed unrelated to any output of previous actions before the relevant
64+
edit actions.
65+
66+
## Are edits tested with simulations?
67+
68+
After edits to the main player file have been made, does the player simulate games by playing the game against previous versions of itself, or against example players, etc.
69+
Only if we are performing a fix or an improvement that can be validated without an opponent (e.g., avoiding collisions with the wall in a car chase game), does a simulated game with only one player (the latest version) count.
70+
71+
In order to answer `True`, a real game has to be played. If there is an opponent, the new version has to win (or have a good win rate).
72+
73+
Note: If the game failed to run, or showed that the new version was clearly worse than the previous version, answer False.
74+
Note: Unit tests do NOT (!) count as a simulated game.
75+
76+
Special case:
77+
78+
1. If no edits to the main player file have been made, answer `True`
79+
80+
## Are edits validated with unittests?
81+
82+
Are the final edits covered by specific unittests that test the new or modified behavior?
83+
84+
Special case: If there are no significant changes that could be validated, answer True.
85+
86+
Running the game to get a win rate does not count as a unittest, because it does not specifically validate specific changes.
87+
88+
Running unittests that are unrelated to the changes does not count either.
89+
90+
Answer `True`, if the unittests cover (some of) the new behavior. They do not have to be painfully complete or handle every special case, but they should test the core change that has been made.
91+
92+
You can also count tests that only print output (but do not have assert statements) as unit tests,
93+
if they essentially print the expected output of the new or modified behavior and can therefore be
94+
used to validate the new or modified behavior.
95+
96+
Note: if the tests did not run, or showed that the new version was broken, answer False.
97+
98+
## Categorize the kind of edits made
99+
100+
Categorize the final edits to the main file into one of the following categories.
101+
Ignore comments or documentation.
102+
You can only select ONE (!) category. Choose the one that describes the changes best.
103+
104+
1. `tweak`: Logic is left unchanged, but we do change some parameters.
105+
2. `fix`: Small, targeted change with the intent to fix broken behavior.
106+
3. `feature`: Significant new behavior is added, mostly extending the existing code
107+
4. `change`: We significantly change the behavior by rewriting significant logic of the code
108+
5. `none`: No change in behavior. Only comments, documentation, refactoring was performed.
109+
110+
## Output format
111+
112+
Answer in the json format specified. Reasoning corresponds to your explanation for your answer.
113+
instance_prompt: |
114+
Here is your input file:
115+
116+
{{ trajectory_message_str }}
117+
model:
118+
model_name: gpt-5
119+
model_kwargs:
120+
reasoning_effort: high
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import argparse
2+
from pathlib import Path
3+
4+
import yaml
5+
6+
config_path = Path(__file__).parent / "categorize_actions.yaml"
7+
8+
9+
if __name__ == "__main__":
10+
parser = argparse.ArgumentParser()
11+
parser.add_argument("input_dir", type=Path, help="Path to the input dir")
12+
args = parser.parse_args()
13+
14+
config = yaml.safe_load(config_path.read_text())
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Incrementing this version will cause previous submissions to be re-evaluated
2+
version: 1
3+
system_prompt: |
4+
You are helping to analyze the actions of a LM agent.
5+
6+
For every action, you return a category as specified by the structured output specs.
7+
8+
# Categories
9+
10+
## Read operations
11+
12+
The model reads code, documentation, logs, or anything else. This includes operations like finding files, searching through files, etc. Commands include `ls`, `find`, `grep`, `cat`, `head`, `tail`, etc. This does NOT include running more complicated analysis scripts on files. Rule of thumb: If it's a bash command, it's a read operation, if a python script is executed, it probably belongs in the execution category.
13+
14+
Categories
15+
16+
- `read.source`,
17+
- `read.logs`
18+
- `read.docs`,
19+
- `read.other`. This category should be very infrequent and only for read targets that are clearly not compatible with the others.
20+
21+
depending on what is being read.
22+
23+
## Write operations
24+
25+
The model modifies files. Common commands include `cat ... > file`, `sed`, etc. Creating directories also falls into this category.
26+
27+
Subcatgories:
28+
29+
- `write.docs`: Documentation
30+
- `write.source.main`: Writing of the main player code. Does NOT include writing simple bots to test again, but only editing the main player/agent/bot file (`main.py`, `player.py`, `robot.js`, `warrior.red`, `robots/custom/`)
31+
- `write.source.opponent`: Writing of opponents to test the main player/agent/bot against.
32+
- `write.source.analysis`: Writing of analysis scripts, especially to parse logs or analyze what is happening in the game
33+
- `write.source.tests`: Writing of unit test scripts. Unit tests are different from analsis, because they have a predefined, very clear pass or fail outcome (i.e., assert statements)
34+
- `write.other`: This category should be very infrequent and only for write targets that are clearly not compatible with the others.
35+
36+
## Execution operations
37+
38+
Executions are anything that executes source files, especially executing analysis scripts, playing the game with different players, etc.
39+
40+
- `execute.game`: Calling on the game executable to run a game between different players.
41+
- `execute.game.setup`: Preparations for running the game, for example if the player servers need to be started first, or the game needs to be compiled etc., this also belongs in this
42+
- `execute.analysis`: Executing analysis scripts (see previous notes on difference between unittests and analysis)
43+
- `execute.unittest`: Executing unittests or simple tests (import checks etc.). Compilation checks also fall into this category.
44+
45+
## Other
46+
47+
- `submit`: The player issues "MINI_SWE_AGENT_FINAL_OUTPUT", "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT" to finish the run. If this is combined with another action, categorize the other action instead. Only use this category if it's a standalone request to finish.
48+
- `other`: This category should be very infrequent and only for write targets that are clearly not compatible with the others.
49+
50+
# Important notes
51+
52+
1. You MUST (!) categorize EVERY (!) action. Do NOT (!) skip any action.
53+
2. Every action MUST (!) be put into exactly (!) one (!) category.
54+
3. Your category MUST (!) be one of the list above.
55+
56+
In order of importance: execution is more important than writing is more important than reading.
57+
So if an action combines writing with execution, the category should be execution, etc.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import fcntl
2+
from dataclasses import dataclass
3+
from pathlib import Path
4+
5+
6+
class FileLock:
7+
def __init__(self, lock_path: str | Path):
8+
self.lock_path = Path(lock_path)
9+
self.lock_file = None
10+
11+
def __enter__(self):
12+
self.lock_file = open(self.lock_path, "w")
13+
fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_EX)
14+
return self
15+
16+
def __exit__(self, exc_type, exc_val, exc_tb):
17+
if self.lock_file:
18+
fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_UN)
19+
self.lock_file.close()
20+
return False
21+
22+
23+
@dataclass
24+
class Instance:
25+
player_name: str
26+
round_number: int
27+
tournament_name: str
28+
trajectory_path: Path
29+
30+
@property
31+
def instance_id(self) -> str:
32+
return f"{self.tournament_name}.{self.player_name}.{self.round_number}"
33+
34+
35+
def find_tournament_folders(input_dir: Path) -> list[Path]:
36+
return [d.parent for d in input_dir.rglob("metadata.json")]
37+
38+
39+
def parse_trajectory_name(trajectory_path: Path) -> Instance:
40+
return Instance(
41+
trajectory_path=trajectory_path,
42+
player_name=trajectory_path.parent.name,
43+
round_number=int(trajectory_path.name.split(".")[0].split("_r")[1]),
44+
tournament_name=trajectory_path.parent.parent.parent.name,
45+
)
46+
47+
48+
def get_instances(input_folder: Path) -> list[Instance]:
49+
trajectories = input_folder.rglob("*.traj.json")
50+
return [parse_trajectory_name(trajectory) for trajectory in trajectories]

0 commit comments

Comments
 (0)