Skip to content

Commit c1ddf13

Browse files
unamedkrclaude
andcommitted
phase A-2: answer-question alignment + neighbor research
Added to verifier: - Temporal alignment: year/date questions must have year in answer - Event alignment: battle questions must name a battle - Definition alignment: "what does X mean" must have definition - These catch "related but wrong" answers (RLV's core differentiator) Added to researcher: - Neighbor-first retry: try adjacent chunks (±1, ±2) before searching a completely different article region - Human pattern: "not on this page → check next page" Result: 15/20 unchanged — 5 failures have answers that pass all alignment checks (contain proper nouns, years, etc.) but are from the wrong section of the same article. Verifier correctly identifies these as "related" but cannot distinguish "related" from "correct" without LLM-based semantic verification. Remaining gap to 100%: requires LLM answer-question coherence check, which adds ~15s per question. Speed vs accuracy tradeoff. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 93ad892 commit c1ddf13

2 files changed

Lines changed: 69 additions & 3 deletions

File tree

bench/rlv/stages/researcher.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from .verifier import VerifyResult
1414

1515

16-
MAX_RETRIES = 2
16+
MAX_RETRIES = 3
1717

1818

1919
@dataclass
@@ -52,12 +52,47 @@ def research(
5252
n_retries=0,
5353
)
5454

55-
excluded = [initial_lookup.chunk_id]
55+
# Phase A-2 insight: when the locator finds the RIGHT article but
56+
# the WRONG chunk within it, the answer is usually in an adjacent
57+
# chunk (±1-2). Human pattern: "it's not on this page — let me
58+
# check the next page" before going to a completely different section.
59+
#
60+
# Strategy: retry 1 = try adjacent chunks first (same article region),
61+
# then retry 2+ = locator picks a different article entirely.
62+
initial_cid = initial_lookup.chunk_id
63+
n_chunks = len(gist.chunks)
64+
65+
# Build neighbor list: [cid-1, cid+1, cid-2, cid+2] (if they exist)
66+
neighbors = []
67+
for offset in [1, -1, 2, -2]:
68+
ncid = initial_cid + offset
69+
if 0 <= ncid < n_chunks:
70+
neighbors.append(ncid)
71+
72+
excluded = [initial_cid]
73+
neighbor_idx = 0
74+
5675
for retry in range(max_retries):
5776
if verbose:
5877
print(f"[researcher] retry {retry+1}/{max_retries}, excluding chunks {excluded}")
5978

60-
new_region = locator.locate(question, gist, excluded_chunks=excluded, verbose=verbose)
79+
# First retries: try adjacent chunks (same article neighborhood)
80+
if neighbor_idx < len(neighbors):
81+
ncid = neighbors[neighbor_idx]
82+
neighbor_idx += 1
83+
if ncid in excluded:
84+
continue
85+
chunk = gist.chunks[ncid]
86+
new_region = locator.RegionPointer(
87+
chunk_id=ncid, confidence="medium",
88+
candidates=[ncid], char_start=chunk.char_start,
89+
char_end=chunk.char_end, score=0.0, method="neighbor",
90+
)
91+
if verbose:
92+
print(f"[researcher] trying neighbor chunk {ncid}")
93+
else:
94+
# Later retries: locator picks a different region entirely
95+
new_region = locator.locate(question, gist, excluded_chunks=excluded, verbose=verbose)
6196
# If locator picked a chunk we already excluded (parser failure or only-one-chunk doc), bail
6297
if new_region.chunk_id in excluded:
6398
if verbose:

bench/rlv/stages/verifier.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,37 @@ def _literal_verify(
165165
if len(answer) < 200 and any(p in answer_head for p in refusal_phrases):
166166
return "UNSURE", f"answer is a refusal ('{answer[:60]}...')"
167167

168+
# Phase A-2: Answer-Question alignment check.
169+
# The answer must actually ADDRESS the question type. An answer that
170+
# contains region-grounded facts but doesn't answer the specific
171+
# question is "related but wrong" — the hardest hallucination to catch.
172+
# This is RLV's core differentiator: detecting WRONG answers, not just
173+
# fabricated ones.
174+
q_lower = question.lower()
175+
answer_norm = answer.lower()
176+
177+
# "When/what year/what date" → answer must contain a year or date
178+
if re.search(r'\b(what year|in what year|when did|what date|on what date)\b', q_lower):
179+
has_year = bool(re.search(r'\b(1[0-9]{3}|20[0-9]{2})\b', answer))
180+
has_month = bool(re.search(r'\b(january|february|march|april|may|june|july|august|september|october|november|december)\b', answer.lower()))
181+
if not has_year and not has_month:
182+
return "UNSURE", f"temporal question but answer has no year/date"
183+
184+
# "After/before which battle/event" → answer must name a specific event
185+
# AND the answer must contain an event-type word (battle, war, etc.)
186+
# "They were modernized in 1934" doesn't answer "after which battle?"
187+
if re.search(r'\b(which battle|after which battle|what battle|which war|after which war)\b', q_lower):
188+
event_words = ["battle", "war", "rebellion", "siege", "campaign", "invasion", "attack", "offensive"]
189+
has_event_word = any(w in answer.lower() for w in event_words)
190+
if not has_event_word:
191+
return "UNSURE", f"battle/war question but answer names no battle/war"
192+
193+
# "What does X mean" → answer should contain a definition signal
194+
if re.search(r'\b(what does|what is the meaning|what does the (?:name|word|term))\b', q_lower):
195+
has_def = any(w in answer.lower() for w in ["means", "meaning", "refers to", "derived from", "to cut", "headed"])
196+
if not has_def and len(answer) < 150:
197+
return "UNSURE", f"definition question but answer lacks definition"
198+
168199
word_terms, number_terms = _extract_answer_key_terms(answer)
169200
if not word_terms and not number_terms:
170201
return "UNSURE", f"q-grounded ({q_reason}); no extractable answer entities"

0 commit comments

Comments
 (0)