202 lines
7.0 KiB
Python
202 lines
7.0 KiB
Python
"""Regression tests for the O(N) delta-decode in ``_token_window_source_span``.
|
|
|
|
The fixed-token chunker maps each decoded token window back to its exact source
|
|
span. The offset of a window's start used to be recomputed with a full
|
|
``decode(tokens[:start_token])`` prefix decode on every window — O(N) per window,
|
|
O(N²) over a document. It now decodes only the delta since the previous *verified*
|
|
anchor (O(N) total). These tests pin three properties:
|
|
|
|
1. **Equivalence** — the delta path yields byte-identical spans to a reference
|
|
full-prefix implementation, window for window (incl. a non-1:1 tokenizer and a
|
|
real tiktoken BPE tokenizer).
|
|
2. **Exactness** — every emitted span is a verbatim slice of the source.
|
|
3. **Linear decode budget** — total tokens handed to ``decode`` scales ~O(N), not
|
|
O(N²); the pre-optimization prefix decode would blow well past the bound.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from lightrag.chunker import chunking_by_fixed_token
|
|
from lightrag.chunker.token_size import _source_span, _token_window_source_span
|
|
from lightrag.utils import Tokenizer, TokenizerInterface
|
|
|
|
|
|
class _CharTokenizer(TokenizerInterface):
|
|
"""1:1 char-per-token; ``decode(encode(x)) == x`` so windows are verbatim."""
|
|
|
|
def encode(self, content: str) -> list[int]:
|
|
return [ord(ch) for ch in content]
|
|
|
|
def decode(self, tokens: list[int]) -> str:
|
|
return "".join(chr(t) for t in tokens)
|
|
|
|
|
|
class _MultiTokenTokenizer(TokenizerInterface):
|
|
"""Non-uniform char→token ratio: uppercase = 2 tokens, ``.?!`` = 3, else 1.
|
|
|
|
Exercises the offset arithmetic where token count != char count, so a bug that
|
|
confuses the two surfaces immediately.
|
|
"""
|
|
|
|
def encode(self, content: str) -> list[int]:
|
|
tokens: list[int] = []
|
|
for ch in content:
|
|
if ch.isupper():
|
|
tokens.extend([ord(ch), ord(ch) + 1000])
|
|
elif ch in (".", "?", "!"):
|
|
tokens.extend([ord(ch), ord(ch) + 2000, ord(ch) + 3000])
|
|
else:
|
|
tokens.append(ord(ch))
|
|
return tokens
|
|
|
|
def decode(self, tokens: list[int]) -> str:
|
|
result: list[str] = []
|
|
i = 0
|
|
while i < len(tokens):
|
|
base = tokens[i]
|
|
if (
|
|
i + 2 < len(tokens)
|
|
and tokens[i + 1] == base + 2000
|
|
and tokens[i + 2] == base + 3000
|
|
):
|
|
result.append(chr(base))
|
|
i += 3
|
|
elif i + 1 < len(tokens) and tokens[i + 1] == base + 1000:
|
|
result.append(chr(base))
|
|
i += 2
|
|
else:
|
|
result.append(chr(base))
|
|
i += 1
|
|
return "".join(result)
|
|
|
|
|
|
class _CountingTokenizer(TokenizerInterface):
|
|
"""Wraps a char tokenizer and tallies every token handed to ``decode``."""
|
|
|
|
def __init__(self) -> None:
|
|
self.decoded_tokens = 0
|
|
|
|
def encode(self, content: str) -> list[int]:
|
|
return [ord(ch) for ch in content]
|
|
|
|
def decode(self, tokens: list[int]) -> str:
|
|
self.decoded_tokens += len(tokens)
|
|
return "".join(chr(t) for t in tokens)
|
|
|
|
|
|
def _ref_full_prefix_span(
|
|
tokenizer: Tokenizer,
|
|
content: str,
|
|
tokens: list[int],
|
|
start_token: int,
|
|
end_token: int,
|
|
) -> dict[str, int] | None:
|
|
"""The pre-optimization implementation: full ``decode(tokens[:start_token])``."""
|
|
window = tokenizer.decode(tokens[start_token:end_token])
|
|
start = len(tokenizer.decode(tokens[:start_token]))
|
|
end = start + len(window)
|
|
if content[start:end] != window:
|
|
found = content.find(
|
|
window, max(0, start - 32), min(len(content), end + 32 + len(window))
|
|
)
|
|
if found < 0:
|
|
return None
|
|
start, end = found, found + len(window)
|
|
return _source_span(content, start, end)
|
|
|
|
|
|
def _windows(n_tokens: int, chunk_size: int, overlap: int) -> list[tuple[int, int]]:
|
|
step = chunk_size - overlap
|
|
return [
|
|
(start, min(start + chunk_size, n_tokens)) for start in range(0, n_tokens, step)
|
|
]
|
|
|
|
|
|
@pytest.mark.offline
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
[
|
|
" ".join(f"word{i:03d}" for i in range(400)),
|
|
"Alpha. BETA gamma? Delta! " * 120,
|
|
"Repeated phrase. " * 200, # heavily repeated — exact offsets must not drift
|
|
],
|
|
)
|
|
def test_delta_decode_matches_full_prefix_reference(content: str) -> None:
|
|
for impl in (_CharTokenizer(), _MultiTokenTokenizer()):
|
|
tok = Tokenizer(model_name="t", tokenizer=impl)
|
|
tokens = tok.encode(content)
|
|
chunk_size, overlap = 60, 12
|
|
anchor = (0, 0)
|
|
for start_token, end_token in _windows(len(tokens), chunk_size, overlap):
|
|
got, anchor = _token_window_source_span(
|
|
tok, content, tokens, start_token, end_token, anchor=anchor
|
|
)
|
|
ref = _ref_full_prefix_span(tok, content, tokens, start_token, end_token)
|
|
assert got == ref
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_delta_decode_matches_full_prefix_with_tiktoken() -> None:
|
|
pytest.importorskip("tiktoken")
|
|
from lightrag.utils import TiktokenTokenizer
|
|
|
|
tok = TiktokenTokenizer()
|
|
content = (
|
|
"The quick brown fox jumps over the lazy dog. "
|
|
"Pack my box with five dozen liquor jugs. "
|
|
"How vexingly quick daft zebras jump. "
|
|
) * 40
|
|
tokens = tok.encode(content)
|
|
anchor = (0, 0)
|
|
for start_token, end_token in _windows(len(tokens), 24, 6):
|
|
got, anchor = _token_window_source_span(
|
|
tok, content, tokens, start_token, end_token, anchor=anchor
|
|
)
|
|
ref = _ref_full_prefix_span(tok, content, tokens, start_token, end_token)
|
|
assert got == ref
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_fixed_token_spans_are_exact_on_long_doc() -> None:
|
|
content = " ".join(f"token{i:04d}" for i in range(1500))
|
|
tok = Tokenizer(model_name="char", tokenizer=_CharTokenizer())
|
|
|
|
chunks = chunking_by_fixed_token(
|
|
tok,
|
|
content,
|
|
chunk_token_size=120,
|
|
chunk_overlap_token_size=20,
|
|
_emit_source_span=True,
|
|
)
|
|
|
|
assert len(chunks) > 5
|
|
for chunk in chunks:
|
|
span = chunk["_source_span"]
|
|
assert content[span["start"] : span["end"]] == chunk["content"]
|
|
|
|
|
|
@pytest.mark.offline
|
|
def test_decode_budget_is_linear_not_quadratic() -> None:
|
|
# char tokenizer => len(tokens) == len(content). With the old full-prefix
|
|
# decode the helper alone would decode ~sum(starts) == N^2/(2*step) tokens;
|
|
# the delta path decodes ~one step per window plus each window once. Assert
|
|
# the total stays under a small linear multiple of N, a bound the quadratic
|
|
# implementation cannot meet.
|
|
content = "x" * 8000
|
|
counting = _CountingTokenizer()
|
|
tok = Tokenizer(model_name="counting", tokenizer=counting)
|
|
n = len(tok.encode(content)) # 8000
|
|
|
|
chunking_by_fixed_token(
|
|
tok,
|
|
content,
|
|
chunk_token_size=200,
|
|
chunk_overlap_token_size=20,
|
|
_emit_source_span=True,
|
|
)
|
|
|
|
# Empirically ~3.2*N for the delta path; the old prefix decode is ~24*N here.
|
|
assert counting.decoded_tokens <= 6 * n
|