Add harness/judge.py and scripts/triage.py. The judge reads the latest loop run from logs/runs.jsonl, finds canary failures (exact_match=False but fields/linter passed), and asks GPT-5 to classify each as "regression", "variant", or "unclear" with CMOS 18 section citations. CRITICAL: verdicts are ADVISORY ONLY. They are written to logs/triage.jsonl and never feed back into the loop scalar. Using the judge as ground truth would let the formatter-LLM optimize against a judge-LLM from the same model family, inviting shared-bias drift. Design: - harness/judge.py: Verdict dataclass, build_judge_prompt, parse_verdict (handles code fences and normalizes unknown labels to "unclear"), judge() with caller-injection seam matching cmos.formatter. Uses the same _is_reasoning_model branching to skip temperature for gpt-5/o*. Judge model is separately overridable via CMOS_JUDGE_MODEL env var (defaults to OPENAI_MODEL, which defaults to gpt-5). - scripts/triage.py: CLI that walks runs.jsonl, locates a target run (default: latest), filters canary failures, calls judge on each, appends a verdict record to logs/triage.jsonl. --dry-run available for offline testing. Exits 0 with a note when there are no failures. Tests: 6 new unit tests covering prompt building, JSON parsing (including code-fence stripping and unknown-label normalization), and caller injection. No real API calls in the test suite. Validated on iter 3's run (canary 0.286, 10 failures): - 8 correctly flagged as regressions, each with a cited CMOS section (14.72, 14.76, 14.128, 14.190, 14.206, 14.212, 14.267, ...). - 2 flagged as variants: "Kindle" vs "Kindle edition" (CMOS 14.159– 14.161 allows flexibility) and "The New Yorker" vs "New Yorker" (CMOS 14.191 — leading "The" is optional). These surface that the formatter's current rules 17 and 18 are stricter than CMOS strictly requires; documenting here but not acting on yet.
120 lines
4.2 KiB
Python
120 lines
4.2 KiB
Python
"""Triage canary failures from the most recent loop run using an LLM judge.
|
|
|
|
Reads ``logs/runs.jsonl``, finds the latest run, identifies exemplars whose
|
|
``exact_match`` is False (canary failures), calls the judge on each, and
|
|
appends verdicts to ``logs/triage.jsonl``.
|
|
|
|
The judge output is ADVISORY — it never feeds into the loop scalar.
|
|
Verdicts are for a human to review ("is this a variant or a real
|
|
regression?") after the fact.
|
|
|
|
Usage:
|
|
uv run python scripts/triage.py
|
|
uv run python scripts/triage.py --run-index -1 # last run (default)
|
|
uv run python scripts/triage.py --run-index -2 # second-last
|
|
uv run python scripts/triage.py --dry-run # no API calls
|
|
|
|
If there are no canary failures in the target run, the script prints a
|
|
note and exits 0 without calling the judge.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from harness.judge import JUDGE_MODEL, Verdict, judge # noqa: E402
|
|
from harness.score import load_exemplars # noqa: E402
|
|
|
|
|
|
def _dry_run_judge(expected: str, candidate: str, cmos_type: str) -> Verdict:
|
|
return Verdict(label="unclear", reasoning="(dry run — judge not called)")
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--runs-log", default="logs/runs.jsonl")
|
|
ap.add_argument("--triage-log", default="logs/triage.jsonl")
|
|
ap.add_argument("--exemplars", default="exemplars")
|
|
ap.add_argument("--run-index", type=int, default=-1, help="which run to triage")
|
|
ap.add_argument("--dry-run", action="store_true", help="do not call the judge")
|
|
args = ap.parse_args()
|
|
|
|
runs_path = Path(args.runs_log)
|
|
if not runs_path.exists():
|
|
print(f"no runs log at {runs_path}", file=sys.stderr)
|
|
return 2
|
|
|
|
with runs_path.open() as fh:
|
|
runs = [json.loads(line) for line in fh if line.strip()]
|
|
if not runs:
|
|
print("runs log is empty", file=sys.stderr)
|
|
return 2
|
|
try:
|
|
run = runs[args.run_index]
|
|
except IndexError:
|
|
print(f"run index {args.run_index} out of range (have {len(runs)} runs)", file=sys.stderr)
|
|
return 2
|
|
|
|
# Map exemplar name → canonical expected_bibliography and type.
|
|
ex_map = {ex.name: ex for ex in load_exemplars(Path(args.exemplars))}
|
|
|
|
failures = [r for r in run["per_exemplar"] if r["canary"] and not r["exact_match"]]
|
|
if not failures:
|
|
print(f"no canary failures in run at {run['timestamp']} — nothing to triage")
|
|
return 0
|
|
|
|
judge_fn = _dry_run_judge if args.dry_run else judge
|
|
|
|
print(f"triaging {len(failures)} canary failure(s) from run {run['timestamp']}")
|
|
print(f"judge model: {'(dry run)' if args.dry_run else JUDGE_MODEL}")
|
|
print()
|
|
|
|
triage_path = Path(args.triage_log)
|
|
triage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with triage_path.open("a") as out:
|
|
for r in failures:
|
|
name = r["name"]
|
|
ex = ex_map.get(name)
|
|
if ex is None:
|
|
print(f" [{name}] exemplar not found on disk, skipping")
|
|
continue
|
|
expected = ex.expected_bibliography
|
|
candidate = r["candidate"]
|
|
verdict = judge_fn(expected, candidate, ex.type)
|
|
|
|
entry = {
|
|
"triage_timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"run_timestamp": run["timestamp"],
|
|
"exemplar": name,
|
|
"cmos_type": ex.type,
|
|
"expected": expected,
|
|
"candidate": candidate,
|
|
"judge_label": verdict.label,
|
|
"judge_reasoning": verdict.reasoning,
|
|
"judge_model": None if args.dry_run else JUDGE_MODEL,
|
|
}
|
|
out.write(json.dumps(entry) + "\n")
|
|
|
|
icon = {
|
|
"regression": "!!",
|
|
"variant": "ok",
|
|
"unclear": "??",
|
|
}.get(verdict.label, "??")
|
|
print(f" [{icon}] {name}: {verdict.label}")
|
|
if verdict.reasoning:
|
|
print(f" {verdict.reasoning}")
|
|
|
|
print()
|
|
print(f"verdicts appended to {triage_path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|