add tests for result drops in context expansion
This commit is contained in:
parent
4863abc513
commit
3d0fb76f21
1 changed files with 261 additions and 0 deletions
261
tests/test_expand_page_preservation.py
Normal file
261
tests/test_expand_page_preservation.py
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
"""Page-number behaviour of context expansion for multi-result documents.
|
||||||
|
|
||||||
|
These tests exercise ``expand_with_items`` directly against a hand-built
|
||||||
|
item table (a fake repository implementing only the two methods the
|
||||||
|
algorithm calls: ``resolve_refs`` and ``get_items_in_range``). This keeps
|
||||||
|
them deterministic — no embedder, no LLM, no VCR — so the page arithmetic
|
||||||
|
is the only thing under test.
|
||||||
|
|
||||||
|
Two scenarios, mirroring the two regimes the merge/clip logic falls into:
|
||||||
|
|
||||||
|
* Matches FAR apart (page 1 + page 100): expansion ranges never overlap,
|
||||||
|
so the results stay separate and every page survives — but a single wide
|
||||||
|
item window spanning both matches is fetched.
|
||||||
|
* Matches CLOSE together on different pages: expansion ranges overlap and
|
||||||
|
merge; the budget clip then centres on the higher-scoring chunk and the
|
||||||
|
lower-scoring result's page is clipped away.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from haiku.rag.context import expand_with_items
|
||||||
|
from haiku.rag.store.models import SearchResult
|
||||||
|
from haiku.rag.store.models.document_item import DocumentItem
|
||||||
|
|
||||||
|
DOC_ID = "doc-1"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeItemRepo:
|
||||||
|
"""Minimal DocumentItemRepository stand-in over an in-memory item list.
|
||||||
|
|
||||||
|
Records the (start, end) ranges passed to ``get_items_in_range`` so a
|
||||||
|
test can assert how wide a window the algorithm fetched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, items: list[DocumentItem]) -> None:
|
||||||
|
self.items = items
|
||||||
|
self.range_calls: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
async def resolve_refs(self, document_id: str, refs: list[str]) -> dict[str, int]:
|
||||||
|
wanted = set(refs)
|
||||||
|
return {
|
||||||
|
item.self_ref: item.position
|
||||||
|
for item in self.items
|
||||||
|
if item.document_id == document_id and item.self_ref in wanted
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_items_in_range(
|
||||||
|
self, document_id: str, start: int, end: int
|
||||||
|
) -> list[DocumentItem]:
|
||||||
|
self.range_calls.append((start, end))
|
||||||
|
found = [
|
||||||
|
item
|
||||||
|
for item in self.items
|
||||||
|
if item.document_id == document_id and start <= item.position <= end
|
||||||
|
]
|
||||||
|
found.sort(key=lambda i: i.position)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _item(position: int, page: int, n: int = 20) -> DocumentItem:
|
||||||
|
"""An ``n``-char, uniquely-texted plain-text item on a given page."""
|
||||||
|
prefix = f"item{position:03d}-"
|
||||||
|
text = (prefix + "x" * max(0, n - len(prefix)))[:n]
|
||||||
|
return DocumentItem(
|
||||||
|
document_id=DOC_ID,
|
||||||
|
position=position,
|
||||||
|
self_ref=f"#/texts/{position}",
|
||||||
|
label="text",
|
||||||
|
text=text,
|
||||||
|
page_numbers=[page],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_far_apart_matches_preserve_both_pages():
|
||||||
|
"""Page 1 + page 100 matches: no merge, both pages survive.
|
||||||
|
|
||||||
|
The two matches sit ~150 items apart, so their expansion ranges cannot
|
||||||
|
overlap and ``_merge_ranges`` leaves them separate. Each result keeps its
|
||||||
|
own page. Also asserts the fetched item window spans the full gap between
|
||||||
|
the matches (the cost of far-apart matches).
|
||||||
|
"""
|
||||||
|
# Positions 0..79 are page 1; 80..159 are page 100.
|
||||||
|
items = [_item(pos, 1 if pos < 80 else 100) for pos in range(160)]
|
||||||
|
repo = FakeItemRepo(items)
|
||||||
|
|
||||||
|
results = [
|
||||||
|
SearchResult(
|
||||||
|
content=items[2].text,
|
||||||
|
score=0.9,
|
||||||
|
document_id=DOC_ID,
|
||||||
|
doc_item_refs=[items[2].self_ref],
|
||||||
|
page_numbers=[1],
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
content=items[150].text,
|
||||||
|
score=0.8,
|
||||||
|
document_id=DOC_ID,
|
||||||
|
doc_item_refs=[items[150].self_ref],
|
||||||
|
page_numbers=[100],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
expanded = await expand_with_items(repo, DOC_ID, results, max_chars=200)
|
||||||
|
|
||||||
|
# Ranges don't overlap -> two independent results.
|
||||||
|
assert len(expanded) == 2
|
||||||
|
pages = {tuple(r.page_numbers) for r in expanded}
|
||||||
|
assert pages == {(1,), (100,)}, pages
|
||||||
|
|
||||||
|
# A single window was fetched spanning from before the first match to
|
||||||
|
# after the last: the whole ~150-item gap, not two small windows.
|
||||||
|
assert len(repo.range_calls) == 1
|
||||||
|
start, end = repo.range_calls[0]
|
||||||
|
assert start <= 2 and end >= 150
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_matches_on_different_pages_drop_a_page():
|
||||||
|
"""Adjacent matches on different pages: merge + clip drops the low page.
|
||||||
|
|
||||||
|
Two matches four items apart expand into overlapping ranges that merge
|
||||||
|
into one. The merged content exceeds ``max_chars``, so the clip window
|
||||||
|
centres on the higher-scoring chunk (page 2) and the lower-scoring
|
||||||
|
chunk's item (the only page-1 item) is clipped out — so page 1, present
|
||||||
|
on an original search result, is absent from the expanded output.
|
||||||
|
"""
|
||||||
|
# Only positions 0..2 are page 1; the rest page 2. The page-1 match sits
|
||||||
|
# at the low edge so the clip window (centred on the page-2 match) drops
|
||||||
|
# every page-1 item.
|
||||||
|
items = [_item(pos, 1 if pos < 3 else 2) for pos in range(8)]
|
||||||
|
repo = FakeItemRepo(items)
|
||||||
|
|
||||||
|
low_page_result = SearchResult(
|
||||||
|
content=items[1].text,
|
||||||
|
score=0.5, # lower score -> not the clip anchor
|
||||||
|
document_id=DOC_ID,
|
||||||
|
doc_item_refs=[items[1].self_ref],
|
||||||
|
page_numbers=[1],
|
||||||
|
)
|
||||||
|
high_page_result = SearchResult(
|
||||||
|
content=items[5].text,
|
||||||
|
score=0.9, # higher score -> clip centres here
|
||||||
|
document_id=DOC_ID,
|
||||||
|
doc_item_refs=[items[5].self_ref],
|
||||||
|
page_numbers=[2],
|
||||||
|
)
|
||||||
|
|
||||||
|
expanded = await expand_with_items(
|
||||||
|
repo, DOC_ID, [low_page_result, high_page_result], max_chars=100
|
||||||
|
)
|
||||||
|
|
||||||
|
# Overlapping ranges merged into a single result.
|
||||||
|
assert len(expanded) == 1
|
||||||
|
|
||||||
|
result = expanded[0]
|
||||||
|
# Page 1 was present on an input result but is dropped by the clip.
|
||||||
|
assert result.page_numbers == [2]
|
||||||
|
assert 1 not in result.page_numbers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail(
|
||||||
|
strict=True,
|
||||||
|
reason=(
|
||||||
|
"Known bug: expanded page_numbers are recomputed from surviving "
|
||||||
|
"item spans, so a clipped-out input result's page is dropped. Fix "
|
||||||
|
"should union each output result's pages with the pages of all its "
|
||||||
|
"constituent inputs. Remove this xfail when that lands."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def test_expansion_pages_superset_of_inputs():
|
||||||
|
"""Regression guard: expansion must never drop an input result's page.
|
||||||
|
|
||||||
|
Same close-match layout as ``test_close_matches_on_different_pages_drop_a
|
||||||
|
_page``: a page-1 match and a page-2 match merge and the merged content
|
||||||
|
is clipped. The contract we *want* is that the union of every expanded
|
||||||
|
result's ``page_numbers`` covers every page present on the inputs — the
|
||||||
|
surrounding context is added, never at the cost of a page the search
|
||||||
|
already surfaced.
|
||||||
|
|
||||||
|
Currently ``xfail`` because page 1 is dropped; it flips to a hard failure
|
||||||
|
(``strict=True``) once the union-with-inputs fix lands, prompting removal
|
||||||
|
of this marker.
|
||||||
|
"""
|
||||||
|
items = [_item(pos, 1 if pos < 3 else 2) for pos in range(8)]
|
||||||
|
repo = FakeItemRepo(items)
|
||||||
|
|
||||||
|
inputs = [
|
||||||
|
SearchResult(
|
||||||
|
content=items[1].text,
|
||||||
|
score=0.5,
|
||||||
|
document_id=DOC_ID,
|
||||||
|
doc_item_refs=[items[1].self_ref],
|
||||||
|
page_numbers=[1],
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
content=items[5].text,
|
||||||
|
score=0.9,
|
||||||
|
document_id=DOC_ID,
|
||||||
|
doc_item_refs=[items[5].self_ref],
|
||||||
|
page_numbers=[2],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
expanded = await expand_with_items(repo, DOC_ID, inputs, max_chars=100)
|
||||||
|
|
||||||
|
input_pages = {p for r in inputs for p in r.page_numbers}
|
||||||
|
output_pages = {p for r in expanded for p in r.page_numbers}
|
||||||
|
missing = input_pages - output_pages
|
||||||
|
assert not missing, f"expansion dropped input pages: {sorted(missing)}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("r1_pos", "r2_pos", "expected_results", "expected_pages"),
|
||||||
|
[
|
||||||
|
# Matched items adjacent (no item between): expansion ranges overlap
|
||||||
|
# and merge; the ~200-char merged span is clipped to 100 around the
|
||||||
|
# higher-scoring page-2 chunk, evicting the page-1 chunk.
|
||||||
|
pytest.param(3, 4, 1, [2], id="adjacent-merges-and-drops-page-1"),
|
||||||
|
# One full item sits between the matches, mid-document: each match is
|
||||||
|
# already ~99/100 chars full, so it expands one step *downward* (away
|
||||||
|
# from the other match). The ranges never touch, stay separate, and
|
||||||
|
# both pages survive.
|
||||||
|
pytest.param(2, 4, 2, [1, 2], id="gap-stays-separate-keeps-both"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_budget_100_layout_determines_page_drop(
|
||||||
|
r1_pos, r2_pos, expected_results, expected_pages
|
||||||
|
):
|
||||||
|
"""At a 100-char budget with ~99-char chunks, page loss is layout-driven.
|
||||||
|
|
||||||
|
A 99-char chunk nearly fills the 100-char budget, so ``_expand_outward``
|
||||||
|
takes a single step before the budget is spent. Whether that step makes
|
||||||
|
the two matches' ranges overlap — and therefore whether merge+clip drops
|
||||||
|
a page — depends purely on item adjacency, not on the chunks' character
|
||||||
|
distance. Pages are assigned ``1`` for positions < 4 and ``2`` otherwise,
|
||||||
|
so ``r1`` (lower score) is always page 1 and ``r2`` (higher score) page 2.
|
||||||
|
"""
|
||||||
|
items = [_item(pos, 1 if pos < 4 else 2, n=99) for pos in range(7)]
|
||||||
|
repo = FakeItemRepo(items)
|
||||||
|
|
||||||
|
inputs = [
|
||||||
|
SearchResult(
|
||||||
|
content=items[r1_pos].text,
|
||||||
|
score=0.5, # lower score
|
||||||
|
document_id=DOC_ID,
|
||||||
|
doc_item_refs=[items[r1_pos].self_ref],
|
||||||
|
page_numbers=items[r1_pos].page_numbers,
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
content=items[r2_pos].text,
|
||||||
|
score=0.9, # higher score -> clip anchor when merged
|
||||||
|
document_id=DOC_ID,
|
||||||
|
doc_item_refs=[items[r2_pos].self_ref],
|
||||||
|
page_numbers=items[r2_pos].page_numbers,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
expanded = await expand_with_items(repo, DOC_ID, inputs, max_chars=100)
|
||||||
|
|
||||||
|
assert len(expanded) == expected_results
|
||||||
|
output_pages = sorted({p for r in expanded for p in r.page_numbers})
|
||||||
|
assert output_pages == expected_pages
|
||||||
Loading…
Reference in a new issue