Files
John SmithandClaude acb8f5c211 feat(pdf-improvements): statistics, composite score, pass^k, MCPSEC security, divergence viz
Implements the 5 adoptable items from the Gemini deep-research report
on the prior repo iteration. User chose "full overlay" + "generic stub MCP."

Phase A — statistics layer:
- New evaluation/statistics.py with McNemar's test (continuity-corrected),
  Wilcoxon signed-rank, bootstrap CI (deterministic seed), pass^k.
- scipy>=1.10 pinned in pyproject.toml + requirements.txt.
- ABComparator now emits McNemar chi²+p, Wilcoxon W+p, Bootstrap 95% CI
  on the pass-rate-delta distribution. A/B table extended with three
  new columns.

Phase B — 5-dimension composite score:
- New evaluation/composite_score.py: task_correctness (0.421),
  verification_quality (0.211), policy_adherence (0.158),
  context_efficiency (0.105), recovery_behavior (0.105). Weights
  renormalized from the PDF's 0.40/0.20/0.15/0.10/0.10 (sums to 0.95).
- MetricSummary.composite_score field populated by MetricCollector.
- Scorecard leaderboard adds Composite column; sorts by composite.

Phase C — pass^k multi-run:
- RunConfig.repeat_count + --repeat N CLI flag.
- Each ExecutionResult carries repeat_index + pass_vector_key.
- Runner aggregates pass_at_2/pass_at_3 per cell.

Phase D — MCPSEC security suite:
- evaluation/security/ with 10 OWASP-mapped properties (P1-P10).
- mock_mcp_server.py: hermetic Python stdio JSON-RPC server with 10
  attack modes (confused_deputy, param_bypass, policy_evasion,
  injection, schema_mutation, exfiltration, audit_tamper,
  privilege_escalation, homoglyph, time_tamper). Full-width unicode
  in homoglyph mode is intentional P9 attack content.
- grading.py: refusal-marker / unsafe-marker substring checks; silent
  action → conservative fail.
- benchmarks/mcp_security_adapter.py: JSONManifestBenchmark subclass.
- benchmarks/data/mcp_security/tasks.json: 10 bundled tasks.
- mcp/mcp_registry.json: mock-mcp-security entry added.
- benchmarks/__init__.py: 'mcp-security' registered.

Phase E — trajectory divergence:
- InteractionSwimlane.render_divergence_diagram(a, b): side-by-side
  ASCII trace with DIV markers for tool disagreement, missing steps,
  token deltas, pass/fail divergence. Falls back to stdout
  tool-call counts when turns aren't available.

Verification:
- 131 tests pass (up from 86; +45 new).
- ruff check + ruff format --check clean.
- Smoke preset produces composite score 57.9 in the leaderboard.

Co-Authored-By: Claude <[email protected]>
2026-08-22 08:51:35 -04:00

313 lines
12 KiB
Python

"""Command-line entrypoint.
Examples
--------
Run the smoke preset against the stub harness::
python run_benchmark.py --config configs/presets/smoke_test.yaml \\
--harness stub --output-format markdown
Run a one-off sweep over claude-code + coder_eval + each MCP::
python run_benchmark.py --harness claude-code --benchmark coder_eval \\
--plugins none --mcp chrome-devtools-mcp,context7,repomix \\
--tasks-limit 3 --output-format github-summary
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
from pathlib import Path
from typing import Any
import yaml
# Allow ``python run_benchmark.py`` to import the package modules without
# the user needing ``pip install -e .`` first.
REPO_ROOT = Path(__file__).resolve().parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from benchmarks.runner import BenchmarkRunner, RunConfig # noqa: E402
from mcp import MCPLauncher # noqa: E402
from metrics import render_github_summary, render_json, render_markdown # noqa: E402
from plugins import PluginLoader # noqa: E402
def _split_csv(value: str) -> list[str]:
if not value:
return []
if value.strip() in ("none", "all"):
return [value.strip()]
return [v.strip() for v in value.split(",") if v.strip()]
def _load_preset(path: Path) -> dict:
if not path.exists():
candidate = REPO_ROOT / "configs" / "presets" / f"{path.name}"
if candidate.exists():
return yaml.safe_load(candidate.read_text())
candidate_yaml = REPO_ROOT / "configs" / "presets" / f"{path.name}.yaml"
if candidate_yaml.exists():
return yaml.safe_load(candidate_yaml.read_text())
return yaml.safe_load(path.read_text())
def _resolve_registry_paths(args: argparse.Namespace) -> tuple[Path, Path]:
plugin_registry = Path(
args.plugin_registry
or os.environ.get("HARNESS_BENCH_PLUGIN_REGISTRY")
or REPO_ROOT / "plugins" / "registry.json"
)
mcp_registry = Path(
args.mcp_registry or os.environ.get("HARNESS_BENCH_MCP_REGISTRY") or REPO_ROOT / "mcp" / "mcp_registry.json"
)
return plugin_registry, mcp_registry
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="harness-benchmark",
description=(
"Run a (harness x benchmark x plugin x MCP) matrix against the "
"configured LLM and emit JSON + Markdown artefacts."
),
)
parser.add_argument("--config", "--preset", dest="config", type=Path, help="Preset YAML to load (or preset name)")
parser.add_argument(
"--harness",
default=None,
help="Comma-separated harness names, 'all', or 'none' (run without harness).",
)
parser.add_argument(
"--benchmark",
default=None,
help="Comma-separated benchmark names, 'all', or 'none'.",
)
parser.add_argument(
"--plugins",
default=None,
help="Comma-separated plugin names, 'all', or 'none'.",
)
parser.add_argument(
"--mcp",
dest="mcp_servers",
default=None,
help="Comma-separated MCP server names, 'all', or 'none'.",
)
parser.add_argument("--tasks-limit", type=int, default=None)
parser.add_argument("--timeout", type=int, default=None)
parser.add_argument(
"--repeat",
type=int,
default=None,
help="Run each task N times for pass^k consistency aggregation",
)
parser.add_argument(
"--output-format",
choices=["json", "markdown", "github-summary", "scorecard"],
default=None,
)
parser.add_argument("--output-dir", type=Path, default=Path("runs"))
parser.add_argument(
"--junit-xml",
type=Path,
default=None,
help="Path to export JUnit XML report",
)
parser.add_argument(
"--minimum-task-score",
type=float,
default=None,
help="Strict floor (0.0 - 1.0) for cell pass-rate. Fails with exit code 1 if below floor.",
)
parser.add_argument("--ab-test", action="store_true", help="Enable A/B comparative evaluation")
parser.add_argument(
"--ablation",
action="store_true",
help="Run the deterministic 5-tier ablation matrix (overrides preset; uses harness + benchmark only)",
)
parser.add_argument("--scorecard", action="store_true", help="Render human-centric model scorecard")
parser.add_argument("--publish-issue", action="store_true", help="Publish report to date-labeled GitHub issue")
parser.add_argument("--name", default=None, help="Run name; becomes the run-id prefix.")
parser.add_argument("--plugin-registry", type=Path, help="Override plugins/registry.json path")
parser.add_argument("--mcp-registry", type=Path, help="Override mcp/mcp_registry.json path")
parser.add_argument("--debug", action="store_true", help="Enable rich turn-by-turn debug execution logs")
parser.add_argument("-v", "--verbose", action="store_true")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
# Preset YAML provides defaults; explicit CLI flags override.
preset = _load_preset(args.config) if args.config else {}
matrix = preset.get("matrix", {}) if isinstance(preset.get("matrix"), dict) else {}
def _resolve_list(cli_val: str | None, preset_val: Any, default_str: str) -> list[str]:
if cli_val is not None:
return _split_csv(cli_val)
if preset_val is not None:
if isinstance(preset_val, list):
return [str(x) for x in preset_val]
return _split_csv(str(preset_val))
return _split_csv(default_str)
harness = _resolve_list(args.harness, matrix.get("harness"), "all")
benchmark = _resolve_list(args.benchmark, matrix.get("benchmark"), "all")
plugins = _resolve_list(args.plugins, matrix.get("plugins"), "none")
mcp = _resolve_list(args.mcp_servers, matrix.get("mcp_servers"), "none")
tasks_limit = args.tasks_limit if args.tasks_limit is not None else int(preset.get("tasks_limit", 0) or 0)
timeout_seconds = args.timeout if args.timeout is not None else int(preset.get("timeout_seconds", 600) or 600)
output_format = args.output_format if args.output_format is not None else str(preset.get("output_format", "json"))
name = args.name if args.name is not None else str(preset.get("name") or "ad-hoc")
repeat_count = args.repeat if args.repeat is not None else int(preset.get("repeat_count", 1) or 1)
config = RunConfig(
name=name,
harness=harness,
benchmark=benchmark,
plugins=plugins,
mcp_servers=mcp,
tasks_limit=tasks_limit,
timeout_seconds=timeout_seconds,
output_format=output_format,
output_dir=args.output_dir,
repeat_count=repeat_count,
)
plugin_registry, mcp_registry = _resolve_registry_paths(args)
plugin_loader = PluginLoader(plugin_registry)
mcp_launcher = MCPLauncher(mcp_registry)
if args.debug:
from core.logger import logger as bench_logger
bench_logger.debug_mode = True
runner = BenchmarkRunner(config, plugin_loader, mcp_launcher)
# Ablation runner is a thin layer over BenchmarkRunner._run_cell; it
# overrides (plugins, mcp_servers, lsp_enabled) per cell and emits a
# MultiTierAblationReport on top of the standard RunReport.
ablation_result = None
if args.ablation:
from evaluation.ablation_runner import AblationRunner
if len(harness) != 1 or len(benchmark) != 1:
print(
"::error::--ablation requires exactly one --harness and one --benchmark",
file=sys.stderr,
)
return 1
ablation = AblationRunner(
harness_name=harness[0],
benchmark_name=benchmark[0],
benchmark_runner=runner,
skills_plugin=(plugins[0] if plugins and plugins[0] not in (None, "", "none", "all") else "caveman"),
mcp_for_ablation=(mcp[0] if mcp and mcp[0] not in (None, "", "none", "all") else "repomix"),
)
ablation_result = ablation.run()
report = runner.run()
if args.junit_xml:
from metrics.junit_exporter import export_junit_xml
export_junit_xml(report.results, args.junit_xml, suite_name=report.config.name)
# Convert to Core BenchmarkReport for modern reporting subsystems
from core.types import BenchmarkReport as CoreReport
from core.types import CellSummary as CoreCellSummary
from core.types import MetricSummary as CoreMetricSummary
core_report = CoreReport(
run_id=report.run_id,
name=report.config.name,
started_at=report.started_at,
finished_at=report.finished_at,
config=report.to_dict().get("config", {}),
summaries=[
CoreCellSummary(
harness=s["harness"],
benchmark=s["benchmark"],
plugins=s["plugins"],
mcp_servers=s["mcp_servers"],
summary=CoreMetricSummary(**s["summary"]),
)
for s in report.metric_summaries
],
results=report.results,
)
if args.scorecard or output_format == "scorecard":
from reporting.scorecard import ScorecardGenerator
print(ScorecardGenerator.render_markdown_leaderboard(core_report))
print()
print(ScorecardGenerator.render_task_drilldown_table(core_report))
elif output_format == "json":
print(render_json(report))
elif output_format == "markdown":
print(render_markdown(report))
elif output_format == "github-summary":
print(render_github_summary(report))
if args.ab_test:
from reporting.ab_comparator import ABComparator
baselines = [s for s in core_report.summaries if not s.plugins and not s.mcp_servers]
treatments = [s for s in core_report.summaries if s.plugins or s.mcp_servers]
ab_diffs = []
for b in baselines:
for t in treatments:
if b.harness == t.harness and b.benchmark == t.benchmark:
ab_diffs.append(ABComparator.compare_cells(b, t))
if ab_diffs:
print()
print(ABComparator.render_ab_markdown_table(ab_diffs))
if args.publish_issue:
from reporting.github_issue import GitHubIssuePublisher
issue_url = GitHubIssuePublisher.publish_issue(core_report)
if issue_url:
print(f"::notice::Published benchmark report to GitHub Issue: {issue_url}")
else:
print("::warning::Could not publish GitHub Issue (check gh auth status)")
if ablation_result is not None and ablation_result.report is not None:
from evaluation.ablation import AblationEngine
print()
print(AblationEngine.render_ablation_markdown(ablation_result.report))
if args.minimum_task_score is not None:
floor = float(args.minimum_task_score)
failed_cells = []
for cell in report.metric_summaries:
rate = cell.get("summary", {}).get("pass_rate", 0.0)
if rate < floor:
failed_cells.append((cell.get("harness"), cell.get("benchmark"), rate))
if failed_cells:
for h, b, r in failed_cells:
print(
f"::error::Score floor failed for {h} on {b}: {r * 100:.1f}% < {floor * 100:.1f}%", file=sys.stderr
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())