judge: LLM-as-judge triage for canary failures (advisory only)
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.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
"""Tests for harness/judge.py — LLM-as-judge for canary failures.
|
||||
|
||||
The judge is an advisory triage tool, not part of the hot loop. It
|
||||
classifies canary-mismatch candidates as either "regression" (the
|
||||
formatter made a real CMOS error) or "variant" (the formatter produced a
|
||||
CMOS-legal rendering that happens to differ from the frozen canonical
|
||||
string).
|
||||
|
||||
These tests use a fake caller — no real API calls.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from harness.judge import Verdict, build_judge_prompt, judge, parse_verdict
|
||||
|
||||
|
||||
def test_build_judge_prompt_includes_both_strings_and_type():
|
||||
prompt = build_judge_prompt(
|
||||
expected="Yu, Charles. *Interior Chinatown*. Pantheon Books, 2020.",
|
||||
candidate="Yu, Charles. *Interior Chinatown*. New York: Pantheon Books, 2020.",
|
||||
cmos_type="book",
|
||||
)
|
||||
assert "Yu, Charles" in prompt
|
||||
assert "New York" in prompt
|
||||
assert "book" in prompt.lower()
|
||||
assert "cmos" in prompt.lower() or "chicago" in prompt.lower()
|
||||
|
||||
|
||||
def test_parse_verdict_extracts_label_and_reasoning():
|
||||
raw = json.dumps(
|
||||
{"label": "regression", "reasoning": "place of publication added; CMOS 18 drops it."}
|
||||
)
|
||||
v = parse_verdict(raw)
|
||||
assert v.label == "regression"
|
||||
assert "place of publication" in v.reasoning
|
||||
|
||||
|
||||
def test_parse_verdict_accepts_label_only():
|
||||
raw = json.dumps({"label": "variant", "reasoning": ""})
|
||||
v = parse_verdict(raw)
|
||||
assert v.label == "variant"
|
||||
assert v.reasoning == ""
|
||||
|
||||
|
||||
def test_parse_verdict_normalizes_unexpected_labels_to_unclear():
|
||||
# A judge that returns something other than regression/variant should
|
||||
# end up labeled 'unclear' so the human reviewer knows to look at it.
|
||||
raw = json.dumps({"label": "maybe", "reasoning": "..."})
|
||||
v = parse_verdict(raw)
|
||||
assert v.label == "unclear"
|
||||
|
||||
|
||||
def test_parse_verdict_handles_code_fences():
|
||||
# LLM sometimes wraps JSON in ``` fences; parse_verdict should survive.
|
||||
raw = '```json\n{"label": "variant", "reasoning": "abbreviated publisher"}\n```'
|
||||
v = parse_verdict(raw)
|
||||
assert v.label == "variant"
|
||||
assert "abbreviated" in v.reasoning
|
||||
|
||||
|
||||
def test_judge_uses_injected_caller():
|
||||
captured = {}
|
||||
|
||||
def fake_caller(system: str, user: str) -> str:
|
||||
captured["system"] = system
|
||||
captured["user"] = user
|
||||
return json.dumps(
|
||||
{"label": "regression", "reasoning": "inserted place of publication"}
|
||||
)
|
||||
|
||||
v = judge(
|
||||
expected="Yu, Charles. *Interior Chinatown*. Pantheon Books, 2020.",
|
||||
candidate="Yu, Charles. *Interior Chinatown*. New York: Pantheon Books, 2020.",
|
||||
cmos_type="book",
|
||||
caller=fake_caller,
|
||||
)
|
||||
assert isinstance(v, Verdict)
|
||||
assert v.label == "regression"
|
||||
assert "place of publication" in v.reasoning
|
||||
assert "18" in captured["system"] # CMOS 18 grounding in the system prompt
|
||||
assert "Yu, Charles" in captured["user"]
|
||||
Reference in New Issue
Block a user