arborist/tests/test_judge_code.py
russell@unturf.com 2d3186669f
feat(#000057): stronger code judge — resolve HYBRID with verified quote + on-topic
The code judge bailed to JUDGE_ERROR on 40% of in-corpus answers: HYBRID
(partial grounding) with low NLI entail, where the entity-grounding
rescue needs ZERO unsourced specifics. A single extra proper noun
('Emperor Honorius', 'Alexander Molossus' — an alias/paraphrase) blocked
rescue even with verbatim quotes verified and the answer correct.

New HYBRID resolution tier: rescue to CORRECT_GROUNDED when the verifier
confirmed >=1 verbatim quote, the subject anchor is in gold (on-topic),
there is NO unsourced NUMERIC specific (wrong dates/counts stay residue),
and NLI isn't strongly contradicting. Unsourced proper nouns are treated
as aliases/paraphrase; unsourced numerics (the real factual-error class)
keep the answer as JUDGE_ERROR. Validated on the 12 real residue cases:
9 -> CORRECT (all genuinely right), 3 stay residue (unsourced numerics).
JUDGE_ERROR 40% -> ~10%. self-test 4/4; 2 new tier tests; suite 2549.
2026-05-21 13:10:01 -04:00

275 lines
11 KiB
Python

"""Tests for the deterministic code-only judge (``bench/judge_code.py``).
The self-test fixture set is the BEHAVIOURAL CONTRACT — these are
exactly the 4 cases the Opus judge (``bench/judge.py``) was hand-built
to grade correctly. A code judge that disagrees on these is not a
trustworthy instrument. CI fails the build if the contract slips.
We deliberately do NOT pin specific NLI probabilities or specific
``rules_fired`` traces — those are tuning knobs we expect to move.
The label is the public contract; the trace is implementation detail.
Runs WITHOUT the ``[nli]`` extra installed too: NLI is optional, the
abstention / lexical / specificity paths give correct labels on the
fixture set even when NLI is unavailable (case 1 then routes via
JUDGE_ERROR — see ``test_self_test_passes_with_nli_or_skips``).
"""
from __future__ import annotations
import pytest
from bench.judge_code import (
JUDGE_MODEL,
JUDGE_RULESET_ID,
Verdict,
judge,
judge_batch,
self_test,
_is_abstention,
_specifics_not_in_gold,
_unwrap_claim_lattice_json,
)
# --- module surface --------------------------------------------------------
def test_module_identifiers_pinned():
"""Audit-trail parity with bench/judge.py: stable model + prompt
id strings so verdict logs from this judge are sortable / joinable
against logs from the Opus judge."""
assert JUDGE_RULESET_ID == "code-judge-v1"
assert "code" in JUDGE_MODEL.lower()
assert "no llm" in JUDGE_MODEL.lower()
def test_verdict_dataclass_shape():
v = judge("Q?", "I cannot determine that.", "gold")
assert isinstance(v, Verdict)
# Same fields as bench.judge.Verdict so the two are drop-in compat.
assert v.label in {"CORRECT_GROUNDED", "WRONG", "FABRICATED",
"ABSTAINED", "JUDGE_ERROR"}
assert v.prompt_id == JUDGE_RULESET_ID
assert v.model == JUDGE_MODEL
assert isinstance(v.rationale, str) and v.rationale
assert isinstance(v.raw, str) and v.raw # JSON-serialised trace
assert isinstance(v.decision, dict)
# --- rule 1: empty / no-gold guards ----------------------------------------
def test_empty_answer_is_abstained():
v = judge("Q?", "", "gold text")
assert v.label == "ABSTAINED"
assert "empty_answer" in v.decision["rules_fired"]
def test_empty_gold_is_judge_error():
v = judge("Q?", "Some claim.", "")
assert v.label == "JUDGE_ERROR"
assert "no_gold" in v.decision["rules_fired"]
# --- rule 2: explicit abstention -------------------------------------------
@pytest.mark.parametrize("phrase", [
"I cannot determine that from the provided source.",
"I do not know.",
"I can't tell from the article.",
"The source does not mention this.",
"The article doesn't contain that information.",
"Not enough information is provided.",
"Unable to determine from the passage.",
"Cannot be determined from the source.",
])
def test_abstention_phrases_classify_as_abstained(phrase):
v = judge("Q?", phrase, "irrelevant gold content")
assert v.label == "ABSTAINED", phrase
assert "abstention_phrase" in v.decision["rules_fired"]
def test_assertive_answer_is_not_abstention():
"""A model that makes a confident claim is NOT abstaining, even if
the claim happens to be ungrounded — that's WRONG / FABRICATED,
not ABSTAINED."""
is_abs, _ = _is_abstention(
"The capital of France is Berlin and the population is 12 million."
)
assert is_abs is False
# --- rule 4: specificity (the FABRICATED gate) -----------------------------
def test_specifics_picked_up_when_not_in_gold():
answer = "The Higgs boson cafe was founded by Napoleon in 1066."
gold = "This article discusses thermodynamics and entropy."
s = _specifics_not_in_gold(answer, gold)
# Should flag the proper noun(s) and the date.
joined = " ".join(s).lower()
assert "napoleon" in joined
assert "1066" in joined
def test_specifics_excluded_when_in_gold():
"""A proper noun / number that ALSO appears in gold is grounded
by definition (case-insensitive) and must NOT be flagged."""
answer = "Napoleon led the army in 1812."
gold = "Napoleon Bonaparte's campaign in 1812 was disastrous."
s = _specifics_not_in_gold(answer, gold)
joined = " ".join(s).lower()
assert "napoleon" not in joined
assert "1812" not in joined
# --- the canonical behavioural contract: 4-case self-test ------------------
def test_self_test_passes():
"""The same 4 fixtures the Opus judge was hand-built to grade
correctly. If this fails, the code judge is not an interchangeable
instrument."""
assert self_test() == 0
# --- batch parity ----------------------------------------------------------
def test_judge_batch_loops_correctly():
triples = [
("Q1", "I cannot determine.", "gold a"),
("Q2", "", "gold b"),
]
out = judge_batch(triples)
assert [v.label for v in out] == ["ABSTAINED", "ABSTAINED"]
assert all(isinstance(v, Verdict) for v in out)
# --- claim-lattice JSON unwrap ---------------------------------------------
def test_unwrap_single_claim_lattice():
"""The Arborist arm's `claim_lattice` answer_mode emits a JSON
envelope; the judge must unwrap to the inner claim prose so the
verifier's span/entity paths see plain text, not brace syntax."""
a = ('{"claims":[{"text":"The current president of Iceland is '
'Ólafur Ragnar Grímsson.","evidence_ids":["E1"]}]}')
u = _unwrap_claim_lattice_json(a)
assert u == "The current president of Iceland is Ólafur Ragnar Grímsson."
def test_unwrap_multi_claim_concatenates_with_periods():
"""Multiple claims → concatenated sentences, with periods inserted
where the claim text doesn't already terminate. extract_claim_spans
treats each as its own span."""
a = ('{"claims":[{"text":"X is A.","evidence_ids":["E1"]},'
'{"text":"Y is B","evidence_ids":["E2"]}]}')
u = _unwrap_claim_lattice_json(a)
assert u == "X is A. Y is B."
@pytest.mark.parametrize("plain", [
"Just a regular answer.",
"",
"I cannot determine that.",
"Napoleon founded the cafe in 1066.",
])
def test_unwrap_passes_plain_prose_through(plain):
"""Non-claim-lattice answers must pass through unchanged so the
judge's behaviour on solo-arm answers is unaffected."""
assert _unwrap_claim_lattice_json(plain) == plain
def test_unwrap_passes_malformed_json_through():
"""Malformed JSON is left intact so the downstream verifier can
see what it gets — no silent rewriting on broken input."""
a = '{"claims":[broken json'
assert _unwrap_claim_lattice_json(a) == a
def test_unwrap_passes_unrelated_json_through():
"""JSON that doesn't have the claim-lattice signature (claims +
text keys) is not the Arborist envelope; pass through."""
a = '{"answer": "X is A", "confidence": 0.9}'
assert _unwrap_claim_lattice_json(a) == a
def test_arborist_envelope_correctly_grounded():
"""End-to-end: Arborist claim_lattice envelope around a clearly
correct claim grades to CG (was ABSTAINED / WRONG before the
unwrap)."""
gold = ("{{Infobox Political post |post = President |body = Iceland "
"|incumbent = [[Ólafur Ragnar Grímsson]] "
"|incumbentsince = 1 August 1996")
answer = ('{"claims":[{"text":"The current president of Iceland is '
'Ólafur Ragnar Grímsson.","evidence_ids":["E1"]}]}')
v = judge("who is the president of Iceland?", answer, gold)
assert v.label == "CORRECT_GROUNDED"
assert v.decision.get("lattice_unwrapped") is True
# --- graceful NLI degradation ----------------------------------------------
def test_judge_handles_nli_unavailable(monkeypatch):
"""When the [nli] extra is missing, shadow_check returns an
unavailable ShadowResult — the judge must NOT crash; it just
proceeds without rule-3 (contradiction) signal and without the
HYBRID corroboration path. Labels stay sound for the explicit
abstention / UNGROUNDED-specifics paths (which short-circuit
before NLI is even consulted)."""
import bench.judge_code as jc
monkeypatch.setattr(jc, "_nli_check", lambda a, g: None)
# Abstention path returns at rule 2, BEFORE the NLI block runs —
# so the trace doesn't (and shouldn't) carry nli_available. The
# contract here is "no crash + correct label", not "NLI metadata
# is populated on every path".
v = jc.judge("Q?", "I cannot determine.", "gold")
assert v.label == "ABSTAINED"
assert "abstention_phrase" in v.decision["rules_fired"]
assert "nli_available" not in v.decision # rule 2 short-circuit
# An answer that reaches the NLI block must record nli_available
# = False when _nli_check returns None.
v = jc.judge("Q?", "Some claim about something.", "different content here")
assert v.decision.get("nli_available") is False
# FABRICATED path still works (no NLI needed).
v = jc.judge(
"Who founded the cafe?",
"Napoleon founded it in 1066.",
"This article is about thermodynamics.",
)
assert v.label == "FABRICATED"
# --- relaxed HYBRID rescue (2026-05-21): tolerate unsourced proper nouns
# (aliases/paraphrase) but still block unsourced numerics (date/count
# errors), gated on a verified quote + on-topic + no contradiction. -----
_EPIRUS_GOLD = ("Alexander I of Epirus was a king of Epirus from 350 to "
"331 BC, belonging to the Aeacid dynasty. He was the son "
"of Neoptolemus I and brother of Olympias.")
def test_hybrid_rescued_when_only_unsourced_specific_is_proper_noun(monkeypatch):
# NLI off so the lexical tier is isolated (not the NLI corroboration
# path). One verbatim quote (verified) + one quote NOT in gold
# (-> HYBRID); the only unsourced specifics are proper nouns.
import bench.judge_code as jc
monkeypatch.setattr(jc, "_nli_check", lambda a, g: None)
answer = ('Alexander I of Epirus "was a king of Epirus from 350 to '
'331 BC". Also called Alexander Molossus, he "led famed '
'campaigns in southern Italy against the Romans".')
v = jc.judge("who was Alexander the first of epirus?", answer, _EPIRUS_GOLD)
assert v.label == "CORRECT_GROUNDED", (v.label, v.rationale)
assert v.decision.get("audit_mode") == "HYBRID"
assert "hybrid+verified_quote_on_topic" in v.decision["rules_fired"]
def test_hybrid_stays_residue_when_unsourced_specific_is_numeric(monkeypatch):
# NLI off. One verified quote + an unverified quote with a WRONG date
# -> unsourced numeric -> NOT rescued (date/count errors stay residue).
import bench.judge_code as jc
monkeypatch.setattr(jc, "_nli_check", lambda a, g: None)
answer = ('Alexander I of Epirus "was a king of Epirus". He '
'"reigned from 999 to 888 BC".')
v = jc.judge("who was Alexander the first of epirus?", answer, _EPIRUS_GOLD)
assert v.label == "JUDGE_ERROR", (v.label, v.rationale)
assert "hybrid+verified_quote_on_topic" not in v.decision["rules_fired"]