Spaces:
Sleeping
Sleeping
File size: 8,012 Bytes
1c08abf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | """Canary detection runner: run-judges | build-report.
run-judges scores the four dimension judges over a canary set -- each canary is
a golden question plus an injected answer with known planted defects -- and
writes a predictions JSON. build-report joins those predictions with the canary
ground truth and renders docs/_generated/canary_detection.md.
run-judges with real judges spends API budget (one judge call per canary x
dimension); it is the paid path, mirrored on `make calibrate`, and is not run in
tests or CI. build-report is free and offline. Tests drive the run by injecting
MockJudge through the build_judges seam, so no API calls happen.
Examples:
python scripts/run_canary_eval.py run-judges \
--canaries tests/stats/fixtures/canary/canaries.json \
--out results/canary/predictions.json
python scripts/run_canary_eval.py build-report \
--canaries tests/stats/fixtures/canary/canaries.json \
--predictions tests/stats/fixtures/canary/predictions.json
"""
from __future__ import annotations
import argparse
import asyncio
import importlib
import json
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any
import structlog
if TYPE_CHECKING:
from agent_bench.evaluation.judges.base import Judge
# Run as a script (python scripts/run_canary_eval.py) puts scripts/ on
# sys.path[0], not the repo root, so the repo-local stats packages are not
# importable until we add the repo root (same bootstrap as run_epochs.py).
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from stats_adapters import canary # noqa: E402
logger = structlog.get_logger()
REPO = Path(__file__).resolve().parents[1]
DIMENSIONS = ("groundedness", "completeness", "relevance", "citation_faithfulness")
DEFAULT_OUT = REPO / "docs/_generated/canary_detection.md"
# The committed report renders from golden-derived canary fixtures with simulated
# verdicts; say so in the artifact so no reader mistakes the simulation for a
# real judge measurement. The committed-report test pins the artifact to this
# exact string, so it is the single source of truth for the demonstration
# provenance.
DEFAULT_PROVENANCE = (
"golden-derived canary fixtures (tests/stats/fixtures/canary) with real "
"corpus source passages; the judge verdicts are simulated, not a measurement "
"of any real judge. Replace with real run-judges output for a measurement"
)
def _build_item_and_output(c: dict) -> tuple[Any, Any]:
"""Build the (GoldenQuestion, AgentResponse) pair a judge scores from one
canary record. agent_bench imports are deferred so the offline build-report
path imports neither agent_bench nor the embedding stack.
``source_chunks`` must align one-to-one with ``sources``: the citation judge
maps a "[source: X]" claim to its evidence via ``zip(sources, source_chunks)``,
so a missing or misaligned chunk silently scores the citation against no
evidence. Misalignment is a loud authoring error.
"""
from agent_bench.agents.orchestrator import AgentResponse, SourceReference
from agent_bench.core.types import TokenUsage
from agent_bench.evaluation.harness import GoldenQuestion
sources = c.get("sources", [])
source_chunks = c.get("source_chunks", [])
if len(source_chunks) != len(sources):
raise ValueError(
f"canary {c['id']!r}: source_chunks must align one-to-one with sources "
f"(one retrieved chunk per source, for the citation judge); got "
f"{len(source_chunks)} chunks for {len(sources)} sources"
)
item = GoldenQuestion(
id=c["id"],
question=c.get("question", ""),
expected_answer_keywords=[],
expected_sources=[],
category=c.get("category", "retrieval"),
difficulty="easy",
requires_calculator=False,
source_snippets=c.get("source_snippets", []),
reference_answer=c.get("reference_answer", ""),
)
output = AgentResponse(
answer=c.get("answer", ""),
sources=[SourceReference(source=s) for s in sources],
ranked_sources=c.get("ranked_sources", []),
source_chunks=source_chunks,
iterations=1,
usage=TokenUsage(input_tokens=0, output_tokens=0, estimated_cost_usd=0),
latency_ms=0,
)
return item, output
async def score_canaries(canaries: list[dict], judges: dict[str, Judge]) -> list[dict[str, Any]]:
"""Score each canary on each dimension with the supplied judges
({dimension: Judge}). Returns one prediction dict per canary x dimension:
{item_id, dimension, **ScoreResult}. Judges are injected, so tests pass
MockJudge and no API call happens.
"""
preds: list[dict[str, Any]] = []
for c in canaries:
item, output = _build_item_and_output(c)
for dim in DIMENSIONS:
result = await judges[dim].score(item, output)
preds.append({"item_id": c["id"], "dimension": dim, **result.model_dump()})
return preds
def build_judges(provider: str, model_id: str) -> dict[str, Judge]:
"""Construct the four real dimension judges (paid path). Seam: tests
monkeypatch this to inject MockJudge.
Reuses the calibration runner's rubric-loading judge factory so the canary
judges are byte-identical to production. It is imported dynamically because
run_calibration is a sibling script, not a library API; a static import
would also pull its module into this script's type-check scope.
"""
from agent_bench.core.config import load_config
make_judge = getattr(importlib.import_module("run_calibration"), "_make_judge")
cfg = load_config()
return {dim: make_judge(provider, model_id, dim, cfg) for dim in DIMENSIONS}
async def cmd_run_judges(
canaries_path: Path, out_path: Path, *, provider: str, model_id: str
) -> None:
canaries = json.loads(canaries_path.read_text())
judges = build_judges(provider, model_id)
preds = await score_canaries(canaries, judges)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(preds, indent=2) + "\n")
logger.info("canary_run_judges_complete", count=len(preds), path=str(out_path))
def cmd_build_report(
canaries_path: Path,
predictions_path: Path,
out_path: Path,
*,
provenance: str = DEFAULT_PROVENANCE,
) -> None:
canaries = json.loads(canaries_path.read_text())
predictions = json.loads(predictions_path.read_text())
md = canary.build_report(canaries, predictions, provenance=provenance)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(md)
logger.info("canary_build_report_complete", path=str(out_path))
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_run = sub.add_parser("run-judges", help="PAID: score the dimension judges over a canary set")
p_run.add_argument("--canaries", type=Path, required=True)
p_run.add_argument("--out", type=Path, required=True)
p_run.add_argument("--provider", default="anthropic")
p_run.add_argument("--model-id", default="claude-haiku-4-5")
p_rep = sub.add_parser(
"build-report", help="FREE: render the detection report from predictions"
)
p_rep.add_argument("--canaries", type=Path, required=True)
p_rep.add_argument("--predictions", type=Path, required=True)
p_rep.add_argument("--out", type=Path, default=DEFAULT_OUT)
p_rep.add_argument("--provenance", default=DEFAULT_PROVENANCE)
args = parser.parse_args()
if args.cmd == "run-judges":
asyncio.run(
cmd_run_judges(args.canaries, args.out, provider=args.provider, model_id=args.model_id)
)
elif args.cmd == "build-report":
cmd_build_report(args.canaries, args.predictions, args.out, provenance=args.provenance)
if __name__ == "__main__":
main()
|