arborist/tests/test_verify.py
russell@unturf.com 01a4390693
verify: layered strategies + entity policies, rename VISUAL → UNGROUNDED
- aborist/qa/verify.py: three strategies tried in sequence. quote uses
  sequential pairing (1st & 2nd `"`, 3rd & 4th, ...) which eliminates
  the phantom inter-pair captures regex pairing produced on adjacent
  quote pairs like `"title" prose "quote"`. span checks bullet/sentence
  lines verbatim. entity matches multi-word proper nouns; gated by
  policy ∈ {strict, hybrid, drop, proximity}. Default proximity
  promotes to STRICT only when N=3 verified entities cluster within
  W=300 chars in source — separates structural grounding (cast list,
  infobox) from incidental mention (scattered plot summary).

- aborist/store.py: schema CHECK now `('STRICT','HYBRID','UNGROUNDED')`.
  Migration helper rebuilds legacy `('STRICT','HYBRID','VISUAL')`
  tables via temp-table copy, translating VISUAL → UNGROUNDED in the
  SELECT. Idempotent — DDL inspection skips the rebuild on already-
  migrated DBs.

- aborist/cli.py: new `aborist reclassify` re-runs the verifier
  against existing live records under the current entity policy; no
  LLM calls. --compare runs all four policies side-by-side, --dry-run
  reports transitions without writing. Cold-source records skipped.
  One providence_reclassify audit event per changed row.

- AuditMode.UNGROUNDED replaces VISUAL across search backend, FTS5,
  test fixtures, CLAUDE.md. The substrate (Merkle-AGI) name was
  about FOR-style visualization; in the RAG layer the semantic is
  "no recoverable grounding," so the label now says that.

DEFAULT_QUERY_POLICY gains entity_policy + entity_proximity_n +
entity_proximity_window so any tuning folds into governance_policy_hash
and invalidates cache cleanly.
2026-04-28 16:58:31 -04:00

347 lines
14 KiB
Python

"""Faithfulness verifier: classify answer grounding into v9.8 audit_mode.
Three layered strategies (quote → span → entity), tried in order. The
first that finds evidence classifies the answer. `verifier_method`
records which path fired.
Tests cover the full trichotomy under each path plus regressions:
- TMNT: model wrote bios not in context — must be HYBRID/UNGROUNDED
- Matrix: Wikipedia infobox + paraphrased prose. Spans don't match,
but every multi-word proper noun does — entity path classifies it.
"""
from __future__ import annotations
from aborist.qa.verify import (
extract_claim_spans,
extract_proper_nouns,
extract_quotes,
verify_quotes,
)
# ---------------------------------------------------------------- extract
def test_extract_quotes_finds_double_quoted_spans():
text = 'He said "the cat is on the mat" and then "rain in spain falls".'
assert extract_quotes(text) == [
"the cat is on the mat",
"rain in spain falls",
]
def test_extract_quotes_skips_short_spans():
# 8-char minimum keeps "is a", "the", "ok" out.
text = '"is a" "ok" "long enough span" "no"'
assert extract_quotes(text) == ["long enough span"]
def test_extract_quotes_sequential_pairs_skip_inter_pair_prose():
"""Fox caught on 2026-04-28: model wrote `"Hackers" is as follows: "In 1988..."`
and naive regex matching paired the closer of "Hackers" with the
opener of "In 1988", capturing the framing prose between them as a
phantom quote. Sequential pairing (1st & 2nd, 3rd & 4th, ...) treats
inter-pair text as the model's framing — never as a quoted span."""
text = (
'The plot of the 1995 film "Hackers" is as follows, directly '
'quoted from the source:\n\n"In 1988, Seattle youth Dade Murphy."'
)
quotes = extract_quotes(text)
# "Hackers" is 7 chars — below MIN_QUOTE_CHARS, dropped.
# "In 1988, Seattle youth Dade Murphy." pairs cleanly.
# The phantom prose between them is never captured.
assert quotes == ["In 1988, Seattle youth Dade Murphy."]
def test_extract_quotes_handles_three_adjacent_pairs():
"""`"A_____" stuff "B_____" more "C_____"` → three clean pairs."""
text = '"alpha-99" stuff "bravo-99" more "charlie9"'
quotes = extract_quotes(text)
assert quotes == ["alpha-99", "bravo-99", "charlie9"]
def test_extract_quotes_handles_curly_quotes():
text = '“smart quoted span here” and "ascii quoted span"'
quotes = extract_quotes(text)
assert "smart quoted span here" in quotes
assert "ascii quoted span" in quotes
# ---------------------------------------------------------------- classify
def test_strict_when_all_quotes_verify():
context = "Capitalism is an economic system based on private ownership."
answer = 'The source defines it: "an economic system based on private ownership"'
v = verify_quotes(answer, context)
assert v["audit_mode"] == "STRICT"
assert v["verifier_method"] == "quote"
assert v["n_quotes"] == 1
assert v["n_verified"] == 1
assert v["unverified_quotes"] == []
def test_visual_when_no_quotes_no_spans_no_entities():
"""Truly emergent: no double quotes, no span match, no entity match."""
context = "Apples are red."
answer = "freedom rests on autonomy alone, without coercion."
v = verify_quotes(answer, context)
assert v["audit_mode"] == "UNGROUNDED"
assert v["verifier_method"] == "none"
assert v["n_quotes"] == 0
def test_visual_when_no_quote_verifies():
context = "Capitalism is an economic system based on private ownership."
answer = '"this exact span is not in the source at all"'
v = verify_quotes(answer, context)
assert v["audit_mode"] == "UNGROUNDED"
assert v["verifier_method"] == "quote"
assert v["n_quotes"] == 1
assert v["n_verified"] == 0
assert v["unverified_quotes"] == ["this exact span is not in the source at all"]
def test_hybrid_when_some_quotes_verify_some_dont():
context = "Capitalism is an economic system based on private ownership."
answer = (
'The source says "an economic system" but then claims '
'"Marx personally invented capitalism in 1867" which is a stretch.'
)
v = verify_quotes(answer, context)
assert v["audit_mode"] == "HYBRID"
assert v["verifier_method"] == "quote"
assert v["n_quotes"] == 2
assert v["n_verified"] == 1
assert v["unverified_quotes"] == [
"Marx personally invented capitalism in 1867"
]
# ---------------------------------------------------------------- normalization
def test_case_and_whitespace_insensitive_match():
context = "The Eight Forms of Capital include living, social, and intellectual."
answer = '"the eight forms of capital"' # collapsed ws + lowercase + multi-space
v = verify_quotes(answer, context)
assert v["audit_mode"] == "STRICT"
assert v["n_verified"] == 1
# ---------------------------------------------------------------- regression
def test_tmnt_regression_must_not_be_strict():
"""Fox's catch on 2026-04-28: model emerged TMNT bios from training
while only the names appeared in context (an episode plot summary).
The state machine must downgrade — STRICT here would be a lie."""
context = (
"Master Splinter is framed for attempting to kill the Ultimate Daimyo. "
"Raphael and Michelangelo find themselves pitted against each other in "
"the tournament. Donatello and Usagi must defend Leonardo from assassins."
)
answer = (
'1. Leonardo - "leader of the group, he wields a blue katana"\n'
'2. Splinter - "a mutated rat who was once Hamato Yoshi"\n'
'3. Plot - "Raphael and Michelangelo find themselves pitted against each other"'
)
v = verify_quotes(answer, context)
assert v["audit_mode"] == "HYBRID"
assert v["verifier_method"] == "quote"
assert v["n_verified"] == 1
assert len(v["unverified_quotes"]) == 2
assert any("blue katana" in q for q in v["unverified_quotes"])
assert any("Hamato Yoshi" in q for q in v["unverified_quotes"])
# ---------------------------------------------------------------- spans / entities
def test_extract_spans_strips_bullets_and_framing():
answer = (
"Based on the provided sources, the main characters are:\n\n"
"- Neo\n"
"- Trinity\n"
"1. Morpheus is a leader\n"
)
spans = extract_claim_spans(answer)
# Framing line dropped; bullet markers stripped; short single-word
# bullets ("Neo", "Trinity") below MIN_SPAN_CHARS dropped.
assert "Morpheus is a leader" in spans
assert not any(s.startswith("Based on the") for s in spans)
def test_extract_proper_nouns_picks_multi_word_phrases():
text = (
"Neo (Thomas A. Anderson), played by Keanu Reeves. "
"Morpheus, played by Laurence Fishburne."
)
nouns = extract_proper_nouns(text)
assert "Thomas A. Anderson" in nouns
assert "Keanu Reeves" in nouns
assert "Laurence Fishburne" in nouns
# Single-word names skipped at this layer.
assert "Neo" not in nouns
assert "Morpheus" not in nouns
def test_span_path_classifies_when_bullet_appears_verbatim():
"""A model that doesn't quote but writes lines verbatim from source
still earns evidence via the span path."""
context = "Apple Inc. was founded by Steve Jobs, Steve Wozniak, and Ronald Wayne in 1976."
answer = (
"- Apple Inc. was founded by Steve Jobs, Steve Wozniak, and Ronald Wayne in 1976\n"
)
v = verify_quotes(answer, context)
assert v["audit_mode"] == "STRICT"
assert v["verifier_method"] == "span"
assert v["n_verified"] >= 1
def test_matrix_entity_path_strict_under_default_proximity():
"""Default entity_policy='proximity': Matrix-style answer where the
source has a tight cluster of cast-list entities (infobox) classifies
STRICT. Distinguishes structural grounding from incidental mention."""
context = (
"starring = [[Keanu Reeves]] [[Laurence Fishburne]] "
"[[Carrie-Anne Moss]] [[Hugo Weaving]] [[Joe Pantoliano]]. "
"Thomas A. Anderson is the protagonist. The film features "
"Agent Smith as the antagonist. Joe Pantoliano portrays Cypher."
)
answer = (
"Based on the provided sources, the main characters are:\n\n"
"- Neo (Thomas A. Anderson), played by Keanu Reeves\n"
"- Morpheus, played by Laurence Fishburne\n"
"- Trinity, played by Carrie-Anne Moss\n"
"- Agent Smith, played by Hugo Weaving\n"
"- Cypher, played by Joe Pantoliano\n"
)
v = verify_quotes(answer, context)
assert v["audit_mode"] == "STRICT"
assert v["verifier_method"] == "entity"
assert v["n_verified"] >= 5
def test_tmnt_entity_path_demoted_under_default_proximity():
"""Companion to the Matrix test: an answer with only one multi-word
entity ("Teenage Mutant Ninja Turtles") cannot satisfy proximity's
N=3 cluster requirement → demoted to HYBRID even though the entity
verifies. This is what separates incidental mention from structural
grounding."""
context = (
"Master Splinter is framed for attempting to kill the Ultimate Daimyo. "
"Raphael and Michelangelo find themselves pitted against each other. "
"Donatello and Usagi defend Leonardo. Teenage Mutant Ninja Turtles "
"appears throughout the article."
)
answer = (
"Based on the provided sources, the names of the Teenage Mutant Ninja "
"Turtles' brothers are:\n\n"
"1. Leonardo (Leo)\n"
"2. Raphael (Raph)\n"
"3. Donatello (Donnie)\n"
"4. Michelangelo (Mikey)\n\n"
"Their master's name is Splinter."
)
v = verify_quotes(answer, context)
assert v["audit_mode"] == "HYBRID"
assert v["verifier_method"] == "entity"
def test_entity_policy_strict_promotes_when_all_match():
"""Legacy policy: entity_policy='strict' promotes to STRICT when all
entities verify. Kept for back-compat / experimentation; overclaims."""
context = "Keanu Reeves, Laurence Fishburne, Carrie-Anne Moss appear."
answer = "The cast includes Keanu Reeves, Laurence Fishburne, and Carrie-Anne Moss."
v = verify_quotes(answer, context, entity_policy="strict")
assert v["audit_mode"] == "STRICT"
assert v["verifier_method"] == "entity"
def test_entity_policy_drop_skips_path_entirely():
"""drop policy: never classifies via entity path. Verified entities
in source are ignored; the answer falls through to UNGROUNDED/none."""
context = "Keanu Reeves stars in this film."
answer = "Keanu Reeves played the lead role."
v = verify_quotes(answer, context, entity_policy="drop")
assert v["audit_mode"] == "UNGROUNDED"
assert v["verifier_method"] == "none"
def test_entity_policy_proximity_promotes_only_on_cluster():
"""proximity policy: STRICT only if N=3 verified entities cluster
within W=300 chars in source. Tight infobox = STRICT.
Scattered prose = HYBRID."""
# Tight cluster: 3 names within ~50 chars.
tight_ctx = "Cast: Keanu Reeves, Laurence Fishburne, Carrie-Anne Moss starred."
answer = "The cast: Keanu Reeves, Laurence Fishburne, and Carrie-Anne Moss."
v = verify_quotes(answer, tight_ctx, entity_policy="proximity")
assert v["audit_mode"] == "STRICT"
assert v["verifier_method"] == "entity"
# Scattered: same 3 names across 1000+ chars of unrelated prose.
spread_ctx = (
"Keanu Reeves appeared briefly in scene one. "
+ "filler text " * 60
+ "Laurence Fishburne had a cameo. "
+ "more filler " * 60
+ "Carrie-Anne Moss made an appearance."
)
v = verify_quotes(answer, spread_ctx, entity_policy="proximity")
assert v["audit_mode"] == "HYBRID"
assert v["verifier_method"] == "entity"
def test_entity_path_hybrid_when_some_proper_nouns_unverified():
"""Mixed: some proper nouns verify, others are emergent.
Default 'hybrid' policy → HYBRID either way."""
context = "Keanu Reeves and Laurence Fishburne starred together."
answer = "The film featured Keanu Reeves, Laurence Fishburne, and Marlon Brando."
v = verify_quotes(answer, context)
assert v["audit_mode"] == "HYBRID"
assert v["verifier_method"] == "entity"
assert "Marlon Brando" in v["unverified_quotes"]
# ----------------------------------------------- wikitext-base-v1 integration
def test_wikitext_context_strips_markup_before_substring_test():
"""Without the strip the verifier sees [[Cloud Strife]] in source and a
bare 'Cloud Strife' in the answer's quoted span; substring fails. With
aborist.wikitext.to_base wired into verify_quotes, the context becomes
plain prose and the model's quote verifies. This is the case from the
real FF7 query that prompted wiring wikitext-base-v1 into the verifier."""
raw_wikitext_context = (
"[[Cloud Strife]], an unsociable mercenary who claims to be a former "
"1st Class member of [[Shinra Electric Power Company|Shinra]]'s "
"[[SOLDIER (Final Fantasy VII)|SOLDIER]] unit;<ref>Smith 2010</ref>"
)
answer = (
'The protagonist is described as: "Cloud Strife, an unsociable '
"mercenary who claims to be a former 1st Class member of Shinra's "
'SOLDIER unit;"'
)
v = verify_quotes(answer, raw_wikitext_context)
assert v["verifier_method"] == "quote"
assert v["n_verified"] == 1
assert v["audit_mode"] == "STRICT"
assert v["unverified_quotes"] == []
def test_wikitext_strip_does_not_rescue_genuine_hallucination():
"""A quote the model invented stays unverified even after stripping."""
raw_wikitext_context = (
"[[Cloud Strife]] is the [[protagonist]] of [[Final Fantasy VII]]."
)
answer = (
'The story is: "Set in a dystopian world, Final Fantasy VII\'s story '
'centers on mercenary Cloud Strife..."'
)
v = verify_quotes(answer, raw_wikitext_context)
assert v["verifier_method"] == "quote"
assert v["n_verified"] == 0
assert v["audit_mode"] == "UNGROUNDED"
assert len(v["unverified_quotes"]) == 1