arborist/bench/analyze_judge_disagreement.py
russell@unturf.com 5a17f617e2
feat(#000057): reconcile code judge against Opus — 4 calibrated rules
Opus is trusted; this commit closes the systematic gaps surfaced by
B's rescore on the 17:01 sweep (2289 records, of which 468 had real
Opus verdicts). Off-diagonal cells in descending size and the
root-cause fix for each:

  cell n=114  code:WRONG  · opus:CORRECT_GROUNDED
    Root cause: NLI fires contradiction p in [0.5, 0.75] on factual
    answers like 'Ólafur Ragnar Grímsson is president of Iceland'
    against wikitext-shaped infobox gold — clause-level candidate
    selection picks up co-mentioned earlier office-holders, reads
    temporal-frame mismatch as contradiction. The TRUE contradiction
    signal (WW2 1812 self-test fixture) measures p=0.985 — clean
    margin above noise.
    Fix: raise theta_contra 0.5 → 0.85 (code-judge override of the
    NLI manifest's 0.5 contradiction_veto).

  cell n=60   code:FABRICATED  · opus:WRONG
    Root cause: 'Anthony Albanese' answer vs Julia Gillard gold gets
    FABRICATED (specifics-not-in-gold) but Opus correctly distinguishes
    WRONG (source contradicts by naming someone else) from
    FABRICATED (source silent on topic).
    Fix: when verifier UNGROUNDED + specifics not in gold AND the
    question's subject anchor IS in gold, demote FABRICATED → WRONG.
    Subject anchor uses proper-noun-shaped terms from the question
    (Iceland / Australia / Higgs) — not the last-content-token
    heuristic, which mis-fires on coincidental matches like 'cafe'
    appearing in a 'gold does not mention any cafe' denial.

  cell n=18   code:WRONG  · opus:ABSTAINED
    Root cause: abstention patterns missed Hermes's most common
    refusal phrasings — 'I do not have accurate information', 'I do
    not have access to a reference knowledge base', 'I lack access
    to'. Original patterns required determine/know/tell verbs right
    after 'do not'.
    Fix: three new patterns for the 'do not have / lack ...
    information / access / knowledge' family.

  cell n=13   code:ABSTAINED  · opus:CORRECT_GROUNDED
    Root cause: verifier's strategy-2 needs prose shape; terse-name
    answers ('Pratibha Patil', 'Jalal Talabani') fall to
    UNGROUNDED-no-specifics → ABSTAINED, missing valid CG.
    Fix: short-answer entity-grounding fast path. When answer is
    short (≤15 tokens) AND every specific asserted is present in
    gold (no unsourced) AND at least one specific WAS asserted AND
    the question's subject anchor is in gold → CG. Guards against
    'wrong topic, right name' false-positives via the subject check.

Structural reorder: NLI contradiction now runs AFTER the
abstention check and short-answer fast path (instead of preempting
the verifier), so the verifier's STRICT/HYBRID positive signal
isn't overridden by NLI noise. NLI still leads the path on truly
unbounded answers — verifier UNGROUNDED + NLI ≥ 0.85 contradiction
keeps the WRONG label.

Self-test 4/4 INSTRUMENT TRUSTWORTHY. pytest contract 18/18.
v2 rescore on the same 17:01 sweep runs in the background to
measure agreement-matrix improvement empirically.

Added bench/analyze_judge_disagreement.py — the harness that drove
this calibration (reads B's rescore JSONL, bucketises off-diagonal
cells, dumps configurable samples per cell with question / answer /
gold / both rationales). Reusable for the next calibration round.
2026-05-19 18:14:38 -04:00

197 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""Reconcile the code judge against the trusted Opus judge.
fox 2026-05-19: "we do trust opus from claude highly even though it
costs a lot." The B rescore (control_sweep_*_code_judge.jsonl) carries
both the original Opus verdict (`verdict` field, from the live sweep)
and the deterministic code verdict (`code_verdict` field, from the
rescore). Where the two disagree, Opus is the reference — the code
judge is the instrument being calibrated.
This script:
1. bucketises disagreements by (code_verdict, opus_verdict) cell;
2. dumps a configurable sample from each cell with full context
(question, answer, code rationale, opus rationale-tail);
3. emits a markdown report so the patterns are reviewable
side-by-side rather than scrolling per-record.
Reads only records where Opus produced a real verdict
(verdict != 'JUDGE_ERROR') — the rest is Opus failing, not
disagreement.
NO LLM calls. Pure read of the JSONL.
Usage::
python -m bench.analyze_judge_disagreement \\
--in bench/qa_results/control_sweep_<TS>_code_judge.jsonl \\
--sample 10
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from bench.control_ab import _gold # noqa: E402
LABELS = ["CORRECT_GROUNDED", "WRONG", "FABRICATED", "ABSTAINED",
"JUDGE_ERROR"]
def _opus_rationale_tail(judge_raw: str) -> str:
"""Heuristic: the last non-sentinel non-empty line of the Opus raw
output is usually the model's brief rationale before the
FINAL_VERDICT=X sentinel."""
out = []
for ln in reversed((judge_raw or "").splitlines()):
s = ln.strip()
if not s:
continue
if "FINAL_VERDICT" in s:
continue
out.append(s)
if len(out) >= 2:
break
return " · ".join(reversed(out))[:300]
def _gold_excerpt(shards_dir: Path, fixture_by_i: dict, i: int,
cap: int = 600, cache: dict | None = None) -> str:
cache = cache if cache is not None else {}
if i in cache:
return cache[i]
fxr = fixture_by_i.get(i)
if not fxr:
cache[i] = ""
return ""
g = _gold(shards_dir, fxr.get("shard", ""), fxr["target_root"]) or ""
cache[i] = g[:cap]
return cache[i]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--in", dest="in_path", required=True)
ap.add_argument("--fixture",
default="bench/qa_questions_stale_map.json")
ap.add_argument("--shards-dir",
default=str(Path.home() / ".arborist" / "shards"))
ap.add_argument("--sample", type=int, default=8,
help="per-cell sample size to dump")
ap.add_argument("--out-md", default="",
help="markdown report path "
"(default: alongside --in with _disagreement.md)")
a = ap.parse_args()
in_path = Path(a.in_path)
out_md = (Path(a.out_md) if a.out_md
else in_path.with_name(
in_path.stem.replace("_code_judge", "")
+ "_judge_disagreement.md"))
items = json.loads(Path(a.fixture).read_text())
fixture_by_i = {i + 1: it for i, it in enumerate(items)}
shards_dir = Path(a.shards_dir)
gold_cache: dict[int, str] = {}
cells: dict[tuple, list[dict]] = defaultdict(list)
cell_count: Counter = Counter()
opus_total: Counter = Counter()
for ln in in_path.read_text().splitlines():
if not ln.strip():
continue
r = json.loads(ln)
if r.get("arm") == "skip":
continue
opus = r.get("verdict") or "JUDGE_ERROR"
code = r.get("code_verdict") or "JUDGE_ERROR"
opus_total[opus] += 1
# We're calibrating against records Opus actually graded.
if opus == "JUDGE_ERROR":
continue
cell_count[(code, opus)] += 1
cells[(code, opus)].append(r)
# Bucketise into "agreement" (on-diagonal), "off-diagonal", and
# rank off-diagonal cells by count so the biggest disagreements
# surface first.
on_diag = sum(cell_count[(lab, lab)] for lab in LABELS)
total = sum(cell_count.values())
off_diag_cells = sorted(
((cnt, code, opus) for (code, opus), cnt in cell_count.items()
if code != opus),
reverse=True,
)
L: list[str] = [
f"# Judge disagreement reconciliation — `{in_path.name}`",
"",
"fox 2026-05-19: Opus is trusted; code judge is being calibrated. "
"Below: every (code_verdict, opus_verdict) cell off the diagonal, "
f"sample={a.sample} records per cell, sorted by cell size "
"(biggest disagreement first).",
"",
f"**Restricted to records Opus actually graded** "
f"(`verdict != 'JUDGE_ERROR'`): {total} of "
f"{sum(opus_total.values())} (the other "
f"{opus_total['JUDGE_ERROR']} are Opus failures, not "
"disagreement).",
"",
f"On-diagonal (agree): **{on_diag}/{total} = "
f"{on_diag/total:.1%}**.",
"",
"## Per-cell summary",
"",
"| n | code says | opus says |",
"|---|---|---|"
]
for cnt, code, opus in off_diag_cells:
L.append(f"| {cnt} | {code} | {opus} |")
L += [""]
# Per-cell dumps, biggest first.
for cnt, code, opus in off_diag_cells:
if cnt == 0:
continue
L += [
f"## code=**{code}** · opus=**{opus}** "
f"({cnt} records · showing {min(a.sample, cnt)})",
"",
]
for r in cells[(code, opus)][:a.sample]:
i = r.get("i")
q = r.get("question_asked") or r.get("question_orig") or ""
ans = (r.get("answer") or "").replace("\n", " ")[:300]
cr = (r.get("code_rationale") or "")[:200]
jr = _opus_rationale_tail(r.get("judge_raw") or "")[:200]
gold = _gold_excerpt(shards_dir, fixture_by_i, i,
cap=400, cache=gold_cache)
gold_short = gold.replace("\n", " ")[:300]
L += [
f"### item {i} · {r.get('arm')}/{r.get('model')}/{r.get('variant')}",
f"- **Q:** {q}",
f"- **A:** {ans}",
f"- **Gold excerpt:** {gold_short}",
f"- **Code says {code}:** {cr}",
f"- **Opus says {opus}:** {jr}",
"",
]
out_md.parent.mkdir(parents=True, exist_ok=True)
out_md.write_text("\n".join(L) + "\n")
print(f" disagreement report: {out_md}")
print(f" on-diagonal: {on_diag}/{total} = {on_diag/total:.1%}")
for cnt, code, opus in off_diag_cells[:10]:
print(f" n={cnt:>4} code={code:<20} opus={opus}")
return 0
if __name__ == "__main__":
raise SystemExit(main())