|
12 | 12 | import pytest |
13 | 13 |
|
14 | 14 | from app.config import ChunkStrategy, RetrievalConfig |
| 15 | +from app.generation.providers import count_tokens |
15 | 16 | from app.db.models import ExtractionMethod |
16 | 17 | from app.ingest.chunk import build_chunks, chunk_fixed, chunk_recursive |
17 | 18 | from app.ingest.parse import Heading, ParsedPage |
@@ -204,3 +205,81 @@ def test_ligatures_are_folded_to_ascii(): |
204 | 205 | assert clean_extracted_text("effectiveness") == "effectiveness" |
205 | 206 | assert clean_extracted_text("difficult") == "difficult" |
206 | 207 | assert clean_extracted_text("flow") == "flow" |
| 208 | + |
| 209 | + |
| 210 | +# --- overlap actually happening --------------------------------------------- |
| 211 | + |
| 212 | + |
| 213 | +def prose(paragraphs: int, words_per: int = 140) -> str: |
| 214 | + """Paragraphs sized like real academic prose — comfortably larger than the |
| 215 | + overlap budget, which is the case that used to produce no overlap.""" |
| 216 | + return "\n\n".join( |
| 217 | + " ".join(f"p{p}w{i}" for i in range(words_per)) + "." for p in range(paragraphs) |
| 218 | + ) |
| 219 | + |
| 220 | + |
| 221 | +def carried_words(previous: str, current: str) -> int: |
| 222 | + """Longest suffix of `previous` that is a literal prefix of `current`.""" |
| 223 | + pw, cw = previous.split(), current.split() |
| 224 | + for n in range(min(len(pw), len(cw), 400), 0, -1): |
| 225 | + if pw[-n:] == cw[:n]: |
| 226 | + return n |
| 227 | + return 0 |
| 228 | + |
| 229 | + |
| 230 | +def test_overlap_happens_even_when_paragraphs_exceed_the_overlap_budget(recursive_config): |
| 231 | + """The regression this guards. |
| 232 | +
|
| 233 | + Carrying only *whole* segments silently produced no overlap on real prose: |
| 234 | + a paragraph routinely exceeds the entire overlap budget (90 tokens at |
| 235 | + 600/15%), so the carry loop broke on its first iteration. Config B was |
| 236 | + labelled "recursive + 15% overlap" while behaving identically to no |
| 237 | + overlap, which would have made the A-to-B comparison measure nothing. |
| 238 | + """ |
| 239 | + # A production-sized budget, so the 15% overlap is ~90 tokens rather than |
| 240 | + # the 18 the small test fixture would give. |
| 241 | + config = recursive_config.model_copy(update={"chunk_size_tokens": 600}) |
| 242 | + overlap_budget = int(600 * config.chunk_overlap_ratio) |
| 243 | + |
| 244 | + chunks = chunk_recursive([make_page(1, prose(12))], config) |
| 245 | + assert len(chunks) > 2 |
| 246 | + |
| 247 | + carried = [carried_words(a.content, b.content) for a, b in pairwise(chunks)] |
| 248 | + with_overlap = [c for c in carried if c > 0] |
| 249 | + |
| 250 | + assert len(with_overlap) >= 0.8 * len(carried), ( |
| 251 | + f"only {len(with_overlap)}/{len(carried)} boundaries carried text — " |
| 252 | + "the overlap setting is not taking effect" |
| 253 | + ) |
| 254 | + |
| 255 | + # Carried spans should use a real share of the budget, not a token or two, |
| 256 | + # and must never exceed it. |
| 257 | + for a, b in pairwise(chunks): |
| 258 | + n = carried_words(a.content, b.content) |
| 259 | + if not n: |
| 260 | + continue |
| 261 | + tokens = count_tokens(" ".join(b.content.split()[:n])) |
| 262 | + assert tokens <= overlap_budget * 1.2, f"carried {tokens} tokens > budget {overlap_budget}" |
| 263 | + assert tokens >= overlap_budget * 0.25, ( |
| 264 | + f"carried only {tokens} tokens of a {overlap_budget}-token budget" |
| 265 | + ) |
| 266 | + |
| 267 | + |
| 268 | +def test_zero_overlap_config_carries_nothing(recursive_config): |
| 269 | + no_overlap = recursive_config.model_copy(update={"chunk_overlap_ratio": 0.0}) |
| 270 | + chunks = chunk_recursive([make_page(1, prose(12))], no_overlap) |
| 271 | + |
| 272 | + carried = [carried_words(a.content, b.content) for a, b in pairwise(chunks)] |
| 273 | + assert not any(carried), f"overlap=0 still carried text: {carried}" |
| 274 | + |
| 275 | + |
| 276 | +def test_carried_overlap_respects_the_token_budget(recursive_config): |
| 277 | + from app.ingest.chunk import _tail_within_budget |
| 278 | + from app.generation.providers import count_tokens |
| 279 | + |
| 280 | + text = " ".join(f"word{i}" for i in range(500)) + "." |
| 281 | + for budget in (10, 45, 90): |
| 282 | + tail = _tail_within_budget(text, budget) |
| 283 | + assert tail, f"no tail produced for budget {budget}" |
| 284 | + assert count_tokens(tail) <= budget, f"tail exceeded budget {budget}" |
| 285 | + assert text.endswith(tail), "the tail must come from the end of the text" |
0 commit comments