Nomearod/agentbench
0
1"""Calibration runner: generate-outputs | run-judges | build-table.2 3Orchestrates Steps A, C, D from the design doc's data flow. Step B4(hand-labeling) is manual — done in a Jupyter notebook reading5results/calibration_v1_system_outputs.json and appending to6measurements/2026-05-04-judge-calibration-labels.jsonl.7 8Examples:9 python scripts/run_calibration.py generate-outputs --concurrency 510 python scripts/run_calibration.py run-judges --row-config=configs/calibration/rows/baseline.yaml11 python scripts/run_calibration.py build-table12 python scripts/run_calibration.py build-table --strict13"""14 15from __future__ import annotations16 17import argparse18import asyncio19import hashlib20import json21from pathlib import Path22 23import structlog24import yaml25 26logger = structlog.get_logger()27 28REPO = Path(__file__).resolve().parents[1]29CALIBRATION_SPEC = REPO / "agent_bench/evaluation/datasets/calibration_v1.json"30SYSTEM_OUTPUTS = REPO / "results/calibration_v1_system_outputs.json"31LABELS_PATH = REPO / "measurements/2026-05-04-judge-calibration-labels.jsonl"32KAPPA_TABLE_OUT = REPO / "docs/_generated/kappa_table.md"33 34 35def _resolve_concurrency(cli_value: int | None) -> int:36 """CLI flag overrides config field; default is 5. Logs the resolved value."""37 if cli_value is not None:38 resolved = cli_value39 else:40 cfg_path = REPO / "configs/default.yaml"41 cfg_concurrency = None42 if cfg_path.exists():43 cfg = yaml.safe_load(cfg_path.read_text()) or {}44 cfg_concurrency = (cfg.get("evaluation", {}) or {}).get(45 "calibration_concurrency"46 )47 resolved = cfg_concurrency if cfg_concurrency is not None else 548 logger.info("calibration_concurrency_resolved", value=resolved)49 return resolved50 51 52# --- Subcommand: generate-outputs (Step A) ---53 54 55def _build_corpus_orchestrator(cfg, corpus_name: str, embedder, provider):56 """Build a per-corpus Orchestrator wired to that corpus's HybridStore.57 58 Mirrors the per-corpus construction in scripts/evaluate.py so calibration59 runs use the same retrieval stack as production evaluation. The embedder60 and provider are shared across corpora — only the store/retriever/61 SearchTool differ.62 """63 from agent_bench.agents.orchestrator import Orchestrator64 from agent_bench.rag.retriever import Retriever65 from agent_bench.rag.store import HybridStore66 from agent_bench.tools.calculator import CalculatorTool67 from agent_bench.tools.registry import ToolRegistry68 from agent_bench.tools.search import SearchTool69 70 corpus_cfg = cfg.corpora[corpus_name]71 store = HybridStore.load(corpus_cfg.store_path, rrf_k=cfg.rag.retrieval.rrf_k)72 reranker = None73 if cfg.rag.reranker.enabled:74 from agent_bench.rag.reranker import CrossEncoderReranker75 76 reranker = CrossEncoderReranker(model_name=cfg.rag.reranker.model_name)77 retriever = Retriever(78 embedder=embedder,79 store=store,80 default_strategy=cfg.rag.retrieval.strategy,81 candidates_per_system=cfg.rag.retrieval.candidates_per_system,82 reranker=reranker,83 reranker_top_k=cfg.rag.reranker.top_k,84 )85 registry = ToolRegistry()86 registry.register(87 SearchTool(88 retriever=retriever,89 default_top_k=cfg.rag.retrieval.top_k,90 refusal_threshold=corpus_cfg.refusal_threshold,91 )92 )93 registry.register(CalculatorTool())94 return Orchestrator(95 provider=provider,96 registry=registry,97 max_iterations=cfg.agent.max_iterations,98 temperature=cfg.agent.temperature,99 )100 101 102async def cmd_generate_outputs(concurrency: int) -> None:103 """Run the orchestrator against the 30 calibration items with a frozen104 configuration; write results/calibration_v1_system_outputs.json.105 106 The calibration spec is mixed-corpus (k8s + fastapi). Each item carries a107 `corpus` field; we build one Orchestrator per corpus and route by that108 field. A KeyError on an unrecognized corpus is preferable to silently109 misrouting an item to the wrong store.110 """111 from agent_bench.core.config import load_config112 from agent_bench.core.provider import AnthropicProvider113 from agent_bench.evaluation.harness import load_golden_dataset114 from agent_bench.rag.embedder import Embedder115 116 spec = json.loads(CALIBRATION_SPEC.read_text())117 target_ids = {i["id"]: i for i in spec["items"]}118 119 fastapi = load_golden_dataset(120 REPO / "agent_bench/evaluation/datasets/tech_docs_golden.json"121 )122 k8s = load_golden_dataset(123 REPO / "agent_bench/evaluation/datasets/k8s_golden.json"124 )125 items = [q for q in (fastapi + k8s) if q.id in target_ids]126 if len(items) != len(target_ids):127 missing = set(target_ids) - {q.id for q in items}128 raise SystemExit(129 f"calibration items not found in goldens: {sorted(missing)}"130 )131 132 cfg = load_config()133 provider = AnthropicProvider(cfg)134 embedder = Embedder(model_name=cfg.embedding.model, cache_dir=cfg.embedding.cache_dir)135 136 item_corpus = {it.id: target_ids[it.id]["corpus"] for it in items}137 unknown: dict[str, list[str]] = {}138 for it_id, corpus in item_corpus.items():139 if corpus not in cfg.corpora:140 unknown.setdefault(corpus, []).append(it_id)141 if unknown:142 examples = "; ".join(143 f"{cor!r}: {sorted(ids)[:3]}" for cor, ids in sorted(unknown.items())144 )145 raise KeyError(146 f"calibration spec references corpora not in cfg.corpora — "147 f"{examples}; configured corpora: {sorted(cfg.corpora)!r}"148 )149 150 corpora_needed = sorted(set(item_corpus.values()))151 orchestrators = {152 name: _build_corpus_orchestrator(cfg, name, embedder, provider)153 for name in corpora_needed154 }155 156 sem = asyncio.Semaphore(concurrency)157 158 async def _run_one(item):159 async with sem:160 response = await orchestrators[item_corpus[item.id]].run(161 question=item.question,162 system_prompt="You are a helpful assistant.",163 )164 answer = response.answer165 sources = sorted(s.source for s in response.sources)166 sys_hash = hashlib.sha256(167 f"{item.id}\x00{answer}\x00{','.join(sources)}".encode("utf-8")168 ).hexdigest()169 return {170 "item_id": item.id,171 "question": item.question,172 "category": item.category,173 "answer": answer,174 "sources": [s.source for s in response.sources],175 "ranked_sources": response.ranked_sources,176 "source_chunks": response.source_chunks,177 "source_snippets": item.source_snippets,178 "reference_answer": item.reference_answer,179 "system_output_hash": sys_hash,180 "stratum": target_ids[item.id]["stratum"],181 "corpus": target_ids[item.id]["corpus"],182 }183 184 records = await asyncio.gather(*[_run_one(it) for it in items])185 SYSTEM_OUTPUTS.parent.mkdir(parents=True, exist_ok=True)186 SYSTEM_OUTPUTS.write_text(json.dumps(records, indent=2) + "\n")187 logger.info(188 "generate_outputs_complete", count=len(records), path=str(SYSTEM_OUTPUTS)189 )190 191 192# --- Subcommand: run-judges (Step C, one row per invocation) ---193 194 195def _make_provider(name: str, cfg, *, model: str | None = None):196 from agent_bench.core.provider import AnthropicProvider, OpenAIProvider197 198 if name == "anthropic":199 return AnthropicProvider(cfg, model=model)200 if name == "openai":201 return OpenAIProvider(cfg, model=model)202 raise ValueError(f"unknown provider: {name}")203 204 205def _make_judge(206 provider_name: str,207 model_id: str,208 dimension: str,209 cfg,210 *,211 use_cot: bool = True,212 use_anchors: bool = True,213 abstain_allowed_override: bool | None = None,214):215 from agent_bench.evaluation.judges.base import Rubric216 from agent_bench.evaluation.judges.citation_faithfulness import (217 CitationFaithfulnessJudge,218 )219 from agent_bench.evaluation.judges.completeness import CompletenessJudge220 from agent_bench.evaluation.judges.groundedness import GroundednessJudge221 from agent_bench.evaluation.judges.relevance import RelevanceJudge222 223 judge_class = {224 "groundedness": GroundednessJudge,225 "relevance": RelevanceJudge,226 "completeness": CompletenessJudge,227 "citation_faithfulness": CitationFaithfulnessJudge,228 }229 rubric_dir = REPO / "agent_bench/evaluation/rubrics"230 rubric = Rubric.from_markdown_file(rubric_dir / f"{dimension}.md")231 if not use_anchors:232 # Strip ### Example sections — body_markdown changes, so233 # ScoreResult.rubric_version naturally distinguishes anchored vs234 # stripped variants when the calibration report buckets results.235 rubric = rubric.strip_anchors()236 return judge_class[dimension](237 judge_provider=_make_provider(provider_name, cfg, model=model_id),238 rubric=rubric,239 model_id=model_id,240 use_cot=use_cot,241 abstain_allowed_override=abstain_allowed_override,242 )243 244 245def _row_judge_options(row: dict) -> dict:246 """Pull `options` from a row config and project to _make_judge kwargs.247 248 Defaults (when keys are missing) match the baseline contract: CoT on,249 anchors on, abstain follows the rubric (no override).250 """251 opts = row.get("options") or {}252 abstain_allowed = opts.get("abstain_allowed")253 return {254 "use_cot": bool(opts.get("use_cot", True)),255 "use_anchors": bool(opts.get("use_anchors", True)),256 # None = follow rubric; explicit True/False = override257 "abstain_allowed_override": (258 None if abstain_allowed is None else bool(abstain_allowed)259 ),260 }261 262 263def _build_item_and_output(rec: dict):264 from agent_bench.agents.orchestrator import AgentResponse, SourceReference265 from agent_bench.core.types import TokenUsage266 from agent_bench.evaluation.harness import GoldenQuestion267 268 item = GoldenQuestion(269 id=rec["item_id"],270 question=rec["question"],271 expected_answer_keywords=[],272 expected_sources=[],273 category=rec["category"],274 difficulty="easy",275 requires_calculator=False,276 source_snippets=rec.get("source_snippets", []),277 reference_answer=rec.get("reference_answer", ""),278 )279 output = AgentResponse(280 answer=rec["answer"],281 sources=[SourceReference(source=s) for s in rec["sources"]],282 ranked_sources=rec.get("ranked_sources", []),283 source_chunks=rec.get("source_chunks", []),284 iterations=1,285 usage=TokenUsage(input_tokens=0, output_tokens=0, estimated_cost_usd=0),286 latency_ms=0,287 )288 return item, output289 290 291async def cmd_run_judges(row_config_path: Path, concurrency: int) -> None:292 """Score the frozen system outputs with the row's judge configuration."""293 from agent_bench.core.config import load_config294 from agent_bench.evaluation.variance.jury import jury295 from agent_bench.evaluation.variance.rubric_permute import rubric_permute296 297 if not SYSTEM_OUTPUTS.exists():298 raise SystemExit(299 f"{SYSTEM_OUTPUTS} not found — run `generate-outputs` first."300 )301 row = yaml.safe_load(row_config_path.read_text())302 outputs = json.loads(SYSTEM_OUTPUTS.read_text())303 304 cfg = load_config()305 sem = asyncio.Semaphore(concurrency)306 all_results: list[dict] = []307 strategy = row["strategy"]308 309 def _skip_oos(rec: dict, dim: str) -> bool:310 return rec["category"] == "out_of_scope" and dim != "relevance"311 312 judge_opts = _row_judge_options(row)313 314 if strategy == "single":315 # Build one judge per dimension up-front, then gather all316 # (dim, item) pairs in a single asyncio.gather call. Previous317 # design serialized across dimensions (each dim awaited fully318 # before the next started), leaving Phase-11 wall-clock on the319 # table when the calibration spend is API-rate-limited.320 judges_by_dim = {321 dim: _make_judge(322 row["provider"], row["model_id"], dim, cfg, **judge_opts323 )324 for dim in row["dimensions"]325 }326 327 async def score_one(rec: dict, dim: str, judge):328 async with sem:329 if _skip_oos(rec, dim):330 return None331 item, output = _build_item_and_output(rec)332 result = await judge.score(item, output)333 return {"item_id": rec["item_id"], "dimension": dim, **result.model_dump()}334 335 coros = [336 score_one(rec, dim, judge)337 for dim, judge in judges_by_dim.items()338 for rec in outputs339 ]340 gathered = await asyncio.gather(*coros)341 all_results.extend([r for r in gathered if r is not None])342 343 elif strategy == "rubric_permute":344 # Sequential per-item by design: PermutedJudge writes to the345 # sidecar JSONL with append mode and within-call ordering matters346 # for downstream per-permutation analysis (the kappa_table joins347 # by item_id but the sidecar order encodes the permutation seed348 # sequence). Across-dim parallelism is left for v1.1 once the349 # sidecar contract proves stable.350 for dim in row["dimensions"]:351 judge = _make_judge(352 row["provider"], row["model_id"], dim, cfg, **judge_opts353 )354 sidecar = REPO / row.get(355 "sidecar_path", "results/calibration_v1_permute_members.jsonl"356 )357 permuted = rubric_permute(358 judge,359 n=row["options"]["n_permutations"],360 seeds=row["options"]["seeds"],361 sidecar_path=sidecar,362 )363 for rec in outputs:364 if _skip_oos(rec, dim):365 continue366 item, output = _build_item_and_output(rec)367 result = await permuted.score(item, output)368 all_results.append({"item_id": rec["item_id"], "dimension": dim, **result.model_dump()})369 370 elif strategy == "jury":371 # Same sequential rationale as rubric_permute: jury writes a372 # per-member sidecar and downstream analysis benefits from stable373 # ordering. The asyncio.gather inside Jury.score does parallelize374 # member calls within an item; the across-item / across-dim375 # serialization is the conservative choice.376 for dim in row["dimensions"]:377 members = [378 _make_judge(m["provider"], m["model_id"], dim, cfg, **judge_opts)379 for m in row["members"]380 ]381 sidecar = REPO / row["sidecar_path"]382 weights = (383 _compute_kappa_weights(384 REPO / row["weights_source"],385 dim,386 expected_judge_ids={m.judge_id for m in members},387 )388 if row.get("aggregation") == "kappa_weighted"389 else None390 )391 j = jury(392 judges=members,393 aggregation=row["aggregation"],394 weights=weights,395 quorum=row.get("quorum"),396 sidecar_path=sidecar,397 )398 for rec in outputs:399 if _skip_oos(rec, dim):400 continue401 item, output = _build_item_and_output(rec)402 result = await j.score(item, output)403 all_results.append({"item_id": rec["item_id"], "dimension": dim, **result.model_dump()})404 else:405 raise SystemExit(f"unknown strategy: {strategy}")406 407 out_path = REPO / row["output_path"]408 out_path.parent.mkdir(parents=True, exist_ok=True)409 out_path.write_text(json.dumps(all_results, indent=2) + "\n")410 logger.info(411 "run_judges_complete",412 row=row["label"],413 count=len(all_results),414 path=str(out_path),415 )416 417 418def _compute_kappa_weights(419 predictions_path: Path,420 dimension: str,421 expected_judge_ids: set[str],422) -> dict[str, float]:423 """Compute per-judge weight = max(0, Cohen's κ vs gold labels) for the424 dimension, from a predictions file (JSON list or JSONL).425 426 v1.1 replaces v1's stub (which returned 1.0 for every judge_id seen,427 causing asymmetric coverage to amplify rather than suppress an428 unweighted member). Hard-errors if `predictions_path` is missing,429 if any `expected_judge_ids` member has no scored (non-abstain)430 predictions for `dimension`, or if no labels are available for the431 dimension.432 433 The κ → weight mapping clips negative κ to 0; a member with κ ≤ 0 on434 a dimension contributes weight 0 (effective exclusion via weighting).435 This is the "soft exclusion" behavior — explicit per-dimension436 exclusion is tracked separately on the v1.2 fix-list.437 438 Pragmatic v1.1: `predictions_path` may point at the same calibration439 set used for κ reporting (circular weighting); this is documented in440 the v1.1 jury-rescue DECISIONS entry. v1.2 will require a held-out441 validation set.442 """443 from agent_bench.evaluation.calibration.metrics import cohen_kappa444 445 if not predictions_path.exists():446 raise FileNotFoundError(447 f"weights source {predictions_path} does not exist; v1.1 "448 f"requires explicit κ-derived weights — no silent fallback"449 )450 451 # Load predictions: JSON list (baseline-style) or JSONL (sidecar-style).452 raw = predictions_path.read_text()453 if predictions_path.suffix == ".jsonl":454 preds = [json.loads(line) for line in raw.splitlines() if line.strip()]455 else:456 preds = json.loads(raw)457 458 if not LABELS_PATH.exists():459 raise FileNotFoundError(460 f"labels file {LABELS_PATH} does not exist; cannot compute "461 f"κ-derived weights"462 )463 labels: dict[str, int] = {}464 for line in LABELS_PATH.read_text().splitlines():465 if not line.strip():466 continue467 rec = json.loads(line)468 if rec.get("dimension") != dimension or rec.get("abstained"):469 continue470 labels[rec["system_output_hash"]] = int(rec["score"])471 472 if not labels:473 raise ValueError(474 f"no gold labels for dimension={dimension!r} in {LABELS_PATH}; "475 f"cannot compute κ-derived weights"476 )477 478 # Group predictions by judge_id, joining to labels by system_output_hash.479 # The sidecar JSONL has one record per (judge × item × dim); the baseline480 # JSON has the same. Both expose `judge_id` of the form `{model}_{dim}`,481 # `system_output_hash`, `score`, and (for the abstain-aware filter) the482 # `Unknown` sentinel.483 by_judge: dict[str, list[tuple[int, int]]] = {}484 for p in preds:485 # JSONL sidecar lacks `dimension` field; we filter by suffix on486 # judge_id instead, which encodes dimension.487 if not p["judge_id"].endswith(f"_{dimension}"):488 continue489 if p["score"] == "Unknown":490 continue491 h = p["system_output_hash"]492 if h not in labels:493 continue494 by_judge.setdefault(p["judge_id"], []).append(495 (labels[h], int(p["score"]))496 )497 498 missing = expected_judge_ids - by_judge.keys()499 if missing:500 raise ValueError(501 f"weights source {predictions_path} has no predictions for "502 f"expected judge_ids {sorted(missing)} on dimension={dimension!r}. "503 f"Source covers {sorted(by_judge.keys())}. v1.1 requires "504 f"symmetric coverage — point weights_source at a predictions "505 f"file containing every jury member's verdicts (e.g. the jury "506 f"sidecar from a prior run)."507 )508 509 weights: dict[str, float] = {}510 for jid in expected_judge_ids:511 pairs = by_judge[jid]512 y_lab = [a for a, _ in pairs]513 y_pred = [b for _, b in pairs]514 kappa = cohen_kappa(y_lab, y_pred)515 weights[jid] = max(0.0, kappa)516 logger.info(517 "kappa_weight_computed",518 judge_id=jid,519 dimension=dimension,520 kappa=kappa,521 weight=weights[jid],522 n=len(pairs),523 )524 return weights525 526 527# --- Subcommand: build-table (Step D) ---528 529 530def cmd_build_table(strict: bool) -> None:531 from agent_bench.evaluation.calibration.report import generate_kappa_table532 533 predictions_glob = str(REPO / "results/calibration_v1_judge_*.json")534 generate_kappa_table(535 predictions_glob=predictions_glob,536 labels_path=str(LABELS_PATH),537 output_path=str(KAPPA_TABLE_OUT),538 strict=strict,539 )540 logger.info("build_table_complete", path=str(KAPPA_TABLE_OUT), strict=strict)541 542 543def main() -> None:544 parser = argparse.ArgumentParser(545 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter546 )547 sub = parser.add_subparsers(dest="cmd", required=True)548 549 p_gen = sub.add_parser(550 "generate-outputs", help="Step A: generate frozen system outputs"551 )552 p_gen.add_argument("--concurrency", type=int, default=None)553 554 p_run = sub.add_parser("run-judges", help="Step C: score one ablation row")555 p_run.add_argument("--row-config", type=Path, required=True)556 p_run.add_argument("--concurrency", type=int, default=None)557 558 p_tab = sub.add_parser(559 "build-table", help="Step D: aggregate predictions into κ table"560 )561 p_tab.add_argument(562 "--strict",563 action="store_true",564 help="Raise on missing predictions/labels (final-artifact path)",565 )566 567 args = parser.parse_args()568 if args.cmd == "generate-outputs":569 asyncio.run(cmd_generate_outputs(_resolve_concurrency(args.concurrency)))570 elif args.cmd == "run-judges":571 asyncio.run(572 cmd_run_judges(args.row_config, _resolve_concurrency(args.concurrency))573 )574 elif args.cmd == "build-table":575 cmd_build_table(strict=args.strict)576 577 578if __name__ == "__main__":579 main()580 