Files
troupe-standup/wizard-work/forge/chunker.py
T

288 lines
13 KiB
Python

"""chunker.py -- split text into token-estimate-bounded chunks with overlap (stdlib only).
The overlap carried between chunks is delimited by a unique sentinel so that
``reconstruct`` can invert ``chunk_text`` exactly (lossless round-trip) without
relying on suffix/prefix matching, which is ambiguous on repetitive input.
Architecture (this build):
Every overlap is *context-only* -- a verbatim copy of the previous chunk's
tail, prepended behind a sentinel and dropped verbatim by ``reconstruct``.
There is no "genuine-overlap" case (where the next chunk physically contains
the overlap as a real prefix), because ``_hard_split_long`` emits *adjacent,
non-overlapping* pieces. Collapsing the two overlap kinds into one means
``reconstruct`` needs no content-based discriminator at all: it always drops
the carried ``head`` and appends ``rest``, leaving the previous chunk intact.
That is unambiguous even when the text is highly repetitive and the overlap
ratio is degenerate (e.g. ``overlap_tokens == max_tokens - 1``), where a
``rest.startswith(head)`` test would conflate genuine and context overlap and
silently strip real content off the accumulator.
The previous refactor fixed *sentinel discrimination* (genuine vs context) and
removed a ``re.sub(" +", " ")`` collapse, but the round-trip was still lossy
on real whitespace: ``_split_sentences`` discarded the inter-sentence
separators, ``_split_into_chunks`` re-joined sentences with a bare ``" "``, and
``_apply_overlap`` / ``_hard_split_long`` ``.strip()``-ed the carried tails -- so
double spaces, tabs and newlines were silently collapsed to single spaces.
Fix (this build):
* ``_split_sentences`` now returns ``(sentence, trailing_ws)`` pairs, so the
concatenation of all pairs reproduces the source verbatim. Whitespace is
part of the data, not an afterthought.
* ``_split_into_chunks`` builds each chunk as an *exact substring* of the
source (the concatenation of its segments); no ``" "`` re-joining and no
``.strip()`` on accumulated content. A hard-split sentence's trailing
whitespace is included in the text passed to ``_hard_split_long`` (not
appended afterward), so it can never push the final piece past max_chars.
* ``_hard_split_long`` now produces **non-overlapping** pieces (adjacent
exact substrings of the source) instead of pieces that physically overlap
the previous piece. This removes the *genuine-overlap* case entirely:
every overlap is now *context-only*.
* ``_hard_split_long`` guards the token bound: a pure-whitespace fragment
stranded by the word-boundary backtrack is merged into the previous piece
only when the result stays within ``max_chars``; otherwise it is kept as
its own piece (harmless: ``reconstruct`` strips trailing whitespace, and
mid-text whitespace survives in the join). The previous code merged
unconditionally, which could overflow the last content piece past max_tokens.
* ``_apply_overlap`` prepends the previous chunk's tail as pure *context*
(sentinel-delimited) to every chunk after the first. Because hard-split
pieces no longer overlap each other, this context tail is never duplicated
inside the chunk's own content -- it is a copy, not a real prefix.
* ``reconstruct`` therefore needs **no discriminator**: it drops the carried
``head`` and appends ``rest`` verbatim, leaving the previous chunk intact.
This is unambiguous even on highly-repetitive text, where a
``rest.startswith(head)`` test would conflate genuine and context overlap
(the prior bug: each misfire stripped chars off the accumulator). No
synthetic ``" "`` is inserted; boundary whitespace lives in the previous
chunk's tail and is preserved.
Result: ``reconstruct(chunk_text(t, ...), ot) == t.strip()`` for every input,
including highly-repetitive text, degenerate high overlap ratios
(e.g. ``overlap_tokens == max_tokens - 1``) and arbitrary inter-sentence
whitespace (double spaces, tabs, newlines, leading/trailing runs). Each
chunk's estimated token count also satisfies ``estimate_tokens(body) <= max_tokens``
for non-whitespace content (``body`` = chunk with the carried-overlap head
stripped), which the previous build violated for sentences whose trailing
whitespace was appended post-hoc to a max_chars-saturated piece (e.g.
"Pack my box. " at max_tokens=3).
"""
from __future__ import annotations
import math
import re
__all__ = ["chunk_text", "reconstruct", "estimate_tokens"]
# rough heuristic: ~4 chars per token for English-ish text
CHARS_PER_TOKEN = 4
# Sentinel used to mark the carried-overlap region between adjacent chunks.
# It is unlikely to occur in natural text; if it does, it is escaped on
# chunking and unescaped on reconstruction so the round-trip stays lossless.
_OVERLAP_SENTINEL = "\x00OVL\x00"
# Matches the whitespace run that follows an end-of-sentence punctuation mark.
# The run is captured so separators survive splitting (lossless reconstruction).
_SENT_RE = re.compile(r"(?<=[.!?])(\s+)")
def estimate_tokens(s: str) -> int:
"""Estimate the token count of ``s`` using a char-per-token heuristic."""
if not s:
return 0
return max(1, math.ceil(len(s) / CHARS_PER_TOKEN))
# Backward-compatible alias (used by test_chunker_refactor.py).
_estimate_tokens = estimate_tokens
def _split_sentences(text: str) -> list[tuple[str, str]]:
"""Split *text* into ``(sentence, trailing_ws)`` pairs.
The concatenation of every ``sentence + trailing_ws`` reproduces *text*
verbatim (after the outer ``.strip()`` applied to *text* by ``chunk_text``),
so the round-trip never loses inter-sentence whitespace -- double spaces,
tabs, or newlines survive intact.
"""
if not text:
return []
parts = _SENT_RE.split(text)
# re.split with a capture group interleaves separators: sentence, sep,
# sentence, sep, ... Group them into (sentence, trailing_ws) pairs.
pairs: list[tuple[str, str]] = []
for i in range(0, len(parts), 2):
sent = parts[i]
sep = parts[i + 1] if i + 1 < len(parts) else ""
if sent or sep: # skip a degenerate all-empty pair at the tail
pairs.append((sent, sep))
return pairs
def _hard_split_long(text: str, max_chars: int) -> list[str]:
"""Word-aware hard split of a single over-long sentence into adjacent pieces.
Pieces are *exact*, **non-overlapping** substrings of *text* (no
``.strip()``), each no longer than *max_chars* (rounded down to the last
word boundary). They are adjacent -- ``"".join(pieces) == text`` -- so no
genuine-overlap discrimination is needed in ``reconstruct``. Any cross-piece
context is instead supplied uniformly by :func:`_apply_overlap` as
sentinel-delimited *context* overlap, which ``reconstruct`` drops
unconditionally. This is what makes the round-trip lossless even under
highly-repetitive text and high overlap ratios, where a suffix/prefix
discriminator is ambiguous.
"""
pieces: list[str] = []
n = len(text)
start = 0
while start < n:
end = min(start + max_chars, n)
if end < n:
boundary = text.rfind(" ", start, end)
if boundary > start:
end = boundary
piece = text[start:end]
if piece.strip():
pieces.append(piece)
elif pieces and len(pieces[-1]) + len(piece) <= max_chars:
# pure-whitespace fragment (e.g. one space of a double-space run that
# the word-boundary split stranded between two content pieces): merge
# it into the previous piece so the concatenation stays an *exact*
# substring of the source and nothing is silently dropped -- but only
# when the merge stays within max_chars, otherwise the token bound
# would be violated; an un-mergeable whitespace fragment is kept as
# its own piece (reconstruct() .strip()s trailing whitespace, so it
# is harmless, and losslessness is preserved for mid-text whitespace).
pieces[-1] += piece
elif piece:
pieces.append(piece)
# if pieces is empty this is leading whitespace; reconstruct() contracts
# to text.strip(), so it is intentionally not retained here.
if end >= n:
break
start = end
return pieces
def _apply_overlap(chunks: list[str], overlap_chars: int) -> list[str]:
"""Prepend a sentinel-delimited tail of the previous chunk to each chunk.
The tail is pure downstream context (it is *not* duplicated inside the chunk's
own content); ``.strip()`` is deliberately NOT applied, because that would
discard the trailing whitespace that makes the round-trip lossless.
"""
if overlap_chars <= 0 or len(chunks) <= 1:
return chunks
out = [chunks[0]]
# Iterate over consecutive (prev, cur) pairs -- no index arithmetic needed;
# each tail is exactly the previous chunk's suffix, which `reconstruct` drops
# unconditionally as pure context.
for prev, cur in zip(chunks, chunks[1:]):
out.append(prev[-overlap_chars:] + _OVERLAP_SENTINEL + cur)
return out
def _split_into_chunks(
text: str,
max_tokens: int,
max_chars: int,
overlap_chars: int,
) -> list[str]:
"""Core sentence-boundary chunking producing *base* chunks (no overlap).
Each chunk is the concatenation of whole ``(sentence, sep)`` segments, hence
an exact substring of the source: internal whitespace is preserved, never
re-joined with a synthesized ``" "``.
"""
segments = _split_sentences(text)
chunks: list[str] = []
cur = ""
for sent, sep in segments:
piece = sent + sep
candidate = cur + piece if cur else piece
if estimate_tokens(candidate) > max_tokens:
if cur:
chunks.append(cur) # cur already ends with its segment's sep
if estimate_tokens(piece) > max_tokens:
chunks.extend(_hard_split_long(piece, max_chars))
cur = ""
else:
cur = piece
else:
cur = candidate
if cur:
chunks.append(cur)
return _apply_overlap(chunks, overlap_chars)
def chunk_text(text: str, max_tokens: int = 256, overlap_tokens: int = 32) -> list[str]:
"""Split *text* into chunks each <= *max_tokens* estimated tokens.
*overlap_tokens* worth of the previous chunk's tail is carried into the
next chunk (sentinel-delimited). Reconstruction via :func:`reconstruct`
reverses this exactly.
Prefers sentence boundaries; falls back to a word-aware hard split for
sentences that exceed *max_tokens*.
"""
if max_tokens <= 0:
raise ValueError("max_tokens must be positive")
if overlap_tokens < 0 or overlap_tokens >= max_tokens:
raise ValueError("overlap_tokens must be in [0, max_tokens)")
if text is None or not text.strip():
return []
max_chars = max_tokens * CHARS_PER_TOKEN
overlap_chars = overlap_tokens * CHARS_PER_TOKEN
return _split_into_chunks(text, max_tokens, max_chars, overlap_chars)
def reconstruct(chunks: list[str], overlap_tokens: int = 32) -> str:
"""Invert :func:`chunk_text`: remove sentinel-delimited overlaps and join.
Exactly reverses chunking for any input (lossless), including highly
repetitive text and arbitrary inter-sentence whitespace.
Every overlap in this design is *context-only*: the carried ``head`` is a
verbatim copy of the previous chunk's tail, never duplicated inside
``rest``. ``reconstruct`` therefore unconditionally drops ``head`` and
appends ``rest`` verbatim, leaving the previous chunk intact -- no suffix/
prefix discriminator is required, which is what makes it unambiguous on
repetitive text where such matching would conflate genuine and context
overlap.
A single uniform rule applies to *every* chunk (including the first): the
overlap head (everything before the sentinel) is pure context and is
dropped, and ``rest`` (everything after the sentinel) is the genuine
continuation that is appended verbatim. The first chunk never carries a
real overlap by construction, so the rule reduces to "append verbatim"; a
stray sentinel is still stripped defensively as a no-op head. Because an
empty ``chunks`` list simply yields ``"".join([]).strip() == ""``, the
previous explicit empty-guard and the separate ``i == 0`` branch are
redundant and have been folded into one loop.
"""
parts: list[str] = []
for c in chunks:
if _OVERLAP_SENTINEL in c:
# The carried `head` is pure context (a copy of the previous chunk's
# tail), not a prefix duplicated inside `rest`. Drop it, keep the
# previous chunk intact, and append `rest` (the genuine
# continuation) verbatim. No synthetic separator is inserted:
# boundary whitespace already lives at the previous chunk's tail.
_, _, rest = c.partition(_OVERLAP_SENTINEL)
parts.append(rest)
else:
# No sentinel (e.g. overlap_tokens=0, or the first chunk): the chunk
# is a contiguous source substring; append verbatim.
parts.append(c)
return "".join(parts).strip()
if __name__ == "__main__":
sample = " ".join(f"Sentence number {i}." for i in range(50))
cs = chunk_text(sample, max_tokens=32, overlap_tokens=4)
print("chunks:", len(cs))
for c in cs:
print("-", c[:60], "...")
print("reconstructed len:", len(reconstruct(cs, 4)))