Files
company-haness/.claude/hooks/benchmark.py
T

312 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""benchmark.py — 골든태스크 품질 회귀 벤치마크 (리뷰 3주차).
plain Claude vs 이 하네스를 같은 골든태스크로 실행·채점·비교한다. 개선이 증명되지 않는
role/fan-out/framework의 제거 근거를 만든다. 이 도구는 **측정 인프라**다 — 실제 비교 데이터는
두 arm으로 과제를 실행하고 record 로 점수를 적재해야 쌓인다(정직: 데이터 없으면 '미실행' 표시).
repo-level `benchmark/`(워크스페이스 비의존): golden-tasks.yaml · benchmark-rubric.yaml ·
runs.jsonl(append-only) · BENCHMARK.md(비교 리포트).
Usage:
benchmark.py list # 골든태스크 목록
benchmark.py run --task GT-01 --arm plain|harness [--execute] [--timeout 900]
# fixture+verify 가 있는 과제를 임시 복사본에서 실행·자동채점. 기본 --dry-run(미실행, 예산보호),
# --execute 를 줘야 claude CLI 를 호출하고 verify 로 객관 채점 후 runs.jsonl 에 적재한다.
benchmark.py record --task GT-01 --arm plain|harness \
--scores "first-pass-acceptance=1,tests-pass-rate=0.9,rework-count=1,tokens=8000" [--note ...]
# fixture 없는(문서/결정 등 수동채점) 과제용 — 사람이 채점한 점수를 적재.
benchmark.py compare # runs.jsonl -> BENCHMARK.md (plain vs harness delta)
"""
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
import yaml
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(os.path.dirname(HERE))
BENCH = os.path.join(ROOT, "benchmark")
TASKS = os.path.join(BENCH, "golden-tasks.yaml")
RUBRIC = os.path.join(BENCH, "benchmark-rubric.yaml")
RUNS = os.path.join(BENCH, "runs.jsonl")
OUT = os.path.join(BENCH, "BENCHMARK.md")
def _load(path, key):
return (yaml.safe_load(open(path, encoding="utf-8")) or {}).get(key, {})
def _tasks():
return _load(TASKS, "golden-tasks")
def _rubric():
return _load(RUBRIC, "benchmark-rubric")
def _runs():
if not os.path.exists(RUNS):
return []
out = []
for line in open(RUNS, encoding="utf-8"):
line = line.strip()
if line:
try:
out.append(json.loads(line))
except json.JSONDecodeError:
pass
return out
def cmd_list():
t = _tasks()
tasks = t.get("tasks", [])
print(f"골든태스크 {len(tasks)}개 (카테고리: {', '.join(t.get('categories', []))})")
for x in tasks:
print(f" {x['id']} [{x['category']}/{x.get('difficulty','-')}] {x['prompt']}")
def cmd_record(opt):
task, arm = opt.get("task"), opt.get("arm")
valid_ids = {x["id"] for x in _tasks().get("tasks", [])}
arms = _rubric().get("arms", ["plain", "harness"])
if task not in valid_ids:
sys.stderr.write(f"unknown task {task!r} — golden-tasks.yaml 참고(list)\n")
sys.exit(2)
if arm not in arms:
sys.stderr.write(f"arm은 {arms} 중 하나여야 한다(got {arm!r})\n")
sys.exit(2)
scores = {}
for kv in (opt.get("scores") or "").split(","):
kv = kv.strip()
if "=" in kv:
k, v = kv.split("=", 1)
try:
scores[k.strip()] = float(v)
except ValueError:
scores[k.strip()] = v.strip()
rec = {"at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"task": task, "arm": arm, "scores": scores, "note": opt.get("note")}
os.makedirs(BENCH, exist_ok=True)
with open(RUNS, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
print(f"[benchmark] recorded {task}/{arm}: {scores}")
def _mean(vals):
vals = [v for v in vals if isinstance(v, (int, float))]
return sum(vals) / len(vals) if vals else None
# ─────────────────────────────────────────────── REAL runner + automated grader (R3)
# 재리뷰 지적: benchmark.py 는 '임의 점수 recorder'였다(모델 미실행). 이제 fixture+verify 가 있는
# 과제를 실제로 실행한다 — 임시 복사본에서 claude CLI(one-shot -p)를 두 arm(plain/harness)으로
# 돌리고, verify(pytest 등)로 **객관 채점**한다. 정직: 실행은 실제 API 예산을 쓰므로 기본은
# --dry-run(플러밍만 확인, 미실행). --execute 를 줘야 CLI 를 호출한다. 위조 점수 없음.
_CLAUDE_CMD = os.environ.get("ORGOS_BENCH_CLAUDE", "claude")
def _task_by_id(tid):
for t in _tasks().get("tasks", []):
if t.get("id") == tid:
return t
return None
def _git(args, cwd):
return subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True)
def _setup_workdir(task, arm):
"""fixture 를 임시 dir 로 복사하고 git 기준선 커밋. harness arm 은 repo .claude 를 얹는다.
(workdir, fixture_abs) 반환. fixture 없으면 (None, None)."""
fx = task.get("fixture")
if not fx:
return None, None
# fixture 경로는 benchmark/ 기준(golden-tasks.yaml 위치). 절대경로면 그대로.
fixture_abs = fx if os.path.isabs(fx) else os.path.join(BENCH, fx)
if not os.path.isdir(fixture_abs):
return None, None
work = tempfile.mkdtemp(prefix=f"bench_{task['id']}_{arm}_")
for name in os.listdir(fixture_abs):
s = os.path.join(fixture_abs, name)
d = os.path.join(work, name)
(shutil.copytree if os.path.isdir(s) else shutil.copy2)(s, d)
_git(["init", "-q"], work)
_git(["add", "-A"], work)
_git(["-c", "user.email=b@b", "-c", "user.name=b", "commit", "-qm", "baseline"], work)
if arm == "harness":
# 하네스 arm: .claude(settings/hooks/agents)를 얹어 게이트가 실제로 작동하게 한다.
shutil.copytree(os.path.join(ROOT, ".claude"), os.path.join(work, ".claude"))
return work, fixture_abs
def _grade(work, task):
"""arm 실행 후 객관 채점. verify 실행 + git diff 로 점수 산출."""
scores = {}
verify = task.get("verify")
if verify:
vr = subprocess.run(verify, cwd=work, shell=True, capture_output=True, text=True, timeout=300)
out = (vr.stdout or "") + (vr.stderr or "")
scores["first-pass-acceptance"] = 1.0 if vr.returncode == 0 else 0.0
m = re.search(r"(\d+)\s+passed(?:,\s*(\d+)\s+failed)?", out)
if m:
p = int(m.group(1)); f = int(m.group(2) or 0)
scores["tests-pass-rate"] = round(p / (p + f), 3) if (p + f) else 0.0
else:
scores["tests-pass-rate"] = 1.0 if vr.returncode == 0 else 0.0
# unnecessary-change-lines: expected-changed-files 밖의 diff 라인 수.
exp = set(task.get("expected-changed-files") or [])
ns = _git(["diff", "--numstat", "HEAD"], work).stdout
extra = 0
for line in ns.splitlines():
parts = line.split("\t")
if len(parts) == 3:
add, dele, path = parts
if path not in exp and not path.startswith(".claude/"):
extra += (int(add) if add.isdigit() else 0) + (int(dele) if dele.isdigit() else 0)
scores["unnecessary-change-lines"] = extra
return scores
def cmd_run(opt):
task = _task_by_id(opt.get("task"))
arm = opt.get("arm")
if not task:
sys.stderr.write(f"unknown task {opt.get('task')!r} (list 참고)\n"); sys.exit(2)
if arm not in _rubric().get("arms", ["plain", "harness"]):
sys.stderr.write(f"arm 은 plain|harness 여야 한다(got {arm!r})\n"); sys.exit(2)
if not task.get("fixture"):
sys.stderr.write(f"{task['id']} 은 실행 fixture 가 없다 — 수동 record 대상(run 불가)\n"); sys.exit(2)
execute = bool(opt.get("execute"))
work, fixture_abs = _setup_workdir(task, arm)
if not work:
sys.stderr.write(f"fixture 설정 실패: {task.get('fixture')}\n"); sys.exit(1)
cli = [_CLAUDE_CMD, "-p", task["prompt"], "--dangerously-skip-permissions"]
env = dict(os.environ)
env["CLAUDE_PROJECT_DIR"] = work
if arm == "harness":
env["ORGOS_WORKSPACE"] = work # 하네스 arm: workspace 를 작업 dir 로
print(f"== benchmark run: {task['id']} / {arm} ==")
print(f" workdir: {work}")
print(f" verify : {task.get('verify')}")
print(f" CLI : {' '.join(cli[:2])} \"<prompt>\" {' '.join(cli[3:])}")
if not execute:
# 기본: 플러밍만 확인(미실행). fixture/verify 가 실제로 돌아가는지 baseline 채점으로 증명.
base = _grade(work, task)
print(f" [dry-run] 미실행(예산 보호). baseline verify → first-pass-acceptance="
f"{base.get('first-pass-acceptance')} (버그 상태라 0 이어야 정상).")
print(" 실제 실행: --execute 를 주면 claude CLI 를 호출하고 자동 채점·record 한다.")
return
if not shutil.which(_CLAUDE_CMD):
sys.stderr.write(f"claude CLI('{_CLAUDE_CMD}') 미가용 — ORGOS_BENCH_CLAUDE 로 지정하세요\n"); sys.exit(1)
print(" [execute] claude CLI 호출 중… (실제 API 예산 소비)")
try:
subprocess.run(cli, cwd=work, env=env, timeout=int(opt.get("timeout", 900) or 900))
except subprocess.TimeoutExpired:
print(" [execute] 타임아웃 — 부분 결과로 채점")
scores = _grade(work, task)
rec = {"at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"task": task["id"], "arm": arm, "scores": scores, "note": "auto(run)",
"workdir": work}
os.makedirs(BENCH, exist_ok=True)
with open(RUNS, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
print(f" [execute] auto-graded {task['id']}/{arm}: {scores}")
def cmd_compare():
rub = _rubric()
dims = rub.get("dimensions", {})
runs = _runs()
by = {"plain": {}, "harness": {}}
for r in runs:
arm = r.get("arm")
if arm not in by:
continue
for k, v in (r.get("scores") or {}).items():
by[arm].setdefault(k, []).append(v)
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
n_plain = sum(1 for r in runs if r.get("arm") == "plain")
n_harness = sum(1 for r in runs if r.get("arm") == "harness")
L = ["# 🏁 골든태스크 벤치마크 (plain Claude vs 하네스)", "",
f"생성: {ts} · 실행 표본: plain {n_plain} · harness {n_harness} "
f"(골든태스크 {len(_tasks().get('tasks', []))}개)",
"> 측정 인프라. 표본이 없으면 '미실행'으로 **정직히** 표시한다(위장 없음). "
"delta>0 = 하네스 이득(방향 보정됨).", "",
"| dimension | 방향 | weight | plain | harness | delta | 판정 |",
"|---|---|--:|--:|--:|--:|:--:|"]
wins = losses = ties = 0
composite = 0.0
for dim, spec in dims.items():
direction = spec.get("direction", "higher-better")
w = spec.get("weight", 1)
pm = _mean(by["plain"].get(dim, []))
hm = _mean(by["harness"].get(dim, []))
if pm is None or hm is None:
L.append(f"| {dim} | {direction} | {w} | "
f"{'-' if pm is None else round(pm,3)} | "
f"{'-' if hm is None else round(hm,3)} | - | ⚪ 미실행 |")
continue
raw = (hm - pm) if direction == "higher-better" else (pm - hm)
verdict = "✅ 하네스" if raw > 1e-9 else ("❌ plain" if raw < -1e-9 else " 동률")
if raw > 1e-9:
wins += 1; composite += w
elif raw < -1e-9:
losses += 1; composite -= w
else:
ties += 1
L.append(f"| {dim} | {direction} | {w} | {round(pm,3)} | {round(hm,3)} | "
f"{round(raw,3):+} | {verdict} |")
L += ["",
f"**요약**: 하네스 우세 {wins} · plain 우세 {losses} · 동률 {ties} · "
f"가중 composite {composite:+g} (양수=하네스 이득).",
"", "> 판정 규칙(rubric.decision-rule): 하네스가 카테고리에서 delta<=0이면 그 role/fan-out/"
"framework는 비용만 늘리는 것 → 제거/경량화 후보. 표본을 채워 이 표를 실증한다."]
if n_plain == 0 and n_harness == 0:
L += ["", "⚠️ 아직 실행 표본이 없다. `benchmark.py record`로 두 arm의 점수를 적재하면 "
"이 표가 실증 데이터로 채워진다(현재는 프레임만)."]
os.makedirs(BENCH, exist_ok=True)
with open(OUT, "w", encoding="utf-8") as f:
f.write("\n".join(L) + "\n")
print(f"[benchmark] compare -> {os.path.relpath(OUT, ROOT)} "
f"(plain {n_plain} · harness {n_harness} 표본)")
def main():
a = sys.argv[1:]
if not a:
sys.stderr.write(__doc__)
sys.exit(1)
cmd = a[0]
opt = {}
i = 1
while i < len(a):
if a[i].startswith("--"):
k = a[i][2:]
opt[k] = a[i + 1] if i + 1 < len(a) and not a[i + 1].startswith("--") else True
i += 2
else:
i += 1
if cmd == "list":
cmd_list()
elif cmd == "run":
cmd_run(opt)
elif cmd == "record":
cmd_record(opt)
elif cmd == "compare":
cmd_compare()
else:
sys.stderr.write(f"unknown command: {cmd}\n")
sys.exit(1)
if __name__ == "__main__":
main()