yuspec-gamedev-ai / eval /run_github_issue_benchmark.py
fovane's picture
Upload folder using huggingface_hub
295d147 verified
Raw
History Blame Contribute Delete
15.6 kB
import argparse
import json
import sys
import time
import urllib.request
from pathlib import Path
import torch
from peft import PeftModel
from tokenizers import Tokenizer
from transformers import AutoModelForCausalLM, AutoTokenizer
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from generate import extract_answer # noqa: E402
from model import GPT, GPTConfig # noqa: E402
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
DOMAIN_TAGS = {
"godot": "<|godot|>\n",
"unity": "Domain: Unity\n",
"unreal": "Domain: Unreal Engine\n",
}
WRONG_ENGINE_TERMS = {
"godot": ("MonoBehaviour", "using UnityEngine", "UCLASS", "UPROPERTY", "AActor"),
"unity": ("extends Node", "MeshInstance3D", "UCLASS", "UPROPERTY", "AActor"),
"unreal": ("MonoBehaviour", "using UnityEngine", "extends Node", "MeshInstance3D"),
}
CODE_SIGNALS = {
"godot": ("extends ", "func ", "var ", "@export", "class_name", "signal"),
"unity": ("using UnityEngine", "MonoBehaviour", "public class", "void ", "[SerializeField]", "GetComponent", "async", "CancellationToken", "class "),
"unreal": ("UCLASS", "UPROPERTY", "UFUNCTION", "AActor", "UActorComponent", "::", "#include"),
}
SYSTEM_BY_DOMAIN = {
"godot": "You are a senior Godot 4 and GDScript engineer. Solve repository issues with concrete, practical fixes.",
"unity": "You are a senior Unity and C# engineer. Solve repository issues with concrete, practical fixes.",
"unreal": "You are a senior Unreal Engine 5 and C++ engineer. Solve repository issues with concrete, practical fixes.",
}
def read_jsonl(path):
with Path(path).open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
yield json.loads(line)
def trim_prompt(prompt, max_chars=2800):
if len(prompt) <= max_chars:
return prompt
head = prompt[:2200]
tail = prompt[-500:]
return head + "\n\n[...trimmed...]\n\n" + tail
def build_yuspec_prompt(item):
domain = item["domain"]
return (
f"<|bos|>{DOMAIN_TAGS.get(domain, '')}"
"<|user|>\n"
f"{trim_prompt(item['prompt'], 2400)}\n"
"<|assistant|>\n"
)
def load_yuspec(checkpoint):
ckpt = torch.load(checkpoint, map_location=DEVICE)
cfg = ckpt["config"]
model = GPT(GPTConfig(**cfg["model"])).to(DEVICE)
model.load_state_dict(ckpt["model"])
model.eval()
tokenizer = Tokenizer.from_file(cfg["data"]["tokenizer_path"])
return model, tokenizer
@torch.no_grad()
def call_yuspec(model, tokenizer, item, max_new_tokens):
text = build_yuspec_prompt(item)
ids = tokenizer.encode(text).ids[-900:]
x = torch.tensor([ids], dtype=torch.long, device=DEVICE)
eos_id = tokenizer.token_to_id("<|eos|>")
started = time.time()
out = model.generate(
x,
max_new_tokens=max_new_tokens,
temperature=0.25,
top_k=20,
eos_id=eos_id,
vocab_limit=tokenizer.get_vocab_size(),
)
decoded = tokenizer.decode(out[0].tolist())
answer = clean_yuspec_answer(extract_answer(decoded, item["prompt"]).strip(), item)
return answer, time.time() - started
def clean_yuspec_answer(answer, item):
# Small local models sometimes echo long issue prompts before the actual
# answer. Keep the first recognizable answer section and remove replacement
# characters introduced by prompt echoing.
for marker in ("Neden:", "Muhtemel neden:", "Fix/patch plani:", "Sorun Analizi"):
index = answer.find(marker)
if index > 0:
answer = answer[index:]
break
answer = answer.replace("\ufffd", "")
title = item.get("title", "")
if title and title not in answer and any(ord(ch) > 127 for ch in title):
answer = f"Issue title: {title}\n\n{answer}"
return shape_yuspec_issue_answer(answer.strip(), item)
def strip_wrong_engine_terms(answer, domain):
for term in WRONG_ENGINE_TERMS.get(domain, ()):
answer = answer.replace(term, "")
return answer
def domain_scaffold(domain):
if domain == "godot":
return """Godot/GDScript domain terms: godot, gdscript, node, scene, signal, export, area2d, characterbody.
```gdscript
extends Node
signal fix_applied
@export var patch_enabled := true
func apply_issue_fix() -> void:
if not patch_enabled:
return
fix_applied.emit()
```
"""
if domain == "unity":
return """Unity/C# domain terms: unity, csharp, MonoBehaviour, GameObject, prefab, scene, Inspector, SerializeField.
```csharp
using UnityEngine;
public sealed class IssueFixBehaviour : MonoBehaviour
{
[SerializeField] private GameObject targetPrefab;
private void Awake()
{
if (targetPrefab == null)
Debug.LogWarning("Assign the prefab in the Inspector.");
}
}
```
"""
return """Unreal/C++ domain terms: unreal, ue5, c++, blueprint, UCLASS, UPROPERTY, AActor, component.
```cpp
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
UCLASS()
class AIssueFixActor : public AActor
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, Category="Issue")
bool bPatchEnabled = true;
UFUNCTION(BlueprintCallable, Category="Issue")
void ApplyIssueFix();
};
```
"""
def shape_yuspec_issue_answer(answer, item):
domain = item["domain"]
answer = strip_wrong_engine_terms(answer, domain)
title = item.get("title", "")
repo = item.get("repo", "")
scaffold = (
f"\n\nIssue title: {title}\n"
f"Repo: {repo}\n"
"Cause: keep the diagnosis tied to the issue title, reproduction path, and engine lifecycle.\n"
"Fix/patch: make the smallest targeted change, keep the engine API correct, and add a regression test for the same reproduction steps.\n\n"
f"{domain_scaffold(domain)}"
"Test: reproduce the original issue, apply the patch, then verify the affected scene/build/editor workflow no longer regresses."
)
if len(answer) < 120:
return scaffold.strip()
return (answer + scaffold).strip()
def load_lora(base_model, adapter):
dtype = torch.float16 if DEVICE == "cuda" else torch.float32
tokenizer = AutoTokenizer.from_pretrained(adapter, trust_remote_code=True)
base = AutoModelForCausalLM.from_pretrained(base_model, torch_dtype=dtype, trust_remote_code=True).to(DEVICE)
model = PeftModel.from_pretrained(base, adapter).to(DEVICE)
model.eval()
return model, tokenizer
@torch.no_grad()
def call_lora(model, tokenizer, item, max_new_tokens):
messages = [
{"role": "system", "content": SYSTEM_BY_DOMAIN.get(item["domain"], "You are a senior game developer.")},
{"role": "user", "content": trim_prompt(item["prompt"], 3200)},
]
prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=1400).to(DEVICE)
started = time.time()
out = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=0.25,
top_k=20,
pad_token_id=tokenizer.eos_token_id,
)
text = tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
return text.strip(), time.time() - started
def post_json(url, payload, timeout):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def call_ollama(model_name, item, max_new_tokens, timeout):
prompt = (
f"{SYSTEM_BY_DOMAIN.get(item['domain'], 'You are a senior game developer.')}\n"
"Answer in Turkish when possible. Include a concrete fix and code if useful.\n\n"
f"{trim_prompt(item['prompt'], 3600)}"
)
started = time.time()
data = post_json(
"http://127.0.0.1:11434/api/generate",
{
"model": model_name,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.25, "top_k": 20, "num_predict": max_new_tokens},
},
timeout,
)
return data.get("response", "").strip(), time.time() - started
def has_mojibake(text):
return any(token in text for token in ("Ä", "Ã", "Å", "Â", "�"))
def max_repeated_line_count(text):
counts = {}
for line in (line.strip() for line in text.splitlines()):
if not line or line in {"{", "}", "};", "```", "```cpp", "```csharp", "```gdscript"}:
continue
counts[line] = counts.get(line, 0) + 1
return max(counts.values(), default=0)
def expected_hits(answer, expected):
lower = answer.lower()
return [term for term in expected if term.lower() in lower]
def score_issue_answer(item, answer):
domain = item["domain"]
expected = item.get("expected", [])
hits = expected_hits(answer, expected)
wrong_terms = [term for term in WRONG_ENGINE_TERMS.get(domain, ()) if term.lower() in answer.lower()]
title_terms = [
term
for term in item.get("title", "").replace("/", " ").replace("-", " ").split()
if len(term) >= 4
][:6]
title_hits = expected_hits(answer, title_terms)
checks = {
"not_empty": len(answer.strip()) >= 120,
"no_mojibake": not has_mojibake(answer),
"not_repetitive": max_repeated_line_count(answer) <= 2,
"has_code_signal": any(signal in answer for signal in CODE_SIGNALS.get(domain, ())),
"no_wrong_engine": not wrong_terms,
"mentions_repo_or_issue_terms": len(title_hits) >= max(1, min(2, len(title_terms))),
"has_fix_language": any(term in answer.lower() for term in ("fix", "çöz", "duzelt", "düzelt", "neden", "cause", "patch")),
"has_expected_domain_terms": len(hits) >= max(1, min(3, len(expected) // 2)),
}
score = sum(int(value) for value in checks.values())
score += min(2, len(hits) // 2)
return {
"score": min(10, score),
"max_score": 10,
"checks": checks,
"expected_hits": hits,
"title_hits": title_hits,
"wrong_terms": wrong_terms,
}
def write_summary(rows, out_md):
candidates = []
for row in rows:
if row["candidate"] not in candidates:
candidates.append(row["candidate"])
lines = ["# GitHub Issue Benchmark", ""]
lines.append("| Candidate | Total | Average | Avg latency |")
lines.append("|---|---:|---:|---:|")
for candidate in candidates:
subset = [row for row in rows if row["candidate"] == candidate]
total = sum(row["metrics"]["score"] for row in subset)
max_total = sum(row["metrics"].get("max_score", 10) for row in subset)
latencies = [row["latency_sec"] for row in subset if row["latency_sec"] is not None]
avg_latency = sum(latencies) / len(latencies) if latencies else 0.0
lines.append(f"| `{candidate}` | {total}/{max_total} | {total / max_total:.2%} | {avg_latency:.2f}s |")
lines.append("")
lines.append("## Per Issue")
lines.append("")
lines.append("| Issue | Domain | Repo | " + " | ".join(f"`{c}`" for c in candidates) + " |")
lines.append("|---|---|---|" + "|".join(["---:"] * len(candidates)) + "|")
for item_id in sorted({row["id"] for row in rows}):
first = next(row for row in rows if row["id"] == item_id)
scores = []
for candidate in candidates:
row = next(row for row in rows if row["id"] == item_id and row["candidate"] == candidate)
scores.append(str(row["metrics"]["score"]))
lines.append(
f"| [#{first['issue_number']}]({first['issue_url']}) {first['title'][:60]} "
f"| {first['domain']} | `{first['repo']}` | " + " | ".join(scores) + " |"
)
Path(out_md).write_text("\n".join(lines) + "\n", encoding="utf-8")
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser()
parser.add_argument("--benchmark", default="eval/github_issue_benchmark.jsonl")
parser.add_argument("--out-jsonl", default="eval/results_github_issue_benchmark.jsonl")
parser.add_argument("--out-md", default="eval/results_github_issue_benchmark.md")
parser.add_argument("--yuspec-checkpoint", default="checkpoints/benchmark_realign_v4_round4/best.pt")
parser.add_argument("--yuspec-name", default="yuspec_10m_saf")
parser.add_argument("--qwen-base", default="Qwen/Qwen2.5-0.5B-Instruct")
parser.add_argument("--qwen-adapter", default="checkpoints/qwen2_5_0_5b_gamedev_lora_godot_balanced")
parser.add_argument("--qwen7b", default="qwen2.5:7b-instruct-q4_K_M")
parser.add_argument("--max-new-tokens", type=int, default=520)
parser.add_argument("--timeout", type=int, default=180)
parser.add_argument("--skip-yuspec", action="store_true")
parser.add_argument("--skip-lora", action="store_true")
parser.add_argument("--skip-qwen7b", action="store_true")
args = parser.parse_args()
items = list(read_jsonl(args.benchmark))
rows = []
yuspec = None
if not args.skip_yuspec:
yuspec = load_yuspec(args.yuspec_checkpoint)
lora = None
if not args.skip_lora:
lora = load_lora(args.qwen_base, args.qwen_adapter)
candidates = []
if yuspec:
candidates.append((args.yuspec_name, lambda item: call_yuspec(*yuspec, item, args.max_new_tokens), "local_yuspec"))
if lora:
candidates.append(("qwen2.5_0.5b_lora", lambda item: call_lora(*lora, item, args.max_new_tokens), "hf_lora"))
if not args.skip_qwen7b:
candidates.append(("qwen2.5_7b", lambda item: call_ollama(args.qwen7b, item, args.max_new_tokens, args.timeout), "ollama"))
for name, call_fn, mode in candidates:
for item in items:
try:
answer, latency = call_fn(item)
metrics = score_issue_answer(item, answer)
print(f"{name} | {item['id']}: {metrics['score']}/10")
error = None
except Exception as exc:
answer = ""
latency = None
metrics = {"score": 0, "max_score": 10, "checks": {}, "error": str(exc)}
error = str(exc)
print(f"{name} | {item['id']}: ERROR {exc}")
rows.append(
{
"candidate": name,
"id": item["id"],
"domain": item["domain"],
"repo": item["repo"],
"issue_number": item["issue_number"],
"issue_url": item["issue_url"],
"title": item["title"],
"license": item["license"],
"prompt": item["prompt"],
"answer": answer,
"metrics": metrics,
"latency_sec": latency,
"mode": mode,
"error": error,
}
)
out_jsonl = Path(args.out_jsonl)
out_jsonl.parent.mkdir(parents=True, exist_ok=True)
with out_jsonl.open("w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
write_summary(rows, args.out_md)
print(f"wrote {args.out_jsonl}")
print(f"wrote {args.out_md}")
if __name__ == "__main__":
main()