qa(verify): Rule 8 — title-relevance promoted to hard verifier check

Promotes the title-relevance sidecar (diagnose_title_relevance,
qa/inspect.py) to a hard check inside both verify_claim_lattice
and verify_claim_lattice_json. New violation kind TITLE_MISMATCH;
demote-to-HYBRID semantics matching the existing WARRANT_MISSING
pattern.

Catches the 2026-05-02 fox-surfaced retrieval-driven hallucination
class:

  Q: "explain spin glass modeling & tensors?"
  Pre-Rule-8: EVIDENCE-WARRANTED 1/1, claim cited to Quantum
              chromodynamics chunk (single-line "See Also: spin
              glass" reference). Token-coverage check passed
              accidentally on shared physics vocabulary.
  Post-Rule-8: POINTER-LINKED-PARTIAL · title mismatch 1/1.
              Cited source title 'Quantum chromodynamics' shares
              zero stemmed tokens with claim's {spin, glass,
              modeling, tensor, ...} → demote.

Implementation:
  _claim_title_overlap(claim_text, source_title) returns True iff
  the source title shares ≥1 stemmed content token with the claim.
  Uses qa.evidence._content_tokens (≥4-char, post-stopword) and
  inline minimal stem (s-strip on tokens >4 chars, skip ss-enders)
  to avoid an import cycle.

  Per-claim loop in both verifiers checks every cited source's
  title; ANY-match suffices (only TITLE_MISMATCH when ALL cited
  titles miss). Vacuous-pass when claim or title has no
  extractable tokens.

  Renderer (_ladder_rung_for_lattice) treats TITLE_MISMATCH
  alongside WARRANT_MISSING as the POINTER-LINKED-triggering
  signal — both indicate citation/claim structural misalignment.
  _render_warrant_tail surfaces "· title mismatch" alongside
  "· warrant missing" so an operator sees the specific failure
  mode at the audit-line.

Test fixtures updated where the synthetic claims were too minimal
(e.g. "Velociraptors are shown attacking workers" cited to
"Jurassic Park (film)" — claim had no topic anchor). Real model
output naturally references the topic (the model sees the title
in the evidence map and uses it); the fixture revisions reflect
that. 4 new tests in test_verify_json.py covering the helper +
end-to-end TITLE_MISMATCH demote.

Live verification:
  Spin-glass query: POINTER-LINKED-PARTIAL · title mismatch ✓
  Homer/Mr. Burns:  EVIDENCE-WARRANTED ✓ (no regression)

CLAUDE.md updated with the Rule 8 convention; existing
diagnose_title_relevance sidecar marked legacy / dict-form for
per-cache-key inspect use.

Full suite: 738 passed (was 734, +4).
This commit is contained in:
russell@unturf.com 2026-05-01 20:59:14 -04:00
parent bde1bd61f4
commit 0d8eed0df8
No known key found for this signature in database
5 changed files with 273 additions and 31 deletions

View file

@ -162,10 +162,23 @@ revert without reading why. When in doubt, walk the
token in question must appear in answer). Suppressed for date /
count / cause shapes ("when", "why", "how many"). See
`aborist/qa/inspect.py`.
- **Title-relevance sidecar**: `diagnose_title_relevance(claim,
cited_titles)` flags retrieval-driven hallucinations where the
cited chunk's source title shares zero stems with the claim's
content tokens. See `aborist/qa/inspect.py`.
- **Title-relevance hard check (Rule 8)**: `_claim_title_overlap`
in `aborist/qa/verify.py`. For each claim that resolved, at least
one cited evidence's source title must share ≥1 stemmed content
token with the claim text. When NO cited title overlaps, record
a `TITLE_MISMATCH` violation & demote `STRICT → HYBRID`. Catches
retrieval-driven hallucinations where the cited chunk's SOURCE
is structurally unrelated to the claim's subject (2026-05-02
spin-glass case: claim about spin glass cited to a chunk from
*Quantum chromodynamics*; span had incidental physics-vocab
overlap, but the source title shared zero stems with the claim).
Renderer surfaces a `· title mismatch` tail on the audit-line
label alongside `· warrant missing`.
- **Title-relevance sidecar (legacy diagnostic)**:
`diagnose_title_relevance(claim, cited_titles)` in
`aborist.qa.inspect` returns the same signal in dict form for
per-cache-key inspection. Sidecar; never enters proof path.
Pre-dates the Rule 8 promotion (2026-05-02).
## Live endpoints

View file

@ -465,7 +465,11 @@ def _ladder_rung_for_lattice(audit_mode: str, violations: list[dict] | None) ->
if audit_mode == "UNGROUNDED":
return "UNGROUNDED"
kinds = {v.get("kind") for v in (violations or [])}
if "WARRANT_MISSING" in kinds:
# WARRANT_MISSING and TITLE_MISMATCH both indicate the citation
# is structurally misaligned with the claim — pointer resolved
# but the cited evidence (span or source) doesn't actually
# support the claim. Both drop to POINTER-LINKED.
if "WARRANT_MISSING" in kinds or "TITLE_MISMATCH" in kinds:
rung = "POINTER-LINKED"
elif kinds & _SOFT_DEMOTE_VIOLATION_KINDS:
rung = "ANCHOR-WARRANTED"
@ -528,20 +532,30 @@ def _render_audit_label(
def _render_warrant_tail(result: dict) -> str:
"""If the verdict carries any WARRANT_MISSING violations
(relation-shape question + claim names answer entity not in
cited span), return a ` · warrant missing` tail for the
audit-line label. Otherwise empty string.
"""Append a tail to the audit-line label that names the specific
warrant failure mode when one fired. Surfaces the cap reason at
the user-facing layer without overloading audit_mode.
Surfaces fox's feedback-3 distinction ("pointer-linked but
relation warrant missing") at the user-facing layer without
overloading the audit_mode field. The verifier already capped
audit_mode at HYBRID for warrant-missing cases; this just
names the cap reason."""
Two failure modes today:
- WARRANT_MISSING: relation/date/etc. anchor extracted from
claim doesn't appear in any cited span (per-span check).
- TITLE_MISMATCH: cited evidence's source title shares no
content tokens with the claim (per-source check). 2026-05-02
spin-glass case: claim about spin glass cited to *Quantum
chromodynamics*.
When both fire on different claims of the same answer, surface
both tails so the operator sees the full picture."""
violations = result.get("violations") or []
if any(v.get("kind") == "WARRANT_MISSING" for v in violations):
return " · warrant missing"
return ""
kinds = {v.get("kind") for v in violations}
parts: list[str] = []
if "WARRANT_MISSING" in kinds:
parts.append("warrant missing")
if "TITLE_MISMATCH" in kinds:
parts.append("title mismatch")
if not parts:
return ""
return " · " + " · ".join(parts)
def _maybe_render_json_envelope_as_bullets(answer: str) -> str:

View file

@ -985,6 +985,43 @@ DEFAULT_LAZY_ANCHOR_DEMOTE_MIN_PAIRS = 3
DEFAULT_MAX_CLAIMS_PER_ANSWER = 12
# Title-relevance check (Rule 8). Cited evidence's source title must
# share at least one stemmed content token with the claim text.
# Catches the retrieval-driven hallucination class fox surfaced
# 2026-05-02 on "explain spin glass modeling & tensors?": claim
# tokens {spin, glass, modeling, tensor, ...} cited to a chunk from
# the *Quantum chromodynamics* article whose title tokens are
# {quantum, chromodynamics} — zero overlap. Token-coverage check
# inside the chunk passed accidentally on shared physics vocabulary;
# the SOURCE was never about the claim's subject.
def _claim_title_overlap(claim_text: str, source_title: str | None) -> bool:
"""Return True iff the source title shares ≥1 stemmed content
token with the claim text. Vacuous-pass when either side has no
extractable tokens (defensive — prevents the rule from firing
on degenerate inputs)."""
if not source_title or not claim_text:
return True
from aborist.qa.evidence import _content_tokens as _ct
claim_tokens = _ct(claim_text)
title_tokens = _ct((source_title or "").replace("_", " "))
if not claim_tokens or not title_tokens:
return True
# Reuse the retrieval-side stem helper so possessive / plural
# collapse the same way ('movies' vs 'movie', 'simpsons' vs
# 'simpson'). Defined in qa/query.py to avoid an import cycle:
# inline a minimal copy here instead.
def _stem(t: str) -> str:
if len(t) > 4 and t.endswith("s") and not t.endswith("ss"):
return t[:-1]
return t
claim_stems = {_stem(t) for t in claim_tokens}
title_stems = {_stem(t) for t in title_tokens}
return bool(claim_stems & title_stems)
def verify_claim_lattice(
answer_text: str,
evidence_map,
@ -1396,6 +1433,47 @@ def verify_claim_lattice(
if warrant_missing_claims and audit_mode == "STRICT":
audit_mode = "HYBRID"
# Rule 8 — Title-relevance check. For each claim that resolved,
# at least one cited evidence's source title must share a
# stemmed content token with the claim. Catches the
# retrieval-driven hallucination class (2026-05-02 spin-glass
# case): claim about spin glass cited to a chunk from
# *Quantum chromodynamics* — token-coverage check passed on
# incidental physics vocabulary, but the SOURCE was never about
# the claim's subject.
title_mismatch_claims: list[int] = []
for cs in claim_statuses:
if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"):
continue
cited_eids = cs.get("evidence_ids") or []
cited_titles = [
obj.title
for eid in cited_eids
for obj in [evidence_map_by_evidence_id_local(evidence_map, eid)]
if obj is not None
]
if not cited_titles:
continue
any_overlap = any(
_claim_title_overlap(cs.get("text") or "", t)
for t in cited_titles
)
if not any_overlap:
title_mismatch_claims.append(cs.get("claim_idx"))
violations.append({
"kind": "TITLE_MISMATCH",
"claim_idx": cs.get("claim_idx"),
"claim_text": (cs.get("text") or "")[:200],
"cited_titles": cited_titles,
"rationale": (
"no cited source's title shares a content token "
"with the claim — pointer-linked but the cited "
"document is structurally unrelated to the claim"
),
})
if title_mismatch_claims and audit_mode == "STRICT":
audit_mode = "HYBRID"
return {
"n_quotes": n_pairs,
"n_verified": n_pairs_verified,
@ -1411,6 +1489,7 @@ def verify_claim_lattice(
"lazy_anchor_ratio": lazy_anchor_ratio,
"lazy_anchor_demoted": lazy_anchor_demoted,
"warrant_missing_claim_idxs": warrant_missing_claims,
"title_mismatch_claim_idxs": title_mismatch_claims,
}
@ -1750,6 +1829,43 @@ def verify_claim_lattice_json(
if warrant_missing_claims and audit_mode == "STRICT":
audit_mode = "HYBRID"
# Rule 8 — Title-relevance check (mirrors the pointer variant).
# See verify_claim_lattice for rationale (2026-05-02 spin-glass
# case). Demote-to-HYBRID semantics; JSON variant emits the same
# TITLE_MISMATCH violation kind & title_mismatch_claim_idxs field.
title_mismatch_claims: list[int] = []
for cs in claim_statuses:
if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"):
continue
cited_eids = cs.get("evidence_ids") or []
cited_titles = [
obj.title
for eid in cited_eids
for obj in [evidence_map_by_evidence_id_local(evidence_map, eid)]
if obj is not None
]
if not cited_titles:
continue
any_overlap = any(
_claim_title_overlap(cs.get("text") or "", t)
for t in cited_titles
)
if not any_overlap:
title_mismatch_claims.append(cs.get("claim_idx"))
violations.append({
"kind": "TITLE_MISMATCH",
"claim_idx": cs.get("claim_idx"),
"claim_text": (cs.get("text") or "")[:200],
"cited_titles": cited_titles,
"rationale": (
"no cited source's title shares a content token "
"with the claim — pointer-linked but the cited "
"document is structurally unrelated to the claim"
),
})
if title_mismatch_claims and audit_mode == "STRICT":
audit_mode = "HYBRID"
# Same `verifier_method` as the pointer variant ("claim_lattice")
# so the providence_cache CHECK constraint accepts both. The mode
# is disambiguated downstream via `answer_mode` on the run-DAG &
@ -1766,4 +1882,5 @@ def verify_claim_lattice_json(
"evidence_id_pairs": evidence_id_pairs,
"json_fixups": json_fixups,
"warrant_missing_claim_idxs": warrant_missing_claims,
"title_mismatch_claim_idxs": title_mismatch_claims,
}

View file

@ -168,11 +168,15 @@ def test_verify_strict_when_all_resolve():
em = build_evidence_map(_sample_chunks())
# Claim text must textually overlap the cited span (G+: 6th hard
# check — claim content tokens must appear in the cited evidence).
# AND at least one cited source's title must share a content
# token with the claim (Rule 8, post-2026-05-02): so we keep
# "film" in each claim to anchor against the
# `Jurassic Park (film)` title.
# E1 span = "Tyrannosaurus rex appears in the climactic scene."
# E2 span = "Velociraptors are shown attacking workers."
answer = (
"Tyrannosaurus rex appears in the film. [E1]\n"
"Velociraptors are shown attacking workers. [E2]\n"
"Velociraptors are shown attacking workers in the film. [E2]\n"
)
v = verify_claim_lattice(answer, em)
assert v["audit_mode"] == "STRICT"
@ -201,7 +205,10 @@ def test_verify_renders_with_literal_spans_from_runtime():
def test_verify_passes_multi_pointer_claims():
em = build_evidence_map(_sample_chunks())
answer = "Tyrannosaurus and velociraptors appear. [E1,E2]\n"
# Title is "Jurassic Park (film)" — claim must share a content
# token (Rule 8). Add "film" so the claim anchors against the
# source title.
answer = "Tyrannosaurus and velociraptors appear in the film. [E1,E2]\n"
v = verify_claim_lattice(answer, em)
assert v["audit_mode"] == "STRICT"
assert v["n_quotes"] == 2
@ -823,17 +830,19 @@ def test_per_chunk_evidence_map_query_path(tmp_path):
src_conn = connect(shard)
try:
ingest_source(src_conn, FakeSource([
Document(uri="t://chunked", content=txt, source_type="test", title="Chunked")
Document(uri="t://chunked", content=txt, source_type="test", title="Dinosaurs")
]))
finally:
src_conn.close()
# Stub answer that hits all three pointer ids — proves the
# evidence map exposed >1 chunk to the model.
# evidence map exposed >1 chunk to the model. Each claim names
# "dinosaur" so Rule 8 (title-relevance) anchors against the
# source title "Dinosaurs".
stub = StubClient(answer=(
"Brachiosaurus is a sauropod. [E1]\n"
"Velociraptor is a theropod. [E2]\n"
"T-rex is apex. [E3]\n"
"Brachiosaurus is a sauropod dinosaur. [E1]\n"
"Velociraptor is a theropod dinosaur. [E2]\n"
"T-rex is the apex dinosaur. [E3]\n"
))
# Override the per-source chunk cap so all three chunks of this
# single test source surface as E1/E2/E3. Production default caps

View file

@ -82,6 +82,7 @@ def _ev(
span: str,
role: str = "primary_answer_source",
pointer_id: str | None = None,
title: str = "Test Doc",
) -> EvidenceObject:
"""Stub evidence object with deterministic eid for the test.
@ -89,12 +90,17 @@ def _ev(
since 2026-04-30; if the test wants the verifier to resolve a
citation, it must set ``pointer_id`` explicitly. ``evidence_id``
stays content-addressed for the cache/run-DAG handle.
``title`` defaults to "Test Doc" but tests using Rule 8 (title-
relevance, post-2026-05-02) should pass a title whose content
tokens overlap the claim text. Otherwise the verifier flags
TITLE_MISMATCH and demotes STRICT → HYBRID.
"""
return EvidenceObject(
evidence_id=eid,
source_root="00" * 32,
document_uri="test://doc",
title="Test Doc",
title=title,
chunk_idx=0,
chunk_root="11" * 32,
offset_start=0,
@ -108,13 +114,13 @@ def _ev(
def test_verify_json_strict_when_all_claims_resolve():
evidence = [
_ev("E1f8e4c2a", "Brachiosaurus appears in the Jurassic Park film as a herbivore.", pointer_id="E1"),
_ev("E2c9d7b3f", "Velociraptor is featured prominently throughout Jurassic Park.", pointer_id="E2"),
_ev("E1f8e4c2a", "Brachiosaurus appears in the Jurassic Park film as a herbivore.", pointer_id="E1", title="Jurassic Park (film)"),
_ev("E2c9d7b3f", "Velociraptor is featured prominently throughout Jurassic Park.", pointer_id="E2", title="Jurassic Park (film)"),
]
answer = json.dumps({
"claims": [
{"text": "Brachiosaurus appears in the film", "evidence_ids": ["E1"]},
{"text": "Velociraptor is featured", "evidence_ids": ["E2"]},
{"text": "Velociraptor is featured in the film", "evidence_ids": ["E2"]},
]
})
v = verify_claim_lattice_json(answer, evidence)
@ -151,7 +157,7 @@ def test_verify_json_ungrounded_on_schema_invalid():
def test_verify_json_recovers_from_markdown_fence():
"""JSON-fenced output still parses & verifies; fence fixup logged."""
evidence = [
_ev("E1f8e4c2a", "Brachiosaurus appears in the Jurassic Park film as a herbivore.", pointer_id="E1"),
_ev("E1f8e4c2a", "Brachiosaurus appears in the Jurassic Park film as a herbivore.", pointer_id="E1", title="Jurassic Park (film)"),
]
answer = (
'```json\n'
@ -199,6 +205,86 @@ def test_verify_json_blocks_disallowed_source_role():
assert v["audit_mode"] == "UNGROUNDED"
def test_claim_title_overlap_passes_when_title_shares_token():
"""Rule 8 helper: title shares ≥1 stemmed content token with claim."""
from aborist.qa.verify import _claim_title_overlap
assert _claim_title_overlap(
"Homer Simpson's boss is Mr. Burns.",
"Homer Simpson",
)
assert _claim_title_overlap(
"Brachiosaurus appears in the Jurassic Park film.",
"Jurassic Park (film)",
)
# Stem-aware: 'simpsons' (plural) collapses to 'simpson'.
assert _claim_title_overlap(
"The Simpsons family includes Homer.",
"Simpson family",
)
def test_claim_title_overlap_fails_on_qcd_for_spin_glass():
"""Rule 8 helper: spin-glass case from 2026-05-02. Claim about
spin glass cited to Quantum chromodynamics → no token overlap."""
from aborist.qa.verify import _claim_title_overlap
assert not _claim_title_overlap(
"Spin glass modeling involves the use of mathematical tensors.",
"Quantum chromodynamics",
)
def test_claim_title_overlap_vacuous_pass_on_empty():
"""Defensive: empty title or empty claim → vacuous pass."""
from aborist.qa.verify import _claim_title_overlap
assert _claim_title_overlap("Some claim here.", None)
assert _claim_title_overlap("Some claim here.", "")
assert _claim_title_overlap("", "Some Title")
def test_verify_json_title_mismatch_demotes_to_hybrid():
"""End-to-end Rule 8: claim cited to a source with no title-token
overlap → TITLE_MISMATCH violation, audit_mode demoted to HYBRID.
Span is intentionally rich with claim tokens (covers Rule 5
citation-coverage threshold) so the failure path is Rule 8
cleanly, not earlier rule rejection."""
evidence = [
# Title is QCD; claim is about spin glass modeling. Span
# contains enough claim tokens to clear Rule 5 (>=30%).
_ev(
"Eed1b6e39",
(
"Spin glasses are disordered magnetic systems. "
"The spin glass modeling literature uses tensors to "
"represent interactions between magnetic moments. "
"Tensor methods illuminate the mathematical structure."
),
pointer_id="E1",
title="Quantum chromodynamics",
),
]
answer = json.dumps({
"claims": [
{
"text": (
"Spin glass modeling involves the use of "
"mathematical tensors to represent interactions."
),
"evidence_ids": ["E1"],
}
]
})
v = verify_claim_lattice_json(answer, evidence)
assert v["audit_mode"] == "HYBRID", (
f"expected HYBRID via TITLE_MISMATCH; got {v['audit_mode']} "
f"with violations {[v['kind'] for v in v['violations']]}"
)
assert any(vio["kind"] == "TITLE_MISMATCH" for vio in v["violations"])
def test_verify_json_too_many_claims_demotes_to_hybrid():
"""York-england shape: 13 claims (cap default = 12) trips
TOO_MANY_CLAIMS even when each individual claim verifies. Demotes
@ -212,7 +298,9 @@ def test_verify_json_too_many_claims_demotes_to_hybrid():
pid = f"E{i}"
eid = f"E{i:08x}c2a"
text = f"York fact number {i} is described in this span."
evidence.append(_ev(eid, text, pointer_id=pid))
# Title overlaps "york" so Rule 8 passes; this test exercises
# the TOO_MANY_CLAIMS cap, not title-relevance.
evidence.append(_ev(eid, text, pointer_id=pid, title="York facts"))
claims.append({"text": text, "evidence_ids": [pid]})
answer = json.dumps({"claims": claims})
v = verify_claim_lattice_json(answer, evidence)
@ -234,7 +322,8 @@ def test_verify_json_at_cap_can_still_strict():
pid = f"E{i}"
eid = f"E{i:08x}c2a"
text = f"York fact number {i} appears in span."
evidence.append(_ev(eid, text, pointer_id=pid))
# Title shares "york" with each claim → Rule 8 passes.
evidence.append(_ev(eid, text, pointer_id=pid, title="York facts"))
claims.append({"text": text, "evidence_ids": [pid]})
answer = json.dumps({"claims": claims})
v = verify_claim_lattice_json(answer, evidence)