init: company-haness 설계
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user