Files
zk-data-agent/benchmarks/suites/base.py
T
2026-04-06 03:42:44 +02:00

341 lines
10 KiB
Python

"""
Base class for benchmark suites and shared benchmark helpers.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
_SAFE_COMPONENT_RE = re.compile(r"[^A-Za-z0-9_.-]+")
def _safe_component(value: str) -> str:
cleaned = _SAFE_COMPONENT_RE.sub("_", value).strip("._")
return cleaned or "item"
def resolve_temp_root() -> Path:
root = Path(tempfile.gettempdir()).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def make_temp_workspace(prefix: str, suite_name: str, problem_id: str) -> str:
temp_root = resolve_temp_root()
safe_prefix = _safe_component(prefix)
safe_suite = _safe_component(suite_name)
safe_problem = _safe_component(problem_id)
return tempfile.mkdtemp(
prefix=f"{safe_prefix}_{safe_suite}_{safe_problem}_",
dir=str(temp_root),
)
@dataclass
class BenchmarkResult:
problem_id: str
passed: bool
expected: str = ""
actual: str = ""
duration_sec: float = 0.0
error: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class SuiteReport:
suite_name: str
total: int
passed: int
failed: int
score_pct: float
duration_sec: float
model: str
results: list[BenchmarkResult]
timestamp: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"suite_name": self.suite_name,
"total": self.total,
"passed": self.passed,
"failed": self.failed,
"score_pct": self.score_pct,
"duration_sec": round(self.duration_sec, 2),
"model": self.model,
"timestamp": self.timestamp,
"results": [asdict(r) for r in self.results],
}
class BenchmarkSuite(ABC):
name: str = "base"
description: str = ""
category: str = "general"
def __init__(
self,
*,
data_dir: str | None = None,
limit: int | None = None,
agent_timeout: float = 300.0,
verbose: bool = False,
artifacts_dir: str | None = None,
save_passing_artifacts: bool = False,
) -> None:
self.data_dir = data_dir or str(
Path(__file__).resolve().parent.parent / "data"
)
self.limit = limit
self.agent_timeout = agent_timeout
self.verbose = verbose
self.artifacts_dir = artifacts_dir
self.save_passing_artifacts = save_passing_artifacts
self.project_root = str(Path(__file__).resolve().parent.parent.parent)
@abstractmethod
def load_dataset(self) -> list[dict[str, Any]]:
...
@abstractmethod
def build_prompt(self, problem: dict[str, Any]) -> str:
...
@abstractmethod
def evaluate(self, problem: dict[str, Any], workspace: str) -> BenchmarkResult:
...
def _run_shell(
self,
cmd: str,
cwd: str,
timeout: float = 30.0,
) -> tuple[int, str]:
try:
proc = subprocess.run(
cmd,
shell=True,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
)
return proc.returncode, (proc.stdout + proc.stderr).strip()
except subprocess.TimeoutExpired:
return 1, f"[TIMEOUT after {timeout}s]"
except Exception as exc:
return 1, str(exc)
def run_agent(self, instruction: str, workspace: str) -> tuple[int, str, float]:
import shlex
agent_cmd = (
f"{sys.executable} -m src.main agent "
f"{shlex.quote(instruction)} "
f"--cwd {shlex.quote(workspace)} "
f"--allow-write "
f"--allow-shell"
)
if self.verbose:
print(f" agent cmd: {agent_cmd[:160]}...")
start = time.time()
code, output = self._run_shell(
agent_cmd,
cwd=self.project_root,
timeout=self.agent_timeout,
)
duration = time.time() - start
if self.verbose:
print(f" agent exit={code} duration={duration:.1f}s")
return code, output, duration
def setup_workspace(self, problem: dict[str, Any], workspace: str) -> None:
del problem, workspace
def recover_output_files(
self,
problem: dict[str, Any],
workspace: str,
agent_output: str,
metadata: dict[str, Any],
) -> None:
del problem, workspace, agent_output, metadata
def _artifact_root(self, index: int, problem_id: str) -> Path | None:
if not self.artifacts_dir:
return None
root = Path(self.artifacts_dir)
root.mkdir(parents=True, exist_ok=True)
return root / f"{index:03d}_{_safe_component(problem_id)}"
def _save_artifacts(
self,
*,
index: int,
problem: dict[str, Any],
prompt: str,
agent_output: str,
workspace: str,
result: BenchmarkResult,
agent_exit_code: int,
) -> None:
artifact_root = self._artifact_root(index, result.problem_id)
if artifact_root is None:
return
if result.passed and not self.save_passing_artifacts:
return
artifact_root.mkdir(parents=True, exist_ok=True)
(artifact_root / "problem.json").write_text(
json.dumps(problem, indent=2) + "\n",
encoding="utf-8",
)
(artifact_root / "prompt.txt").write_text(prompt, encoding="utf-8")
(artifact_root / "agent_output.txt").write_text(agent_output, encoding="utf-8")
workspace_dst = artifact_root / "workspace"
if workspace_dst.exists():
shutil.rmtree(workspace_dst, ignore_errors=True)
shutil.copytree(workspace, workspace_dst)
result_payload = asdict(result)
result_payload["agent_exit_code"] = agent_exit_code
result_payload["workspace"] = workspace
(artifact_root / "result.json").write_text(
json.dumps(result_payload, indent=2) + "\n",
encoding="utf-8",
)
result.metadata["artifact_path"] = str(artifact_root)
def run_all(self) -> SuiteReport:
problems = self.load_dataset()
if self.limit is not None:
problems = problems[: self.limit]
print()
print("=" * 72)
print(f" {self.name} BENCHMARK")
print(f" {self.description}")
print("=" * 72)
model = os.environ.get("OPENAI_MODEL", "unknown")
print(f" Model: {model}")
print(f" Problems: {len(problems)}")
print(f" Timeout: {self.agent_timeout}s per problem")
print("=" * 72)
print()
suite_start = time.time()
all_results: list[BenchmarkResult] = []
for index, problem in enumerate(problems, 1):
pid = str(problem.get("id", problem.get("task_id", f"problem-{index}")))
print(f"[{index}/{len(problems)}] {pid}")
workspace = make_temp_workspace("claw", self.name, pid)
prompt = ""
agent_output = ""
agent_exit_code = -1
try:
self.setup_workspace(problem, workspace)
prompt = self.build_prompt(problem)
agent_exit_code, agent_output, duration = self.run_agent(prompt, workspace)
result_metadata: dict[str, Any] = {"agent_exit_code": agent_exit_code}
self.recover_output_files(problem, workspace, agent_output, result_metadata)
result = self.evaluate(problem, workspace)
result.duration_sec = duration
result.metadata.update(result_metadata)
self._save_artifacts(
index=index,
problem=problem,
prompt=prompt,
agent_output=agent_output,
workspace=workspace,
result=result,
agent_exit_code=agent_exit_code,
)
status = "PASS ✅" if result.passed else "FAIL ❌"
print(f" -> {status} ({duration:.1f}s)")
except Exception as exc:
result = BenchmarkResult(
problem_id=pid,
passed=False,
error=str(exc),
metadata={"agent_exit_code": agent_exit_code},
)
self._save_artifacts(
index=index,
problem=problem,
prompt=prompt,
agent_output=agent_output,
workspace=workspace,
result=result,
agent_exit_code=agent_exit_code,
)
print(f" -> ERROR ❌ {exc}")
finally:
shutil.rmtree(workspace, ignore_errors=True)
all_results.append(result)
print()
suite_duration = time.time() - suite_start
passed = sum(1 for item in all_results if item.passed)
total = len(all_results)
report = SuiteReport(
suite_name=self.name,
total=total,
passed=passed,
failed=total - passed,
score_pct=round(100.0 * passed / total, 1) if total else 0.0,
duration_sec=suite_duration,
model=model,
results=all_results,
timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"),
)
self._print_report(report)
return report
@staticmethod
def _print_report(report: SuiteReport) -> None:
print()
print("=" * 72)
print(f" {report.suite_name} — RESULTS")
print("=" * 72)
print()
for result in report.results:
icon = "" if result.passed else ""
print(f" {icon} {result.problem_id:<40} {result.duration_sec:.1f}s")
print()
print("" * 72)
print(
f" Total: {report.total} | Passed: {report.passed} "
f"| Failed: {report.failed} | Score: {report.score_pct:.1f}%"
)
print(f" Total time: {report.duration_sec:.1f}s")
print("" * 72)
print()
@staticmethod
def save_report(report: SuiteReport, path: str) -> None:
with open(path, "w", encoding="utf-8") as fh:
json.dump(report.to_dict(), fh, indent=2)
print(f" Report saved to {path}")