arborist/bench/judge_code.py
russell@unturf.com 866e67f43d
fix(#000057): code judge — short-entity-grounded fast path runs before NLI contradiction
Reorder rule 3 (short-answer entity grounding) above rule 4 (NLI
contradiction) so positive lexical evidence cannot be overridden by
NLI clause-level noise. Surfaced by the 2026-05-19 arborist+qwen-
nothink smoke:

  i=3 · who is the prime minister of Poland?
  ans: 'Donald Tusk is listed as the Prime Minister of Poland.'
  gold: ...lists Tusk + Marcinkiewicz + Belka + Kaczynski + Kopacz...
  NLI contradiction p=0.892  (above 0.85 threshold)
  NLI entailment   p=0.744   (also high on the correct clause)

Tusk WAS PM in 2010 (served 2007-2014); answer is correct against
the corpus-vintage gold. The NLI contradiction signal came from
clause-level candidate selection picking a NON-Tusk PM the source
also mentions; entailment was high on the Tusk clause. Mixed signal
that the WRONG rule then over-confidently resolved.

The fix is a rule reorder, not a threshold change — the fast path's
positive-evidence combination (specifics-in-gold AND subject-in-gold)
is a strictly stronger signal than NLI's clause-level max
contradiction, so when it fires it should win. The combination
discriminates Poland-Tusk (Tusk ∈ gold, Poland ∈ gold → CG) from
Anthony-Albanese (Albanese ∉ gold → fast path declines → falls
through to UNGROUNDED-subject-in-gold → WRONG, unchanged).

Self-test 4/4 INSTRUMENT TRUSTWORTHY unchanged. pytest 27/27.
Poland-Tusk regression smoke: now CG via short_entity_grounded ✓.

No regression risk on the existing reconciliation cells:
- Iceland CG: short_entity_grounded was already winning (was rule
  4, now rule 3 — same outcome, earlier exit)
- WWII-1812 WRONG: '1812' ∉ gold → fast path declines, NLI fires ✓
- Higgs-cafe FABRICATED: 'Higgs' ∉ gold → fast path declines ✓
- Anthony Albanese WRONG: 'Albanese' ∉ gold → fast path declines ✓
- Abstention phrases: rule 2 still fires first ✓
2026-05-19 20:03:21 -04:00

775 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Code-only judge for the #000057 control experiment — no LLM, no quota.
Drop-in alternative to ``bench/judge.py`` (Opus headless via
``claude -p``). Same :class:`bench.judge.Verdict`-shaped dataclass,
same closed verdict vocabulary
(``CORRECT_GROUNDED`` / ``WRONG`` / ``FABRICATED`` / ``ABSTAINED`` /
``JUDGE_ERROR``), same ``judge(question, answer, gold_source)`` entry
point — so a caller can swap one for the other without changing its
scoring/aggregation code.
**Why a code judge first** (fox 2026-05-19): the Opus judge burned our
Anthropic quota on the huge-N sweep. A deterministic code judge gives
us the data-collection arm we need RIGHT NOW (zero quota, free,
replayable, no model-family hygiene threat — same-family judging is
the Opus-judge's live limitation per ``judge.py:26-29``). LLM-based
judging (Opus batched needle-haystack, or Grok credit-card) is a
later, separate concern; it operates on the residue this judge can
not classify deterministically.
The pipeline, in fixed order — first hit decides:
1. **Empty / no-gold guard.** Whitespace answer → ABSTAINED.
Empty gold → JUDGE_ERROR ("no gold source").
2. **Abstention phrases.** Lexical scan for explicit refusals
("I cannot determine", "the source does not state", …). Hit →
ABSTAINED. ABSTAINED is NOT failure — it's honest about not
answering and matches the Opus-judge prompt's definition.
3. **NLI contradiction.** ``arborist.qa.nli.shadow_check`` against
the gold source. If ``max_contradiction >= theta_contra`` (pinned
manifest threshold, default 0.5) → WRONG. Strongest possible
signal: the source *contradicts* the answer.
4. **Lexical verifier vs gold.** ``arborist.qa.verify.verify_quotes``
classifies the answer's grounding against the gold source as
STRICT / HYBRID / UNGROUNDED (the existing four-strategy ladder:
quote → span → entity → paraphrase). Maps to:
* STRICT → CORRECT_GROUNDED
* HYBRID + NLI entailment ≥ theta_entail → CORRECT_GROUNDED
* UNGROUNDED + specifics (proper-nouns or
dates not in gold) → FABRICATED
* UNGROUNDED + no specifics → ABSTAINED
(thin, unsupported,
but not a fabrication)
* HYBRID without NLI corroboration → JUDGE_ERROR
(ambiguous; residue
for LLM judge)
The JUDGE_ERROR class is a feature: ``rationale`` carries
``"code judge ambiguous: <reason>"`` so the residue is greppable for
later promotion to an LLM judge. Existing callers
(``control_sweep.py``, ``control_ab.py``) already treat JUDGE_ERROR
non-fatally and count it in the JE bucket.
Threats to validity, stated:
* Lexical grounding is a *proxy* for "supported by source" — the
Opus judge can read & reason. Code judge can not. STRICT means
"verbatim/span present in gold"; an answer can be STRICT but
still subtly wrong if the gold's claim differs in interpretation.
NLI guards against that (an answer whose proposition gold
contradicts will trip rule 3 BEFORE rule 4 sees STRICT).
* NLI is optional (``[nli]`` extra). When the model is unavailable
we fall back to rules 1+2+4 only and label the rationale so the
operator sees the degradation.
* Specificity heuristic for FABRICATED has false-negatives on
answers that fabricate WITHOUT proper nouns or numbers
("the answer to that is yes" with no grounding) — those will land
in ABSTAINED, the conservative class. False-positives on STRICT
answers cannot happen (STRICT pre-empts the FABRICATED branch).
Self-test (``python -m bench.judge_code --self-test``) covers the
same 4 fixtures as ``judge.py:self_test()`` so the two instruments
can be cross-checked when fox does eventually re-fire the Opus
judge on the residue.
"""
from __future__ import annotations
import json
import os
import re
import sys
from dataclasses import dataclass, field
from arborist.qa.verify import verify_quotes, extract_proper_nouns
JUDGE_RULESET_ID = "code-judge-v1"
JUDGE_MODEL = "code (deterministic, no LLM)"
# Opus-judge parity: same closed vocabulary so a Verdict from this
# module aggregates against the same buckets without translation.
_VERDICTS = ("CORRECT_GROUNDED", "WRONG", "FABRICATED", "ABSTAINED")
# Code-judge NLI corroboration threshold (rule 4 — HYBRID lexical +
# NLI entailment → CORRECT_GROUNDED). Distinct from the NLI manifest's
# ``entailment_block_veto=0.9``, which is calibrated for the OPPOSITE
# use case (high bar to override a STRICT lexical signal — additive
# negative evidence needs strong confidence). Corroborating a HYBRID
# lexical signal is additive POSITIVE evidence on an already-positive
# anchor; a moderate threshold is appropriate. Self-test fixture #1
# ("World War II ended in 1945." vs gold mentioning "it ended in 1945")
# measures NLI entailment=0.769 — clearly entailed, well clear of the
# 0.55 floor used here. Anything below 0.55 defers to JUDGE_ERROR
# (residue for an LLM judge later) rather than risk a false promote.
_CODE_JUDGE_THETA_ENTAIL_CORROBORATE = 0.55
# Code-judge NLI contradiction threshold (rule 3 — gold contradicts
# the answer → WRONG). The 2026-05-19 reconciliation against the
# trusted Opus judge measured **114 records** where the code judge
# fired WRONG via NLI contradiction p ∈ [0.51, 0.74] on answers Opus
# correctly graded CG — clear factual claims like "Ólafur Ragnar
# Grímsson is the president of Iceland" against the corpus infobox
# that DOES list him as incumbent. NLI is noisy in the 0.50.8 band
# on wikitext-shaped gold (clause-level candidate selection picks up
# co-mentioned earlier office-holders & reads temporal-frame mismatch
# as contradiction). For TRUE contradictions the signal is far higher
# (the WW2 self-test fixture #2 measures p=0.985). Threshold raised
# from the manifest's 0.5 → 0.85 to keep the strong contradictions
# (cleanly above the floor) while dropping the noise band.
_CODE_JUDGE_THETA_CONTRA = 0.85
# Short-answer threshold (rule 3.5 — terse correct-noun answers).
# The verifier's strategy 2 needs prose-shaped spans; very short
# answers ("Pratibha Patil", "Jalal Talabani", "In 1945.") fall
# through to UNGROUNDED-no-specifics → ABSTAINED, missing valid CG.
# The reconciliation against Opus identified 13 such records.
# Compensate by accepting a short answer as grounded when every
# specific (proper noun + numeric) it asserts is present in gold AND
# the question's subject anchor is present in gold (catches "wrong
# topic, right name" false-positives).
_CODE_JUDGE_SHORT_ANSWER_TOKENS = 15
@dataclass
class Verdict:
label: str # one of _VERDICTS, or "JUDGE_ERROR"
rationale: str
raw: str # serialised decision trace (logged for replay)
prompt_id: str = JUDGE_RULESET_ID
model: str = JUDGE_MODEL
# Surface the decision trace separately for programmatic callers
# who want to bucket by which rule fired. `raw` is the same data
# JSON-serialised — both are kept so the human-facing ``judge.py``
# interface is preserved.
decision: dict = field(default_factory=dict)
# ----------------------------------------------------------- preprocess
# Claim-lattice JSON envelope detector. Arborist's `claim_lattice`
# answer_mode emits {"claims":[{"text":"...","evidence_ids":["E1"]},...]}.
# Without unwrapping, the verifier sees brace + key syntax noise
# instead of the actual claim prose — the proper-noun & span
# extractors give weak signal, every Arborist record falls to
# UNGROUNDED. Unwrapping to concatenated sentences lets the existing
# lexical paths handle these uniformly with plain-prose answers,
# preserving the judge's per-record cost (no special-case grader).
def _unwrap_claim_lattice_json(answer: str) -> str:
"""Extract claim text(s) from claim-lattice JSON envelopes. Returns
the original answer when it isn't claim-lattice shaped, so
plain-prose answers pass through unchanged.
The detection is conservative: must start with ``{`` AND contain
``"claims"`` AND contain ``"text"`` — three independent signals
that this is the Arborist envelope rather than coincidental JSON.
Parse failures fall back to the original answer (let the
downstream verifier see what it gets — no silent rewriting on
malformed input)."""
a = (answer or "").strip()
if not (a.startswith("{") and '"claims"' in a and '"text"' in a):
return answer
try:
obj = json.loads(a)
except Exception: # noqa: BLE001 — malformed JSON, let it through
return answer
if not isinstance(obj, dict):
return answer
claims = obj.get("claims")
if not isinstance(claims, list):
return answer
texts: list[str] = []
for c in claims:
if isinstance(c, dict):
t = c.get("text")
if isinstance(t, str) and t.strip():
texts.append(t.strip())
if not texts:
return answer
# Concatenate as discrete sentences so verify.extract_claim_spans
# can pick each one up as its own span. Append a period only when
# the claim text doesn't already end in sentence punctuation.
return " ".join(
t if t[-1:] in ".!?:;" else (t + ".")
for t in texts
)
# ----------------------------------------------------------- rule 2
# Lexical abstention phrases. Kept conservative: only fires when the
# answer EXPLICITLY says it can not / will not answer or that the
# source does not contain the information. A model that answers
# "I think it is X" is NOT abstaining — it is making a claim.
#
# Pattern set expanded 2026-05-19 from the Opus-reconciliation
# residue: Hermes's most common abstention phrasings under
# `--variant source_relative` are "I do not have accurate information
# about", "I do not have access to real-time / a reference knowledge
# base to", "I do not have enough information to". The original
# patterns missed all three (they require a verb like determine/know
# right after "do not"). Eighteen records mis-classified as WRONG
# trace to this gap.
_ABSTAIN_PATTERNS = (
re.compile(r"\b(?:i\s+(?:cannot|can[' ]?t|do\s+not|don[' ]?t)\s+"
r"(?:determine|know|tell|say|find|locate|verify))\b", re.I),
# NEW: "I do not have <qualifier?> information / access / data ..."
re.compile(r"\bi\s+(?:do\s+not|don[' ]?t|cannot|can[' ]?t)\s+"
r"have\s+(?:\w+\s+){0,4}"
r"(?:information|access|data|knowledge|details)\b", re.I),
# NEW: "I do not have access to ..." stand-alone
re.compile(r"\bi\s+(?:do\s+not|don[' ]?t)\s+have\s+access\b", re.I),
# NEW: "I lack (access / information / knowledge)"
re.compile(r"\bi\s+lack\s+(?:\w+\s+){0,3}"
r"(?:access|information|knowledge|data|details)\b", re.I),
re.compile(r"\b(?:not\s+(?:enough|sufficient)\s+information)\b", re.I),
re.compile(r"\b(?:no\s+information\s+(?:is\s+)?(?:available|provided|given))\b", re.I),
re.compile(r"\b(?:the\s+(?:source|article|text|context|passage)\s+"
r"(?:does\s+not|doesn[' ]?t|fails\s+to)\s+"
r"(?:contain|mention|state|specify|provide|include))\b", re.I),
re.compile(r"\b(?:unable\s+to\s+(?:determine|answer|verify|locate))\b", re.I),
re.compile(r"\b(?:no\s+(?:answer|response)\s+(?:can\s+be|is)\s+(?:given|provided|made))\b", re.I),
re.compile(r"\b(?:cannot\s+be\s+(?:determined|answered|found|verified)\s+from)\b", re.I),
)
def _is_abstention(answer: str) -> tuple[bool, str]:
"""Hit on any of the explicit-refusal patterns above.
Returns ``(is_abstention, matched_pattern_excerpt)``."""
for pat in _ABSTAIN_PATTERNS:
m = pat.search(answer)
if m:
return True, m.group(0)
return False, ""
# ----------------------------------------------------------- rule 3
def _nli_check(answer: str, gold: str):
"""Run NLI shadow on (gold, answer). Returns the
:class:`arborist.qa.nli.ShadowResult` or ``None`` when the
``[nli]`` extra is unavailable.
Note: ``shadow_check`` itself returns an unavailable-result
object rather than raising, so the actual ``None`` branch
here is only the import-failure path."""
try:
from arborist.qa.nli import shadow_check
except Exception: # noqa: BLE001 — fail-open to lexical-only judging
return None
try:
return shadow_check(claim=answer, source=gold)
except Exception: # noqa: BLE001
return None
# ----------------------------------------------------------- rule 4
# Tokens that are evidence the answer asserts SPECIFICS without
# grounding — proper nouns (multi-word via verify.extract_proper_nouns
# AND single-word via the local scanner below) and bare numerics that
# look like dates / counts / measurements.
_NUMERIC_SPECIFIC_RE = re.compile(
r"\b(?:\d{2,4}(?:st|nd|rd|th)?|\d+\.\d+|\d{3,}|"
# 4-digit year, ordinal year, decimal, large count
r"\d{1,2}/\d{1,2}(?:/\d{2,4})?|" # date 1/2 or 1/2/34
r"\$\d[\d,]*(?:\.\d+)?)\b"
)
# Single-word capitalised tokens. The verifier's
# ``extract_proper_nouns`` is deliberately multi-word-only (to keep
# the verifier's lexical contract conservative — single capitalised
# tokens are noisy as evidence of grounding). For the FABRICATION
# check we go the other direction: we want to catch "Napoleon" alone
# when it appears in an UNGROUNDED answer with no source backing.
# Sentence-leading tokens get filtered as a soft-noise reduction.
_SINGLE_CAP_TOKEN_RE = re.compile(r"\b[A-Z][a-z]{2,}\b")
# Common English words that frequently appear sentence-leading and
# would otherwise produce noise (the FABRICATED class is about asserted
# proper-noun-shaped specifics, not "The"/"This"/"It"). Lower-case
# comparison so the filter is shape-agnostic.
_CAP_TOKEN_STOPWORDS = frozenset({
"the", "this", "that", "these", "those", "it", "its", "they", "their",
"there", "then", "thus", "such", "some", "all", "any", "many",
"most", "much", "more", "less", "few", "several", "each", "every",
"and", "but", "or", "nor", "yet", "so", "for", "however", "moreover",
"also", "though", "although", "while", "since", "because", "if",
"when", "where", "what", "which", "who", "whom", "whose", "why", "how",
"is", "are", "was", "were", "be", "been", "being", "has", "have",
"had", "do", "does", "did", "can", "could", "should", "would", "may",
"might", "must", "shall", "will", "according", "based", "during",
"after", "before", "given", "let", "note", "see", "from", "with",
"without", "into", "onto", "upon", "about", "around",
})
def _all_answer_specifics(answer: str) -> list[str]:
"""Every specific (proper noun + numeric) the answer asserts.
Symmetric with :func:`_specifics_not_in_gold` but unfiltered —
used to check 'this answer asserted SOMETHING specific' before
we decide whether to call it ABSTAINED."""
out: list[str] = []
seen: set[str] = set()
def _add(tok: str):
k = tok.lower()
if k in seen:
return
out.append(tok)
seen.add(k)
for noun in extract_proper_nouns(answer):
_add(noun)
for m in _SINGLE_CAP_TOKEN_RE.finditer(answer):
tok = m.group(0)
if tok.lower() in _CAP_TOKEN_STOPWORDS:
continue
_add(tok)
for m in _NUMERIC_SPECIFIC_RE.finditer(answer):
_add(m.group(0))
return out
# Stopwords for the question-subject-anchor extractor. Mirrors the
# inspect-module convention: drop function words / WH-question words /
# generic role nouns so the LAST remaining content token is the
# question's primary subject ("Iceland" in "who is the president of
# Iceland?"; "Australia" in "according to the reference knowledge base,
# who is the prime minister of Australia?"). The anchor is also
# tolerant of the variant-rewriting prefixes (`According to ...`,
# `As of 2010 ...`) because those add to the front, not the back.
_SUBJ_STOPWORDS = frozenset({
"who", "what", "when", "where", "why", "how", "which", "whose", "whom",
"is", "are", "was", "were", "be", "been", "being", "has", "have", "had",
"do", "does", "did", "the", "a", "an", "of", "in", "on", "at", "for",
"by", "to", "from", "as", "according", "reference", "knowledge", "base",
"current", "currently", "today", "now", "presently", "office",
"head", "leader",
# Generic role nouns kept OUT of the subject — they describe the
# *role*, the polity is the subject. "who is the prime minister of
# Australia" → subject = australia, not minister.
"president", "prime", "minister", "chancellor", "premier", "governor",
"general", "secretary", "king", "queen", "emperor", "leader",
"chairman", "chair", "speaker", "monarch", "ruler",
})
_TOKEN_RE = re.compile(r"\b\w+\b", re.U)
def _question_subject_anchor(question: str) -> str:
"""Last content token after stopword-strip — the inspect-module
fallback used when the question carries no proper-noun-shaped
terms. Returns lowercase or empty string."""
toks = [t.lower() for t in _TOKEN_RE.findall(question or "")]
content = [t for t in toks if t not in _SUBJ_STOPWORDS and not t.isdigit()]
return content[-1] if content else ""
def _question_subject_terms(question: str) -> list[str]:
"""Proper-noun-shaped subject terms from the question. Tries
extract_proper_nouns (multi-word PNs) first, then layers single
capitalised tokens (modulo stopwords). For "who is the president
of Iceland?" → ["Iceland"]. For "who founded the Higgs boson
cafe in 1066?" → ["Higgs"]. For "who is the president of the
Quorum of the Twelve Apostles?" → ["Quorum", "Twelve", "Apostles"]
(whichever the multi-word extractor surfaces + single caps the
extractor misses).
Used as the strong-anchor signal for ``_subject_in_gold``: a
proper-noun-shaped term in the question is the topic's identity,
not just a co-occurring stopword ("cafe" matching gold's "any
cafe" denial is the bug this avoids). Returns [] when the
question has no proper-noun-shaped term — caller falls back to
the last-content-token anchor."""
out: list[str] = []
seen: set[str] = set()
def _add(t: str):
k = t.lower()
if k in seen or k in _SUBJ_STOPWORDS or k in _CAP_TOKEN_STOPWORDS:
return
out.append(t)
seen.add(k)
for pn in extract_proper_nouns(question or ""):
# Multi-word PNs land whole — record both the phrase and its
# head tokens for substring matching against gold.
_add(pn)
for tok in pn.split():
_add(tok)
for m in _SINGLE_CAP_TOKEN_RE.finditer(question or ""):
_add(m.group(0))
return out
def _subject_in_gold(question: str, gold: str) -> tuple[bool, str]:
"""True iff the question's subject is present in gold. Strong
anchor: proper-noun-shaped terms from the question — ALL such
terms must appear in gold (case-insensitive substring).
Conservative because the WRONG-vs-FABRICATED tie-break demotes
FABRICATED → WRONG when this fires, and we'd rather under-fire
(keep FABRICATED) than over-fire (the Higgs-boson-cafe gold
coincidentally contains "cafe" in a denial — single-token anchor
would mis-fire, multi-PN anchor doesn't).
Returns ``(found, anchor)`` where ``anchor`` records which term
decided it (for the decision trace). Falls back to the last-
content-token heuristic when the question has no proper-noun-
shaped terms (rare on this corpus)."""
gold_lc = gold.lower()
terms = _question_subject_terms(question)
if terms:
# Require every proper-noun-shaped term to appear in gold —
# all the topic's identity tokens, not just one.
missing = [t for t in terms if t.lower() not in gold_lc]
if not missing:
return True, terms[0]
return False, missing[0]
# Fallback for proper-noun-free questions: the last-content-token
# anchor (was the original heuristic before reconciliation).
anchor = _question_subject_anchor(question)
if not anchor:
return False, ""
return (anchor in gold_lc), anchor
def _is_short_answer(answer: str) -> bool:
"""The verifier's strategy-2 span extractor needs prose; very
short answers fall through to UNGROUNDED-no-specifics. Treat as
'short' for the entity-grounding fast path."""
return len(_TOKEN_RE.findall(answer or "")) <= _CODE_JUDGE_SHORT_ANSWER_TOKENS
def _specifics_not_in_gold(answer: str, gold: str) -> list[str]:
"""Proper-noun and numeric specifics that the answer asserts but
the gold source never mentions. Three layers:
1. ``extract_proper_nouns`` from the verifier — multi-word
capitalised entities (e.g. "Higgs Boson", "World War II").
2. Local single-word capitalised-token scanner — catches
"Napoleon" / "Mars" / "Mozart" that the verifier's multi-word
gate filters out.
3. Numeric specifics — dates, years, large counts, money.
Used only on the UNGROUNDED branch: a STRICT or HYBRID answer
pre-empts this path and does NOT get tagged as FABRICATED."""
out: list[str] = []
seen: set[str] = set()
gold_lc = gold.lower()
def _add(tok: str):
k = tok.lower()
if k in seen:
return
if k not in gold_lc:
out.append(tok)
seen.add(k)
# Layer 1: verifier's multi-word proper-noun extractor.
for noun in extract_proper_nouns(answer):
_add(noun)
# Layer 2: single-word capitalised tokens (modulo sentence-leading
# noise — drop tokens immediately following a sentence terminator
# OR at position 0, AND in the stopword set).
sentence_lead_positions: set[int] = {0}
for m in re.finditer(r"[.!?]\s+(?=[A-Z])", answer):
sentence_lead_positions.add(m.end())
for m in _SINGLE_CAP_TOKEN_RE.finditer(answer):
tok = m.group(0)
if tok.lower() in _CAP_TOKEN_STOPWORDS:
continue
# If sentence-leading AND a 3-letter-or-less stop-shaped token,
# ignore. But "Napoleon" sentence-leading is still a specific.
# We accept any sentence-leading non-stopword that's >=3 chars.
_add(tok)
# Layer 3: numerics.
for m in _NUMERIC_SPECIFIC_RE.finditer(answer):
_add(m.group(0))
return out
# ----------------------------------------------------------- judge()
def judge(question: str, answer: str, gold_source: str) -> Verdict:
"""Deterministic verdict on (question, answer, gold_source).
Same shape & verdict vocabulary as ``bench.judge.judge``; no
network, no LLM, no quota cost. JUDGE_ERROR is reserved for the
HYBRID-without-NLI-corroboration class so a downstream LLM judge
(Opus batched, Grok) can pick that residue up later.
Rule order (reconciled against Opus 2026-05-19):
1. empty / no-gold guards
2. explicit abstention phrases (lexical regex; broad set
covering the Hermes "I do not have ... information / access"
family that the original narrow patterns missed)
3. strong NLI contradiction (theta_contra=0.85, raised from 0.5
after measuring 114 false-positives in the 0.5-0.75 band)
4. short-answer entity-grounding fast path — for terse answers
where the verifier's prose-shape extractor would whiff,
accept CG when every asserted specific is in gold AND the
question's subject anchor is in gold (catches "Pratibha
Patil" → gold "Pratibha Devisingh Patil ..." → CG)
5. lexical verifier (verify_quotes)
STRICT → CG
HYBRID + NLI entail ≥ 0.55 → CG
HYBRID + subject-anchor + all-grounded → CG (entity-
grounding rescue)
HYBRID otherwise → JUDGE_ERROR
(residue)
UNGROUNDED + specifics + subject-in-gold → WRONG (source
contradicts —
Opus's WRONG
vs FABRICATED
distinction)
UNGROUNDED + specifics, no subject → FABRICATED
UNGROUNDED + no specifics → ABSTAINED
"""
raw_answer = (answer or "").strip()
trace: dict = {"rules_fired": []}
# Preprocess — unwrap claim-lattice JSON envelopes BEFORE any
# downstream check. After this point ``a`` is plain prose for both
# arms (solo & arborist-claim_lattice), and the verifier / NLI /
# specifics paths grade them on equal terms.
a = _unwrap_claim_lattice_json(raw_answer)
if a != raw_answer:
trace["lattice_unwrapped"] = True
trace["unwrapped_len"] = len(a)
g = (gold_source or "").strip()
# Rule 1 — empty / no-gold guards.
if not a:
trace["rules_fired"].append("empty_answer")
return _v("ABSTAINED", "empty answer", trace)
if not g:
trace["rules_fired"].append("no_gold")
return _v("JUDGE_ERROR", "no gold source — cannot ground", trace)
# Rule 2 — explicit abstention phrase.
is_abs, abs_match = _is_abstention(a)
if is_abs:
trace["rules_fired"].append("abstention_phrase")
trace["abstain_match"] = abs_match
return _v("ABSTAINED", f"explicit refusal: {abs_match!r}", trace)
# Pre-compute shared signals once.
nli = _nli_check(a, g)
nli_avail = nli is not None and nli.available
trace["nli_available"] = nli_avail
if nli_avail:
trace["nli_max_contra"] = nli.max_contradiction
trace["nli_max_entail"] = nli.max_entailment
subj_in_gold, subj_anchor = _subject_in_gold(question, g)
trace["subject_anchor"] = subj_anchor
trace["subject_in_gold"] = subj_in_gold
# Rule 3 — short-answer entity-grounding fast path. RUNS BEFORE
# NLI contradiction (since 2026-05-19 Poland-Tusk smoke): when the
# answer asserts only specifics that are ALL in gold AND the
# question's subject anchor is in gold, that is strong positive
# evidence — NLI clause-level noise cannot override it. The
# Poland case (qwen+arborist answer "Donald Tusk is listed as the
# Prime Minister of Poland.", gold lists multiple PMs across
# decades) measured NLI contradiction p=0.89 ABOVE the 0.85
# threshold while NLI entailment was also 0.74 on the correct
# clause — mixed signal. The fast path's positive-evidence
# combination (specifics-in-gold + subject-in-gold) discriminates
# truth from contradiction without depending on NLI's clause-
# level aggregation. Rescue when: (a) answer is short, (b) every
# specific asserted is present in gold (no unsourced specifics),
# (c) at least one specific WAS asserted, (d) the question's
# subject anchor is in gold (guards against "wrong topic, right
# name" false positives — e.g. "Anthony Albanese" wouldn't fire
# because Albanese ∉ gold).
short = _is_short_answer(a)
trace["short_answer"] = short
if short and subj_in_gold:
all_specs = _all_answer_specifics(a)
unsourced = _specifics_not_in_gold(a, g)
trace["short_path_all_specifics"] = all_specs[:10]
trace["short_path_unsourced"] = unsourced[:10]
if all_specs and not unsourced:
trace["rules_fired"].append("short_entity_grounded")
return _v("CORRECT_GROUNDED",
f"short answer + all specifics in gold "
f"({', '.join(all_specs[:3])}"
f"{'' if len(all_specs) > 3 else ''}) + "
f"subject anchor {subj_anchor!r} in gold",
trace)
# Rule 4 — strong NLI contradiction (gold-contradicts-claim).
# Runs AFTER the entity-grounding fast path so positive lexical
# evidence can't be overridden by NLI noise. The 0.85 threshold is
# the code-judge floor for "this is a real contradiction, not NLI
# noise on wikitext-shaped gold" — see the _CODE_JUDGE_THETA_CONTRA
# constant for the measurement that drove the recalibration from
# the manifest's 0.5.
theta_contra = _CODE_JUDGE_THETA_CONTRA
trace["theta_contra_code_judge"] = theta_contra
if nli_avail and nli.max_contradiction >= theta_contra:
trace["rules_fired"].append("nli_contradiction")
return _v("WRONG",
f"NLI contradiction p={nli.max_contradiction:.3f} "
f">= theta_contra={theta_contra:.2f}",
trace)
# Rule 5 — lexical verifier against gold.
v = verify_quotes(a, g)
mode = v.get("audit_mode", "UNGROUNDED")
method = v.get("verifier_method", "none")
trace["audit_mode"] = mode
trace["verifier_method"] = method
trace["n_quotes"] = v.get("n_quotes", 0)
trace["n_verified"] = v.get("n_verified", 0)
trace["rules_fired"].append(f"verify:{mode}")
if mode == "STRICT":
return _v("CORRECT_GROUNDED",
f"verifier STRICT via {method} "
f"({v.get('n_verified', 0)}/{v.get('n_quotes', 0)})",
trace)
if mode == "HYBRID":
# HYBRID alone is ambiguous. Three tip-points: NLI
# corroboration, OR the entity-grounding rescue (all asserted
# specifics in gold + subject anchor in gold), OR defer.
theta = _CODE_JUDGE_THETA_ENTAIL_CORROBORATE
trace["theta_entail_corroborate"] = theta
if nli_avail and nli.max_entailment >= theta:
trace["rules_fired"].append("hybrid+nli_entail")
return _v("CORRECT_GROUNDED",
f"verifier HYBRID via {method} + NLI entailment "
f"p={nli.max_entailment:.3f} >= "
f"theta_corroborate={theta:.2f}",
trace)
unsourced = _specifics_not_in_gold(a, g)
all_specs = _all_answer_specifics(a)
if subj_in_gold and all_specs and not unsourced:
trace["rules_fired"].append("hybrid+entity_grounded")
return _v("CORRECT_GROUNDED",
f"verifier HYBRID via {method} + all asserted "
f"specifics in gold + subject anchor "
f"{subj_anchor!r} in gold",
trace)
return _v("JUDGE_ERROR",
f"code judge ambiguous: HYBRID via {method} without "
f"NLI entailment corroboration or entity-grounding — "
f"residue for LLM judge",
trace)
# mode == "UNGROUNDED" — WRONG vs FABRICATED vs ABSTAINED.
specifics = _specifics_not_in_gold(a, g)
trace["ungrounded_specifics"] = specifics[:10]
if specifics:
# WRONG vs FABRICATED: if gold mentions the question's
# subject (the topic) but the answer's asserted specifics
# aren't in gold, gold is contradicting the answer (it knows
# the topic, has a different specific). If gold doesn't
# mention the subject at all, source is silent on the topic
# and the answer's specifics are FABRICATED.
if subj_in_gold:
trace["rules_fired"].append("ungrounded+subject_in_gold")
return _v("WRONG",
f"verifier UNGROUNDED + subject anchor "
f"{subj_anchor!r} in gold + specifics not in "
f"gold: {', '.join(specifics[:5])}"
f"{'' if len(specifics) > 5 else ''} — gold "
f"has the topic but a different value",
trace)
return _v("FABRICATED",
f"verifier UNGROUNDED + specifics not in gold + "
f"subject anchor {subj_anchor!r} NOT in gold: "
f"{', '.join(specifics[:5])}"
f"{'' if len(specifics) > 5 else ''} — source "
f"silent on the topic",
trace)
return _v("ABSTAINED",
"verifier UNGROUNDED + no asserted specifics — "
"thin / non-answer rather than a fabrication",
trace)
def _v(label: str, rationale: str, trace: dict) -> Verdict:
trace["label"] = label
return Verdict(
label=label,
rationale=rationale,
raw=json.dumps(trace, sort_keys=True, default=str),
decision=trace,
)
# ----------------------------------------------------------- batch
def judge_batch(triples) -> list[Verdict]:
"""Convenience: judge a list of ``(question, answer, gold)`` triples.
Code judge is already cheap & local so there's no batching gain;
the helper exists for interface parity with a future LLM-batch
judge that genuinely benefits from packing N records into one
needle-haystack call."""
return [judge(q, a, g) for (q, a, g) in triples]
# ----------------------------------------------------------- self-test
def self_test() -> int:
"""Same 4 fixtures as ``bench.judge.self_test`` — if the
deterministic judge fails these, the instrument is broken and
must not be used.
The fixtures were originally designed for an Opus judge whose
decision is by reading; for a code judge they're still the right
*behavioural contract*: the verdict label must match regardless
of how the judge arrived at it."""
cases = [
("When did World War II end?",
"World War II ended in 1945.",
"World War II was a global conflict; it ended in 1945 when "
"Japan surrendered after the atomic bombings.",
"CORRECT_GROUNDED"),
("When did World War II end?",
"World War II ended in 1812.",
"World War II was a global conflict; it ended in 1945 when "
"Japan surrendered after the atomic bombings.",
"WRONG"),
("Who founded the Higgs boson cafe in 1066?",
"The Higgs boson cafe was founded by Napoleon in 1066.",
"This article is about thermodynamics and entropy. It does "
"not mention any cafe, Napoleon, or the year 1066.",
"FABRICATED"),
("What is the capital of the fictional land of Qzzyx?",
"I cannot determine that from the provided source.",
"This article discusses photosynthesis in C4 plants.",
"ABSTAINED"),
]
ok = 0
for q, a, g, expect in cases:
v = judge(q, a, g)
hit = v.label == expect
ok += hit
print(f" [{'ok' if hit else 'MISS'}] expect={expect} got={v.label}"
f" · rules={','.join(v.decision.get('rules_fired', []))}"
f" · {v.rationale[:80]}")
verdict = ("INSTRUMENT TRUSTWORTHY" if ok == len(cases)
else "DO NOT RUN — code judge unreliable")
print(f"code-judge self-test: {ok}/{len(cases)} ({verdict})")
return 0 if ok == len(cases) else 1
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--self-test":
raise SystemExit(self_test())
if len(sys.argv) != 4:
print("usage: python -m bench.judge_code QUESTION ANSWER GOLD",
file=sys.stderr)
print(" python -m bench.judge_code --self-test",
file=sys.stderr)
raise SystemExit(2)
v = judge(sys.argv[1], sys.argv[2], sys.argv[3])
print(json.dumps({"label": v.label, "rationale": v.rationale,
"model": v.model, "prompt_id": v.prompt_id,
"decision": v.decision},
indent=2, default=str))