#!/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])} \"\" {' '.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()