feat: add pass-at-k code evaluation

pull/808/head
RileyChen23 2026-07-12 17:38:20 +08:00
parent 8e06fb6d52
commit 07643d5f05
7 changed files with 274 additions and 1 deletions

View File

@ -1,6 +1,12 @@
"""Utilities for execution-feedback code generation experiments.""" """Utilities for execution-feedback code generation experiments."""
from .reward import CodeReward, score_verification from .reward import CodeReward, score_verification
from .evaluation import (
EvaluationReport,
aggregate_reports,
estimate_pass_at_k,
evaluate_candidates,
)
from .verifier import ( from .verifier import (
CodeTask, CodeTask,
TestCase, TestCase,
@ -13,9 +19,13 @@ from .verifier import (
__all__ = [ __all__ = [
"CodeReward", "CodeReward",
"CodeTask", "CodeTask",
"EvaluationReport",
"TestCase", "TestCase",
"VerificationResult", "VerificationResult",
"VerificationStatus", "VerificationStatus",
"aggregate_reports",
"estimate_pass_at_k",
"evaluate_candidates",
"extract_python_code", "extract_python_code",
"score_verification", "score_verification",
"verify_code", "verify_code",

116
code_agent/evaluation.py Normal file
View File

@ -0,0 +1,116 @@
"""Evaluation metrics for execution-verified code generation."""
from __future__ import annotations
import math
from collections import Counter
from dataclasses import asdict, dataclass
from typing import Any, Iterable, Sequence
from .verifier import CodeTask, VerificationResult, VerificationStatus, verify_code
def estimate_pass_at_k(num_samples: int, num_correct: int, k: int) -> float:
"""Unbiased pass@k estimator used by common code-generation benchmarks."""
if num_samples <= 0:
raise ValueError("num_samples must be positive")
if not 0 <= num_correct <= num_samples:
raise ValueError("num_correct must be between 0 and num_samples")
if not 1 <= k <= num_samples:
raise ValueError("k must be between 1 and num_samples")
if num_samples - num_correct < k:
return 1.0
return 1.0 - math.comb(num_samples - num_correct, k) / math.comb(num_samples, k)
@dataclass(frozen=True)
class EvaluationReport:
task_id: str
candidate_count: int
correct_count: int
compile_rate: float
execution_rate: float
test_pass_rate: float
pass_at_k: dict[str, float]
status_counts: dict[str, int]
results: tuple[VerificationResult, ...]
def to_dict(self, *, include_results: bool = True) -> dict[str, Any]:
payload = asdict(self)
if include_results:
payload["results"] = [result.to_dict() for result in self.results]
else:
payload.pop("results", None)
return payload
_COMPILED_STATUSES = {
VerificationStatus.PASSED,
VerificationStatus.RUNTIME_ERROR,
VerificationStatus.TIMEOUT,
VerificationStatus.WRONG_ANSWER,
}
_EXECUTED_STATUSES = {
VerificationStatus.PASSED,
VerificationStatus.RUNTIME_ERROR,
VerificationStatus.WRONG_ANSWER,
}
def evaluate_candidates(
task: CodeTask,
candidates: Sequence[str],
*,
ks: Iterable[int] = (1, 5, 10),
timeout_seconds: float = 2.0,
memory_mb: int = 256,
) -> EvaluationReport:
if not candidates:
raise ValueError("at least one candidate is required")
results = tuple(
verify_code(candidate, task, timeout_seconds=timeout_seconds, memory_mb=memory_mb)
for candidate in candidates
)
count = len(results)
correct = sum(result.status == VerificationStatus.PASSED for result in results)
status_counts = Counter(result.status.value for result in results)
total_tests = sum(result.total_tests for result in results)
passed_tests = sum(result.passed_tests for result in results)
requested_ks = sorted(set(int(k) for k in ks if 1 <= int(k) <= count))
pass_at_k = {f"pass@{k}": estimate_pass_at_k(count, correct, k) for k in requested_ks}
return EvaluationReport(
task_id=task.task_id,
candidate_count=count,
correct_count=correct,
compile_rate=sum(result.status in _COMPILED_STATUSES for result in results) / count,
execution_rate=sum(result.status in _EXECUTED_STATUSES for result in results) / count,
test_pass_rate=passed_tests / total_tests if total_tests else 0.0,
pass_at_k=pass_at_k,
status_counts=dict(sorted(status_counts.items())),
results=results,
)
def aggregate_reports(reports: Sequence[EvaluationReport]) -> dict[str, Any]:
if not reports:
raise ValueError("at least one evaluation report is required")
status_counts: Counter[str] = Counter()
for report in reports:
status_counts.update(report.status_counts)
metric_names = sorted({name for report in reports for name in report.pass_at_k})
pass_at_k = {
name: sum(report.pass_at_k[name] for report in reports if name in report.pass_at_k)
/ sum(name in report.pass_at_k for report in reports)
for name in metric_names
}
candidate_count = sum(report.candidate_count for report in reports)
return {
"task_count": len(reports),
"candidate_count": candidate_count,
"correct_count": sum(report.correct_count for report in reports),
"compile_rate": sum(report.compile_rate * report.candidate_count for report in reports) / candidate_count,
"execution_rate": sum(report.execution_rate * report.candidate_count for report in reports) / candidate_count,
"test_pass_rate": sum(report.test_pass_rate * report.candidate_count for report in reports) / candidate_count,
"pass_at_k": pass_at_k,
"status_counts": dict(sorted(status_counts.items())),
}

View File

@ -21,7 +21,7 @@ import time
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any
class VerificationStatus(str, Enum): class VerificationStatus(str, Enum):
@ -44,6 +44,14 @@ class TestCase:
def to_json(self) -> dict[str, Any]: def to_json(self) -> dict[str, Any]:
return {"args": list(self.args), "kwargs": self.kwargs, "expected": self.expected} return {"args": list(self.args), "kwargs": self.kwargs, "expected": self.expected}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "TestCase":
return cls(
args=tuple(payload.get("args", ())),
kwargs=dict(payload.get("kwargs", {})),
expected=payload.get("expected"),
)
@dataclass(frozen=True) @dataclass(frozen=True)
class CodeTask: class CodeTask:
@ -60,6 +68,23 @@ class CodeTask:
# Fail early instead of discovering non-serializable tests in a worker. # Fail early instead of discovering non-serializable tests in a worker.
json.dumps([case.to_json() for case in self.tests], ensure_ascii=False) json.dumps([case.to_json() for case in self.tests], ensure_ascii=False)
def to_dict(self) -> dict[str, Any]:
return {
"task_id": self.task_id,
"prompt": self.prompt,
"entry_point": self.entry_point,
"tests": [case.to_json() for case in self.tests],
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "CodeTask":
return cls(
task_id=str(payload["task_id"]),
prompt=str(payload["prompt"]),
entry_point=str(payload["entry_point"]),
tests=tuple(TestCase.from_dict(case) for case in payload["tests"]),
)
@dataclass(frozen=True) @dataclass(frozen=True)
class VerificationResult: class VerificationResult:

66
scripts/eval_codegen.py Normal file
View File

@ -0,0 +1,66 @@
"""Evaluate JSONL code-generation candidates with deterministic tests."""
import argparse
import json
import os
import sys
from pathlib import Path
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from code_agent import CodeTask, aggregate_reports, evaluate_candidates
def read_jsonl(path: str):
with open(path, "r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, start=1):
if line.strip():
try:
yield json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON at {path}:{line_number}: {exc}") from exc
def main():
parser = argparse.ArgumentParser(description="Execution-based evaluation for code generation")
parser.add_argument("--tasks", required=True, help="JSONL file containing CodeTask records")
parser.add_argument("--predictions", required=True, help="JSONL records with task_id and candidates")
parser.add_argument("--output", help="Optional JSON report path")
parser.add_argument("--ks", type=int, nargs="+", default=[1, 5, 10], help="pass@k values")
parser.add_argument("--timeout", type=float, default=2.0, help="Timeout per candidate in seconds")
parser.add_argument("--memory-mb", type=int, default=256, help="POSIX worker memory limit")
args = parser.parse_args()
tasks = {record["task_id"]: CodeTask.from_dict(record) for record in read_jsonl(args.tasks)}
predictions = {record["task_id"]: record["candidates"] for record in read_jsonl(args.predictions)}
missing = sorted(set(tasks) - set(predictions))
unknown = sorted(set(predictions) - set(tasks))
if missing or unknown:
raise ValueError(f"Task/prediction mismatch: missing={missing}, unknown={unknown}")
reports = [
evaluate_candidates(
task,
predictions[task_id],
ks=args.ks,
timeout_seconds=args.timeout,
memory_mb=args.memory_mb,
)
for task_id, task in tasks.items()
]
output = {
"summary": aggregate_reports(reports),
"tasks": [report.to_dict() for report in reports],
"safety_note": (
"The local verifier is for controlled experiments, not hostile code. "
"Use a dedicated container or micro-VM for untrusted submissions."
),
}
rendered = json.dumps(output, ensure_ascii=False, indent=2)
if args.output:
Path(args.output).write_text(rendered + "\n", encoding="utf-8")
print(rendered)
if __name__ == "__main__":
main()

1
tests/fixtures/code_predictions.jsonl vendored Normal file
View File

@ -0,0 +1 @@
{"task_id":"add-two","candidates":["def add(a, b):\n return a + b","def add(a, b):\n return a - b"]}

1
tests/fixtures/code_tasks.jsonl vendored Normal file
View File

@ -0,0 +1 @@
{"task_id":"add-two","prompt":"Implement add(a, b) and return the sum.","entry_point":"add","tests":[{"args":[1,2],"expected":3},{"args":[-5,5],"expected":0}]}

View File

@ -0,0 +1,54 @@
import pytest
from code_agent import (
CodeTask,
TestCase as CodeTestCase,
VerificationStatus,
aggregate_reports,
estimate_pass_at_k,
evaluate_candidates,
)
TASK = CodeTask(
task_id="square",
prompt="Implement square(x).",
entry_point="square",
tests=(
CodeTestCase(args=(2,), expected=4),
CodeTestCase(args=(-3,), expected=9),
),
)
def test_pass_at_k_estimator():
assert estimate_pass_at_k(10, 0, 1) == 0.0
assert estimate_pass_at_k(10, 1, 10) == 1.0
assert estimate_pass_at_k(10, 2, 1) == pytest.approx(0.2)
with pytest.raises(ValueError):
estimate_pass_at_k(2, 1, 3)
def test_evaluation_reports_execution_metrics():
candidates = [
"def square(x):\n return x * x",
"def square(x):\n return x + x",
"def square(x)\n return x * x",
]
report = evaluate_candidates(TASK, candidates, ks=(1, 3))
assert report.correct_count == 1
assert report.compile_rate == pytest.approx(2 / 3)
assert report.execution_rate == pytest.approx(2 / 3)
assert report.test_pass_rate == pytest.approx(0.5)
assert report.pass_at_k["pass@1"] == pytest.approx(1 / 3)
assert report.pass_at_k["pass@3"] == 1.0
assert report.results[2].status == VerificationStatus.SYNTAX_ERROR
summary = aggregate_reports([report])
assert summary["candidate_count"] == 3
assert summary["status_counts"] == {
"passed": 1,
"syntax_error": 1,
"wrong_answer": 1,
}