Files
magnus919_agent-skills/eval_runner/paired.py
T
usernameandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 476d7e11b0 feat(ci): add linting, formatting, coverage, and security configs
Add ruff linter/formatter with pre-commit hooks, pytest-cov with 60%
coverage threshold, CODEOWNERS, Dependabot for pip/GHA updates, and
.env.example. Auto-fix existing ruff violations across eval_runner/
and scripts/.

10 agent-readiness criteria resolved: lint_config, formatter,
pre_commit_hooks, naming_consistency, dead_code_detection,
test_coverage_thresholds, test_performance_tracking, codeowners,
dependency_update_automation, env_template.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-07-29 17:43:41 -04:00

267 lines
9.3 KiB
Python

"""Paired evaluation orchestrator.
Runs matched candidate and baseline trials in clean, isolated environments,
grades both with a deterministic verifier, and produces a case-level comparison
report. The candidate skill is staged read-only; the baseline has no skill.
Mutable state is reset for every trial.
"""
from __future__ import annotations
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .adapter import HarnessAdapter
from .comparison import build_comparison_report, format_comparison_summary, write_comparison_report
from .grader import grade_output
from .manifest import build_manifest, write_manifest
from .models import AdapterInput, EvalCase
from .path_safety import contained_path
from .sandbox import cleanup_sandbox, stage_paired_sandboxes
def run_paired_trial(
adapter: HarnessAdapter,
case: EvalCase,
skill_path: Path,
output_dir: Path,
model: str,
model_label: str | None = None,
) -> dict[str, Any]:
"""Run one case in candidate and baseline conditions, grade, and compare."""
candidate_sandbox, baseline_sandbox = stage_paired_sandboxes(skill_path)
try:
candidate_output_dir = contained_path(output_dir, "candidate", case.id)
baseline_output_dir = contained_path(output_dir, "baseline", case.id)
candidate_input = AdapterInput(
skill_path=candidate_sandbox,
case=case,
work_dir=contained_path(output_dir, "work", "candidate", case.id),
output_dir=candidate_output_dir,
model=model,
permissions={"skill_readonly": True, "grader_visible": False},
limits={"timeout_seconds": 120, "network_policy": "unspecified"},
)
baseline_input = AdapterInput(
skill_path=baseline_sandbox,
case=case,
work_dir=contained_path(output_dir, "work", "baseline", case.id),
output_dir=baseline_output_dir,
model=model,
permissions={"skill_readonly": False, "grader_visible": False},
limits={"timeout_seconds": 120, "network_policy": "unspecified"},
)
c_started = datetime.now(timezone.utc)
candidate_result = adapter.execute(candidate_input)
c_finished = datetime.now(timezone.utc)
b_started = datetime.now(timezone.utc)
baseline_result = adapter.execute(baseline_input)
b_finished = datetime.now(timezone.utc)
reported_model = model_label or model
candidate_manifest = build_manifest(
adapter_name=adapter.name,
adapter_version=adapter.version,
harness_name=adapter.name,
harness_version=adapter.version,
model_provider="unspecified" if not reported_model else reported_model.split("/")[0],
model_id=reported_model or "unspecified",
adapter_input=candidate_input,
adapter_output=candidate_result,
started_at=c_started,
finished_at=c_finished,
)
baseline_manifest = build_manifest(
adapter_name=adapter.name,
adapter_version=adapter.version,
harness_name=adapter.name,
harness_version=adapter.version,
model_provider="unspecified" if not reported_model else reported_model.split("/")[0],
model_id=reported_model or "unspecified",
adapter_input=baseline_input,
adapter_output=baseline_result,
started_at=b_started,
finished_at=b_finished,
)
manifests_dir = contained_path(output_dir, "manifests")
write_manifest(candidate_manifest, manifests_dir)
write_manifest(baseline_manifest, manifests_dir)
candidate_grade = grade_output(case.id, case.assertions, candidate_result)
baseline_grade = grade_output(case.id, case.assertions, baseline_result)
report = build_comparison_report(
skill_name=skill_path.name,
case_id=case.id,
candidate_grade=candidate_grade,
baseline_grade=baseline_grade,
candidate_manifest=candidate_manifest,
baseline_manifest=baseline_manifest,
)
write_comparison_report(report, contained_path(output_dir, "reports"))
return report
finally:
try:
cleanup_sandbox(candidate_sandbox)
finally:
cleanup_sandbox(baseline_sandbox)
def run_paired_evaluation(
adapter: HarnessAdapter,
cases: list[EvalCase],
skill_path: Path,
output_dir: Path,
model: str,
model_label: str | None = None,
) -> list[dict[str, Any]]:
"""Run all cases as paired trials and return comparison reports."""
reports = []
for case in cases:
report = run_paired_trial(adapter, case, skill_path, output_dir, model, model_label)
reports.append(report)
return reports
def main() -> int:
import argparse
from .cli_adapter import CliSubprocessAdapter
from .fake_adapter import FakeAdapter
from .runner import load_cases, resolve_skill_path
parser = argparse.ArgumentParser(
prog="eval-paired",
description="Run paired candidate vs baseline skill evaluations.",
)
parser.add_argument("manifest", type=Path, help="path to an evals.json manifest")
parser.add_argument("--adapter", choices=["fake", "cli", "openai"], default="fake")
parser.add_argument("--output-dir", type=Path, default=Path("eval-output-paired"))
parser.add_argument("--model", default="")
parser.add_argument(
"--model-label",
default=None,
help="logical model label recorded in artifacts (defaults to --model)",
)
parser.add_argument("--case", dest="case_id", default=None)
parser.add_argument("--command", default=None)
parser.add_argument("--prompt-mode", default="stdin", choices=["stdin", "arg"])
parser.add_argument("--prompt-flag", default="--prompt")
parser.add_argument("--timeout", type=int, default=120)
parser.add_argument("--extra-args", default=None)
parser.add_argument("--base-url", default=None, help="OpenAI-compatible API base URL")
parser.add_argument("--api-key", default=None, help="API key (optional)")
parser.add_argument("--max-tokens", type=int, default=4096)
parser.add_argument(
"--max-skill-chars", type=int, default=None, help="truncate skill content to N chars"
)
parser.add_argument(
"--no-thinking", action="store_true", help="disable thinking/reasoning mode (llama.cpp)"
)
args = parser.parse_args()
manifest_path = args.manifest.resolve()
if not manifest_path.is_file():
print(f"error: manifest not found: {manifest_path}", file=sys.stderr)
return 2
cases = load_cases(manifest_path)
if not cases:
print(f"error: no cases found in {manifest_path}", file=sys.stderr)
return 2
if args.case_id:
cases = [c for c in cases if c.id == args.case_id]
if not cases:
print(f"error: case '{args.case_id}' not found", file=sys.stderr)
return 2
skill_path = resolve_skill_path(manifest_path)
if args.adapter == "fake":
adapter: HarnessAdapter = FakeAdapter()
elif args.adapter == "cli":
if not args.command:
print("error: --command required for cli adapter", file=sys.stderr)
return 2
import shlex
command = shlex.split(args.command)
adapter = CliSubprocessAdapter(
command,
prompt_mode=args.prompt_mode,
prompt_flag=args.prompt_flag,
timeout_seconds=args.timeout,
extra_args=args.extra_args.split() if args.extra_args else [],
)
elif args.adapter == "openai":
if not args.base_url:
print("error: --base-url required for openai adapter", file=sys.stderr)
return 2
if not args.model:
print("error: --model required for openai adapter", file=sys.stderr)
return 2
from .openai_adapter import OpenAICompatAdapter
adapter = OpenAICompatAdapter(
base_url=args.base_url,
model=args.model,
max_tokens=args.max_tokens,
timeout_seconds=args.timeout,
api_key=args.api_key,
max_skill_chars=args.max_skill_chars,
chat_template_kwargs={"enable_thinking": False} if args.no_thinking else None,
)
else:
print(f"error: unknown adapter '{args.adapter}'", file=sys.stderr)
return 2
output_dir = args.output_dir.resolve()
print(f"paired evaluation: {skill_path.name}")
print(f"adapter: {adapter.name} v{adapter.version}")
print(f"cases: {len(cases)}")
print(f"output: {output_dir}")
print()
reports = run_paired_evaluation(
adapter,
cases,
skill_path,
output_dir,
args.model,
args.model_label,
)
improvements = 0
regressions = 0
for report in reports:
print(format_comparison_summary(report))
print()
delta = report["paired_delta"]
if delta == "candidate_improvement":
improvements += 1
elif delta == "candidate_regression":
regressions += 1
print(
f"summary: {len(reports)} case(s), {improvements} improvement(s), {regressions} regression(s)"
)
return 1 if regressions > 0 else 0
if __name__ == "__main__":
raise SystemExit(main())