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.
197 lines
6.8 KiB
Python
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())
|