API reference
Every function releases the GIL for its whole native pass (py.detach); the
GIL-held residue of a call is only pyo3's argument borrow and the return
marshalling. Signatures below are the typed surface of
python/tors/__init__.pyi, pinned to the live functions by
tests/test_pyi_drift.py.
tors.normalize
def normalize(text: str) -> str: ...
Applies, in order:
- Unicode NFC normalization.
\r\nand\rfolded to\n.- Trailing whitespace (spaces/tabs) before a newline is dropped.
- Runs of 3 or more consecutive newlines collapse to exactly 2.
- A final strip, using Python's exact whitespace set (including the
0x1c-0x1fseparator control characters, whichstr.strip()treats as whitespace but Rust'schar::is_whitespace()does not).
This is a common preprocessing pipeline for text extracted from PDFs, OCR, and other messy sources.
GIL behavior
The entire transform runs inside py.detach (PyO3's GIL-release call): the GIL is free
for the rest of your program for the whole call, not just part of it. This is the reason
tors exists: Python's re module and str methods never release the GIL regardless of
input size, so the equivalent pure-Python pipeline holds the GIL for its whole duration no
matter what thread it runs on.
Identity return
When the complete pipeline is a no-op on the input, i.e. already NFC (Unicode
quick-check Yes), no CR, no spaces/tabs before a newline, no 3+ newline runs, and no
whitespace at either end, normalize returns the original input object:
tors.normalize(s) is s # True whenever the pipeline changes nothing
Zero allocation, zero copy, zero marshalling (the same idiom CPython's
unicodedata.normalize fast path uses). An output-equals-input comparison after any
full pass extends the guarantee beyond provably-clean input: tors.normalize(s) is s
holds exactly whenever tors.normalize(s) == s. The same contract applies to
tors.nfc / nfd / nfkc / nfkd, tors.html_unescape, and the string element of
tors.finalize / tors.finalize_utf8.
Example
import tors
tors.normalize("line one \n\n\n\nline two\r\n")
# "line one\n\nline two"
Async: await tors.aio.normalize(...) runs this under asyncio.to_thread (see Async use).
tors.finalize
def finalize(text: str) -> tuple[str, str]: ...
(normalize(text), sha256) in the same single GIL-released pass, where sha256 is the
lowercase-hex SHA-256 of the normalized text's UTF-8 bytes: byte-identical to
hashlib.sha256(normalized.encode("utf-8")).hexdigest(). It exists for pipelines that
normalize then content-hash (dedupe gates), collapsing two whole-text passes into one.
On the identity path the digest is computed straight from the borrowed input buffer and
the string element is the original object (tors.finalize(s)[0] is s when the pipeline
changes nothing). A str holding lone surrogates is refused at the argument boundary
with UnicodeEncodeError ("surrogates not allowed"): pyo3's &str extraction
behavior, the same boundary every str-in function here documents.
text, digest = tors.finalize("line one \n\n\n\nline two\r\n")
# ("line one\n\nline two", "e986ba08...f7b942a")
Async: await tors.aio.finalize(...) runs this under asyncio.to_thread (see Async use).
tors.strip_controls
def strip_controls(text: str) -> str: ...
Replace every maximal run of C0 controls (U+0000–U+001F, tabs and newlines
included) and DEL (U+007F) with a single ASCII space, one GIL-released native
pass: byte-identical to re.compile(r"[\x00-\x1f\x7f]+").sub(" ", text). The
scrub model-authored display text needs before it is stored or served (a chatty
or injected model cannot plant terminal control sequences in a row the UI
renders).
Two scope cuts. C1 controls (U+0080–U+009F) pass through
untouched: the adopted call-site regexes do not cover them either, so covering
them here would silently change adopted behavior; C1 scrubbing is a follow-up
with its own contract, not a silent extension of this one. And \t, \n,
\r are scrubbed (they are C0): do not reach for this on multi-line prose you
want to keep line-shaped. No edge strip either: a control run at either end
becomes an edge space for the caller to .strip().
tors.strip_controls(s) is s exactly when s holds no C0/DEL character.
tors.strip_controls("score: 4\x00\x01great\x7f")
# "score: 4 great "
Async: await tors.aio.strip_controls(...) runs this under asyncio.to_thread (see Async use).
tors.nfc / tors.nfd / tors.nfkc / tors.nfkd
def nfc(text: str) -> str: ...
def nfd(text: str) -> str: ...
def nfkc(text: str) -> str: ...
def nfkd(text: str) -> str: ...
unicodedata.normalize(form, text) for each form, in one GIL-released pass each: the
standalone normalization forms without normalize's folding/collapsing/strip
stages. Compatibility decompositions fire under the K-forms and only under them
("\ufb01" stays "\ufb01" for nfc/nfd, becomes "fi" for nfkc/nfkd). Same
identity-return contract as normalize (tors.nfc(s) is s whenever tors.nfc(s) ==
s). tors ships its own Unicode tables (Unicode 16.0.0, the same UCD CPython 3.14
ships), so the same input produces the same output on every supported Python: on older
interpreters the only divergences from that interpreter's own unicodedata are on
codepoints it leaves unassigned; no assigned codepoint diverges in any form (pinned
exhaustively per interpreter by tests/test_parity.py).
tors.html_unescape
def html_unescape(text: str) -> str: ...
html.unescape(text) over the full HTML5 named-entity table (all 2231 entries of
html.entities.html5, with- and without-semicolon spellings) plus CPython's exact
numeric-reference classification (the Windows-1252 remap, the 126
invalid-codepoints-to-empty quirk, out-of-range to U+FFFD, the greedy-decimal

FFFF; → "\nFFFF;" behavior, the longest-matching-prefix fallback with verbatim
remainder). On text the unescape leaves unchanged it returns the input object itself
(CPython's own no-& fast-path idiom).
Raises ValueError on CPython 3.11+ when a decimal numeric reference's digit run
exceeds sys.get_int_max_str_digits() (default 4300): the stdlib's own integer string
conversion limit, with its exact message ("Exceeds the limit (4300 digits) for integer
string conversion: value has 4301 digits; use sys.set_int_max_str_digits() to increase
the limit"). Leading zeros count toward the run; the raise precedes every
classification; the limit is read from the running interpreter once per call, so
sys.set_int_max_str_digits() changes are honored on the next call. Hex references are
exempt (base 16 is a power of two: the limit applies only to non-power-of-two bases).
CPython 3.10 has no limit at all, and tors matches it there: nothing raises.
tors.html_unescape("Tom & Jerry é")
# "Tom & Jerry é"
tors.grapheme_count
def grapheme_count(text: str) -> int: ...
The number of UAX #29 extended grapheme clusters (ZWJ emoji sequences are one cluster,
combining-mark chains join their base, CRLF is one cluster, regional-indicator pairs
join). The stdlib has no segmenter at all: the gap this exists to fill. The return is
a single int: no list marshalling class at all, the cleanest GIL cell in the suite.
Pinned by a hand-derived UAX #29 table plus structural properties
(tests/test_segmentation.py).
tors.grapheme_count("a\r\n\U0001f469\u200d\U0001f52c") # 3
tors.word_bounds
def word_bounds(text: str) -> list[tuple[int, int]]: ...
The UAX #29 word-boundary segments as (start, end) pairs in Python str index
(codepoint) units: text[start:end] is the segment; bounds cover [0, len(text)) and
joining the slices reproduces the input. Offsets, never string lists (marshalling
thousands of small PyStrings under the GIL would eat the win). One measured caveat:
the return marshalling constructs one 2-tuple of ints per segment under the GIL,
O(number-of-segments), a measured 328–344 ms hold at 12 MiB of prose
(3.67M segments; worst-gap band, box-pace-dependent: the dev box the test ledger
records measured 428–497 ms, and the load-stable constant is the ratio, ~0.72 of the
call's wall). Fine at document scale; for whole-file sizes use the iterator
below.
tors.word_bounds("Hello, world!")
# [(0, 5), (5, 6), (6, 7), (7, 12), (12, 13)]
tors.word_bounds_iter
def word_bounds_iter(text: str) -> Iterator[tuple[int, int]]: ...
The streaming spelling of word_bounds: the same (start, end) sequence (pinned to
sequence-parity with the list API over every tricky row and hypothesis text), yielded
lazily. The segmentation runs under one GIL-released pass when the iterator is
constructed, and each __next__ holds the GIL only to construct one tuple (µs-scale):
worst heartbeat gap 15.4 ms at 12 MiB, against the list shape's structurally
unattainable 328–344 ms band; and the full drain is also ~2.1x faster in wall time
than the list API (measured at 12 MiB). __length_hint__ reports the
remaining bound count and tracks partial consumption. The list API stays the right
shape for small inputs and one-shot batch work.
tors.word_count
def word_count(text: str) -> int: ...
The grapheme_count precedent for the word segmenter: a single int return (no
marshalling class at all), the whole scan GIL-released, O(1) memory where
len(word_bounds(text)) materializes the full tuple list. word_count(t) ==
len(word_bounds(t)), pinned: the answer for a caller that only needs the count, not
the bounds themselves.
tors.word_count("Hello, world!")
# 5
tors.decode_utf8
def decode_utf8(raw: bytes, *, errors: Literal["strict", "replace"] = "strict") -> str: ...
raw.decode("utf-8", errors=...) byte-exact over arbitrary bytes, including every
ill-formed class. Strict raises the very UnicodeDecodeError CPython's own decoder
raises (same type, start/end/reason, object, and message), and replace emits
CPython's exact U+FFFD placements (maximal-subpart substitution). An errors value
outside {"strict", "replace"} raises ValueError (the unicodedata.normalize
closed-set convention). The argument must be exactly bytes (bytearray/memoryview
raise TypeError): the pass reads a zero-copy borrow of the immutable buffer with the
GIL released, and a writable buffer would be a data race, not a semantic difference.
Async: await tors.aio.decode_utf8(...) runs this under asyncio.to_thread (see Async use).
tors.finalize_utf8
def finalize_utf8(
raw: bytes, *, errors: Literal["strict", "replace"] = "strict"
) -> tuple[str, str]: ...
Decode + normalize + hash in one GIL-released call:
(normalize(raw.decode("utf-8", errors=...)), sha256-of-the-result): the shape an
extraction pipeline wants for its text reads. Strict (default) raises the stdlib's own
UnicodeDecodeError on invalid bytes; errors="replace" flows the U+FFFD
substitutions through the pipeline. Same bytes-in GIL model as decode_utf8: no
argument-materialization class at all.
Async: await tors.aio.finalize_utf8(...) runs this under asyncio.to_thread (see Async use).
tors.b64_encode_bytes
def b64_encode_bytes(raw: bytes) -> str: ...
base64.b64encode(raw).decode("ascii") (RFC 4648 standard alphabet, padded), for
content-addressing paths that today hold the GIL for the whole encode. The argument
contract matches the bytes-in surface (exactly bytes).
Async: await tors.aio.b64_encode_bytes(...) runs this under asyncio.to_thread (see Async use).
tors.b64_decode
def b64_decode(s: str, *, validate: bool = True) -> bytes: ...
base64.b64decode(s, validate=...) over ASCII strings: same decoded bytes, same
raised exception (the real binascii.Error, so except binascii.Error and
except ValueError both keep working), same messages, including all five strict-mode
messages and the lenient mode's discard-non-alphabet rules ('Zm9v=Zg==' → b'foof').
The core is a line-for-line port of the post-gh-145264 binascii_a2b_base64_impl
(CPython's 3.13/3.14 maintenance branches): in lenient mode, excess padding is ignored
and data after a completed pad sequence is decoded, not silently dropped
('Zg==Zg==' → b'f\x06'). CPython before that fix truncated the trailing data
there, a parser differential it fixed as a security issue, and tors ships the fixed
machine on every interpreter it supports rather than matching each stdlib it happens
to run under (pre-fix stdlibs, and 3.10's distinct regex validator, are documented
divergences).validate=Trueis the default (decode-side callers want invalid input
to fail loudly); a non-ASCIIstr(including one holding lone surrogates) raises the
stdlib's own plainValueError; non-strinput raisesTypeError`.
Async: await tors.aio.b64_decode(...) runs this under asyncio.to_thread (see Async use).
tors.utf8_is_valid
def utf8_is_valid(raw: bytes) -> bool: ...
Answers "is this well-formed UTF-8?" without materializing a str or paying exception
flow: SIMD validation, GIL-released, bool out. True exactly when
raw.decode("utf-8") would succeed. The stdlib has no boolean primitive for the
question (its only spelling is decode-and-catch); the argument contract matches the
bytes-in surface.
tors.decode_utf16
def decode_utf16(
raw: bytes,
*,
errors: Literal["strict", "replace"] = "strict",
byteorder: Literal["native", "little", "big"] = "native",
) -> str: ...
raw.decode("utf-16", errors=...), or the "utf-16-le"/"utf-16-be" spellings via
byteorder=: byte-exact over arbitrary bytes, one GIL-released pass. byteorder="native"
(the default) sniffs a leading BOM (FF FE little, FE FF big) and strips it from the
output, falling back to the host's own endianness when no BOM is present, matching the
plain "utf-16" codec name exactly. byteorder="little"/"big" never sniff or strip a
BOM: a leading BOM-like byte pair decodes as the literal U+FEFF character, matching
"utf-16-le"/"utf-16-be".
Strict mode raises CPython's own UnicodeDecodeError, including .encoding (always the
resolved label, "utf-16-le" or "utf-16-be", never the bare "utf-16" name, even under
byteorder="native"). UTF-16 has four distinct reasons, all verified against a running
interpreter:
"truncated data": a lone trailing byte, one short of a full code unit, with no pending high surrogate. Span: that one byte."unexpected end of data": a high surrogate with fewer than two bytes following it. Span: the surrogate through the end of the input (the partial tail has nothing useful left to do with it, so it folds into the one error)."illegal UTF-16 surrogate": a high surrogate followed by a full code unit that is not a valid low surrogate. Span: the high surrogate's two bytes alone; the following unit is reprocessed from scratch."illegal encoding": a low surrogate reached other than as the second half of a valid pair. Span: its own two bytes alone.
errors="replace" emits exactly one U+FFFD per error unit and continues decoding:
CPython's own granularity, not one U+FFFD per byte.
No encode_utf16 or finalize_utf16: encoding a trusted str carries none of the
untrusted-bytes-parsing risk that motivates the decode side, and str.encode("utf-16-le")
already covers it; a fused decode+normalize+hash twin has no demonstrated caller need,
unlike finalize_utf8's.
tors.decode_utf16(b"\xff\xfeh\x00i\x00") # "hi"
tors.decode_utf16(b"h\x00i", errors="replace") # "h�"
Async: await tors.aio.decode_utf16(...) runs this under asyncio.to_thread (see Async use).
tors.utf16_is_valid
def utf16_is_valid(
raw: bytes, *, byteorder: Literal["native", "little", "big"] = "native"
) -> bool: ...
tors.utf8_is_valid's shape for the UTF-16 side: True exactly when
decode_utf16(raw, byteorder=byteorder) (strict) would succeed, with no str
materialized on either path.
tors.utf16_is_valid(b"h\x00i\x00") # True
tors.utf16_is_valid(b"h\x00i") # False
tors.detect_encoding
def detect_encoding(raw: bytes, *, tld: str | None = None) -> str: ...
Best-guess character encoding for non-UTF8 legacy/OCR byte content, via chardetng
(the detector Firefox ships), GIL-released. Unlike utf8_is_valid/decode_utf8
there is no ground truth here: this is a heuristic guesser, not a validator, and it
always returns some codec name rather than raising for well-formedness reasons. The
intended pipeline: call utf8_is_valid first, and only reach for detect_encoding
on the bytes that already failed that check, then decode with the returned name.
The returned name is a Python bytes.decode()-usable codec name: mostly the WHATWG
Encoding Standard label chardetng reports ("windows-1252", "Shift_JIS",
"UTF-8"), translated to Python's own spelling for the two labels in this
detector's candidate set that Python's codec registry doesn't recognize under
their WHATWG names ("windows-874" → "cp874"; the logical-ordered Hebrew label
"ISO-8859-8-I" → "ISO-8859-8", since the byte mapping is identical and a
decode doesn't consult the bidi-ordering hint the -I suffix carries). tld is
an optional top-level-domain hint that
disambiguates language-family-ambiguous input: accepted in whatever natural
spelling a caller has (".jp", "JP", or "jp"; a full domain or non-ASCII input
degrades to "no hint" rather than raising, since chardetng's own tld parameter
panics on anything but a bare lowercase ASCII label with no period: a caller
shouldn't crash over a hint spelled the normal way).
tors.detect_encoding("Café".encode("windows-1252"))
# "windows-1252"
tors.diff_opcodes
def diff_opcodes(
a: str, b: str, *, deadline_ms: float | None = None
) -> list[tuple[str, int, int, int, int]]: ...
Async: await tors.aio.diff_opcodes(...) runs this under asyncio.to_thread (see Async use).
difflib.SequenceMatcher(None, a, b).get_opcodes()'s shape at native speed:
(tag, i1, i2, j1, j2) tuples with tag in {"equal", "replace", "delete",
"insert"}, ranges monotone/contiguous/covering both sides, adjacent delete+insert
merged into replace exactly as difflib presents it, indices in Python str
(codepoint) units. Character-level, like difflib on str operands: that is what
makes difflib the parity oracle; tors.diff_opcodes_lines (below) is the
line-level spelling. Exact agreement with difflib is pinned on the classes whose
canonical opcode list is forced: verified by difflib's own answer carrying the
canonical single-op shape (pure insert/delete, single-run replace, all-equal,
empty operands); structural validity (the opcodes reconstruct both sides) is
pinned by hypothesis over arbitrary pairs; and the boundary cases where the two
algorithms pick different valid alignments (repeated-flank contexts, where
difflib's longest-match recursion emits a non-minimal insert+delete split and
Myers + run-maximization emits the minimal contiguous change) are documented
and tested, never silent. The four tag strings are interned once per call, so
op[0] is "equal" holds exactly as it does for difflib's own tuples.
deadline_ms bounds the superlinear worst case: on hard inputs (few anchorable unique
records: a character-level permutation is the measured shape) the Myers search's work
grows roughly ~n² with size (measured: 50k chars 0.32 s, 1M chars 183.6 s). On expiry the incomplete result is discarded and TimeoutError is raised
naming the elapsed cost and the deadline; None (the default) is the unbounded diff,
unchanged; a non-positive or non-finite value raises ValueError before any work runs
(an enormous-but-finite value is legal: it saturates to "unbounded" rather than
erroring).
tors.diff_opcodes("qabxcd", "abYcd")
# [("delete", 0, 1, 0, 0), ("equal", 1, 3, 0, 2),
# ("replace", 3, 4, 2, 3), ("equal", 4, 6, 3, 5)]
tors.diff_opcodes_lines
def diff_opcodes_lines(
a: str, b: str, *, deadline_ms: float | None = None
) -> list[tuple[str, int, int, int, int]]: ...
Async: await tors.aio.diff_opcodes_lines(...) runs this under asyncio.to_thread (see Async use).
The line-level spelling of diff_opcodes: the same opcode shape, engine, validity
contract, boundary-class divergences, and deadline_ms machinery; but the operands
are tokenized as lines (each line keeps its \n, the last may lack one) and the
indices address lines, so a_lines[i1:i2] slicing reconstructs. Measured on the
12 MiB near-identical pair: 5 line-opcodes and 1.9–2.6 ms walls where the char-level
spelling emits 235 opcodes over 76–88 ms: one call instead of a Python split
round-trip plus the diff.
The tokenization is '\n'-only, not full str.splitlines(). a_lines/b_lines
must be built as a.split("\n")-with-terminators-reattached (equivalently,
re.split(r"(?<=\n)", a) with a trailing empty string dropped if a ends
in '\n') for the reconstruction contract above to hold: not
Python's str.splitlines(keepends=True), which additionally breaks on \r,
\r\n collapsed to one line, \v, \f, \x1c–\x1e, \x85, U+2028 LINE SEPARATOR, and U+2029 PARAGRAPH
SEPARATOR. Text using any of those as its only line terminator (classic Mac
\r-only line endings are the realistic case) tokenizes as one line under
diff_opcodes_lines where str.splitlines() would see several, a real,
measured divergence. '\n' is the terminator every
practical document/version-diff pipeline actually uses; the narrower contract is
documented.
tors.diff_opcodes_lines("l1\nl2\nl3\n", "l1\nX\nl3\nl4\n")
# [("equal", 0, 1, 0, 1), ("replace", 1, 2, 1, 2), ("equal", 2, 3, 2, 3),
# ("insert", 3, 3, 3, 4)]
tors.replace_many
def replace_many(text: str, replacements: dict[str, str]) -> str: ...
Simultaneous multi-pattern replace in one GIL-released native pass, a primitive
CPython does not have: every occurrence of every key is replaced by its value with
find_patterns's exact search semantics.
- leftmost-longest: among the keys matching at a position, the longest wins
regardless of dict order (not
re.subalternation's leftmost-first priority); - non-overlapping: the scan resumes at each match's end;
- never re-scanned: a value that itself contains a key does not cascade:
replace_many("a", {"a": "ba"})is"ba", not"baba".
The alternatives are all worse: chained str.replace calls are N whole-text
GIL-held passes, and re.sub alternation is leftmost-first and rescans its own
output. The canonical consumers are redaction and normalization maps. Identity
contract: replace_many(s, m) is s exactly when replace_many(s, m) == s (no
match, net-identity, or the empty dict all return the original object). Argument
contract: replacements must be exactly a dict[str, str] (a list/tuple of pairs
raises TypeError; dict order cannot matter: pinned); an empty key raises
ValueError("empty pattern"); lone surrogates raise UnicodeEncodeError at the
standard str-in boundary. No list-shape marshalling class: the return is one
string; the GIL-held residue is the argument walk plus the O(output) marshalling,
measured at the ping floor even on a 1.28M-replacement dense map.
tors.replace_many("the cat sat in the catalogue", {"cat": "dog", "catalogue": "library"})
# "the dog sat in the library"
tors.replace_many_masked
def replace_many_masked(text: str, replacements: dict[str, str], mask: str = "*") -> str: ...
The length-preserving redaction spelling of replace_many: same one-automaton
leftmost-longest, non-overlapping, never-rescanned scan, but each matched span's
character length is never allowed to change. For a matched span of L characters
and its key's replacement value of C characters, exactly one branch fires:
- C >= L (value at least as long) means truncation: the value's first L characters
are emitted;
maskis never consulted. - C < L (value shorter) means padding: the whole value is emitted, followed by
L - Ccopies ofmask.
The replacement value is never ignored: it is truncated or padded to the
matched pattern's own character length, not masked outright. To redact a span
completely, use "" as the value: the empty value always takes the padding
branch and becomes pure mask * L.
tors.replace_many_masked("the cat sat", {"cat": "[REDACTED]"}, "*")
# "the [RE sat" -- "[REDACTED]" (10 chars) truncates to "[RE" (3 chars); mask unused
tors.replace_many_masked("the cat sat", {"cat": "X"}, "*")
# "the X** sat" -- "X" (1 char) pads to "X**" (3 chars)
tors.replace_many_masked("the cat sat", {"cat": ""}, "*")
# "the *** sat" -- empty value pads to pure mask
Because every matched span is replaced by exactly as many characters as it
matched, and every non-matching span is copied through untouched, the output's
character count and every non-matching span's offsets equal the input's:
offsets computed before redaction (find_patterns spans, word/sentence bounds,
diff opcodes) stay valid against the redacted text. Byte length may still change
(a multibyte value or mask replaces the matched key's bytes); only the
character-unit guarantee is made, which is the unit every offset this crate
reports uses.
mask must be exactly one character (str of length 1, i.e. one Python
codepoint): a multi-character or empty mask raises ValueError, including a
visually-single grapheme built from more than one codepoint (e.g. NFD "e" +
COMBINING ACUTE ACCENT", two codepoints, is rejected). The rest of the argument
contract is replace_many's exactly: replacements must be a dict[str, str],
an empty key raises ValueError("empty pattern"), lone surrogates raise
UnicodeEncodeError. Identity contract: replace_many_masked(s, m, c) is s
exactly when replace_many_masked(s, m, c) == s (the empty dict, no match, or a
net-identity mask/truncation/padding all return the original object).
tors.sentence_bounds / tors.sentence_bounds_iter
def sentence_bounds(text: str) -> list[tuple[int, int]]: ...
def sentence_bounds_iter(text: str) -> Iterator[tuple[int, int]]: ...
UAX #29 sentence segmentation (rules SB1–SB999) via the same unicode-segmentation
tables as the word segmenter: the other segmenter the stdlib lacks. Offsets are
Python str indices (text[start:end] IS the sentence); per the standard
(SB10/SB11), trailing spaces after a terminator belong to the preceding sentence.
Pinned by a hand-derived rule-cited table ("3.4" SB6, "U.S.A" SB7, "etc. and
so on" SB8, the ideographic 。, the degenerates) plus structural properties and
list/iter sequence parity. Measured at 12 MiB of prose: 170,037 sentences (~1/22nd
the word count), so the list API's marshalling meets the shared GIL budgets. The
limitations note shared with word_bounds: rule-based UAX #29 only, no dictionary
segmentation for spaceless scripts (Thai/Khmer/Burmese/Japanese); ICU4X is the
heavyweight alternative if you need those.
tors.sentence_bounds("One. Two. U.S. stocks fell.")
# [(0, 5), (5, 10), (10, 27)]
tors.sentence_count
def sentence_count(text: str) -> int: ...
word_count's shape over sentences: a single int, O(1) memory, the whole scan
GIL-released. sentence_count(t) == len(sentence_bounds(t)), pinned.
tors.sentence_count("One. Two. U.S. stocks fell.")
# 3
tors.find_patterns
def find_patterns(patterns: list[str], text: str) -> list[tuple[int, int, int]]: ...
Leftmost-longest, non-overlapping multi-pattern substring search in one GIL-released
native pass: every occurrence of every pattern, reported as (start, end,
pattern_index) with end exclusive, offsets in Python str (codepoint) units:
text[start:end] == patterns[pattern_index] for every reported match.
- leftmost: a match is reported at the earliest position any pattern matches;
- longest: among the patterns matching at that position, the longest wins regardless of list order (not regex-alternation leftmost-first priority);
- non-overlapping: the scan resumes at each match's end; results come back in strictly increasing start order;
- duplicates report the first index they occupy in the list.
No stdlib primitive has these semantics (re alternation is leftmost-first), so the
contract is proven against a brute-force pure-Python leftmost-longest reference plus
structural validity over hypothesis-generated multi-byte alphabets. Argument contract:
exactly list[str] (a tuple raises TypeError); an empty pattern string raises
ValueError("empty pattern"); an empty list returns [] immediately; lone surrogates
raise UnicodeEncodeError at the argument boundary.
tors.find_patterns(["cat", "catalogue"], "the cat sat in the catalogue")
# [(4, 7, 0), (19, 28, 1)]
tors.find_patterns_iter
def find_patterns_iter(patterns: list[str], text: str) -> Iterator[tuple[int, int, int]]: ...
The streaming spelling of find_patterns: the whole search (pattern-list walk,
automaton build, scan, byte→char offset conversion) fills an internal buffer under
one GIL-released pass at construction, and each __next__ holds the GIL only for a
single 3-tuple of ints: the streaming answer to the list shape's O(matches)
tuple-marshalling caveat (~13 ms held per 100k matches in the list shape). Same
match sequence as the list API, pinned to sequence parity.
list(tors.find_patterns_iter(["cat", "catalogue"], "the cat sat in the catalogue"))
# [(4, 7, 0), (19, 28, 1)]
tors.count_matches
def count_matches(patterns: list[str], text: str) -> int: ...
The count spelling of find_patterns: the same leftmost-longest, non-overlapping
search answering just the number, with no match vector materialized (the list
spelling would materialize ~250 MiB of matches on a 100 MiB dense corpus just to
answer "how many") and no byte→char offset pass (counting is offset-free).
count_matches(p, t) == len(find_patterns(p, t)), pinned. A single int return: no
marshalling class at all.
tors.count_matches(["cat", "catalogue"], "the cat sat in the catalogue")
# 2
tors.CompiledPatterns
class CompiledPatterns:
def __init__(self, patterns: list[str]) -> None: ...
def __len__(self) -> int: ...
def find(self, text: str) -> list[tuple[int, int, int]]: ...
def find_iter(self, text: str) -> Iterator[tuple[int, int, int]]: ...
def count(self, text: str) -> int: ...
def replace_many(self, text: str, replacements: dict[str, str]) -> str: ...
def replace_many_masked(
self, text: str, replacements: dict[str, str], mask: str = "*"
) -> str: ...
The re.compile() answer to find_patterns/replace_many's per-call automaton build
(the same tors.CompiledLemmaDict pattern, applied to pattern search instead of
lemmatization; see tors.apply_pipeline's docs below): building the Aho-Corasick
automaton is the expensive part of every one of these calls, and a fixed vocabulary
scanned over many documents (a redaction pipeline, a tagger) otherwise rebuilds it on
every single call for no reason. CompiledPatterns(patterns) builds it once under one
GIL-released pass; every method after that is the free function's exact scan minus the
automaton build, sharing the compiled automaton by one Arc clone per call, sound to
reuse across many calls and threads with no synchronization beyond that refcount.
Each method mirrors its free-function twin exactly: cp.find(text) ==
tors.find_patterns(patterns, text), cp.count(text) == tors.count_matches(patterns,
text), and so on, for every method above. The two replace_many* methods validate
their replacements dict at call time (values can change per call; only the pattern
set is fixed at construction): it must key exactly the compiled pattern set, every
compiled pattern paired with a value and no extra keys, or ValueError names the
unknown and missing keys. len(cp) is the number of compiled patterns. Construction
takes the same argument contract as find_patterns' pattern list (list[str],
non-empty
entries); the empty pattern list compiles successfully (every scan finds nothing) and
then accepts only the empty replacements dict. Immutable once built: there is no way to
add or remove a pattern from an existing CompiledPatterns.
cp = tors.CompiledPatterns(["cat", "catalogue"]) # pay the automaton build once
for doc in corpus:
cp.find(doc) # O(1) automaton reuse per call
cp.replace_many("the cat sat", {"cat": "dog", "catalogue": "library"})
# 'the dog sat'
tors.extract_code_blocks
def extract_code_blocks(
text: str, lang: str | None = None
) -> list[tuple[str | None, str, int, int]]: ...
Extracts every fenced code block in text per CommonMark §4.5's fenced-code-block
grammar, one GIL-released native pass: (language, code, start, end) per block, end
exclusive, offsets in Python str (codepoint) units. The grammar is hand-rolled
directly in Rust rather than delegated to a general Markdown parser: LLM chat output
is rarely deeply-nested Markdown, so the extra correctness a full CommonMark engine
buys is mostly wasted weight for this. Rules: 0–3 leading spaces, then 3+ backticks or
tildes of the same character open a fence; an optional info string's first word is the
reported language; content lines are dedented by the fence's own indentation; a
closing fence needs the same character, at least as long as the opener, with nothing
but whitespace after it; absent one, the block simply runs to end of input (an
unterminated fence still yields a block). lang= filters to an exact, case-sensitive
language match. The scope is narrower than full CommonMark: no indented-code-block
recognition, no tab-expansion of indentation; both are documented non-goals.
tors.extract_code_blocks("hi\n```py\nprint(1)\n```\n")
# [("py", "print(1)\n", 3, 22)]
tors.strip_code_fences
def strip_code_fences(text: str) -> str: ...
Unwraps the single most common case on its own: a whole response wrapped in one
fence. If text, trimmed of leading/trailing whitespace, is exactly one fenced
code block, its dedented content comes back; anything else (prose around a fence,
multiple blocks, no fence at all) comes back completely unchanged (not even
whitespace-trimmed), so it is safe to call unconditionally on arbitrary model output.
tors.strip_code_fences(s) is s exactly when s is not that single-block case (the
same identity-return idiom as normalize/replace_many).
tors.strip_code_fences("```py\nprint(1)\n```")
# "print(1)\n"
tors.dedent
def dedent(text: str) -> str: ...
textwrap.dedent(text), byte-exact against CPython 3.14's rewritten algorithm
(Lib/textwrap.py, gh-131792): the longest common leading-whitespace-run string
shared by every non-whitespace-only line is stripped from each line, and
whitespace-only lines normalize to empty. Tabs and spaces are distinct characters for
the common-prefix computation, exactly as the stdlib documents (" x" and "\tx"
share no margin); never tab-expanded. tors.dedent(s) is s exactly when
tors.dedent(s) == s. The natural companion to
extract_code_blocks/strip_code_fences for re-indenting pulled-out code, and a
GIL-released primitive in its own right for any pipeline calling textwrap.dedent on
large text today.
Version note: textwrap.dedent was rewritten in CPython 3.14, and the rewrite
changed observable behavior, not just performance: a line consisting solely of some
other Unicode whitespace character (\v, \f, a non-breaking space, ...) is now
recognized as blank and normalized, where the pre-3.14 implementation only recognized
[ \t] as blank-line whitespace. tors.dedent ships the 3.14 behavior unconditionally
on every Python version it supports (3.10+), the same cross-version parity convention
b64_decode uses (see its docs above): on an older interpreter, tors.dedent and that
interpreter's own textwrap.dedent can diverge on inputs containing a non-space/tab
whitespace-only line. Ordinary space/tab indentation, the overwhelming common case, is
unaffected either way.
tors.dedent(" a\n b\n")
# "a\nb\n"
tors.repair_json
def repair_json(
s: str,
*,
skip_json_loads: bool = False,
ensure_ascii: bool = True,
strict: bool = False,
schema: dict[str, Any] | bool | type[Any] | None = None,
salvage: bool = False,
locale: str | dict[str, str] | None = None,
deadline_ms: float | None = None,
) -> str: ...
Repairs malformed JSON from LLMs, APIs, logs, and user input, returning the
repaired document as a JSON string: a Rust port of the Python library
json_repair (Stefano Baccianella, MIT,
github.com/mangiucugna/json_repair),
with parity pinned to json-repair==0.63.4: the upstream behavior corpus
is ported into tests/test_json_repair_{core,schema,parity}.py, and a
differential suite runs tors and json_repair over the same inputs, so the
pin is proven, not asserted. The repair parser handles the failure modes model output actually
exhibits: missing and trailing commas, unquoted keys and values,
single-quoted and curly-quoted strings, truncated containers, comments,
stray prose around the payload, Python-isms (None/True/False, tuple
literals), doubled quotes, and broken escapes. In the default mode it
repairs instead of raising, whatever the input looks like.
The fast path. Unless skip_json_loads, a strict json.loads-parity
parse of the (fence-unwrapped, below) input runs first, and valid JSON
short-circuits the repair parser. The result is re-serialized through a
json.dumps-parity serializer either way, so valid-but-noncanonical input
normalizes ({ "a" : 1 } comes back as {"a": 1}), exactly like
upstream, which also re-dumps. Under a schema the probe does not
short-circuit: valid JSON is validated and repaired if noncompliant before
any fallback to the schema-guided parser, so a schema is enforced on clean
input too.
The empty-string sentinel. When nothing recoverable is found, the
repaired value is the empty string and repair_json returns the empty
string, not "\"\"": upstream's convention for never returning a bare
pair of quotes. The price is an ambiguity shared with upstream: a
legitimately repaired top-level empty-string value renders identically.
Check result == "" for "nothing recoverable". Under schema= the sentinel
does not escape as a return: the empty-string value is itself validated against
the schema, so a non-string-typed schema answers the same ValueError every other
nonconformant value does (an object schema: "" is not of type "object"):
"nothing recoverable" becomes a raise, not a sentinel. A string-typed
schema accepts it, an empty string being a valid string. Pinned by
tests/test_json_repair_native.py.
The fence pre-pass. Before anything else, the whole input is tested
against CommonMark's fenced-code-block grammar, the same single-block
unwrapping tors.strip_code_fences documents. If the trimmed input is
exactly one fenced block, its content is unwrapped and repaired: tilde
fences, closers longer than their openers, and indented fences are handled
by the grammar rather than by the repair parser's garbage-skip reaching the
same answer by accident, and fenced-but-valid JSON takes the fast path
instead of the repair parser. The pre-pass also recovers fenced top-level
scalars (a fence wrapping just "hi" yields "hi") where upstream returns
"": its one behavior change beyond parity, listed with the divergences
below. A response with prose around a fence, or multiple blocks, is not the
single-block case; compose with tors.extract_code_blocks for those:
tors.repair_json("{ 'name': 'Ada', 'role': 'admin', }")
# '{"name": "Ada", "role": "admin"}'
tors.repair_json('```json\n{"ok": true}\n```')
# '{"ok": true}'
# a multi-block response: pull the json blocks, repair each
blocks = tors.extract_code_blocks(response, lang="json")
repaired = [tors.repair_json(code) for _, code, _, _ in blocks]
skip_json_loads skips the upfront strict parse only, forcing the
repair parser even on valid JSON: upstream's knob for callers who already
know the input is broken. It does not skip the parser-internal suffix
probe: after a prose prefix, once a top-level container starts, the parser
still tries a targeted strict decode of the value from that point; that
probe is part of the repair parser proper and stays on, exactly as
upstream behaves.
ensure_ascii is the one json.dumps serialization knob carried over:
True (the default) escapes every non-ASCII codepoint as \uXXXX (astral
characters as surrogate pairs), False emits them verbatim. Upstream's
other pass-through kwargs (indent, sort_keys, ...) are not ported.
strict flips the documented leniencies into ValueErrors at the
first structural ambiguity instead of repairing past them: duplicate keys,
empty keys, a missing : after a key, an empty parsed value, an object
that parses empty but still carries characters, multiple top-level
elements, doubled quotes. The mode for input that should be valid and whose
first real defect you want named rather than patched. strict=True
together with schema raises
ValueError("schema and strict cannot be used together.").
schema switches on schema-guided repair: the parsed value is aligned
to the schema (scalar coercions ("4" → 4, "yes" → true), fills for
missing values, defaults inserted for absent properties, extra properties
dropped where additionalProperties forbids them, union branches tried
until one validates), then validated in full, with failures raising
ValueError at the offending path ("Expected string at $.name.").
Accepts a JSON Schema dict, a boolean schema, or a pydantic v2 model (class
or instance): the model's model_json_schema() output is used directly,
with field defaults and default_factorys injected into it (factory
first) and Enum-member defaults carried as their .value, so the
model → schema → LLM → repair → Model.model_validate agent loop needs no
manual schema step. Mutually exclusive with strict (above). See
tors.repair_json_diagnostics for the action-by-action log of everything
schema mode does.
salvage is the best-effort spelling of schema mode (upstream's
schema_repair_mode="salvage", spelled as a bool): top-level fragments
that fail the schema are skipped until one validates, invalid array items
and extra properties are dropped rather than raised, and missing required
properties are filled from their subschema's default/const/enum[0].
It requires a schema: salvage=True without one raises
ValueError("salvage=True requires schema.").
deadline_ms (default None = unbounded) bounds the whole repair the
way diff_opcodes' deadline_ms does: a positive-finite-or-None budget
validated up front, TimeoutError on expiry. The clock starts at the top of
the call: the fence pre-pass and the json.loads fast-path attempt burn
the budget too, and a fast path that completes past the budget still
returns its answer (the deadline stops further work; it does not nullify
done work). It is a DoS backstop for the pathological O(n²) parser shapes
tors shares with upstream json_repair (duplicate-key-in-array splices,
empty-object splices, and a backslash-run string scan): a bounded abort,
not a speed-up, since a completing parse is
byte-identical whether or not a deadline is set, and a benign large document
does not trip a generous budget (the deadline discriminates pathological
shape, not size). It applies to all three spellings and is checked with
the GIL released, so TimeoutError is raised after reacquiring it, the
same shape as diff_opcodes, including the message:
"<spelling> deadline exceeded: elapsed 101.2ms > deadline_ms 100.0ms".
Two limits. The bound is soft: the tight loops sample the clock
1-in-256, but every O(n) unit (a buffer splice, a long scan, a wide span
build) forces the very next check to read it, so at most one such unit
runs past an expired budget (measured worst overshoot ~8% at n=1M). And
it bounds CPU time, not native stack growth: a runaway continuation
recursion can still overflow the stack before the budget expires; that
class is depth-guarded separately (MAX_NESTING), not time-bounded.
Cost when unset: nothing on the valid-JSON fast path, and one predicted
branch per dispatch turn in the repair parser (measured ~+6% worst-case
on a multi-MB skip_json_loads=True parse). Cost when set: ≤2% on
top of that (the checks are sampled).
Argument contract: a non-str s raises TypeError (pyo3 extraction); a
schema that is not a dict, bool, model, or None raises
ValueError("schema must be a JSON Schema dict, boolean schema, or pydantic
v2 model."); a bad locale (below) raises a ValueError naming it; a
lone surrogate in s raises UnicodeEncodeError at the argument boundary,
the same str-in convention every function here documents.
GIL. One detached native pass covers the fence pre-pass, the repair
parse, the schema alignment, and the validator compile. The GIL-held
residue is the caller-supplied schema dict walk (converted into the
internal value tree before the pass starts) and the return marshalling
after it: one string for this spelling; for tors.repair_json_loads and
tors.repair_json_diagnostics, the construction of the Python object tree
and the diagnostics list, O(result): the same disclosed marshalling class
the list-returning search functions document (see tors.find_patterns_iter's
O(matches) note and Performance).
Divergences from upstream json_repair, the complete list; the differential suite pins everything else to json-repair==0.63.4:
- Not ported:
stream_stable, the text repair log (superseded bytors.repair_json_diagnostics), the file and CLI flavors (json_fd/load/from_fileand the CLI module),json.dumpspass-through kwargs beyondensure_ascii, and theschema_repair_modestring (thesalvage=bool instead). - Lone surrogates: a
\uXXXXescape that decodes to an unpaired surrogate becomes U+FFFD (a Rust string cannot hold a lone surrogate, and pyo3 could not return one anyway). Input text containing literal lone surrogates never reaches the parser: it raisesUnicodeEncodeErrorat the argument boundary, the same str-in convention every function here documents. - Non-ASCII digits: Unicode Nd digits beyond 0-9 (Arabic-Indic and
friends) do not enter the number path: upstream's
str.isdigitis Unicode-wide, tors's check is ASCII-only. Pure runs (like"١٢٣") fail to repair on both sides; a non-ASCII digit leading ASCII digits ("²5") makes upstream abandon the value where tors skips the mark and parses the digits, the one mixed-run shape where the classes differ. - Fenced top-level scalars are recovered where upstream returns
""(the fence pre-pass above). - The validation boundary: schema validation runs on the Rust
jsonschemacrate, so failure-message wording is that crate's, not Pythonjsonschema's; integers beyond u64 validate lossily as f64; non-finite numbers under a schema raiseValueErrorwhere Python toleratesNaN; union branches are validated wrapped so#/...refs keep root scope (a pathological subschema-local$defsshadowing root$defsdiverges).formatis unasserted on both sides: upstream passes noformat_checker, and tors matches it. - Deep nesting:
ValueError("Input nesting exceeds the supported parser recursion depth.")at 200 nested containers, where upstream raises an uncaughtRecursionErrorat roughly its own recursion limit: the same failure normalized into the error catalog at a lower, pinned threshold. - On by default, tors-native: key-typo remap, enum "Did you mean ..."
suffixes, date/uuid normalization, numeric extraction tiers, and the
diagnostics output are extensions upstream does not have; see
tors.repair_json_diagnostics. One consequence: on the strict fast path tors normalizes already-valid values (date formats, fold-matching key spellings, directly-declaredpropertiesandallOfmembers) where upstream's valid-JSON shortcut returns them untouched, matching what upstream's own repair lane does withskip_json_loads=True, so tors is self-consistent across its two lanes for everything exceptoneOf/anyOf-wrapped guidance, where the fast path does not guess which branch applies (upstream's fast path has the same reach).
tors.repair_json_loads
def repair_json_loads(
s: str,
*,
skip_json_loads: bool = False,
strict: bool = False,
schema: dict[str, Any] | bool | type[Any] | None = None,
salvage: bool = False,
locale: str | dict[str, str] | None = None,
deadline_ms: float | None = None,
) -> dict[str, Any] | list[Any] | str | int | float | bool | None: ...
The json.loads drop-in spelling of tors.repair_json: the same repair
pipeline and the same knobs (skip_json_loads, strict, schema,
salvage; see tors.repair_json's docs above), with the decoded object
returned directly instead of a re-serialized string, so a repaired document
goes straight into use with no json.loads round trip. There is no
ensure_ascii here because nothing is serialized. The empty-string
sentinel carries over as a real "" return ("nothing recoverable",
ambiguous with a legitimately repaired top-level empty-string value, the
same collapse upstream's loads has). Under schema= the sentinel is
schema-validated instead: a non-string-typed schema turns "nothing
recoverable" into the same ValueError every other nonconformant value
raises; see tors.repair_json's sentinel paragraph above.
tors.repair_json_loads("{'users': [{'name': 'Ada',}]}")
# {'users': [{'name': 'Ada'}]}
tors.repair_json_diagnostics
def repair_json_diagnostics(
s: str,
*,
skip_json_loads: bool = False,
strict: bool = False,
schema: dict[str, Any] | bool | type[Any] | None = None,
salvage: bool = False,
locale: str | dict[str, str] | None = None,
deadline_ms: float | None = None,
) -> tuple[dict[str, Any] | list[Any] | str | int | float | bool | None, list[dict[str, Any]]]: ...
The (value, diagnostics) spelling: tors.repair_json_loads's exact
result paired with one record per repair action taken. Upstream narrates
these actions to its logging facility; tors does not port that text log:
this function is the replacement, the same narration as data. Each record
carries action (from the closed vocabulary below), path (json_repair's
path spelling: "$", "$.key", "$.items[3]"), detail (one human
sentence), and, where the action has them, from/to (the value before
and after) and suggestion. The vocabulary:
| action | when it fires |
|---|---|
coerce |
a scalar is converted to the schema's type: "4" → 4, "4.0" → 4, 4 → "4", "1.5" → 1.5, "yes"/"on"/"1" → true and "no"/"off"/"0" → false, a number to its truthiness |
fill |
a missing value (an object value slot that runs straight into , or }, e.g. {"key":}) is filled from the schema: const, else enum[0], else default, else the type's empty value ("", 0, false, [], {}, null) |
insert_default |
a property absent from the object whose subschema declares default (and is not required) gets that default |
remap_key |
tors-native: a key matching no property is remapped to the closest property name; thresholds below |
suggest |
tors-native, report-only: a near-miss that was not auto-corrected: a key, an enum member, an ambiguous date, or a disclosed numeric-format assumption, per the four features below |
drop_property |
an extra property not covered by the schema is dropped (additionalProperties does not allow it) |
drop_item |
an array item is dropped: invalid under its item schema while salvaging, or beyond tuple-form items not covered by additionalItems |
unwrap_string |
a string value holding a JSON document is parsed and unwrapped to the object/array the schema expects (under salvage, repaired first if merely malformed) |
wrap_array |
a non-array value is wrapped in a single-element array to match an array schema |
fill_required |
salvage: a required property missing from the object is filled from its subschema's default/const/enum[0] |
format_date |
tors-native: a date/date-time string is normalized to the RFC 3339 form; below |
skip_fragment |
salvage: a top-level fragment that does not match the schema is skipped while hunting for one that does |
map_array_to_object |
salvage: a list with exactly the schema's property count is mapped onto those property names, in order |
unwrap_root_array |
salvage: a single-item root array [{...}] is unwrapped to {...} |
Scope of the log (v1). Parser-level repair narration (the syntax-layer
fixes repair_json performs without a schema) is not recorded yet, so a
schema-free call returns an empty list; the log covers schema-layer actions
and the tors-native suggestions below.
Key-normalization ladder (deterministic tier). A key that differs
from a property only by case or separator style (first-name,
First Name, FIRST_NAME → first_name) remaps with confidence 1.0
(the match is exact after folding, not a guess), so it fires even on
permissive schemas and on the valid-JSON fast path (the un-remapped shape
strands real data on a dead key while the property takes its default).
One guard keeps it safe: the rename is kept only when the value can live
under the target property (a speculative repair through it succeeds), so
an incompatible value keeps its original, already-valid key instead of
turning valid input into a coercion failure. This tier also reaches
allOf members (pydantic's inheritance shape); oneOf/anyOf stay
unreached on the fast path.
Key-typo remap. When an object key matches no properties entry and no
patternProperties pattern, tors scores it against every property name
with tors.jaro_winkler and remaps it (from the old key, to the new)
only when the evidence is strong AND the alternative is loss or failure:
the best score is >= 0.75, it is unique (the second-best more than 0.05
lower), the target property is absent from the object, and either
additionalProperties is false (the key would otherwise be dropped) or the
target is in required (validation would otherwise fail). An exact
case-insensitive match scores a perfect 1.0. Below the remap bar, a best
score >= 0.60 still emits a report-only suggest diagnostic and falls
through to upstream semantics: no remap, no drop, the key keeps its
spelling.
Enum suggestions. When a value fails an enum check (never const),
the ValueError gains " Did you mean '...'?" naming the closest string
enum member under the same jaro-winkler >= 0.60 threshold, plus a
suggest diagnostic. Enum values are never auto-remapped: a near-miss is
reported, not guessed.
Date, time, and uuid normalization. For string values whose subschema
declares format: "date", "date-time", "time", or "uuid" (directly
or through an allOf member), accepted forms normalize: ISO dates
(YYYY-MM-DD, two-digit padded) and slash dates (YYYY/MM/DD,
padding-tolerant); date-times <date>[T ]HH:MM[:SS[.frac]] on either date
spelling; month-name forms (March 15, 2024, 15 March 2024,
15 Mar, 2024, case-insensitive). Dates come out YYYY-MM-DD;
date-times come out YYYY-MM-DDTHH:MM:SS[.frac] (seconds always
emitted, the fractional part trimmed to its shortest exact form), and an
input that carried an offset (Z or ±HH:MM/±HHMM) normalizes to its UTC
instant with a Z rendering (2024-03-15T14:30:00+0530 →
2024-03-15T09:00:00Z); a missing offset stays missing. format: "time"
gains seconds (14:30 → 14:30:00); format: "uuid" canonicalizes to
lowercase when the shape is a UUID (non-UUID strings pass through for
validation to judge). Numeric X/Y/YYYY (or YYYY/X/Y) forms coerce only
when a component over 12 disambiguates month from day; when both
candidates are 12 or under (03/04/2024) the date is ambiguous and gets a
suggest diagnostic instead of a guess. Invalid calendar dates
(month lengths, leap years) are left for validation. format is not
otherwise enforced: upstream passes no format_checker, and tors matches
it; this normalization is the one place format is consulted at all.
Numeric coercion ladder and locale. String values under an
integer/number property climb a four-tier ladder: (1) the whole
trimmed string parses; (2) the string minus unambiguous noise (underscores,
fullwidth and Arabic-Indic script digits, and, with a known locale, that
locale's own separators) parses; (3) exactly one number token in the prose
extracts ("USD 50" → 50, "$1,234.56" → 1234.56, -"50" → -50), with
percent suffixes read by the declared type ("50%" → 0.5 on number
fields, the fraction; → 50 on integer fields, the percent count); (4)
Auto mode's separator-ambiguity resolution, below. A dropped decimal
marker never extracts (.5 on an integer field refuses, never 5), and
Python's unbounded integer semantics hold at any magnitude:
"12345678901234567890123" coerces exactly, never a saturating cast.
locale= tells tors which separator convention the model uses: a BCP 47
tag string ("de-DE", case-insensitive, - or _; region variants like
de-CH carry their CLDR separators; Lakh-style grouping locales such as
en-IN are refused) or a dict {"decimal": ..., "grouping": ...} of
one-character separators for conventions the table does not carry. With a
known locale every form is deterministic: "1,234" reads 1.234 in German
and 1234 in English, by data rather than by guess. The default (locale=None,
Auto) assumes en-US for the separator-ambiguous shapes, but never
silently: both readings are extracted and filtered by the declared type
and the property schema, a single surviving reading is the deterministic
answer (silent), and when both survive the en-US reading wins with a
suggest diagnostic naming the discarded reading's locale= override
("1,234" on a number field → 1234 plus locale='de-DE'). A single
separated number whose readings all fail the declared type refuses with
the retry-able hint (pass locale='en-US' or 'de-DE' ...); prose without
a single number gets the plain upstream refusal.
value, diags = tors.repair_json_diagnostics(
'{"count": "4"}',
schema={"type": "object", "properties": {"count": {"type": "integer"}}},
)
# ({'count': 4}, [{'action': 'coerce', 'path': '$.count', ...}])
tors.truncate_to_bounds
def truncate_to_bounds(
text: str, max_chars: int, boundary: Literal["word", "sentence"] = "word"
) -> str: ...
Truncates text to at most max_chars codepoints, one GIL-released native pass,
cutting at the last word (or, boundary="sentence", sentence) boundary at or before
max_chars instead of mid-word/mid-sentence: composing word_bounds/
sentence_bounds, the crate's own UAX #29 segmentation already shipped, rather than a
new algorithm. The context-window/token-budget-fitting primitive: text[:max_chars]
risks cutting a word or a grapheme in half, which this avoids.
Every cut point is also grapheme-cluster-safe: a word/sentence boundary that would
split a cluster (Thai SARA AM, combining accents, ZWJ emoji sequences) is never used,
so a combining mark is never separated from its base character. If no boundary fits at
or before max_chars (a single word/sentence longer than the budget, or max_chars ==
0), the fallback is a hard cut at the largest grapheme boundary <= max_chars: a
documented fallback, never a silent surprise, and still cluster-safe, so it
can land short of max_chars when the budget would otherwise split a cluster. The one
invariant that never breaks either way: the result never exceeds max_chars
codepoints. The cut point is then trimmed of trailing whitespace (str.rstrip-
equivalent): cutting right after a word/sentence boundary can otherwise leave a
dangling separator space, since word_bounds segments the inter-word space on its own
and sentence_bounds carries a sentence-terminal's trailing space on the preceding
sentence (UAX #29 SB9-SB11).
tors.truncate_to_bounds(s, n) is s exactly when s already has <= n codepoints
(the Cow identity convention: zero allocation, zero marshalling). max_chars < 0
and an unrecognized boundary both raise ValueError before any work runs.
tors.truncate_to_bounds("cats are cute", 9)
# "cats are"
tors.truncate_to_bounds("One. Two. Three.", 10, boundary="sentence")
# "One. Two."
tors.truncate_ellipsis
def truncate_ellipsis(text: str, max_chars: int) -> str: ...
The DB-column truncation shape: hard cut to at most max_chars codepoints plus
a U+2026 … marker, one GIL-released native pass, never mid-grapheme-cluster.
Unlike truncate_to_bounds there is no word/sentence awareness: a storage
bound is positional, not semantic, and the marker tells the reader the value
continues.
At most max_chars - 1 codepoints are kept plus the one-codepoint marker, so
the result never exceeds max_chars (it falls short when cluster backoff
requires it: a cut landing inside a combining sequence, ZWJ emoji chain, or
regional-indicator flag pair snaps back past the whole cluster rather than
splitting it). On plain text the stored length is exactly the bound. No
trailing-whitespace trim: the cut is positional.
tors.truncate_ellipsis(s, n) is s exactly when s already has <= n
codepoints. max_chars == 0 yields "" (no room for even the marker; the
naive value[:0] + "…" spelling answers "…" here, exceeding a zero bound);
max_chars < 0 raises ValueError before any work runs.
tors.truncate_ellipsis("hello world", 6)
# "hello…"
Async: await tors.aio.truncate_ellipsis(...) runs this under asyncio.to_thread (see Async use).
tors.is_grounded
def is_grounded(
claim: str,
source: str,
*,
fuzzy: bool = False,
threshold: float = 0.85,
deadline_ms: float | None = None,
) -> bool: ...
Checks whether claim is grounded in source: a lexical check, not a
semantic/NLI one, and not a hallucination-detection model.
fuzzy=False (the default) is exact substring containment: the memchr crate's
SIMD-skipped two-way search (memmem, already a dependency), a byte-level find that is
UTF-8-boundary-safe by construction. fuzzy=True compares claim against
overlapping windows of source (stride claim's length / 2) using the
only diffing engine already in the crate (the similar Myers engine backing
diff_opcodes), and reports whether the best window's region score —
2 * matched_chars / (len(claim) + len(claim)), the difflib ratio over
equal-length operands — reaches threshold. Every window is scored against
that claim-length denominator: a window truncated by the source's end (its
last, shorter window) is scored as the claim-length region it truncates —
the missing characters are mismatches, never a discounted
len(claim) + len(window) denominator, which would inflate a source that
just ends partway through the evidence above the identical evidence sitting
mid-source and make the verdict depend on where the evidence sits. The one
exception is a source shorter than the claim: there is nothing to window
over, so the whole source is the evidence and the score is one direct
difflib 2 * matched / (len(claim) + len(source)) ratio (the unwindowed
convention tests/test_grounded.py pins to exact difflib parity); the two
formulas agree at len(source) == len(claim), so the boundary is continuous.
fuzzy=True is a
superset of fuzzy=False: an exact-containment floor runs first, so a claim present
verbatim in source is grounded before any windowing (independent of window alignment,
and before deadline_ms applies: a verbatim substring never times out). The windowed
score is consulted only when there is no exact match.
The floor guarantees the verbatim case unconditionally; near matches get a bounded
guarantee band instead of raw window luck: a same-length source region whose aligned
ratio is r is detected at any offset whenever r >= max(0.75, threshold + 1/32):
a bounded refinement pass re-scans the best coarse windows at a fine stride, a
constant budget on top of the linear scan. One substitution in a 9+ character claim
clears the 0.85 default wherever it sits. Below r = 0.75 detection is
best-effort (the recall floor of the DoS windowing), and a genuine region can be
evicted from the 64 refinement candidates by adversarial decoy text scoring higher,
the regime deadline_ms exists for (both limits are pinned in tests/test_grounded.py).
Windowing, rather than one whole-string diff of claim against all of source, is
DoS discipline: the realistic RAG-grounding shape is a short claim against a
long retrieved passage, so bounding each diff's operands to roughly claim's length
keeps the total work close to linear in source's length instead of the O(source ×
claim) a single unwindowed diff would cost, and windows slide through one reusable
O(claim)-sized buffer, so a 12 MiB passage costs kilobytes rather than a
whole-source char vector, and an early exit stops consuming input mid-source.
deadline_ms (only accepted, and only
meaningful, when fuzzy=True) bounds the whole scan on top of that, the same
discretionary escape hatch diff_opcodes's deadline_ms already has, checked after every window
diff, coarse and refinement alike: TimeoutError
on expiry naming the elapsed cost and the deadline, a positive-finite-or-None
precondition validated before any work runs. Even a single very large window's own
Myers search is itself deadline-bounded (similar's capture_diff_slices_deadline),
so one pathological window cannot blow through the budget uninterrupted between checks.
An empty claim is vacuously grounded in anything on both paths. threshold must be
in [0.0, 1.0].
tors.is_grounded("cat", "the cat sat")
# True
tors.is_grounded(
"the cat sat", "Lorem ipsum. The cats sit on mats today.", fuzzy=True, threshold=0.6
)
# True
tors.similarity_ratio / tors.get_close_matches
def similarity_ratio(a: str, b: str, *, deadline_ms: float | None = None) -> float: ...
def get_close_matches(
word: str,
possibilities: list[str],
n: int = 3,
cutoff: float = 0.6,
*,
deadline_ms: float | None = None,
) -> list[str]: ...
difflib.SequenceMatcher(None, a, b).ratio() and difflib.get_close_matches()'s
shapes at native speed, over the same Myers engine diff_opcodes uses.
similarity_ratio is 2.0 * M / T (T = len(a) + len(b), both in character units)
with M the matched-character total over the Myers equal-ops, difflib's own formula
but over a different alignment: difflib's M comes from its longest-match recursion,
which anchors a match and splits the surrounding change around it, so on
repeated-pattern inputs its M can be smaller than the maximal one. The pinned
divergence rows (tests/test_similarity.py): "ppp" vs "pwpp", difflib 4/7
(its anchored "pp" splits the insert, M = 2) vs tors 6/7 (M = 3 = LCS);
and "qpqpq" vs "qpwqpq", difflib 6/11 (the anchored rotated equal block
"qpq", a non-minimal insert+delete split, M = 3) vs tors 10/11 (M = 5 =
LCS). tors's M is always maximal: M == LCS(a,
b) exactly, the minimal-edit-script consequence of the Myers engine, so the two
ratios agree exactly wherever the alignment is forced (identical operands, empty
pairs, disjoint alphabets, pure insert/delete with differing flanks) and are both
valid but may diverge on repeated-flank contexts. difflib's anchored M is also
direction-dependent (similarity_ratio is symmetric; difflib's ratio() is not, in
general). ("", "") is 1.0, the convention both engines share.
get_close_matches keeps every candidate scoring `similarity_ratio(candidate, word)
= cutoff
and returns the topnsorted by score descending, then by the candidate string descending:heapq.nlargest's tuple order, the stdlib's own tie-break (acsorts afterca, soget_close_matches("ab", ["ac", "ca"], 2, 0.5)returns["ca", "ac"]). Returned elements are the original candidate objects, not copies.n <= 0and acutoffoutside[0.0, 1.0]raiseValueErrorwith difflib's exact message, the offending value interpolated ("n must be > 0: 0","cutoff must be in [0.0, 1.0]: -0.5", measured against the running stdlib on 3.10–3.15;nis taken signed at the pyo3 boundary precisely so everyn <= 0case lands inexcept ValueErroridentically to difflib's own). An emptypossibilitieslist returns[]`.
deadline_ms bounds the whole call: every candidate's diff for get_close_matches,
one pair's diff for similarity_ratio: under one shared clock; TimeoutError on
expiry names the elapsed cost and the deadline, None (the default) is unbounded, and
an enormous-but-finite budget saturates to unbounded rather than erroring.
tors.similarity_ratio("kitten", "sitting")
# 0.6153846153846154
tors.get_close_matches("appel", ["ape", "apple", "peach", "puppy"])
# ['apple', 'ape']
tors.levenshtein / tors.jaro / tors.jaro_winkler
def levenshtein(a: str, b: str, *, deadline_ms: float | None = None) -> int: ...
def jaro(a: str, b: str, *, deadline_ms: float | None = None) -> float: ...
def jaro_winkler(a: str, b: str, *, deadline_ms: float | None = None) -> float: ...
The classic edit-distance/similarity metrics CPython has no stdlib spelling of
(difflib's ratio is not a metric: see above; every real spelling is third-party).
All three operate on character sequences (Rust chars, i.e. Unicode scalar values:
the same unit a Python str index addresses), so an emoji or a combining-mark
sequence costs what its codepoint count says, not its UTF-8 byte count.
levenshtein(a, b) is the unit-cost edit distance (insert/delete/substitute each cost
1) as an int: levenshtein("kitten", "sitting") == 3. Symmetric; ("", "") is
0; one empty operand is the other's character count. Implemented
as a two-row DP (O(len(b)) space, not a full O(n·m) matrix), so memory stays
linear in operand size even on adversarial multi-megabyte inputs; identical
operands short-circuit to 0 via a single equality scan, without entering the DP at
all.
jaro(a, b) is the Jaro similarity in [0.0, 1.0] (higher is more similar): a
flag-based matching window of max(|a|, |b|)//2 - 1, transpositions counted over the
matched subsequences, (m/|a| + m/|b| + (m - t)/m) / 3. ("", "") is 1.0; one
empty operand is 0.0. jaro_winkler(a, b) is jaro(a, b) plus a common-prefix
boost: jaro + l * 0.1 * (1 - jaro): applied only when the Jaro score is > 0.7,
with l the common-prefix length capped at 4 characters; the 0.1 scale, the 4-char
cap, and the > 0.7 threshold are the standard convention from the original papers
(also strsim's exact spelling, used as the differential oracle for all three
functions). Known literature vectors: jaro("MARTHA", "MARHTA") == 17/18 ≈ 0.944,
jaro_winkler("MARTHA", "MARHTA") ≈ 0.961.
All three are O(n·m) worst case; deadline_ms bounds the DP/matching pass with a
check once per row (Levenshtein) or per phase/1024 outer steps (Jaro), the same
DoS-discipline shape as diff_opcodes's: TimeoutError on expiry naming the elapsed
cost and the deadline, None (the default) unbounded, an enormous-but-finite budget
saturating to unbounded.
tors.levenshtein("kitten", "sitting")
# 3
tors.jaro_winkler("MARTHA", "MARHTA")
# 0.9611111111111111
tors.quote / tors.quote_plus / tors.unquote / tors.unquote_plus
def quote(text: str, safe: str = "/") -> str: ...
def quote_plus(text: str, safe: str = "") -> str: ...
def unquote(text: str) -> str: ...
def unquote_plus(text: str) -> str: ...
urllib.parse.quote / quote_plus / unquote / unquote_plus, byte-exact, for str
input (the stdlib's bytes-in/encoding=/errors= overloads are out of scope: the
encode side is always strict UTF-8, the decode side always errors="replace"). The
stdlib's own spellings are pure Python: a GIL-held regex/loop over the whole string;
these are one py.detached native pass each.
quote(text, safe="/") never percent-encodes ASCII letters, digits, or _.-~ (the RFC
3986 unreserved set, the stdlib's _ALWAYS_SAFE), plus the ASCII members of safe;
every other byte of text's UTF-8 encoding becomes %XX with uppercase hex. safe is
byte-level and ASCII-only, exactly like the stdlib's own safe.encode("ascii", "ignore")
normalization: a non-ASCII safe member is silently dropped (tors.quote("é", "é") ==
"%C3%A9", not "é"), and % in safe is honored like any other byte (stays literal).
quote_plus(text, safe="") is not "quote, then replace %20 with +": the stdlib
quotes with " " appended to safe (so a space never encodes at all) and then replaces
every " " with "+"; the observable difference is a literal + in text, which
escapes to %2B unless the caller puts + in safe (tors.quote_plus("a+b") ==
"a%2Bb").
unquote(text) decodes %XX (either hex case) as UTF-8 with errors="replace"
(an invalid sequence becomes one or more U+FFFD, CPython's maximal-subpart rule); a %
not followed by two hex digits (%zz, a trailing %, %e at end of input) stays
verbatim. It also reproduces the stdlib's _asciire fragmentation exactly: each maximal
ASCII run is unquoted and UTF-8-decoded independently, with non-ASCII segments passed
through verbatim: so a multi-byte escape interrupted by a non-ASCII character is not an
escape at all (tors.unquote("%Cé3") == "%Cé3"), and an escape split across an
ASCII/non-ASCII boundary decodes as two independently-replaced fragments
(tors.unquote("%C3é%A9") == "�é�", not "é"). unquote_plus(text) replaces
every + with a space before unquoting (order is semantics, not an
implementation detail), so an escaped %2B survives as a literal + while a raw + becomes a space
(tors.unquote_plus("%2B") == "+", tors.unquote_plus("+") == " ").
On text the call leaves byte-for-byte unchanged, all four return the
original input object: quote/quote_plus when no byte needs encoding, unquote when text has no
%, unquote_plus when text has neither + nor %: CPython's own fast-path idiom,
zero allocation, zero copy, zero marshalling. unquote's borrow is narrower than a plain
equality check would suggest: an input whose every % is invalid hex ("%zz") decodes
to an equal-but-new string in the stdlib, not the original object, and tors.unquote
matches that residue exactly rather than widening the identity lane past parity.
Known deviation: lone (unpaired) surrogates. pyo3's str argument extraction
(PyUnicode_AsUTF8AndSize) requires the whole input to be valid UTF-8 up front, which a
lone surrogate codepoint never is. quote/quote_plus are unaffected in practice: the
stdlib's own encode-based implementation raises the identical UnicodeEncodeError for
such input, so the two still agree, but unquote/unquote_plus diverge: CPython's
unquote only UTF-8-encodes the ASCII runs it is about to percent-decode and passes
every non-ASCII character (surrogates included) through untouched, so it never raises
for a lone surrogate anywhere in text. tors.unquote/tors.unquote_plus raise
UnicodeEncodeError for any text containing a lone surrogate, even one nowhere near a
% escape, because the whole-string extraction fails before the Rust core ever runs.
This is a real, narrow parity gap (native Rust &str/String cannot represent an
unpaired surrogate at all, so silently downgrading to errors="replace"-style
substitution at the boundary would corrupt the character rather than reproduce it):
callers who round-trip surrogateescape-decoded text (e.g. from os.fsdecode) through
unquote should be aware of it.
tors.quote("café/data", "/") # "caf%C3%A9/data"
tors.quote_plus("a b+c") # "a+b%2Bc"
tors.unquote("caf%C3%A9%20data") # "café data"
tors.unquote_plus("a+b%2Bc") # "a b+c"
tors.chunk_text
def chunk_text(
text: str,
max_chars: int,
*,
overlap: int = 0,
boundary: Literal["word", "sentence"] = "word",
) -> list[tuple[int, int]]: ...
Async: await tors.aio.chunk_text(...) runs this under asyncio.to_thread (see Async use).
The context-window/RAG packing primitive: boundary-aware chunking of text into
(start, end) pairs in Python str index (codepoint) units, each chunk at most
max_chars codepoints, cut at word or sentence boundaries wherever the budget
allows: truncate_to_bounds's own cut rule (the largest boundary end within the
budget, a grapheme-safe hard cut when a single word/sentence exceeds it) applied
repeatedly, with the whole-text bounds computed once, GIL-released. Every cut is also
grapheme-cluster-safe, so max_chars can be exceeded only in the pathological case of
a single grapheme cluster (e.g. an oversized ZWJ emoji chain) wider than the remaining
budget: a covering chunker cannot drop content, so that one chunk goes out past
max_chars rather than split the cluster; ordinary text never hits this.
overlap=0 (the default): the original lossless-partition contract, unchanged;
chunks are non-empty, contiguous, strictly increasing, cover [0, len(text)), and
joining the slices reproduces the input exactly.
overlap > 0: each chunk after the first starts overlap codepoints before the
previous chunk's end, SNAPPED to the nearest boundary, never mid-word or
mid-sentence, so a fact split across a cut is still whole in at least one chunk (the
RAG-retrieval shape). This trades the lossless-join guarantee for genuine shared
content between consecutive chunks; every chunk's own <= max_chars and
boundary-safety invariants still hold regardless. overlap must be < max_chars
(ValueError otherwise: an overlap at least as large as the budget means no forward
progress is possible). A chunk shorter than the requested overlap silently
degrades to zero overlap for just that one transition rather than stall or violate
the budget, a documented degradation under the one invariant that never breaks:
forward progress (the chunk count can never exceed the codepoint count).
max_chars < 1 or overlap < 0 raise ValueError; an unrecognized boundary raises
ValueError (the truncate_to_bounds spelling). chunk_cdc's byte-level sibling:
chunk_text is the semantic/embedding-pipeline chunker (word/sentence-aware, sized
for a context window), chunk_cdc is the storage/sync chunker (content-defined byte
boundaries, sized for dedup). Both compose naturally with merkle_root/merkle_diff
for integrity-checked chunks on top of either strategy.
The return marshalling is O(chunks) 2-tuples of ints: the word_bounds list-shape
class, at chunk-count scale rather than segment-count scale.
tors.chunk_text("cats are cute and cats are fun", 12)
# [(0, 8), (8, 17), (17, 26), (26, 30)]
tors.chunk_text("cats are cute and cats are fun", 12, overlap=3)
# [(0, 8), (5, 17), (14, 26), (23, 30)]
tors.chunk_text_iter
def chunk_text_iter(
text: str, max_chars: int, *, overlap: int = 0, boundary: Literal["word", "sentence"] = "word"
) -> Iterator[tuple[int, int]]: ...
chunk_text's streaming twin, the word_bounds/word_bounds_iter shape: the whole
scan runs once under py.detach at iterator construction, and each __next__ holds
the GIL only to build one 2-tuple, rather than marshalling the whole result into a
list under one GIL hold. Same sequence, same argument contract as chunk_text;
prefer it over the list API once a document chunks into the hundreds of
thousands of pieces, where the marshalling cost dominates.
The argument contract includes error precedence, stated here once for the whole
chunking family (every list/_iter pair: chunk_text, chunk_by_words,
chunk_by_sentences, chunk_by_paragraphs, chunk_by_lines, and each one's _iter
twin): a text that fails UTF-8 conversion (a lone-surrogate str, buildable in
CPython, impossible in UTF-8) raises UnicodeEncodeError before any
count/overlap ValueError, identically in both spellings. The list functions take
text as an already-converted argument, so the conversion error always fires first
there; the _iter twins borrow the text before validating counts, so they raise the
same error on the same call: the two spellings of a function never disagree on
which error type a bad call raises (the one message-level divergence, a lone
surrogate in both text and boundary, is frozen by test: both spellings raise
UnicodeEncodeError, the list reporting the text's surrogate and the iter the
boundary's, pyo3 extracting boundary ahead of the iter body but after the list's
text extraction). For chunk_text/chunk_text_iter the
unrecognized-boundary ValueError comes last of all, after the count/overlap
checks, in both spellings.
list(tors.chunk_text_iter("cats are cute and cats are fun", 12))
# [(0, 8), (8, 17), (17, 26), (26, 30)]
tors.chunk_by_words
def chunk_by_words(
text: str, words_per_chunk: int, *, overlap: int = 0
) -> list[tuple[int, int]]: ...
Async: await tors.aio.chunk_by_words(...) runs this under asyncio.to_thread (see Async use).
The unit-count twin of chunk_text: instead of a character budget, each chunk spans
exactly words_per_chunk consecutive word tokens, not word_bounds'
raw segment count. word_bounds follows UAX #29 exactly, which gives an inter-word
space run its own segment ("one two" is three segments: "one", " ", "two", the
same convention word_count already carries): grouping raw segments here would
silently mean "words_per_chunk roughly halved" for ordinary space-separated prose,
the opposite of what a caller reaching for words_per_chunk=100 (a "~100 word chunk"
for an embedding budget) wants. So this filters to segments carrying at least one
non-whitespace codepoint first, and only then windows over what remains: a "word" is
a real token, and whitespace between two tokens inside one chunk still rides along
naturally (each chunk's span is a contiguous slice of the original text between two
real absolute offsets). (start, end) codepoint offsets span the first included word
token's start through the last included token's end, not through any trailing
whitespace after it, so unlike chunk_text's covering-partition contract,
non-overlapping chunks here are not necessarily contiguous. The final chunk may hold
fewer than words_per_chunk tokens when the total doesn't divide evenly. overlap
words repeat at the start of the next chunk. Empty text, or text with no word tokens
at all, returns [].
words_per_chunk < 1 or overlap < 0 raise ValueError; overlap >= words_per_chunk
raises ValueError: the chunk stride is words_per_chunk - overlap tokens, and
unlike chunk_text's character-granularity overlap this stride is always >= 1 by
construction once validated, so forward progress needs no runtime fallback.
Cost at document scale: one word_bounds walk, one grapheme boundary index (a
one-bit-per-codepoint bitmap; on pure-ASCII text it is two SIMD byte scans, no
segmentation walk, shared with chunk_hierarchical and chunk_by_sentences), a
zero-copy merge fast path when no boundary needs it, and one streaming decode pass
for the token filter. Measured on 12 MiB of prose (min-of-3, tools/bench_chunking.py):
~160 ms, ~90 MiB transient (the word-bounds list itself). Before this change it built a HashSet of every grapheme boundary
plus a whole-text Vec<char> unconditionally: ~1.9 s and ~500 MiB on the same input.
tors.chunk_by_words("one two three four five six seven", 3)
# [(0, 13), (14, 27), (28, 33)]
tors.chunk_by_words("one two three four five six seven", 3, overlap=1)
# [(0, 13), (8, 23), (19, 33)]
tors.chunk_by_words_iter
def chunk_by_words_iter(
text: str, words_per_chunk: int, *, overlap: int = 0
) -> Iterator[tuple[int, int]]: ...
chunk_by_words' streaming twin, the same chunk_text_iter shape: one detached
whole-text pass at construction, one 2-tuple per __next__, identical sequence to
the list API.
list(tors.chunk_by_words_iter("one two three four five six seven", 3))
# [(0, 13), (14, 27), (28, 33)]
tors.chunk_by_sentences
def chunk_by_sentences(
text: str, sentences_per_chunk: int, *, overlap: int = 0
) -> list[tuple[int, int]]: ...
Async: await tors.aio.chunk_by_sentences(...) runs this under asyncio.to_thread (see Async use).
chunk_by_words' sentence-count twin (sentence_bounds's UAX #29 segmenter): each
chunk spans sentences_per_chunk consecutive sentence segments, overlap sentences
repeated. Same argument contract, same empty-input answer, same
forward-progress-by-construction guarantee as chunk_by_words.
tors.chunk_by_sentences("One. Two. Three. Four. Five.", 2)
# [(0, 10), (10, 23), (23, 28)]
tors.chunk_by_sentences_iter
def chunk_by_sentences_iter(
text: str, sentences_per_chunk: int, *, overlap: int = 0
) -> Iterator[tuple[int, int]]: ...
chunk_by_sentences' streaming twin, the same chunk_text_iter shape.
list(tors.chunk_by_sentences_iter("One. Two. Three. Four. Five.", 2))
# [(0, 10), (10, 23), (23, 28)]
tors.chunk_by_paragraphs
def chunk_by_paragraphs(
text: str, paragraphs_per_chunk: int, *, overlap: int = 0
) -> list[tuple[int, int]]: ...
Async: await tors.aio.chunk_by_paragraphs(...) runs this under asyncio.to_thread (see Async use).
chunk_by_words/chunk_by_sentences's paragraph-count twin: each chunk spans
paragraphs_per_chunk consecutive paragraphs, overlap paragraphs repeated. A
paragraph boundary here is a run of 2+ consecutive newlines (\r\n counts as one
unit, matching tors.normalize's own CR/CRLF folding): the same "2+ newlines
survive as the paragraph gap" convention normalize's own pipeline already uses (it
collapses 3+ down to exactly 2, never below). This is a heuristic, not a Unicode
Standard segmentation (there is no UAX for paragraphs, unlike UAX #29 for
words/sentences): a single \n is ordinary content, not a break. A
leading or trailing blank-line run is trimmed rather than emitted as an empty
paragraph; text with no qualifying run at all is one paragraph. Unlike the
word/line twins, paragraphs have no content filter here: a whitespace-only
paragraph is emitted as a chunk (only fully-empty spans are dropped), so an
overlapping pair of chunks can share blank content. Same argument contract,
same empty-input answer, same forward-progress-by-construction guarantee
as chunk_by_words/chunk_by_sentences.
No retrieval or LLM-quality claim is made for any chunking strategy in this family:
tors guarantees the mechanical contract (correct boundaries, genuine overlap, the
right knobs).
tors.chunk_by_paragraphs(
"First paragraph here.\n\nSecond paragraph here.\n\nThird paragraph here.", 2
)
# [(0, 45), (47, 68)]
tors.chunk_by_paragraphs_iter
def chunk_by_paragraphs_iter(
text: str, paragraphs_per_chunk: int, *, overlap: int = 0
) -> Iterator[tuple[int, int]]: ...
chunk_by_paragraphs' streaming twin, the same chunk_text_iter shape: one
detached whole-text pass at construction, one 2-tuple per __next__, identical
sequence to the list API, same argument contract (the family-wide error-precedence
paragraph in chunk_text_iter's section included). Like every _iter spelling it
has no async twin (an iterator is not an awaitable shape; see Async use). It exists for the same reason as the other _iter twins: the
list shape's GIL-held marshalling cost is measured for segment-count-heavy outputs
(word_bounds on 12 MiB of prose, 3.67M segments, holds the GIL for 328–344 ms
just marshalling the list; see Performance), and a paragraph-heavy
corpus (a multi-MiB article dump or report batch, one blank line per record) is in
that piece-count class, chunking into hundreds of thousands of pieces.
minutes = (
"Attendees: Ada, Grace, Edsger.\n\n"
"Grace: parser rewrite halves latency.\n\n"
"Edsger: spec drift question, unresolved.\n\n"
"Next sync moves to Thursday."
)
list(tors.chunk_by_paragraphs_iter(minutes, 2))
# [(0, 69), (71, 141)]: 4 paragraphs, 2 chunks of exactly 2; the blank-line
# gap between chunks belongs to neither (chunk 2 starts at "Edsger")
list(tors.chunk_by_paragraphs_iter(minutes, 2, overlap=1))
# [(0, 69), (32, 111), (71, 141)]: overlap repeats whole paragraphs;
# (32, 111) is "Grace: parser rewrite halves latency.\n\nEdsger: spec drift
# question, unresolved."
tors.chunk_by_lines
def chunk_by_lines(
text: str, lines_per_chunk: int, *, overlap: int = 0
) -> list[tuple[int, int]]: ...
Async: await tors.aio.chunk_by_lines(...) runs this under asyncio.to_thread (see Async use).
chunk_by_words/chunk_by_sentences/chunk_by_paragraphs's line-count twin: each
chunk spans lines_per_chunk consecutive lines, overlap lines repeated at the start
of the next chunk. A line break is a \n, a lone \r, or a \r\n pair counted as
one unit (the same CR/CRLF folding convention chunk_by_paragraphs and normalize's
own pipeline use; str.splitlines' exotic separators (\v, \f, NEL, LS, PS)
are not breaks here). A line counts as a line only when it carries at least one
non-whitespace codepoint, the same real-token discipline chunk_by_words applies to
word segments: blank lines neither count toward lines_per_chunk nor split a chunk's
interior; they ride along inside a chunk's span exactly as inter-word whitespace
rides along in chunk_by_words, so lines_per_chunk=200 means 200 content lines.
"Non-whitespace" is definitional here: the Unicode White_Space property
(char::is_whitespace), under which an NBSP-only line is blank and
U+001C–U+001F (FS/GS/RS/US) count as line content, diverging from
Python's str.isspace() (which treats those four as whitespace) and from
str.splitlines (which even breaks on them; tors does not).
(start, end) codepoint offsets span the first included line's start through the
last included line's end (not through the trailing break after it, so unlike
chunk_text's covering-partition contract, non-overlapping chunks here are not
necessarily contiguous); a trailing break at end of text yields no trailing empty
line. The final chunk may hold fewer lines when the total doesn't divide evenly.
Empty text, or text with no content lines at all, returns []. Same argument
contract, same empty-input answer, same forward-progress-by-construction guarantee
as its siblings.
The line-oriented-text shape this exists for: one message per line (a chat thread),
one record per line (a log), one cue per block. Cost at document scale: one
byte-level walk: memchr2 hops between break bytes behind an 8-byte inline
density window before each hop (so dense break runs never pay a hop), the
real-line whitespace filter folded into the same pass, a per-codepoint fallback
for non-ASCII segments, and ASCII certification batched as one 4 KiB stride per
~50 segments (a sliding certificate over the whole scan, not a per-segment
is_ascii check). The structure is a trade against the simpler whole-text
is_ascii gate a sparse scan could use (interleaved best-of-N, 12 MiB):
+0.1-1.0 ms on pure-ASCII densities (chunk_by_lines ~0.45 ms on prose and
~2.2 ms on a one-line-per-~80-bytes log against the gate's ~0.4 and ~1.2-1.3 ms;
chunk_by_paragraphs ~1.5 vs ~1.3 ms on that log) and ~1-3 ms on a CJK-dense log
(~21 vs ~20 ms by-lines, every segment taking the fallback; the by-paragraphs
cell pays the most, ~12 vs ~9 ms, and wobbles ~10-12.5 ms with binary code layout
across rebuilds). What it buys: ~4x on break soup (~10 vs ~42 ms; the gate has no
density guard), ~16x on mixed text (~0.46 vs ~7.6 ms; one non-ASCII byte no longer
forfeits the document to a per-codepoint decoder), and the same wins on
chunk_by_paragraphs' soup cell (~22 vs ~54 ms). Outputs are differential-pinned
identical across all of these shapes; O(text) time, O(1) memory beyond the output,
no segmentation walk and no grapheme boundary
index at all, unlike chunk_by_words/chunk_by_sentences: every split lands
strictly between a break character and adjacent content, so the split point is
structurally grapheme-safe with nothing to check.
log = "INFO boot\nINFO ready\n\nWARN disk at 90%\nERROR io failure\nINFO retry ok\n\nINFO shutdown"
tors.chunk_by_lines(log, 2)
# [(0, 20), (22, 55), (56, 84)]: 6 content lines, 3 chunks of exactly 2; the
# blank between chunks 1 and 2 falls in the gap (chunk 2 starts at "WARN"),
# while the blank inside the final chunk rides along, never counted
tors.chunk_by_lines(log, 2, overlap=1)
# [(0, 20), (10, 38), (22, 55), (39, 69), (56, 84)]: overlap repeats whole
# lines; (10, 38) is "INFO ready\n\nWARN disk at 90%", blank riding along
tors.chunk_by_lines_iter
def chunk_by_lines_iter(
text: str, lines_per_chunk: int, *, overlap: int = 0
) -> Iterator[tuple[int, int]]: ...
chunk_by_lines' streaming twin, the same chunk_text_iter shape: one detached
whole-text pass at construction, one 2-tuple per __next__, identical sequence to
the list API. Like every _iter spelling it has no async twin (an iterator is not
an awaitable shape; see Async use). It exists for the
same reason as the other _iter twins: the list shape's GIL-held marshalling
cost is measured for segment-count-heavy outputs (word_bounds on 12 MiB of
prose, 3.67M segments, holds the GIL for 328–344 ms just marshalling the list;
see Performance), and a line-oriented corpus (a multi-MiB log or
transcript) is in that piece-count class, chunking into hundreds of thousands
of pieces.
list(tors.chunk_by_lines_iter(log, 2))
# [(0, 20), (22, 55), (56, 84)]
tors.chunk_hierarchical
def chunk_hierarchical(
text: str, max_chars: int, separators: Sequence[str | None] | None = None, *, overlap: int = 0
) -> list[tuple[int, int]]: ...
Async: await tors.aio.chunk_hierarchical(...) runs this under asyncio.to_thread (see Async use).
Priority-ordered fallback chunking: the chunk_text/chunk_by_* family's
fourth shape, and the pattern LangChain's RecursiveCharacterTextSplitter
popularized: a list of separator levels, coarsest first, tried in order for
each chunk, falling back to the next level only when the coarser one has no
in-budget cut over the current window.
separators=None (the default) uses tors's own accurate hierarchy:
paragraph → sentence → word → a grapheme-safe raw cut, always the final,
unconditional fallback (this never fails to produce a chunk); it reuses the
same UAX #29 segmenters chunk_by_sentences/chunk_by_words do, rather
than LangChain's own naive literal guesses ("\n\n", ". ", " ").
separators=[...] is a caller-supplied sequence (a list or a tuple) of
literal strings, not regex
(a documented scope line: literals are LangChain's own default
too, cover the motivating markdown-header case completely, and avoid
reopening the regex-semantics question re support was already declined
over), coarsest first, e.g. ["\n## ", "\n\n", ". ", " "] for
markdown-header-aware chunking. Any Sequence of literals and None entries
is accepted: ("\n", None) behaves identically to ["\n", None], while
str, dict, set, and other non-Sequence inputs (generators included)
raise TypeError at argument extraction. A custom sequence replaces the default
hierarchy for the levels it specifies, but the grapheme-safe raw cut is
still always appended as the final fallback regardless; unlike LangChain,
no trailing "" sentinel is required (one is accepted and ignored if
supplied).
An entry in that sequence may also be None: it splices the default
hierarchy's three accurate levels in at that position, the mix an
all-literal list could not express before. ["\n", None] is
line → paragraph → sentence → word → raw cut, the line-oriented-text
shape (a chat thread, one message per line, never split mid-line) whose
oversized-line fallback is the real UAX #29 sentence/word segmenter
rather than the ". "/" " literal guesses an all-literal
["\n", ". ", " "] pins it to: a ". " match after "U.S." is not a
sentence boundary, and the naive list severs "U.S. team" where the
spliced hierarchy does not. [None] is identical to separators=None.
Cost: every level (each of the three default walks, each distinct custom
literal) pays its one whole-text walk at most once per call, and only when a
window consults it: levels are built at their first consultation (the window
loop walks the list strictly through find_map, in priority order), so a
budget that answers every window at the paragraph level never runs the
sentence or word walks at all, and a ["\n", None] thread whose every line
fits the budget builds none of the spliced levels. Duplicate entries, None
or a repeated literal, are recognized at slot construction and skipped, inert
(identical levels can never change the answer; the first occurrence of a level
always dominates its duplicate), so [None] * 100 costs what [None] does
(~0.5 ms at a 2000-codepoint budget over 6 MiB of prose, the paragraph walk
alone) and [" "] * 100 what [" "] does (~9 ms). Two former spellings paid
more: before the dedup, every duplicate entry re-paid the walks plus ~45 MiB
of cut vectors per duplicate on a 6 MiB document, a caller-controlled
unbounded cost ([None] * 100 measured 17.2 s and +3,120 MiB of peak RSS,
[" "] * 100 790 ms and +1,560 MiB, OOM shapes); and between the dedup and
the lazy levels the walks were paid once per call even when no window ever
consulted them, a single-chunk budget over 6 MiB spending ~176 ms building
levels that supplied zero cuts. Both closed, by the construction-time dedup
and the first-consultation deferral.
Rust API note: 0.6.0 changes the public tors-core crate's
chunk_hierarchical(text, max_chars, separators, overlap) signature:
separators moved from Option<&[&str]> to Option<&[Option<&str>]>,
the type the None-entry splice requires, which is breaking for direct
Rust consumers of the separately-published crate at 0.x. Python callers
are unaffected: an all-literal list behaves identically under either
spelling. The merge that introduced it (#28) landed as a plain feat:
with no BREAKING CHANGE: footer, which release-please would not have
surfaced on its own, so the footer is restated on the follow-up fix
commit (#31), which release-please will carry into the 0.6.0 changelog
when that release lands; this note is the docs-side record.
Unlike chunk_text, this is not a lossless covering partition: at
every level except the raw cut, the separator itself is dropped between
chunks: the chunk ends where the separator starts, the next chunk begins
where it ends, the same convention chunk_by_paragraphs already applies
to blank-line runs. A caller splitting on a marker wants it gone, not
duplicated.
overlap snaps the next chunk's start backward to the nearest GRAPHEME
boundary at or before the target, not necessarily a semantic
paragraph/sentence/word boundary the way chunk_text_overlapping's
single-hierarchy overlap snap is (a documented simplification of the
general multi-level case). A target at or before the chunk's own start
silently degrades to zero overlap for just that one transition, the same
snap-collapse chunk_text already applies.
max_chars < 1 or overlap < 0 raise ValueError; overlap >= max_chars
raises ValueError. Empty text returns []. An empty separators sequence
is legal and skips straight to the raw-cut fallback for every chunk. Every
level's cut candidates are additionally grapheme-cluster-safe (the same
Thai SARA AM / combining-mark fix applied crate-wide), including custom
literal separators.
Cost at document scale: one scan per consulted level (each level's walk or
literal search runs at most once per call, at the first window that consults
it: the default hierarchy's paragraph/sentence/word walks, or one literal
search per distinct custom separator), so levels no window descends to are
never scanned at all: a custom hierarchy that never matches under a
whole-document budget is one codepoint count and nothing else, so not even
the literal's own scan runs. The one other whole-text structure is the grapheme
boundary index, a one-bit-per-codepoint bitmap built lazily, only when a
realized level has cuts to filter, a window needs the raw-cut fallback, or
overlap snaps; on pure-ASCII text the index is two SIMD byte scans instead
of a segmentation walk, and a call that realizes no level with cuts and never
falls back or snaps builds none of it. Measured on 12 MiB prose (min-of-3,
tests/test_performance.py and tools/bench_chunking.py): a whole-document
budget (max_chars at or above the text's length) consults no level at all
and costs ~0.1 ms whatever the hierarchy, default, custom, or None-spliced
(formerly ~340 ms for the default hierarchy); a 2000-codepoint budget whose
windows are all served by the paragraph level costs ~1.2 ms (formerly ~350 ms:
the eager build paid the sentence and word walks no window consulted); budgets
that genuinely descend pay the walks they use, ~190 ms at a 600-codepoint
budget (paragraph plus sentence), ~400 ms at 100 (all three levels plus a 20x
denser chunk loop: the word walk ~130 ms plus sentence walk ~190 ms, the
accurate UAX #29 segmentation the function exists to provide). The laziness
prices consultation, it does not skip walks the answer needs. Before #22 an
unconditional Vec<char> collect plus a HashSet of every grapheme boundary
in the document ran before anything else (~1.0-1.2 s for the never-matching
case regardless of budget, ~3.0 s for the default hierarchy, superlinear in
input size), and until the lazy levels every level was built before the first
window, consulted or not.
No retrieval or LLM-quality claim is made for any chunking strategy in
this family: tors guarantees the mechanical contract (correct boundaries,
genuine overlap, the right knobs). This
is additive: chunk_text/chunk_by_words/chunk_by_sentences/
chunk_by_paragraphs/chunk_by_lines remain the right choice for the
common case; chunk_hierarchical is for custom, format-aware, or
multi-granularity needs those simpler functions can't express.
md = "# Title\nSome intro text here.\n## Section\nMore content in this section."
tors.chunk_hierarchical(md, 60, ["\n## ", "\n\n", ". ", " "])
# [(0, 29), (33, 70)] -> "# Title\nSome intro text here." / "Section\nMore content in this section."
thread = (
"Nathan: kicking off the sync.\n"
"Priya: We briefed the U.S. team on the numbers. "
"They asked for a follow-up meeting. The budget holds.\n"
"Nathan: done."
)
tors.chunk_hierarchical(thread, 60, ["\n", None])
# [(0, 29), (30, 78), (78, 131), (132, 145)]
# -> whole lines where they fit; the oversized Priya line falls to real
# sentence boundaries ("on the numbers. " / "meeting. The budget holds.")
tors.chunk_hierarchical(thread, 40, ["\n", ". ", " "])[1]
# (30, 55) -> "Priya: We briefed the U.S"; the naive ". " list severs the name
tors.chunk_hierarchical(thread, 40, ["\n", None])[1]
# (30, 69) -> "Priya: We briefed the U.S. team on the "; the splice does not
tors.chunk_hierarchical(thread, 24, [None]) == tors.chunk_hierarchical(thread, 24)
# True: [None] is separators=None
tors.chunk_cdc
def chunk_cdc(
data: bytes, *, min_size: int = 4096, avg_size: int = 16384, max_size: int = 65534
) -> list[tuple[int, int]]: ...
Async: await tors.aio.chunk_cdc(...) runs this under asyncio.to_thread (see Async use).
FastCDC 2020 content-defined chunking, one GIL-released native pass: (start, end)
byte spans (not codepoints: unlike every other segmentation function here, this
operates on raw bytes, not text) partitioning data exactly: end exclusive, the last
span's end == len(data), no gaps or overlaps.
Content-defined chunking's whole point over fixed-size splitting: cut points are chosen
by local content (a rolling hash over a sliding window), not a fixed stride, so a small
edit near the start of data only perturbs the 1-2 chunks nearest the edit; every
chunk further away reappears unchanged, just shifted by the edit's byte delta. Fixed-size
chunking has no such property: an insertion reshuffles every boundary after it. This is
the natural upstream of tors.merkle_root/tors.merkle_diff's list[bytes] argument
for a byte-level dedup/incremental-sync pipeline (chunk, then hash each chunk into the
tree): same framing tors.finalize's normalize-then-hash dedupe gate already
established at the whole-document level, extended to sub-document granularity.
Empty input returns []. Input shorter than min_size returns exactly one chunk
covering the whole input (the underlying algorithm's own documented special case).
Deterministic: the same bytes at the same parameters always cut at the same offsets.
min_size/avg_size/max_size must satisfy the wrapped fastcdc crate's own
documented bounds: each even; min_size in [64, 1_048_576], avg_size in [256,
4_194_304], max_size in [1024, 16_777_216], and min_size <= avg_size <=
max_size, checked and raised as ValueError before any chunking runs. The crate itself
only debug_assert!s these bounds, a no-op in a release build, so an out-of-range call
would otherwise silently misbehave rather than error: tors validates them itself at the
argument boundary instead, the same discipline is_grounded's threshold and
truncate_to_bounds' max_chars already apply. Defaults are the crate's own documented
example values, not independently chosen.
tors.chunk_cdc(b"hello world " * 10_000)
# [(0, 65534), (65534, 120000)]
tors.merkle_root
def merkle_root(chunks: list[bytes]) -> str: ...
A domain-separated SHA-256 Merkle root over chunks, as lowercase hex, one
GIL-released native pass wrapping the rs_merkle crate. Leaves hash as
SHA-256(0x00 || chunk); internal (two-child) nodes hash as SHA-256(0x01 ||
left || right): the RFC 6962 / Certificate Transparency convention.
Why this is tors's own hasher, not the wrapped crate's built-in one.
rs_merkle's default Sha256Algorithm has NO domain separation: its
hash() is plain undifferentiated SHA-256(data), and its default
concat_and_hash feeds SHA-256(left || right) through that same
function; verified directly against the crate's vendored source
(hasher.rs's default concat_and_hash, algorithms/sha256.rs's hash,
rs_merkle 1.5.0), not just its docs. With no domain byte anywhere, a leaf
hash and an internal-node hash live in the same output space: exactly the
ambiguity behind CVE-2012-2459, the Bitcoin Merkle-tree bug class where a
forged proof can present an internal node's hash as though it were some
leaf's digest. merkle_root/merkle_diff don't expose proof generation
yet, but the hash scheme is part of the root's output contract from v1
regardless: roots are meant to be computed once and compared/stored across
calls, and changing the scheme later would silently change every
previously-computed root. It needs to be correct now, not patched in when
proof generation is added.
An unpaired left node at any layer is promoted unchanged to the next
layer (rs_merkle's own default concat_and_hash, its None => *left arm),
never duplicated against itself. Duplication is Bitcoin's original
convention and the actual mechanism CVE-2012-2459 exploited: two
differently-shaped chunk lists (one a duplicate-padded version of a shorter
one) could otherwise produce the same root; promotion is the standard
mitigation and matches RFC 6962. A single chunk's root is just its own leaf
hash: no internal node is built for it.
Deterministic and order-sensitive: the same chunks in the same order always
produce the same root; reordering changes it (this is a Merkle tree, not an
order-insensitive digest/set hash). An empty chunks list raises
ValueError("root of no chunks"): no non-arbitrary root value exists for
it, and returning some fixed sentinel hash risks being mistaken for a real
chunk's digest by a caller comparing roots. A non-list argument or a
non-bytes list entry raises TypeError.
tors.merkle_root([b"a", b"b", b"c"])
# "36642e73...e6c021ec1" (hex-encoded SHA-256, RFC 6962-style domain-separated)
tors.merkle_diff
def merkle_diff(chunks_a: list[bytes], chunks_b: list[bytes]) -> list[int]: ...
Indices where chunks_a[i] != chunks_b[i], one GIL-released native pass.
Comparison is over chunk digests, the same 0x00-prefixed leaf hash
merkle_root uses at a fixed 32-byte cost per index, rather than raw chunk
contents. Every index at or beyond the shorter list's length is reported:
there is no counterpart chunk to compare against there, so a length
mismatch is, in full, "differs at every trailing index," not a partial
answer. Two empty lists diff to [].
This does not walk a tree. With both chunk lists held
locally as random-access arrays, hashing each chunk once already answers
"does chunk i differ" in O(1) per index afterward: a flat scan over the
digest arrays does exactly the work a tree walk would, without needing one.
A Merkle tree's "skip identical subtrees without transferring them" payoff
matters when the comparison itself is expensive to perform per index (e.g.
over a network); not here, where the O(n) hashing pass already is the
entire cost. merkle_root still builds a real tree: that's its actual
job; merkle_diff just doesn't need one to answer this particular
question. Argument contract matches merkle_root's: a non-list argument
or a non-bytes list entry raises TypeError.
tors.merkle_diff([b"a", b"b", b"c"], [b"a", b"X", b"c"])
# [1]
tors.merkle_diff([b"a", b"b"], [b"a", b"b", b"c", b"d"])
# [2, 3]: every trailing index beyond the shorter list's length
tors.simhash64
def simhash64(text: str) -> int: ...
A 64-bit SimHash fingerprint of text, one GIL-released native pass: the
fuzzy near-duplicate gate that sits alongside tors.finalize's exact
SHA-256 gate. Charikar's weighted-bit-voting construction (the near-web-scale
near-duplicate-detection shape Manku, Jain, and Das Sarma built at Google,
WWW 2007): text is tokenized into words via the same UAX #29 word
segmentation tors.word_bounds drives (split_word_bounds, its sibling
spelling over the same tables), skipping any segment that is entirely
whitespace; each token's UTF-8 bytes are hashed with a deterministic
FNV-1a-64; then, for each of the 64 bit positions, every token votes +1 if
its hash has that bit set and −1 if it does not, and the output bit is 1
wherever the vote sums positive (ties, including the zero-token case,
resolve to 0).
Why FNV-1a and not std's DefaultHasher. A dedupe fingerprint must be
stable across processes and machines: DefaultHasher is seeded per process
(RandomState), so a fingerprint it produced would silently change between
runs, breaking any cross-run/cross-machine dedupe built on it. FNV-1a is deterministic forever and adequate for a
voting hash: it only needs to
spread tokens reasonably uniformly across the 64 bit positions, not resist
adversarial collisions; a collision between two distinct tokens merely
blurs one vote among 64 counters.
What this answers that finalize cannot. finalize's SHA-256 tail and
merkle_root answer "is this text byte-identical": a single changed comma
already fails that gate. simhash64 answers "is this text nearly the
same": Hamming distance (a ^ b).bit_count() (a one-liner at the call
site, which is why this returns the raw int rather than shipping a
redundant distance function) grows slowly with edit distance, so
near-duplicates cluster within a handful of differing bits while unrelated
texts sit far apart. The realistic pipeline runs both gates off one store:
exact dupes at Hamming distance 0 via finalize's hash, near-dupes at
small Hamming distance via simhash64, everything else far apart.
Bag of words: order does not matter. The vote is over the multiset of
tokens, not their sequence: tors.simhash64("the quick brown fox") and
tors.simhash64("fox brown quick the") fingerprint identically. A repeated
token votes once per occurrence, so frequency is still part of the bag:
appending one more "the" to a sentence that already has several is a
different multiset and (usually) a different fingerprint.
Tokenization is UAX #29, not a whitespace split: the same segmentation
word_bounds uses, not an ad-hoc str.split(). This matters for
scriptio-continua text (CJK, Thai, and similar scripts with no spaces
between words): the segmenter still finds word boundaries inside a
space-free run rather than treating it as one giant token. A whitespace-only
text has no word tokens (the WSegSpace rule joins a whitespace run into
one segment, which the tokenizer then skips as not-a-word) and fingerprints
to 0, same as empty text.
Limitations: read before deploying a threshold. SimHash is
not cryptographic and not collision-resistant: FNV-1a is a fast,
uniformly-spreading voting hash, not a security primitive, and two
unrelated documents can coincidentally land close together, especially on
short text. There is no universal near-duplicate cutoff: the distance
a "same document, small edit" pair sits at scales with document length
(short texts have thin per-bit vote margins, on the order of the square
root of the token count, so the same edit flips more bits), and the
unrelated floor depends on vocabulary overlap. The measured anchors
(pinned in src/simhash_impl.rs's test suite and mirrored in
tests/test_simhash.py): over a 95-word document, every single-word
swap/drop/insert moves the fingerprint at most 4 bits; over 8-12-word
sentences, the identical class of edit moves up to 14 bits; unrelated
sentence pairs measured as close as 23 bits apart in the same battery.
Calibrate any "probable near-duplicate" threshold per deployment against
known near-dup and known-far pairs: do not import a textbook rule of
thumb unchanged.
Deterministic across processes, versions, and machines (FNV-1a has no
per-process seed). O(n) in the length of text with O(1) extra memory
beyond the 64 vote counters: no intermediate token list is materialized.
tors.simhash64("the quick brown fox jumps over the lazy dog")
# 14607312263354641902
tors.simhash64("the quick brown fox jumps over the lazy dog!") # one edit
# 14601682762745638348: Hamming distance 6, not ~32 (unrelated-text scale)
tors.simhash64("")
# 0
tors.simhash128
def simhash128(text: str) -> int: ...
The 128-bit spelling of tors.simhash64: identical tokenization, identical
vote (each token's FNV-1a hash votes ±1 per bit, positive sum sets the bit),
run at 128 bits instead of 64: twice the bit positions, not a
64-bit fingerprint zero-extended into a wider int (the FNV-1a offset basis
and prime are the real 128-bit constants, and every one of the 128 bits gets
its own independent vote). Use it for corpora where a 64-bit fingerprint's
near-duplicate band and unrelated floor sit too close together to separate
reliably.
Measured by the same battery as simhash64 and pinned in
src/simhash_impl.rs's test suite: the unrelated floor widens from 23 bits
at 64 bits to 40 at 128, while the near-duplicate bands grow only
sublinearly (95-word document scale: 3 bits worst case versus the 64-bit
4; 8-12-word sentence scale: 20 versus 14). The wider width buys more
separation between the near-duplicate band and the unrelated floor, which is
what a corpus whose 64-bit bands overlap needs. The same calibration caveat
applies: there is no universal cutoff, and thresholds must be measured
against known near-dup and known-far pairs for the corpus at hand.
Same contract otherwise: deterministic across processes and machines
(FNV-1a has no per-process seed), order-independent (a bag-of-words vote),
empty or whitespace-only text fingerprints to 0, and (a ^ b).bit_count()
at the call site gives the Hamming distance.
tors.simhash128("the quick brown fox jumps over the lazy dog")
# 317101931942163558849153286541522090150
tors.simhash128("")
# 0
tors.CompiledLemmaDict
class CompiledLemmaDict:
def __init__(self, mapping: dict[str, str]) -> None: ...
def __len__(self) -> int: ...
A pre-built lemma_dict mapping: the re.compile() answer to tf_idf/
bm25_rank/apply_pipeline's per-call dict-materialization cost (the
measured cost and the loading recipes are in tors.tf_idf's docs below).
CompiledLemmaDict(mapping) extracts the mapping into a Rust HashMap
once, under the GIL (the same linear-in-size walk tf_idf/bm25_rank/
apply_pipeline pay per call for a raw dict: the whole point is paying
it here, a single time, instead); every later call is an Arc::clone. The
argument must be exactly a dict[str, str] (a non-dict argument, or one
with a non-str key or value, raises TypeError); len(cl) is the
number of entries. Immutable once built, and not a caching mechanism:
nothing inside tors remembers a raw dict between calls, so a caller who
mutates their dict and re-passes it is always honored: the handle is
the caller's explicit opt-in to fixness, the same narrow shape
re.compile() has in the stdlib.
tors.tf_idf
def tf_idf(
corpus: list[str],
*,
strip_accents: bool = False,
stemmer: str | None = None,
lemma_dict: dict[str, str] | CompiledLemmaDict | None = None,
) -> list[list[tuple[str, float]]]: ...
Async: await tors.aio.tf_idf(...) runs this under asyncio.to_thread (see Async use).
Stateless TF-IDF over corpus, one GIL-released native pass: no
vocabulary/vectorizer object persists between calls; every call scores
fresh over exactly the documents given. Fills a real gap: Python's stdlib
has no TF-IDF at all, and scikit-learn's TfidfVectorizer pulls in
numpy/scipy for a lightweight pipeline that just wants keyword weighting or
document similarity. Every TF-IDF crate on crates.io is stale or effectively
abandoned, and the math is a few dozen lines with no ML machinery: hand-rolled
directly, the same surgical tradition as html_unescape/extract_code_blocks,
not a dependency pull.
Tokenization: UAX #29 word segments (tors.word_bounds's own tables),
restricted to segments carrying at least one non-whitespace codepoint: a
"term" is a real token, not a raw word_bounds segment (which gives
inter-word whitespace its own segment). Terms are lowercased with Rust's
Unicode-correct str::to_lowercase (not an ASCII-only fold) before
counting: "Cat" and "cat" are the same term.
TF (per document d, term t): the raw count of t in d, not
length-normalized. A caller wanting tf / len(d) divides the returned raw
count themselves; the raw count is the more broadly reusable number (a
length-normalized score would silently discard the total-count information
some callers want directly).
IDF (term t, corpus size N, document frequency df(t) = number of
documents containing t at least once): the smoothed formula
ln((1 + N) / (1 + df(t))) + 1: scikit-learn's own smooth_idf=True
default (as if one extra document existed containing every term exactly
once), not the textbook ln(N / df(t)). The textbook formula
gives a term appearing in every document an IDF of exactly ln(1) = 0, so
its score is 0 regardless of how often it occurs: an unhelpfully sharp
cliff for exactly the "practically universal term" case a caller most wants
a small-but-nonzero weight for. The smoothed form stays strictly positive
there (ln((1+N)/(1+N)) + 1 = 1 exactly, for the universal-term case
df(t) = N) while still monotonically favoring rarer terms.
score(t, d) = tf(t, d) * idf(t).
Output is sparse: one (term, score) list per input document,
alphabetically sorted, holding only that document's own terms, never a
vocabulary-size-by-corpus-size dense structure (wasteful for anything but a
tiny shared vocabulary). An empty corpus returns []. An empty-string
document returns [] at its position: the output always has exactly
len(corpus) entries, position-matched to the input. A non-list argument
or a non-str entry raises TypeError; a lone surrogate raises
UnicodeEncodeError at the argument boundary, the same str-in convention
every other function here documents.
strip_accents=True NFD-decomposes each token (tors.nfd's own
algorithm) and drops every combining-mark codepoint before scoring:
"café" and "cafe" become the same term. This is unconditional: it does
NOT replicate a real bug in scikit-learn's own strip_accents_unicode
(gh-15087), which short-circuits and silently skips stripping when a token
arrives already NFD-decomposed (e.g. "e" + a combining accent rather than
precomposed "é"), tors always strips regardless of whether decomposition
itself was a no-op. NFD, not NFKD: NFKD's extra compatibility decomposition
would also touch ligatures/width variants ("fi" → "fi"), which
accent-folding shouldn't. Default False: accents are preserved unless
asked to fold them.
A token made entirely of combining marks (a bare accent with no base
letter, e.g. from already-decomposed input) strips down to the empty
string; such tokens are dropped, never counted as a "" term. This is
the same structural exclusion scikit-learn's default token_pattern
(r"(?u)\b\w\w+\b", which can never match zero characters) achieves for
TfidfVectorizer, applied here after folding rather than via a regex over
the raw text.
stemmer names a Snowball algorithm ("english", "french",
"german", ... 18 languages via the rust-stemmers crate: an
unrecognized name raises ValueError naming every valid choice), applied
after lowercasing/accent-folding: "running"/"runs"/"runner" all stem
toward "run". Default None: no stemming. Full lemmatization is
explicitly out of scope: it needs a per-language dictionary or a
POS-tagging model, not an algorithm, which breaks tors's no-external-model
posture (the same boundary that kept schema-aware JSON/YAML coercion out of
this crate). Stemming is cruder: it can't distinguish "better" the
comparative from "better" the verb, but it's correct, deterministic, and
dependency-light.
lemma_dict is a caller-supplied word -> lemma map, applied last
(after any stemming): the fully-folded token is looked up, and its mapped
value replaces it if present, else the folded token is kept as-is. tors
does not bundle a lemma dictionary: full lemmatization needs a
per-language dataset or a POS model, the same "no external model"
boundary that kept stemming's own decision above. lemma_dict is the
mechanism, not the data: the same shape replace_many already takes a
caller-supplied replacement map instead of a bundled one. Combining
stemmer and lemma_dict together is unusual but well-defined, not an
error: the dict is consulted on the already-stemmed form. A non-dict
argument, or one with a non-str key/value, raises TypeError. Default
None: no substitution.
Loading a lemma dictionary. lemma_dict takes a plain dict[str, str],
so any source you can turn into one works. Three common ones:
- spaCy's lemma lookup tables ship as JSON, already
word -> lemma:lemma_dict = json.load(open("spacy-lookups-data/spacy_lookups_data/data/en_lemma_lookup.json")). - The Lemmatization Lists project (
michmech/lemmatization-lists) ships TSV in the opposite direction,lemma<TAB>word: invert each row:lemma_dict = {word: lemma for lemma, word in (line.split("\t") for line in open("lemmatization-en.txt"))}. - NLTK's WordNet exception lists (
nltk_data/corpora/wordnet/*.exc, one file per part of speech:adj.exc,adv.exc,noun.exc,verb.exc) are alreadyword lemmapairs, one per line, space-separated:lemma_dict = {k: v for path in exc_paths for line in open(path) for k, v in [line.split()[:2]]}. Each.excfile carries no part-of-speech tag of its own, so merging all four into onelemma_dictmeans a word ambiguous across parts of speech (a verb and a noun spelled the same, lemmatized differently by each) collapses to whichever file's entry was merged in last; if that matters for your corpus, keep onelemma_dictper part of speech instead and pass each into its owntf_idf/bm25_rank/apply_pipelinecall.
lemma_dict's real cost, measured, and its fix. A raw
dict[str, str] marshals the whole Python dict into a Rust
HashMap<String, String> fresh on every call. For a small map (a handful
of entries) that cost is negligible. For a realistically-sized lemma table
(spaCy's own English lookup data is tens of thousands of entries), it is
not. A 20,000-entry lemma_dict costs
roughly 1.4ms of marshalling per call, independent of how much text that
call processes. Called once over a large batch, that cost amortizes away.
Called repeatedly (once per short text, the shape a naive loop reaches
for), that fixed cost dominates and can make apply_pipeline/
tf_idf/bm25_rank measurably slower than an equivalent idiomatic Python
loop (tools/bench_lemma_dict.py measured roughly 10-460x slower at small
per-call batch sizes with a 20,000-entry map).
tors.CompiledLemmaDict is the fix: build the HashMap once, reuse it
across every call. lemma_dict accepts either a raw dict[str, str] (the
per-call cost above) or a CompiledLemmaDict (an Arc::clone per call
after the one-time build: measured at roughly 600x faster than the raw-
dict path for a 20,000-entry map called repeatedly). Building a
CompiledLemmaDict still costs the same materialization time; it pays
that cost once instead of on every call:
compiled = tors.CompiledLemmaDict(lemma_dict) # pay the cost once
for batch in many_batches:
tors.apply_pipeline(batch, lowercase=True, lemma_dict=compiled) # O(1) per call after
CompiledLemmaDict is immutable once built and not a general
caching mechanism (no identity-keyed cache lives inside tors, silently
reusing a stale mapping if a caller mutated their dict between calls:
that footgun is exactly what an explicit, caller-controlled handle avoids).
It does not reopen the case for a stateful pipeline object: it is scoped
to this one parameter, on this one measured cost, the same narrow shape
re.compile() has in the stdlib.
What this is for, and what it is not. A lightweight keyword-weighting and document-similarity primitive for pipelines that don't want an ML dependency: surfacing a document's most distinctive terms, or comparing documents by their score vectors. It is not a full NLP pipeline stage: no lemmatization, no stop-word removal, no n-grams; stemming and accent-folding are opt-in, everything else stays case-folding only. And, matching this crate's posture: this is a correctness/capability primitive, not a retrieval- or model-quality promise; whether TF-IDF weighting helps a particular downstream task is the caller's question to answer.
tors.tf_idf(["the cat sat on the mat", "the dog sat on the log", "birds fly in the sky"])
# [[('cat', 1.6931471805599454), ('mat', 1.6931471805599454), ('on', 1.2876820724517808),
# ('sat', 1.2876820724517808), ('the', 2.0)],
# [('dog', 1.6931471805599454), ('log', 1.6931471805599454), ('on', 1.2876820724517808),
# ('sat', 1.2876820724517808), ('the', 2.0)],
# [('birds', 1.6931471805599454), ('fly', 1.6931471805599454),
# ('in', 1.6931471805599454), ('sky', 1.6931471805599454),
# ('the', 1.0)]]
tors.bm25_rank
def bm25_rank(
query: str,
corpus: list[str],
*,
k1: float = 1.5,
b: float = 0.75,
strip_accents: bool = False,
stemmer: str | None = None,
lemma_dict: dict[str, str] | CompiledLemmaDict | None = None,
) -> list[tuple[int, float]]: ...
Async: await tors.aio.bm25_rank(...) runs this under asyncio.to_thread (see Async use).
Okapi BM25 score for every document in corpus against query, one
GIL-released native pass: (index, score) pairs for every document: no
top-k cutoff baked in, slice/sort the result yourself; sorted by score
descending, ties broken by ascending original index.
A reranking primitive, not a search index. bm25_rank recomputes
corpus statistics from scratch on every call. That is the right shape for
the common RAG pattern of reranking a small, already-retrieved candidate
set (tens to a few hundred documents: a vector-search step's top-k, say)
against one query: no state to manage, composes with the rest of tors's
flat, stateless primitives, cheap enough at that scale to recompute per
call. It is NOT a search engine: a corpus with thousands of
documents queried repeatedly wants a real inverted index built once and
queried many times: recomputing corpus statistics from scratch on every
single query wastes that work every time. For that, reach for a real
search engine (tantivy is the mature, dominant choice in Rust); tors
does not build persistent index objects, the same scope line that kept a
Merkle inclusion-proof API out of this crate.
The formula: for query Q (tokenized to a set of DISTINCT terms: a
repeated query word contributes its IDF once, the standard Robertson/
Spärck-Jones convention) and document D:
score(D, Q) = sum over t in Q of IDF(t) * f(t,D) * (k1 + 1)
-----------------------------------
f(t,D) + k1 * (1 - b + b * |D| / avgdl)
IDF(t) = ln( (N - n(t) + 0.5) / (n(t) + 0.5) + 1 )
N = corpus size, n(t) = number of documents containing t, f(t,D) =
t's occurrence count in D, |D| = D's token count, avgdl = the
corpus's mean document length. IDF is the always-non-negative "+1"
(Lucene-since-2011) variant, not the classic
ln((N-n(t)+0.5)/(n(t)+0.5)) form, which goes negative for a term
appearing in more than half the corpus: a surprising, unwanted answer for
a reranking primitive with no stopword list to filter such terms out
first.
k1 (>= 0, default 1.5) tunes term-frequency saturation: how much repeat
occurrences of a term keep adding to the score; b (in [0, 1], default
0.75) tunes length normalization: how much a longer-than-average document
is penalized. Both are Lucene/Elasticsearch's own defaults. Out-of-range
values raise ValueError.
Tokenization: the same "real word token, lowercased" convention
tf_idf uses (UAX #29 word segments, non-whitespace only). strip_accents/
stemmer/lemma_dict are tf_idf's exact same opt-in knobs (see its docs
for the accent-folding/stemming/lemma-substitution details), applied
identically to query and every corpus document, since scoring a query
normalized differently from its corpus produces meaningless scores, not
just imprecise ones. All default off, reproducing the original
lowercase-only tokenization exactly.
An empty corpus returns []. An empty (or all-whitespace, or
no-real-tokens) query scores every document 0.0: there are no query
terms to accumulate a score over, the correct, unsurprising answer, not an
error. A non-list corpus or a non-str entry raises TypeError; a
lone surrogate raises UnicodeEncodeError at the argument boundary. A
non-dict lemma_dict, or one with a non-str key/value, raises
TypeError.
No deadline_ms: every deadline-bearing primitive in this crate
protects against adversarial-input superlinear blowup (levenshtein/
jaro's O(n·m) DP tables, similarity_ratio's windowed Myers scans).
bm25_rank has no such shape: cost is linear in total corpus token count
plus corpus_size * distinct_query_terms, both driven directly and
proportionally by the sizes of the caller's own arguments, not by
adversarial structure within them. A caller already controls the one lever
that bounds the cost (how large a corpus they pass).
tors makes no claim about retrieval or relevance quality for any
particular corpus or query: BM25 is a well-specified ranking formula,
correctly implemented here, not a model-quality promise.
tors.bm25_rank(
"quick fox",
[
"the quick brown fox jumps over the lazy dog",
"a lazy cat sleeps all day",
"the fox and the dog are friends",
],
)
# [(0, 1.3162195220480066), (2, 0.4798180901812613), (1, 0.0)]
tors.bm25_rank("cafe", ["café société", "totally unrelated text"], strip_accents=True)
# [(0, 0.7617001984175222), (1, 0.0)]
tors.apply_pipeline
def apply_pipeline(
texts: list[str],
*,
nfd: bool = False,
lowercase: bool = False,
strip_accents: bool = False,
stemmer: str | None = None,
lemma_dict: dict[str, str] | CompiledLemmaDict | None = None,
collapse_whitespace: bool = False,
) -> list[str]: ...
Async: await tors.aio.apply_pipeline(...) runs this under asyncio.to_thread (see Async use).
A stateless, general-purpose batch text preprocessor: every requested step
fused into one GIL-released native pass over the whole texts list.
No pipeline object for the pipeline itself: pure function composition.
A re.compile()-style compiled-pipeline handle for the whole pipeline
(build once, .apply() many times) was considered and ruled out: tors
does not build persistent Rust-side state, the same scope line that kept
a Merkle inclusion-proof API and a real search index out of this crate.
Every call re-describes and re-applies its steps fresh. lemma_dict is
the one narrow, measured exception: materializing a large caller-supplied
dict into a Rust HashMap is linear and, for a realistic multi-thousand-
entry lemma table, costs enough per call to matter at small batch sizes
(see tors.CompiledLemmaDict above): that one measured cost is why
lemma_dict alone accepts a pre-built handle. It does not reopen the case
for a general pipeline object: nothing else in apply_pipeline gets one.
Order of operations: nfd → lowercase → strip_accents →
(stemmer / lemma_dict) → collapse_whitespace, each step skipped
entirely when its flag is off/None. nfd/lowercase/strip_accents
are codepoint-level transforms: they don't care about word boundaries,
so they run over the whole text directly (reusing tors.nfd's and
tf_idf's own accent-folding algorithm verbatim). stemmer/lemma_dict
are word-level: the already-transformed text is walked segment-by-segment
(the same UAX #29 split_word_bounds walk tf_idf/bm25_rank tokenize
with), transforming only real-word segments and preserving every other
segment (punctuation, whitespace) verbatim, so the output stays
readable prose, not a token list. collapse_whitespace runs last,
reducing every run of Python-whitespace-equivalent codepoints to exactly
one ASCII space, not tors.normalize's full pipeline (no
CRLF folding, no blank-line-run collapsing, no leading/trailing strip),
just whitespace-run collapsing.
Identity contract: all six steps default off, and
apply_pipeline(texts) with nothing else is a true identity: the
original texts list object comes back unchanged, not just
content-equal output, matching the zero-allocation contract
normalize/quote/replace_many already give for their own no-op case.
Argument validation (every element a str) still runs even on
this fast path: a non-str element always raises TypeError, never
silently passes through untouched. Empty texts → [].
stemmer/lemma_dict are tf_idf's exact same opt-in knobs (see its
docs for the accent-folding/stemming/lemma-substitution details, the
Snowball language list, and why lemmatization stays out of scope as a
bundled dataset). One note specific to this function: rust-stemmers'
Stemmer::stem expects already-lowercased input; passing stemmer
without lowercase=True is not an error, but the stem quality degrades:
this is not silently corrected, matching tors's "the caller composes"
posture throughout.
Relationship to tf_idf/bm25_rank: those two already fuse the same
strip_accents/stemmer/lemma_dict knobs directly into their own
tokenization. Calling apply_pipeline first and then tf_idf/bm25_rank
on the result tokenizes twice for no benefit: reach for their own knobs
when they're the only consumer; reach for apply_pipeline to preprocess
text feeding anything else (chunk_text, find_patterns, your own
logic).
A non-list argument or non-str element raises TypeError; an
unrecognized stemmer name raises ValueError naming every valid
choice; a non-dict lemma_dict, or one with a non-str key/value,
raises TypeError.
tors.apply_pipeline(
[" Café RUNNERS are RUNNING! "],
nfd=True,
lowercase=True,
strip_accents=True,
stemmer="english",
collapse_whitespace=True,
)
# [' cafe runner are run! ']
tors.apply_pipeline(["This is better, right?"], lowercase=True, lemma_dict={"better": "good"})
# ['this is good, right?']
tors.soundex / tors.metaphone
def soundex(text: str) -> str: ...
def metaphone(text: str) -> str: ...
Two classic phonetic-code algorithms, via rphonetic (an Apache Commons
Codec port): soundex (a 1918-patent-era letter-plus-three-digits code,
e.g. "Robert"/"Rupert" both encode to "R163") and metaphone (the
Double Metaphone primary code, Lawrence Philips' 2000 successor to
classic Metaphone, e.g. "jumped" → "JMPT"). Both are
English/Latin-script-oriented heuristics, not general Unicode phonetics:
they group words that sound alike, typically alongside
levenshtein/jaro_winkler distance scoring rather than instead of it,
for name-matching and dedup pipelines.
Input is pre-filtered to ASCII letters before encoding. rphonetic 4.0.0's
Soundex::encode and DoubleMetaphone::encode both panic on ordinary
accented input, confirmed directly against the raw crate: Soundex's own
"clean" step filters by the full-Unicode char::is_alphabetic (too
broad: Cyrillic, CJK, Greek, and accented Latin like 'é' all pass it),
then unconditionally indexes a 26-element ASCII mapping table with
ch as usize - 65, out of bounds for anything outside plain A-Z;
DoubleMetaphone separately panics on multi-byte characters via a
byte-index slice that assumes one byte per character. Both crash on
exactly the realistic input a name-matching consumer would pass:
Soundex::default().encode("José") and
DoubleMetaphone::default().encode("Björk") both panic on the raw crate.
tors never lets a Rust panic reach Python, so text is filtered to ASCII
letters (char::is_ascii_alphabetic) before either algorithm sees it:
accents and non-Latin characters are dropped, not encoded, a documented
degradation consistent with these algorithms' documented English-only
scope even where the upstream crate doesn't crash. Empty input, or input
with no ASCII letters at all, → "".
py.detach around each call; a single str return (no marshalling
class).
tors.soundex("Robert"), tors.soundex("Rupert")
# ('R163', 'R163')
tors.metaphone("jumped")
# 'JMPT'
tors.soundex("José"), tors.metaphone("café")
# ('J200', 'KF'): accents dropped, not crashed on
tors.double_metaphone
def double_metaphone(text: str) -> tuple[str, str]: ...
The full dual-key form of tors.metaphone: (primary, alternate). The
alternate code is the algorithm's whole point: for names readable two
ways (Germanic/Slavic vs. Anglicized) it carries the second
pronunciation, so a name-matching pipeline scores a match when either
key of two names agrees; for words with one plausible pronunciation the
two elements are equal. Same English/Latin-script scope, ASCII-letters
pre-filter, and upstream-panic-avoidance note as soundex/metaphone
above. Empty input (or input with no ASCII letters) → ("", "").
tors.double_metaphone("jumped")
# ('JMPT', 'AMPT')
tors.nysiis
def nysiis(text: str) -> str: ...
The NYSIIS code (New York State Identification and Intelligence System,
1970), via rphonetic's strict commons-codec variant (codes capped at 6
characters). A Soundex successor with better first-letter and vowel
handling. Same scope/pre-filter/panic-avoidance note as soundex; the
filter additionally keeps NYSIIS keys pure ASCII, since the crate's own
clean step would otherwise let accented letters through into the code
itself. Empty input (or input with no ASCII letters) → "".
tors.nysiis("Washington")
# 'WASANG'
tors.daitch_mokotoff
def daitch_mokotoff(text: str) -> list[str]: ...
The Daitch-Mokotoff Soundex codes (1985), via rphonetic's port of
Apache Commons Codec with branching enabled: 6-digit codes designed for
Central/Eastern European surnames, the standard of Jewish-genealogy
surname matching, distinguishing sounds (guttural vs. sibilant) classic
Soundex conflates. Returns a list, not a single string: the rule table
branches on ambiguous transliterations, so one name can legitimately
encode to several codes; two names match if any of their code lists
intersect. Each code is padded to 6 digits, so input with no encodable
letters yields ["000000"], not "". Same
scope/pre-filter/upstream-panic-avoidance note as soundex.
tors.daitch_mokotoff("Peters")
# ['734000', '739400']
tors.refined_soundex
def refined_soundex(text: str) -> str: ...
A Soundex variant with a finer-grained letter-to-digit mapping table
than classic Soundex (more consonant classes distinguished, at the
cost of a longer, uncapped code rather than Soundex's fixed
letter-plus-three-digit shape). A distinct mapping, not a
formatting variant of tors.soundex (confirmed: "Robert" encodes
differently under each). Shares Soundex's exact upstream panic bug
(confirmed directly against the raw crate: RefinedSoundex::default()
.encode("José") panics with an out-of-bounds table index), so it
carries the same ASCII-letters pre-filter. Empty input (or input with
no ASCII letters) → "".
tors.refined_soundex("Robert"), tors.refined_soundex("Rupert")
# ('R901096', 'R901096')
tors.documents
Document-format extraction: PDF, the office and text formats (doc/docx, xls/xlsx, ppt/pptx, rtf, odt/ods/odp, epub, csv/tsv), and HTML, converted to GitHub-Flavored Markdown or plain text, one GIL-released native pass per call, the same discipline as every function above.
This surface ships in a second wheel: pip install tors[documents]. The base tors
wheel re-exports it as tors.documents (an ImportError with the install hint fires
when the payload is absent), and the payload (tors-documents) is version-locked to
tors: same number, released together. The split is weight discipline: the engines
live in tors-core behind the cargo feature documents, default OFF, so the base
build compiles none of them; only the payload wheel does.
The split is also the lazy-import design, and the laziness is measured (fresh
processes, /proc/self/status VmHWM): a bare python peaks at
11.9 MB; import tors at 14.7 MB, with tors.documents absent from sys.modules
(the base wheel carries no engine code at all, so the import has nothing to touch;
a subprocess gate in the suite pins the absence); import tors.documents at
18.7 MB despite all four engines being compiled into the one payload .so, by
demand paging: the engine code pages only materialize as conversions first run. You
pay nothing for documents unless you install and import them.
The engines are chosen per format family by head-to-head measurement (the
comparison and its fixtures are documented in the documents_impl crate docs, and the
suite that pins them is tests/test_documents_engines.py):
| format family | engine | why (measured) |
|---|---|---|
| pdf_oxide 0.3.78 | two-column layouts come back as separate reading-order blocks (not interleaved), /Link annotations render as [text](uri), heading detection on |
|
| HTML | html-to-markdown-rs 3.12 | drops <script>/<style> by construction (the disqualifying failure of the alternatives, which leak CSS/JS text into the body); padded GFM tables, indented nested lists, clean code fences |
| office + text (doc/docx, xls/xlsx, ppt/pptx, rtf, odt/ods/odp, epub, csv/tsv) | anydoc 0.2.4 | renders style-based docx headings and list markers that office_oxide drops entirely; covers rtf/odt/epub/csv, which office_oxide cannot read at all |
backend="oxide" (caller-selectable) |
office_oxide 0.1.10 | the alternative reader for the OOXML + legacy office formats: exact entity text (no &-escaping), against the heading/list losses above; a documented lane for diffing the two engines on your own corpus, never the default (decompression posture: 512 MiB per-part caps, no total-across-parts or output cap; the measured cases are in the max_bytes= paragraph below) |
The input is path OR data: every path-taking function also accepts the
document as data= bytes, the in-memory caller's entry, so an upload already held
as bytes converts with no temp-file roundtrip. Exactly one of the two (both →
ValueError, neither → TypeError, a non-bytes data= → TypeError, all raised
under the GIL before any work runs). A data= call has no file name, so format
resolution rests on format= and the content markers alone.
GIL model, every function in this section: argument marshalling
(validation) happens under the GIL; the one O(n) bytes copy a data= call
pays (a borrow cannot cross py.detach) rides inside the detach with the
rest of the pass (a 400 MB data= call's max
heartbeat gap is ~1.1 ms under a 1 ms ping, where a GIL-side copy starved
the same ping for ~78 ms), and the whole native pass (file read when
path=, format sniff, engine conversion, and for to_text the markdown
strip) runs inside one py.detach, and exceptions are constructed after
the GIL is reacquired; nothing raises from inside the detached region. The
hazard this removes is concrete: the official pdf_oxide pyo3 wheel
measures as GIL-held per call (worst heartbeat gap 23.6ms on a 9-page
document under a 10ms ping, growing with document size); this
payload calls the crate's Rust API directly under py.detach instead. The
band is pinned by the suite
(tests/test_documents_engines.py and tests/test_pdf.py hold the
heartbeat-granularity and 8-thread byte-identical concurrency gates).
Error taxonomy (shared by every path-taking function here):
| exception | raised when |
|---|---|
OSError |
the file is missing or unreadable (IO): a missing path is FileNotFoundError, a directory IsADirectoryError on Linux (the matched subclass, errno text in the message) |
TypeError |
neither path nor data= was passed; a non-str path= (to_markdown(123) names path, never os.fspath's bare error); a non-bytes data= (the refusal names the type only, never the value's content or a heap address, the password= doctrine); a wrong-typed pages= entry (a bool, float, or str where a 0-based int belongs); a non-str backend= (a bool, int, float, or bytes: b"anydoc" is a str-shaped value of the wrong type, not a lane name); a non-int max_bytes= (a bool, str, or float; a bool would launder through an int extraction as 1, so the type is refused first); a non-str password=; all argument-contract failures, raised under the GIL before any work runs |
ValueError |
an unknown format= name; content and extension both fail to name a format; a backend=+format pair the forced engine cannot read; on the PDF-only family, backend="anydoc": a capability refusal (not a format one: PDF+anydoc converts) naming the per-page surface the call needs and the to_markdown/to_text pair that is anydoc's whole PDF surface, raised before any work runs; an invalid pages= selection; a malformed document; an encrypted PDF without its password= (every entry fails closed: the door check raises at open); an input over max_bytes= (an explicit budget binds every lane, the PDF-only family included, checked before a byte is read or copied; the 32 MiB default, post-read, covers the anydoc and office_oxide lanes only); a non-regular path=: a FIFO, device, or socket is a typed refusal naming path and the kind, before open(2) can block (directories keep their OSError above); a NUL byte inside path= (CPython's own open("a\0b") convention, naming path) |
NeedsOcrError (a ValueError subclass) |
the anydoc backend hit a PDF with scanned/image-only pages: route the document to an OCR stage |
Format resolution order: a mislabeled or extensionless file (a temp-file download, say) still converts, because content, not the name, picks the extractor:
- an explicit
format=(extension spelling, no dot, case-insensitive); - the content markers: the binary signatures first (PDF header, RTF open group, OLE
stream names, ZIP package mimetype), then the HTML document marker (after a BOM and
whitespace, the first markup is
<!DOCTYPE htmlor<html, case-insensitive), then the CSV heuristic, the last resort of content resolution: text, not markup, where the first up-to-64 non-empty lines each carry the same count (≥1) of one delimiter candidate (,/;/ TAB, tried in that order), two lines minimum; - the input name's extension, the
path=when there was one (data=has no name; a one-line.tsvor an.xhtmlfragment resolve here, through the same name vocabularyformat=uses); - else
ValueError: nothing names a format.
The typed surface: Backend/Format/PageKind are str enums: each member is
its accepted string, so format="docx" and format=Format.DOCX are the same call, and
plain strings the Rust validator accepts (container variants like "docm"/"xlsm")
keep working without enum churn. to_markdown/to_text return the resolved format as
a Format member; sniff returns Format | None. The rules live native; the typed
view adds typing only, never a second copy of a rule.
One page convention, everywhere: every page number this surface names is a 0-based
index: pages=, PdfClassification.pages_needing_ocr, pdf_extract's list positions,
and NeedsOcrError.pages. anydoc internally reports 1-based page numbers; the core
re-bases its list once, at the seam where the engine's answer crosses into this API, so
a caller routing pages to OCR never has to remember which list carries which convention
(a mixed-convention API is a silent off-by-one aimed at exactly that caller).
tors.documents.to_markdown / tors.documents.to_text
def to_markdown(
path: str | os.PathLike[str] | None = None,
data: bytes | None = None,
format: Format | str | None = None,
backend: Backend | str = Backend.AUTO,
pages: int | list[int] | tuple[int, int] | None = None,
password: str | None = None,
max_bytes: int | None = None,
) -> tuple[Format, str]: ...
def to_text(
path: str | os.PathLike[str] | None = None,
data: bytes | None = None,
format: Format | str | None = None,
backend: Backend | str = Backend.AUTO,
pages: int | list[int] | tuple[int, int] | None = None,
password: str | None = None,
max_bytes: int | None = None,
) -> tuple[Format, str]: ...
Convert any working-format document to GitHub-Flavored Markdown (to_markdown) or
plain text (to_text), returning (format, output) where format is the format the
conversion actually used (a Format member). The document is path (a file, the
only positional, a regular file: FIFOs/devices/sockets are refused before the read)
or data= (its bytes: the same conversion, byte-identical output;
no name to consult, so resolution rests on format= and the content markers).
format= names the format explicitly ("pdf", "html"/"htm"/"xhtml",
"docx", "xlsx", "pptx", "doc", "xls", "ppt", "rtf", "odt", "ods",
"odp", "epub", "csv", "tsv", plus the container variants
"docm"/"xlsm"/"ppsx" mapping onto these: the same OOXML packages with the
content-type override naming the macro/show variant, resolved and sniffed as their
base kinds; "xlsb" routes the Excel kind as vocabulary sugar, but genuine xlsb
content is BIFF12 .bin sheets, not worksheet XML, and is refused: the engines do
not read it); None (the default) resolves
it by the order above, content markers first, the input name's extension last.
backend= picks the engine where they overlap: "auto" (the default) routes by the
measured table; "oxide" forces pdf_oxide for PDF and office_oxide for the
OOXML/legacy office formats; "anydoc" forces anydoc. A forced backend raises
ValueError on a format that engine cannot read, never a silent fallback.
pages= selects a PDF page subset, and is valid on the pdf_oxide lane only (any other
format, or backend="anydoc" on a PDF, raises ValueError):
- a single
int: one 0-based page; - a
listof ints: the explicit set; - a 2-tuple
(start, stop): a half-open range of 0-based page indices ((0, 2)on a two-page document is both pages;(1, 3)selects the pages at indices 1 and 2).
The refusals split by Python's own convention, and the suite's red-team lane pins
the split: a wrong type is TypeError, a bool, float, or str where a page index
belongs (pages=[True], [1.5], ["a"], (True, 2); range(1.0) and seq[1.5]
raise TypeError in the stdlib too, and a bool would otherwise launder through
pyo3's i64 extraction as page 0 or 1); a wrong value or shape is ValueError, a
negative index, an empty list, an empty or backwards range, a tuple that is not the
(start, stop) pair, an int too large for the i64 the binding extracts
(pages=[2**70]). Both classes raise under the GIL, before any native work runs,
each repr'ing the offending value; bounds are validated against the
real page count inside the detached pass. The selection is deduped into document
order (caller-supplied order and repeats are normalized away), the per-page
conversions are joined with pdf_oxide's own inter-page separator, and a full range is
byte-identical to the whole-document conversion.
password= unlocks an encrypted PDF, the PDF kinds only (a password on any other
format is ValueError, "password= applies to PDF documents only": a
silently-ignored password would leave the caller believing a document is protected
on a lane that cannot know; a non-str password= is the same TypeError
convention as every argument here). Without it, an encrypted PDF fails closed on
every entry: the door check raises at open, ValueError, "PDF is encrypted and
requires a password", never empty output masquerading as "no content" (the
pre-fix shape, measured on an RC4-128 fixture: to_markdown/pdf_extract returned
"" on a locked document while pdf_classify raised, the two entries disagreeing
about the same bytes; the check now raises at open for all). A wrong password is its
own clean ValueError, "the password did not unlock this PDF". The unlock rides
the pdf_oxide lane, the default auto and forced oxide both; backend="anydoc"
on a PDF has no unlock and refuses encrypted documents outright.
max_bytes= is the input ceiling, and its contract has two halves. An explicit
max_bytes binds every engine lane, pdf and HTML included, and is enforced
before any work runs: the file's size at open (a path= call never reads an
over-budget byte), the buffer's length on entry (a data= call never copies
one). max_bytes=None (the default) is the post-read doctrine: the 32 MiB
default ceiling covers the anydoc and office_oxide lanes only, the two lanes
that amplify input into resident memory, because the lane is unknowable before
the container sniff, which is exactly why only the explicit budget can be
pre-read. Over either, the call raises ValueError naming both sizes and the
max_bytes= override.
The anydoc amplification, restated at the measured worst case: a many-short-cells
csv amplifies ~146x (a 24 MiB one peaked at 3.4 GiB
RSS, stable across input sizes; the earlier "~36x" figure was a benign
long-cell shape, and short cells are the common upload and the expensive one), so
the 32 MiB default budgets ~4.6 GiB of worst-case headroom on the converting
worker; tighter is often right, and max_bytes= is the knob. The motivating
integrator shape, a service capping uploads at 100 MB, passes
max_bytes=100 * 1024 * 1024 and must budget for the worst case at that
ceiling: ~146x of 100 MiB is ~14 GiB of RSS headroom on the converting worker
(benign csv shapes measure far lower, ~36x; budget for the worst case, not the
benign one).
The opt-in backend="oxide" lane's decompression posture, measured on
office_oxide 0.1.10 (locked): per-part caps of 512 MiB, declared and actual,
refused pre-decompression (a 600 MiB declared part is refused with
"decompression limit exceeded: part 'word/document.xml'
expands to more than 536870912 bytes"), plus an XML nesting cap of 256 on a
16 MiB parse stack. It has no total-across-parts cap and no output cap: a
399 KiB zip carrying a 400 MiB word/document.xml (under the per-part cap)
converts at ~1.6 GiB peak RSS, emitting ~400 MiB of markdown, so
multi-part and output blowups remain the caller's risk on that lane, which is
one reason it is never the default. (The older "333 KiB zip-bomb docx →
1.7 GiB" figure was office_oxide 0.1.9, before the per-part caps, and is
history, not current posture. anydoc, by contrast, caps decompression engine-side:
128 MiB per entry, 512 MiB total; a zip-bomb fixture lane in the suite pins
both engines' caps firing.)
to_text is the same conversion, routing, and pages=/password=/max_bytes=
semantics, with the markdown normalized to plain text, one text shape for every
format and engine: headings keep their text (markers dropped), list items keep
indentation and numbering, table rows join their cells with " | ", code blocks
keep their content without fences, links become label (url). The normalization
runs inside the same detached pass, over the markdown, because each
engine's own plain-text surface differs (pdf_oxide's, measured, merges two-column
layouts line-by-line; the strip preserves the markdown converter's reading-order
blocks). The strip's inline machinery (link labels, image labels, emphasis)
recurses per nesting level and is depth-bounded at 256: past the bound the
remaining […](…) machinery degrades to literal text instead of recursing toward
a stack overflow: a 30,000-deep [[[…x…]]()…]() nest converts (exit 0,
non-empty output) where the unbounded strip crashed the process (the fix's
subprocess pin is in tests/test_documents_engines.py; the exact degradation
shape is unit-pinned in src/gfm_strip_impl.rs).
A multi-sheet workbook renders whole: anydoc emits each sheet as its own
## <sheet name> section (a measured two-sheet workbook, an Alpha table over a
Beta one, comes back as ## Alpha\n\n|...|\n\n## Beta\n\n|...|), so per-sheet
output is the caller's split on the ## headings. A single-sheet workbook
renders with no ## heading at all (the table is the whole output; the
committed engines_samples.xlsx does exactly that), so that split must tolerate
its absence. There is no
sheet-selection argument: whole-document output is the shape downstream callers
consume.
import tors.documents
fmt, markdown = tors.documents.to_markdown("tests/engines_corpus/engines_page.html")
# (Format.HTML, "# Annual Engineering Report\n\n## Transformer Program\n\nSee the
# [field handbook](https://handbook.example.com/torque) for torque tables.\n\n...")
fmt, text = tors.documents.to_text("tests/engines_corpus/engines_link.pdf")
# (Format.PDF, "Visit the field handbook (https://handbook.example.com/guide)\n")
# the /Link annotation survived as `label (url)`: the plain-text link shape
fmt, text = tors.documents.to_text("tests/engines_corpus/engines_units.csv")
# (Format.CSV, "unit | status\nT-101 | healthy\nT-102 | needs review\n")
# table rows join their cells with " | "
# a page subset: page index 1 only (0-based), byte-identical rule included
fmt, md = tors.documents.to_markdown("tests/engines_corpus/engines_two_page.pdf", pages=1)
# (Format.PDF, "second page line\n")
tors.documents.to_markdown("tests/engines_corpus/engines_two_page.pdf", pages=(0, 2))[
1
] == tors.documents.to_markdown("tests/engines_corpus/engines_two_page.pdf")[1]
# True: a full range is the whole document, byte-identical
A file with no usable extension still converts; the content markers decide:
# engines_report.docx's bytes, saved with no extension (a temp-file download):
tors.documents.to_markdown("upload.bin")
# (Format.DOCX, "Quarterly Review Q3 2026\n\n...")
# ...or never write the temp file at all: the bytes in, the same answer out
data = open("engines_report.docx", "rb").read()
tors.documents.to_markdown(data=data) == tors.documents.to_markdown("engines_report.docx")
# True: byte-identical, the pinned contract of the in-memory entry
Async: await tors.documents.aio.to_markdown(...) / to_text(...) run under
asyncio.to_thread (see tors.documents.aio below).
tors.documents.sniff
def sniff(data: bytes) -> Format | None: ...
The standalone content-marker format detector: what to_markdown/to_text would
resolve these bytes to from content alone, with no path and no extension: the PDF
header, the RTF open group, OLE stream names, the ZIP package mimetype, the HTML
document marker. sniff opens and parses the container: anydoc's detection reads
the ZIP/OLE package's metadata (and the main part when the markers need it), so the
call's cost is a package parse, not a marker scan (a 120 KiB zip measured 267 MiB
peak RSS to answer docx). Budget accordingly when sniffing
untrusted leading bytes: the container is parsed before the format is named. (It
still has no async twin: the call is a single short native pass, and the thread hop
plus the parse would price the awaitable spelling above its value; the sync call
is the surface.)
None is not an error: it is the answer "the content names no format", a
signature-less text format such as CSV (name it via format= or let the extension),
or not a document at all. The routing caller's mislabeled-download answer: the bytes'
verdict overrides any label the download carried.
import tors.documents
tors.documents.sniff(b"%PDF-1.7 ...")
# Format.PDF
tors.documents.sniff(b"{\\rtf1\\ansi ...")
# Format.RTF
tors.documents.sniff(b"<!DOCTYPE html>\n<html><body>hi</body></html>")
# Format.HTML
tors.documents.sniff(b"unit,status\nT-101,healthy\nT-102,failing\n")
# Format.CSV (the content heuristic: two non-empty lines, same comma count)
tors.documents.sniff(b"just some words\nover two lines\n")
# None: prose, the content names no format
tors.documents.sniff(b'{"unit": "T-101", "ok": true}\n{"unit": "T-102", "ok": false}\n')
# None: JSON-lines. The comma counts agree, but the record lines open with `{`,
# declined by the CSV heuristic; not a documents format, `format=` the escape hatch
Three doctrine notes, all probed and pinned. XHTML: an <?xml version="1.0"?>
prologue before the doctype is skipped by the HTML marker (XHTML is HTML's XML
serialization), so sniff(b'<?xml version="1.0"?><!DOCTYPE html...') is Format.HTML,
while every other XML vocabulary (<svg, DocBook) sniff-answers None. JSON-lines:
record lines opening with { (or [) are declined by the CSV heuristic: their
comma counts agree across lines (every record serializes the same keys), so the
delimiter witness alone would claim them, and anydoc's csv parser would then mangle
records that are not cells; json-lines is deliberately not a documents format,
None is the answer, and format= is the escape hatch, the same hatch a
csv whose first field opens with a brace takes (the guard reads the first
non-empty line). And the answer is the container-true name a conversion would
report: an OLE workbook sniffs Format.XLS, a ZIP-based one Format.XLSX, so
sniff and to_markdown cannot disagree about what the bytes are.
tors.documents.pdf_extract
def pdf_extract(
path: str | os.PathLike[str] | None = None,
data: bytes | None = None,
password: str | None = None,
backend: Backend | str = Backend.AUTO,
max_bytes: int | None = None,
) -> tuple[list[str], str]: ...
Read a PDF (path, or data= bytes) and return (per_page_plain_text, markdown), one native pass over one
open document, the parse paid once for both outputs. per_page_plain_text is a
list[str], one entry per page in page order: the text-layer-probe view. An
image-only/scanned page is an empty string, not an error, and a zero-page or textless
document yields empty output; routing decisions ("this PDF needs OCR") are the
caller's, made on these values (or on pdf_classify's verdicts), never silently made
here. markdown is pdf_oxide's whole-document conversion: heading detection on,
images off, Tagged-PDF structure-tree reading order falling back to XY-Cut on
untagged documents, /Link annotations rendered as [text](uri).
The PDF family's engine lanes and input budget: backend= and max_bytes=
on all four PDF-only functions, the same vocabulary the conversion pair takes.
backend="auto" (the default) and backend="oxide" both run pdf_oxide, the same
mapping the routing table makes for PDF ("oxide" is the oxide-family engine for
this format), byte-identical output either way. backend="anydoc" is refused
before any work runs with a named ValueError: a capability refusal, not the
format-level one (PDF+anydoc converts on to_markdown/to_text), because
anydoc's entire PDF surface is whole-document markdown (to_markdown(bytes) is
the one function its PDF module exposes, ~anydoc-0.2.4/src/formats/pdf.rs), and
its only per-page knowledge is the NeedsOcr refusal, while these four calls
are the probe-rich ones: pdf_extract's per-page plain text is the OCR-routing
signal (an image-only page comes back as an empty string, the caller's
route-to-OCR witness), pdf_page_count walks the page tree (a count its
reader never returns on success), pdf_classify classifies per page,
and pdf_link_uris walks /Annots (anydoc has no annotation surface at all).
Whole-document markdown from anydoc is one to_markdown(path, backend="anydoc")
call away. max_bytes= is the input budget: an explicit value binds pre-read
exactly as on the conversion pair, the path='s size at open, the data=
length on entry, never an over-budget byte read or copied; None (the default)
keeps the pdf lane unmetered (the 32 MiB default ceiling is the anydoc and
office_oxide lanes' post-read check, lanes these PDF-only calls never run).
password= unlocks an encrypted PDF; without it the entry fails closed,
ValueError at open, never empty output masquerading as "no content" (the empty
strings above are for unlocked documents; a contract failure precedes the work,
so an encrypted document under backend="anydoc" surfaces the capability
refusal, never the door-check error).
import tors.documents
pages, markdown = tors.documents.pdf_extract("tests/engines_corpus/engines_two_page.pdf")
# (["first page line", "second page line"], "first page line\n\n---\n\nsecond page line\n")
# per-page plain text + the joined markdown, one open, one pass
# the lane vocabulary: "oxide" is the engine "auto" already routes PDF to,
# "anydoc" a capability refusal pointing at the conversion pair
tors.documents.pdf_extract("tests/engines_corpus/engines_two_page.pdf", backend="oxide") == (
pages,
markdown,
)
# True: the same pdf_oxide lane either way, byte-identical answers
try:
tors.documents.pdf_extract("tests/engines_corpus/engines_two_page.pdf", backend="anydoc")
except ValueError as exc:
exc
# ValueError('backend "anydoc" cannot serve the per-page text probe (the
# OCR-routing signal): anydoc\'s PDF surface is whole-document conversion
# only — to_markdown/to_text with backend="anydoc" (NeedsOcrError is that
# lane\'s scanned-page signal); use backend=\'auto\' or \'oxide\' here')
# the capability refusal, its message the pointer at the pair
Async: await tors.documents.aio.pdf_extract(...) runs this under
asyncio.to_thread so the event loop stays responsive across the call.
tors.documents.pdf_page_count
def pdf_page_count(
path: str | os.PathLike[str] | None = None,
data: bytes | None = None,
password: str | None = None,
backend: Backend | str = Backend.AUTO,
max_bytes: int | None = None,
) -> int: ...
The page tree and nothing else, no content extraction. For gating expensive
downstream work (an OCR or conversion pass that scales with page count) without
paying for any of it. password= unlocks an encrypted PDF; without it the entry
fails closed (ValueError at open). backend=/max_bytes= follow the family's
shared lane note in the pdf_extract section
above: auto/oxide run pdf_oxide byte-identically, backend="anydoc" the
capability refusal (a count that engine's reader never returns on
success), an explicit budget binding pre-read.
import tors.documents
tors.documents.pdf_page_count("tests/engines_corpus/engines_two_page.pdf")
# 2
Async: await tors.documents.aio.pdf_page_count(...) runs this under
asyncio.to_thread so the event loop stays responsive across the call.
tors.documents.pdf_link_uris
def pdf_link_uris(
path: str | os.PathLike[str] | None = None,
data: bytes | None = None,
password: str | None = None,
backend: Backend | str = Backend.AUTO,
max_bytes: int | None = None,
) -> list[list[str]]: ...
The /Annots link walk: for every page, the URIs of its link annotations whose action
is a URI, in annotation order, one list[str] per page, page order, empty lists for
pages without link annotations. This is the raw navigation surface, deliberately
beside the markdown's inline [text](uri) links because the two answer different
questions: the markdown carries links whose visible text belongs in prose; this walk
carries every URI, including ones behind link rectangles whose text is not itself a
link (a "click here" button, an image, a bare rectangle) which no text rendering
surfaces at all. The caller that motivated it measured the difference on real
resumes: 60 documents, 16 links from the text layer, 34 from the annotations, a
quarter of candidates gained a LinkedIn/GitHub URL no text shape would show.
Verbatim and narrow, both on purpose: the lists are never deduped or canonicalized
(callers canonicalize differently: per-page review panels vs whole-document
projections), and only URI actions surface (GoTo is in-document navigation,
GoToR a remote file; neither is a web URI, and neither is fabricated into one).
Malformed annotation dictionaries are skipped by the engine's parser, not propagated
as page failures. backend=/max_bytes= follow the family's shared lane note in
the pdf_extract section above; the annotation walk
is pdf_oxide's reader (auto/oxide byte-identically), and backend="anydoc" the
capability refusal: anydoc has no annotation surface at all.
password= unlocks an encrypted PDF; without it the entry fails closed
(ValueError at open, never empty lists masquerading as "no links").
import tors.documents
tors.documents.pdf_link_uris("tests/engines_corpus/engines_link.pdf")
# [["https://handbook.example.com/guide"]]: page 0's one link annotation
tors.documents.pdf_link_uris("tests/engines_corpus/engines_two_page.pdf")
# [[], []]: no link annotations anywhere, empty lists, never fabricated
Async: await tors.documents.aio.pdf_link_uris(...) runs this under
asyncio.to_thread so the event loop stays responsive across the call.
tors.documents.pdf_classify
def pdf_classify(
path: str | os.PathLike[str] | None = None,
data: bytes | None = None,
password: str | None = None,
backend: Backend | str = Backend.AUTO,
max_bytes: int | None = None,
) -> PdfClassification: ...
The cheap text-vs-image preflight over a PDF: no content conversion, no OCR,
no rasterization. The answer to "does this PDF have a text layer, or is it an image
we can do nothing with locally", as a PdfClassification (below): every page's
PageKind verdict, the pages needing OCR, and the two derived routing booleans.
Encrypted documents fail closed on every entry (ValueError at open, pdf_oxide's
security rule: a security state is never masked as "all pages empty");
password= unlocks one. backend=/max_bytes= follow the family's shared lane
note in the pdf_extract section above: auto/oxide
run pdf_oxide byte-identically, backend="anydoc" the capability refusal
(per-page classification; that engine's only per-page knowledge is the binary
needs-OCR refusal), an explicit budget binding pre-read.
PageKind is the per-page vocabulary: "text" (a native text layer), "scanned"
(image-dominated: OCR the page), "image_text" (hybrid), "mixed", or "empty".
"empty" is distinct from "scanned", and the distinction is the point: a blank
page is neither extractable nor an image to recover (not an error, not OCR work),
so pages_needing_ocr deliberately excludes it. has_text is true when at least one
page is text/image_text/mixed (extraction will yield something); image_only is
true when every page is scanned and there is at least one page (route the whole
document to an OCR stage).
The indices here are 0-based, like every page number this surface names; see the
convention note in the tors.documents section above.
import tors.documents
cls = tors.documents.pdf_classify("tests/engines_corpus/engines_mixed.pdf")
# PdfClassification(page_count=2, page_kinds=[<PageKind.TEXT: 'text'>,
# <PageKind.SCANNED: 'scanned'>], pages_needing_ocr=[1])
cls.has_text, cls.image_only, cls.pages_needing_ocr
# (True, False, [1]): page index 1 (0-based: the second page) is the scan
cls = tors.documents.pdf_classify("tests/engines_corpus/engines_blank.pdf")
# PdfClassification(page_count=1, page_kinds=[<PageKind.EMPTY: 'empty'>], pages_needing_ocr=[])
# blank ≠ scanned: nothing to extract, nothing to recover, no OCR routing
Async: await tors.documents.aio.pdf_classify(...) runs this under
asyncio.to_thread so the event loop stays responsive across the call.
tors.documents.PdfClassification
The pdf_classify result: the preflight's answer with the routing rules derived
exactly once (the has_text/image_only rules live in the native getters; the typed
view adds typing only). Attributes: page_count: int, page_kinds: list[PageKind]
(every page's verdict, page order), pages_needing_ocr: list[int] (the 0-based
indices of the image-only pages: empty for a born-digital document, every page for a
scan, the difference for a mixed one), has_text: bool, image_only: bool. The
repr is the construction shape shown above.
tors.documents.NeedsOcrError
class NeedsOcrError(ValueError):
pages: list[int] # the 0-based page indices needing OCR
page_count: int
Raised by to_markdown/to_text when the anydoc backend hits a PDF with
scanned/image-only pages: the "route this document to an OCR stage" signal, as an
exception because the conversion cannot proceed on those pages. A
ValueError subclass, so a broad except ValueError still catches it.
.pages holds 0-based page indices, the same convention as pages= and
PdfClassification.pages_needing_ocr (anydoc's 1-based numbers are re-based once, at
the core's seam); .page_count the document's page count.
import tors.documents
try:
tors.documents.to_markdown("tests/engines_corpus/engines_scanned.pdf", backend="anydoc")
except tors.documents.NeedsOcrError as exc:
exc.pages, exc.page_count
# ([0], 1): page index 0 of 1 needs OCR
The default "auto" routing sends PDF to pdf_oxide, whose lane yields empty text for
scanned pages instead (pdf_extract/pdf_classify are the preflight calls); the
exception is the anydoc lane's answer.
tors.documents.PageKind
class PageKind(str, Enum):
TEXT = "text"
SCANNED = "scanned"
IMAGE_TEXT = "image_text"
MIXED = "mixed"
EMPTY = "empty"
One page's pdf_classify verdict, a str enum whose members are their
accepted strings. "empty" is deliberately distinct from "scanned" (see
pdf_classify above): a blank page is
neither extractable nor an image to recover, and pages_needing_ocr
excludes it.
tors.documents.Backend / tors.documents.Format
class Backend(str, Enum):
AUTO = "auto" # route by the measured table (the default)
OXIDE = "oxide" # force pdf_oxide / office_oxide
ANYDOC = "anydoc" # force anydoc
class Format(str, Enum):
PDF = "pdf"
HTML = "html"
DOC = "doc"
DOCX = "docx"
XLS = "xls"
XLSX = "xlsx"
PPT = "ppt"
PPTX = "pptx"
RTF = "rtf"
ODT = "odt"
ODS = "ods"
ODP = "odp"
EPUB = "epub"
CSV = "csv"
TSV = "tsv" # name-only vocabulary: accepted as format= input, never a resolved or sniffed answer (tsv bytes resolve and sniff as csv)
str enums: each member is its accepted string (Backend.AUTO == "auto" is True),
so every plain-string call keeps working and the enums cost nothing at the boundary;
the Rust validator remains the authority, and vocabulary the enums don't enumerate
yet (container variants like "docm"/"xlsm") stays accepted as plain strings. A
resolved format outside the vocabulary is a bug and surfaces as ValueError.
tors.documents.__version__ (and tors_documents.__version__, its source) is the
payload wheel's version, baked from the crate's Cargo.toml at build time: the
same number release-please bumps in lockstep across both wheels, so the two can
never disagree.
tors.documents.aio
The awaitable spellings of the six path functions (to_markdown, to_text,
pdf_classify, pdf_extract, pdf_page_count, pdf_link_uris), each an
unconditional asyncio.to_thread dispatch, signatures identical to the sync
spellings, path/data= flowing through unchanged (pinned by the suite). The same doctrine as tors.aio: every one of these calls is a single
native pass whose cost scales with the document (a small one is milliseconds, a large
one hundreds), the exact class a thread hop pays for. sniff stays sync-only: its
cost is a container parse (see its section above), but it remains a single short
native pass a sync caller runs directly; no awaitable spelling ships. There is
no size-based branching inside any wrapper, and the choice between the sync spelling
and tors.documents.aio is the caller's, made once at the call site.
Cancellation semantics: asyncio.to_thread cannot cancel the native pass, and a
caller can be hurt by that. wait_for/timeout() on one of
these awaitables cancels the future (the asyncio wrapper returns control at the
deadline) while the underlying thread runs the conversion to completion, holding
its memory (the amplification lanes' worth: potentially gigabytes), and repeated
timeouts pile up blocked threads on the shared default executor. Treat these
awaitables as uncancellable work: size the input before the call (max_bytes= is
the pre-read guard), and only call with a timeout you are also willing to abandon
the thread to.
import tors.documents.aio
fmt, text = await tors.documents.aio.to_text("report.docx")
# same conversion, same (Format, text) return, off the event loop's turn