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. The one deliberate exception is utf8_byte_len's no-detach
spelling — its whole body is the argument borrow plus an O(1) read, so a
detach would bracket no work and starve a co-resident event loop's
heartbeat instead of feeding it (the inverted static pin in
tests/test_utf8_byte_len.py holds this both ways: utf8 must NOT detach,
utf16's twin must). 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; the facade is a submodule — import tors.aio once, not an attribute of import tors).
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.scrub_pii
def scrub_pii(
text: str,
rules: Sequence[Literal["contact_email", "contact_phone", "api_keys"]] | None = None,
*,
salt: str | None = None,
families: Sequence[str] | None = None,
) -> str: ...
Replace contact material — email addresses and phone numbers —
and credential material — provider/platform API keys — inside
free text with correlation tokens, in one GIL-released pass. The call-site
driver: telemetry is the one store a data purge cannot reach, so an error
excerpt, rejection message, or response-body excerpt that echoes a person's
address or number — or the credential the request authenticated with —
must be scrubbed before it reaches the log: provider and platform error
text can quote the credential back (five private consumers evidenced; the
strongest, a platform whose own code comments that a vendor auth failure
"can quote the key" and keeps the full text in an admin-served ledger).
The contact rules are a port of a private consumer's telemetry-safety
module, pinned byte-identical to it at salt=""; the api_keys rule is an
extension past that contract.
What the tokens are: @domain~<digest> for an email (the domain is the
non-identifying half an operator actually reasons about — "the ambiguity is on
the corporate domain"), prefix~<digest> for a phone number, where prefix
is the dialling prefix of a +-led match: its first three code points (a
canonical E.164's country code: "+47" compact, "+1 " for the
international spelling of a NANP number, where the third code point is the
space), while any other spelling gets the digest alone: a domestic match's
head digits are the area code, the identifying half of the number, exactly
the context a visible prefix would surface, and <family prefix>~<digest>
for an API key, where the family
prefix is kept VERBATIM (sk-, sk-ant-, github_pat_, AIza, Bearer) —
the non-secret half that tells the operator WHICH credential to rotate. In
every rule <digest> is the first 12 hex chars of sha256(salt + match).
A token is a correlation handle, not a secret: it lets an operator tie two log
lines to the same address without the record holding the address.
tors.scrub_pii("unknown candidate fungai.chetima@example.com called from +14155552671 twice")
# "unknown candidate @example.com~3aa8d1d0bb1a called from +14~115f5ee5ea90 twice"
tors.scrub_pii("ring +1 (415) 555-2671 about ticket 4096")
# "ring +1 ~dc750721a848 about ticket 4096"
The three rules, a closed set (anything else is a ValueError naming it):
contact_email— the deliberately permissive local part[A-Za-z0-9._%+\-]+, a domain of ASCII letters/digits/dots/hyphens, and a two-plus-letter ASCII tail after the largest dot the greedy backtracking can reach (a@b.co9scrubsa@b.coand leaves the9;a@b.c.dnever matches;a@.b.comatches with the domain kept verbatim). URLs,mailto:links, and code spans get no special treatment: whatever email sits inside them matches — over-matching costs a token where a literal string would have read fine, while under-matching leaks. Plus-addressed spellings match whole, the tag included:ada+tag@azx.iois one match, multiple tags and a trailing+included (pinned in the battery).contact_phone, two matchers —- international: anchored on a literal
+because a bare digit run is an order number, byte count, or timestamp, and redacting that would destroy the diagnostic the scrubber exists to preserve. The grammar is the ported\+\d[\d\-. ()]{6,}\d: a digit directly after the+, six or more middle class characters, a final digit — the middle counts separators, so a long spelling matches on seven digits (+1 415 555) while+1234567never does. The separators are the ones humans and upstream APIs use (( ) - .and space); the digit class is Unicode Nd (every decimal-digit script); and a~or a second+breaks a run. One run is ONE match: two numbers split by one space (+4712345678 1234567890) are a single match with a single digest over the whole run (space-bridged over-merge, documented not fixed). A+before a run marks international intent for the whole run: the grammar matches, or the run is the ported non-match, and the domestic matcher never fires behind a+—+ (415) 555-2671stays untouched, exactly as the source leaves it.ticket 4096above survives untouched. - domestic (the extension past the ported source): un-plussed NANP
shapes — a full run of exactly ten digits, or eleven with an ASCII
leading
1, in any( ) - .spelling,1 (415) 555-2671and1-415-555-2671included. THREE discipline rules, all load-bearing: (1) the match must carry at least one SEPARATOR, so a bare unseparated digit run is never scrubbed even at exactly ten digits — it is an order number or id (the same reasoning that anchors the international matcher on+), and the requirement is also what keeps a token's own digest hex unmatchable, so phone-only stays strictly idempotent and scrub-twice convergence cannot be chained adversarially through chosen digests; (2) the match must start at a CLEAN boundary — its first char glued to neither~nor a lowercasea-f(the token-interior alphabet: hexa-fplus~, exactly — conjunction, not disjunction: either predecessor alone is dirty; not "letters" —g/z/A-Fare clean and still match), so a run starting inside a token digest can never flow out through a separator into following text (…~e292cb255128 4096stays untouched) — and a token span itself (~+ 12 digest hex) is a breaker: runs never start inside a digest (the scan resumes after the token instead of spending a composed digest-plus-number run whole, which would silently swallow the real number after it), and the byte after a token is a clean boundary even when the digest ends hex-dirty, so an adjacent number still scrubs exactly; and (3) no partial match inside a longer run — twelve-plus digits is an id, not a phone. Leading spaces are skipped (word separation); a leading structural separator (-,.,(,)) is ABSORBED into the match, not stripped (x -415-555-2671scrubs-415-555-2671whole); trailing separators survive past the last digit, same as international; and eleven digits not led by ASCII1are excluded (the NANP trunk-prefix shape) — ten-digit runs carry no leading-digit check at all, so a0-led ten-digit shape (020-794-6095) scrubs like any other and only its eleven-digit0-led spelling stays out (the+form is the international spelling of those). Its token is the digest alone, no digits of the number in it; the+-led spelling of the same digits is what keeps the dialling prefix. One reachable interaction is documented rather than fixed: an email token whose DOMAIN spells a domestic number (user@555.1234567.co) has its digit half re-tokenized by the phone pass — over-redaction in the safe direction, converging on the second scrub like every other shape. api_keys(the credential extension past the ported source) — the evidence-backed closed set of provider/platform key families, each a literal prefix plus a minimal tail (the shared[A-Za-z0-9_-]alphabet except where the table names its own) consumed MAXIMALLY (a key glued to further charset material is one long key):
| family | shape (tail is [A-Za-z0-9_-] except where the row names its own alphabet) |
token prefix |
|---|---|---|
| OpenAI | sk- / sk-proj- / sk-svcacct- + tail{20,} |
the matched prefix verbatim |
| Anthropic | sk-ant- + tail{20,} |
sk-ant- |
AIza + tail{35,} |
AIza |
|
| Fireworks | fw- / fw_ + tail{20,} |
verbatim |
| Modal | ak- / wk- + tail{20,} |
verbatim |
| GitHub | ghp_ / gho_ / ghu_ / ghs_ / ghr_ + tail{36,}; github_pat_ + tail{22,} |
verbatim |
| GitLab | glpat- / glagent- / glsoat- / glrtr- / glcbt- / glptt- / glimt- / gloas- / glft- / gldt- / glrt- / glwt- / glffct- + tail{20,} and _gitlab_session= + [A-Za-z0-9+/=]{40,} (the token-prefix set of GitLab's documented token overview, the session-cookie marker included) |
verbatim |
| Minted | azxdev_ + tail{20,}; wd- / w- + tail{43,}; cn- + tail{20,} |
verbatim |
| JWT | Bearer eyJ + three base64url segments, single-dot separated |
Bearer |
| AWS | AKIA / ASIA + [0-9A-Z]{16,}; A3T + [0-9A-Z]{17,}; AGPA / AIDA / AIPA / ANPA / ANVA / AROA + [0-9A-Z]{16,} (the access-key-ID regex's full prefix set — the legacy and resource-ID siblings the same regex carries, every row 20 chars total) |
the matched head verbatim |
| XAI | xai- + tail{20,} |
xai- |
| GCP OAuth | ya29. + tail{20,} |
ya29. |
| PEM | -----BEGIN <words> PRIVATE KEY----- … -----END <same words> PRIVATE KEY----- (the PGP label's PRIVATE KEY BLOCK----- close accepted the same way, both markers required); an unterminated BEGIN is a non-match and the whole block is the match (the PKCS#8 bare BEGIN PRIVATE KEY header carries no algorithm words and is excluded) |
PEM |
| Azure | AccountKey= + [A-Za-z0-9+/=]{40,} |
AccountKey= |
AWS AKIA/ASIA access-key IDs are now INCLUDED, evidence-backed
(the table's aws row). Deliberately EXCLUDED, on zero evidence:
Slack xox… and Stripe sk_live_/pk_live_. Three further
exclusions are shape decisions, not evidence gaps, each with its why:
the AWS secret key (the 40-char secret carries no public prefix —
undetectable without false-positive shape matching), Azure client
secrets (no distinctive public prefix), and Mistral keys (no
distinctive public prefix). One exclusion is evidence WITH a shape
cut: Google's OAuth refresh-token spelling 1//… — the prefix is
documented, but its verbatim token head would be the one family head
ending in an Nd digit, and a phone number and a refresh token in one
space-bridged run (+14155552671 1//…) compose a longer international
phone match through the token's own head (the phone pass runs after
keys and cannot tell a token head from run material), breaking the
report's overlap contract. The row waits for a head that ends outside
the digit class. The set is closed on evidence —
the families five private consumers' leaked-credential shapes
backed — and growing it is a new-evidence decision, never a
drive-by; an unlisted provider's key shape passes through whole.
The JWT family is MARKER-SCOPED (Bearer eyJ… only): a bare eyJ…
triple is never touched, because one consumer's API legitimately
carries eyJ-shaped non-secret cursors in its payloads, and redacting
those would destroy the diagnostic this scrubber exists to preserve.
Three discipline rules. (1) The prefixes are tried LONGEST-FIRST with
fall-through: sk-ant- outranks bare sk- when its own grammar
holds, and a too-short sk-ant- tail falls through to the sk-
family, whose tail swallows the ant- spelling — still scrubbed,
with the generic prefix. (2) A prefix glued to a preceding
key-charset char is MID-TOKEN and never fires: xak-… survives
whole, the same reasoning as the phone rule's clean-boundary cut (in
real text a key glued to a word is that word's fragment), and it is
what keeps a second key glued to a token's digest hex from firing,
UNLESS that char ends a complete escape sequence (%XX, \uXXXX,
\UHHHHHHHH, \xHH, \NNN octal, \X, or an ANSI CSI sequence
ESC [ params final): logs carry keys inside JSON strings
(\n), .NET spellings (\u0027), URL encodings (%3D), C byte
reprs (\x1f), octal spellings (git's quoted-path \346…
output), Python's ascii()/backslashreplace non-BMP spelling
(\U0001F600), and ANSI-colored terminal output (\x1b[31m…),
and the escape's tail letter/digit is formatting material,
not the word a key head would be glued to. The sequence must be
COMPLETE and DIRECTLY before the head: a doubled backslash escapes
itself (the neighbor stays a literal, the head stays mid-token —
\xHH and \NNN recount their backslash run; \uXXXX/\UHHHHHHHH
do not,
the one released over-trigger), a percent sign without two hex
digits is prose, a one-digit \x4 is prose, octal munch is
maximal (\1234 is escape \123 + a literal 4), a [ without
the ESC byte is prose ([31msk-… stays mid-token), and the
control-char spelling \cX (shell/Perl) stays a documented
non-match — the escape grammar is closed on evidence like the
family set. A -----BEGIN
head directly after a DASH RUN or a SHARED CLOSE is likewise a clean
boundary — a preceding block's -----END …----- close is armor, not
a word: either a dash run directly before the head (the close's own
run, END CERTIFICATE----------BEGIN …), or the shared close, the
head's five dashes doubling as the preceding close's five
(…CERTIFICATE-----BEGIN …, a word directly before the head). The
carve only opens the boundary; the PEM match downstream still
requires both markers with the same words, so only a complete,
self-validating private-key block redacts, its glue word verbatim.
(3) The tail run is
MAXIMAL, dots included nowhere: sk-….x.co
scrubs the key and leaves .x.co (only the JWT grammar carries dots,
inside its own marker-scoped shape; the ya29. dot is prefix, not
tail — see the table). The digest is of the FULL match
(prefix + tail).
Key-family selection (families=). The family set is closed:
tors.KEY_FAMILIES: tuple[str, ...]
# ("openai", "anthropic", "google", "fireworks", "modal", "github",
# "minted", "jwt", "aws", "xai", "gcp_oauth", "pem", "azure", "gitlab")
the canonical tuple, in the KeyFamily discriminant order, and the base for all-but-X
selections ([f for f in tors.KEY_FAMILIES if f != "jwt"]). families=None (the
default) is every key family this version knows; the set grows when new
families land — semver-visible — so callers needing stability list names
explicitly. A list selects exactly those families (order irrelevant,
duplicates deduped; tuples accepted); a bare string is a TypeError,
never an iterated character list. A valid selection is harmless —
ignored — when api_keys is not in the active rules, but validation is
still at the boundary: an unknown name is a ValueError naming the accepted set — families must be one of ('openai', 'anthropic', 'google', 'fireworks', 'modal', 'github', 'minted', 'jwt', 'aws', 'xai', 'gcp_oauth', 'pem', 'azure', 'gitlab'), not "ssn" — and [] is a ValueError, never a silent no-op — families selects no key families; use None for all or list names.
Threat model: diagnostic-preserving, NOT adversarial-robust. The
grammars above are parity-correct against the ported source at
salt="", and parity is exactly why they are narrow: widening them
silently would break the byte-identical contract. The residual
bypassables below are DOCUMENTED, not fixed — attacker-controlled
formatting bypasses this scrubber, and that is the expected trade-off
for a scrubber that must never eat order numbers, byte counts, and
timestamps:
/,:,,,;(and every other non-class char) split runs:a/b.co,a:b.co,a,b.conever match, exactly as the source leaves them.- Fullwidth
+(U+FF0B) is not the literal+, and fullwidth/odd spaces are not the ASCII space class:+12345678stays untouched. - RFC local characters outside
[A-Za-z0-9._%+-]fragment-leak:!,#,$,&,',*,/,=,?,^,`,{,|,},~split the local part, soa!b@x.coscrubsb@x.coand thea!survives (pinned in the battery). - RFC quoted-string locals leak WHOLE, not as fragments:
"user@name"@example.comand"a@b"@x.conever match at all —"is outside the local class, and the quote immediately before the real@blocks the match, so the whole address survives. Canonicalize (strip RFC quotes / split display-names) before scrubbing if quoted locals are in threat; no grammar widening — widening would break thesalt=""byte-identical contract. - IP-literal and dotted-quad domains leak WHOLE:
user@[192.168.1.1]never matches ([breaks the domain run) anduser@192.168.1.1never matches (the trailing quad has no two-letter ASCII tail), so the whole address survives. Canonicalize (normalize IP literals / dotted quads to a scrubbed spelling) before scrubbing if these spellings are in threat; no grammar widening (parity). - IDN / non-ASCII domains leak whole: the email classes are ASCII-only,
so
a@exämple.comnever matches (the documented non-match). For internationalized domains, idna-to-punycode BEFORE scrubbing (user@exämple.com→user@xn--exmple-cua.com) so the ASCII grammar can see it — and treat the pre-image as sensitive until that step runs. - Bare digit runs never match even at ten/eleven digits (
4155552671,14155552671are order numbers), and short+-led runs never match (+1234567, seven digits, is the ported non-match). - Extensions survive past the last digit:
415-555-2671 x1234scrubs415-555-2671and leavesx1234untouched, same as international (trailing separators survive; the extension is not part of the match). - NPA/NXX are unvalidated: any ten-digit NANP-width run with a separator matches — area/exchange codes are never checked against the NANP plan.
- Email-then-phone composition over-redacts in the safe direction: the
email local removal can trim a too-long digit run into exactly ten
(or eleven-with-
1) digits that then scrub (1415 555 2671 12345a@b.coscrubs the trimmed1415 555 2671though the input run was sixteen digits). No digits leak; the id is partially tokenized. The parity lanes route these inputs to the extension lane. - A natural (non-email)
~+ 12-lowercase-hex span is treated as a token breaker: the number after it scrubs even when glued hex-dirty. Over-redaction in the safe direction — and the reason a digest tail can never suppress the number after it. - Punctuation inside a key splits the tail:
sk-abc…/…restnever matches whole, and an inserted/,:,;, or.leaks the key's fragments — the same attacker-formatting trade-off as the contact grammars (the shared tail charset is deliberately narrow — the Azure row names its own wider alphabet — and widening it would eat identifiers that merely look key-shaped). Invisible characters inside a key or a PEM marker behave the same way: a zero-width space (U+200B), RTL override (U+202E), or combining mark inserted into the tail run or the-----BEGINliteral breaks the grammar and leaks. Directly BEFORE a key head the same characters are the safe direction — they are not key-charset bytes, so the head fires clean (pinned). Canonicalize (strip Cf, map combining marks away) before scrubbing if invisible-character insertion is in threat. - An unlisted provider's key shape leaks WHOLE: the family table is the
evidence-backed closed set above — Slack
xox…and Stripe are excluded on zero evidence (AWSAKIA/ASIAmoved to the table on evidence), the prefix-less shapes pass through as documented above — and a new family is a new-evidence decision with its own pins, never a silent widening. - Contact-glue under-redaction: a key glued with ZERO separator to
preceding contact material is mid-token under the boundary rule and
survives whole, and no later pass recovers it — a phone number's last
digit (
+14155552671sk-…), an email domain's last letter (a@b.cosk-…), a prior token's digest hex (+14~abc123def456sk-…): field concatenation without separators, the machine-plausible form. The composed call still scrubs the contact half; the key half leaks. Separate fields before scrubbing if concatenations are in threat. - Preserved-span cloaking under family selection: a span an unselected
family spends whole stays whole — including any selected family's key
inside it (a key in a deselected PEM block's body, a key shape inside
a deselected JWT's segments). Deselecting families reintroduces leak
risk for material inside the preserved spans;
families=Noneis the only full-coverage selection, andreport["skipped"]names exactly what each narrowed call preserved. -
3-segment JWT/JWE survival: the JWT grammar is exactly three segments, so a 5-part JWE behind
Bearerscrubs through the third segment and keeps parts 4-5 verbatim. Split or reject multi-dotBearermaterial before scrubbing if JWEs are in threat. - A bare
eyJ…triple (noBearermarker) is never touched, even three well-shaped base64url segments: one consumer's API legitimately carries eyJ-shaped non-secret cursors, so the JWT family fires only behind the marker — the deliberate under-redaction side of that scoping. - The kept family prefix is a coarse provider label (
sk-vssk-ant-), not a credential: it exists so the token tells the operator which credential to rotate. It narrows the search space for whoever already holds the log — the same known-salt reasoning as below, priced in.
If your threat includes adversarial formatting (an attacker choosing
the spelling to dodge the scrubber), canonicalize BEFORE scrubbing —
and treat the scrub as one layer, not the whole control. tors.nfkc
alone is insufficient: NFKC folds fullwidth alphanumerics and some
compatibility spaces, but it does not fold every separator the phone
grammar splits on. Map explicitly first:
import re, unicodedata
_SEP = re.compile(r"[\t\n\r\f\v\u0020\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+")
def canonicalize_for_scrub(text: str) -> str:
text = unicodedata.normalize("NFKC", text)
# Zs/Zl/Zp + \t\n\r\f\v -> U+0020: every Unicode space separator
# (Zs, Zl, Zp) plus ASCII whitespace the grammar does not class.
text = _SEP.sub(" ", text)
# Then canonicalize separators/domains to the grammar above:
# `/ : , ;` -> " " (or ""), fullwidth + (U+FF0B) -> "+",
# IDN -> idna punycode, RFC quotes -> stripped.
return text
Then scrub the canonicalized text.
rules=None applies every rule in the canonical order — the keys
substitution over the whole string first, then the email substitution over
its result, then the phone substitution over that, each exactly once —
because the credential must be eaten whole before the contact passes scan
(a key's tail can spell a dash-separated domestic phone run, and a whole
key can spell an email local part: sk-…@x.co would otherwise be one
email match), and an email's local part may itself
carry a +-led digit run (user+14155552671@example.com is one email, and
the phone rule must see its token, never the address). One reachable
composition is documented rather than fixed: the keys pass eats a
key-shaped local part and leaves <family>~<digest>@domain, whose digest
hex is itself local-part material, so the email pass tokens
hex@domain — over-redaction in the safe direction, converging on the
second scrub. [] is the identity
(the original object); duplicates dedupe and listing order is irrelevant;
each name restricts the scrub to that rule.
The report twin: tors.scrub_pii_report
The same scrub under the same single GIL-released pass, plus the accounting:
def scrub_pii_report(
text: str,
rules: Sequence[Literal["contact_email", "contact_phone", "api_keys"]] | None = None,
*,
salt: str | None = None,
families: Sequence[str] | None = None,
) -> ScrubPiiReport: ...
report["text"] == scrub_pii(text, rules, salt=salt, families=families)
for the same arguments, byte-exact — the shape below is the accounting
for that string:
{"text": <scrubbed str>, "redacted": {"contact_email": 1, "api_keys": 3, "openai": 2, "jwt": 1}, "skipped": {"jwt": 1}, "spans": [{"type": "api_keys:openai", "start": 12, "end": 64}]}
redacted counts what scrubbed: per-rule totals (contact_email,
contact_phone, api_keys) plus per-family totals under their lowercase
names (openai, jwt, …) — absent types omitted. skipped counts what
detection saw but redaction preserved: families NOT in the active
selection that would have matched anyway (detection runs, redaction does
not) — the "preserved a JWT, log it separately" signal: select
all-but-jwt, scrub, then route report["skipped"] to the separate
credential-rotation queue. Always {} when families=None; {} when
api_keys is inactive. spans mark every redaction, ordered by start —
codepoint indices into the INPUT, each typed <rule>
(contact_email, contact_phone) or api_keys:<family>
(api_keys:openai, api_keys:jwt, …). text[start:end] is the match
it replaced, with two documented exceptions where passes compose: a
match that began inside a prior token's digest is recorded from that
token's input end (only the input-side suffix is addressable), and a
match that ran into a prior token's verbatim head overlaps the
producing span (the input bytes fed two tokens; both spans are
recorded, keys before email before phone at shared starts; a span END
at the head boundary maps affinely to the head end, so the span text
is exactly what the match ate). Empty input
is the empty report: {"text": "", "redacted": {}, "skipped": {},
"spans": []}. Salt, idempotence, and the surrogate boundary behave
exactly as documented above.
The salt trade-off, stated plainly. salt=None resolves PER RULE:
the contact rules use tors's documented default "tors/scrub_pii/v1"
and the keys rule its own "tors/scrub_keys/v1" — a fixed, non-secret,
versioned domain-separation tag each (frozen: changing either would
silently change every deployment's token values), and deliberately
different so a key digest can never alias a contact digest at the
default settings. An explicit salt string salts every rule alike (the
"" spelling is unsalted for every rule — the migration lane:
byte-identical tokens with an unsalted upstream). A KNOWN salt, the public defaults
included, does
not make the digest secret: the E.164 space is small enough to enumerate, so
an attacker with a candidate list can still confirm whether a specific
number appeared — and the same holds for EMAIL addresses (common
local-parts on common domains are enumerable too), for API keys
(provider key alphabets are smaller than their length suggests: the
secret tail of an sk- key is ~62^20, far beyond enumeration, but the
PREFIX half is kept in the clear by design — see above), and for salt=""
(the unsalted migration lane re-publishes the source chain's documented
weakness by design: byte-identical tokens with an unsalted upstream).
The digest is sha256(salt + match) with plain concatenation, so
salt/match boundaries can alias (salt="a" + match="bc" digests
like salt="ab" + match="c") — a second reason the salt is
domain-separation, not a key. The digest is 12 hex chars (48 bits):
at ~119k distinct matches the birthday merge rate is ~2.5% — two
different contacts sharing one token — frozen-for-stability (widening
the digest would break the salt="" byte-identical contract).
The tokens are redaction, not pseudonymization crypto;
deployments that care pass their own secret salt, and a consumer migrating
from an unsalted upstream scrubber passes salt="" to keep its token values
byte-identical — any other salt changes them.
Idempotence, as it true is. Tokens are individually fixed points, and the
output is a fixed point unless an email token is immediately followed by
@-shaped text — reachable when two email matches were adjacent
(a@b.co9@x.yz) or when a match is followed by an unmatched @-run whose
own local part the match consumed (x@b.co@w.vu). A token's digest hex is
local-part material, so a second pass fires once more on that boundary and
then holds: scrubbing twice always converges. If your excerpts can chain
emails like that and you need a guaranteed fixed point, scrub twice
(scrub_pii(scrub_pii(x)) — the documented helper shape; the fuzz target
asserts convergence structurally — no input match survives verbatim into
the converged output and the third pass is the identity — not token-exact
values, which the parity gates pin; the hypothesis ports mirror that
boundary); everything else is one pass. Phone-only is strictly idempotent,
and keys-only is too, by construction: a key token's family prefix ends
-, _, =, or ., or is a bare head (AIza, Bearer, PEM, the
4-char AKIA/ASIA head), the byte after it is ~ — never tail
charset — and no family prefix can be spelled inside 12 lowercase digest
hex, so the second scrub never re-fires (a key token's ~ + 12 hex is a
breaker for the phone pass as well, so a number after it keeps its own
clean run).
tors.scrub_pii(s, ...) is s exactly when no active rule matches. A str
holding lone surrogates is refused at the argument boundary with
UnicodeEncodeError ("surrogates not allowed"), the same boundary every
str-in function here documents — the ported chain diverges there by design:
its classes never match a surrogate, so it returns such text with the
matches AROUND the surrogate still scrubbed (scrub-plus-surround), while
tors refuses the whole call at the boundary. Pin the call-site shape
accordingly (sanitize-or-skip surrogates before scrubbing if your pipeline
ingests lone-surrogate text), and see the parity gates which pin the
divergence in both directions. Live re-sync cadence/owner: the quoted pin
in tests/reference.py is the CI oracle; the live source module is
re-checked manually whenever its grammar changes and at least once per
Unicode/dependency bump (owner: the scrub_pii maintainer).
Async: await tors.aio.scrub_pii(...) runs this under asyncio.to_thread (see Async use). The thread-hop costs tens of microseconds: noise next to a millisecond pass, real overhead next to a microsecond-scale excerpt — prefer the sync spelling below ~100KB of error-excerpt text, tors.aio above it. await tors.aio.scrub_pii_report(...) is the report twin under the same hop.
tors.scrub_log_text
def scrub_log_text(
text: str,
rules: Sequence[
Literal[
"pg_detail_lines",
"uri_userinfo",
"uri_query_creds",
"uri_query_creds_extended",
"libpq_conninfo_creds",
"secret_tokens",
]
]
| None = None,
) -> str: ...
Named-rule log and exception-text scrubbing, five linear scans + splice
under one py.detach: the scrub a worker applies to
str(exc)/repr(exc)/rendered tracebacks before any of it reaches a log
line, a span, or an exported attribute, byte-identical to the compiled
regexes that define its grammar — pinned by a differential
harness that races tors against that reference (see
Design and scope for why this is a named-rule surface
rather than a pattern parameter).
Six names, one closed set:
pg_detail_lines— PostgreSQLDETAIL:lines quote caller-supplied row values, so the whole line is dropped. Both separator spellings: real newlines (line content deleted, the newline kept — a blank line is left behind; a CRLF line's\ris consumed with the content; thetraceback.format_exceptiongutter run of anExceptionGroup/except*sub-exception — repeated|/+markers, one layer per nesting level — absorbed before the anchor), and therepr()-flattened\nDETAIL:runs a traceback's final line carries (consumed up to the next escaped separator or the repr tail — a quote followed by the run of)/]closersrepr()ends with,')plain and')])inside an ExceptionGroup's list — which is preserved:PostgresError('msg\nDETAIL: … exists.')comes back asPostgresError('msg')).
[!WARNING] SECURITY POLICY, changed in this release (issue #107), inverting the 0.7.0 behavior: the repr-flattened run's lookahead is FAIL-CLOSED. A run whose tail matches neither safe delimiter — an unterminated repr (no closing quote), or one with more text behind the quote — scrubs THROUGH END OF LINE. 0.7.0 left such runs alone (a pinned non-match); the consumer chain's stated policy is that a delimiter miss must delete MORE text, never less of the secret, and tors follows it. The deletion can now also eat text a userinfo mask would have needed (
scrub("a://u:p\\nDETAIL:x@h")is"a://u:p"— the DETAIL deletion takes the whole tail); runuri_userinfoseparately when credential removal must outrank DETAIL parity. -uri_userinfo—scheme://user:password@hostbecomesscheme://user:***@host: scheme and username preserved verbatim, empty username handled, password ending at the first@. -uri_query_creds— the URI-query anchor of the credential pass over the five shared credential names, the grammar shared withlibpq_conninfo_credsbelow:[?&]name=value→[?&]name=***.[!WARNING] SECURITY-POLICY CHANGE (the weighted-RRF-and-secret-params release), widening the default chain:
uri_query_creds_extendedis new, and the full chain (rules=Noneincluded) now runs the EXTENDED key set. A text that carries?sig=/?api_key=/?token=/?key=(and the rest of the extended names) scrubbed differently before this release: the query value is masked where 0.x left it verbatim — over-redaction, never under (a delimiter miss must delete MORE text, never less of the secret). The shared five's behavior is byte-identical everywhere;uri_query_credsselected EXPLICITLY keeps the exact five-name contract. -uri_query_creds_extended— the SAME[?&]anchor over the EXTENDED credential-key set: the shared five plus the ops-standard query-parameter names (access_key,api_key,apikey,auth,key,passkey,pw,sas_token,secret,sig,token). The names are the published scanner lists, transcribed and closed: ESLintno-credentials-in-query-params(eslint-plugin-browser-security)'s default sensitive terms (password,token,secret,api_key/apiKey,auth), detect-secrets' AWS secret-keyword list (key,pass,password,pw,pwd,token), and Azure's own SAS query grammar (?sv=...&sig=...) — the credential-in-URL problem class CWE-598 names (query strings land in access logs, proxy logs, browser history, and theRefererheader of every outbound link). Judicious cuts, stated: nosession/sessionid/sid(session identifiers are not credentials; masking them corrupts a log line for nothing), noaccess_token/refresh_token/auth_token(the_tokensuffix family is real but open-ended —oauth_token=does not mask under the closed set, the documented seam; more names arrive as evidence names them, never by pattern). -libpq_conninfo_creds— the keyword/value anchor of the SAME pass:name=valuewhere the name is not the tail of a longer word (the live chain's(?<![A-Za-z0-9_])lookbehind), so libpq conninfo text (host=db password='hun ter2'— no://, no?) masks too, andcpwd=is not mistaken forpwd=.
The shared value grammar, all three names: the credential names
(uri_query_creds/libpq_conninfo_creds match the five shared
parameters password, passphrase, passwd, pwd, sslpassword;
uri_query_creds_extended the extended superset) matched
CASE-INSENSITIVELY (libpq names are case-insensitive and operators'
DSNs echo back whatever casing was written), and the value is either
a libpq single-quoted string — which may carry spaces and honors the
\' and \\ escapes — or an unquoted token running to whitespace or
&, deliberately NOT stopping at @ (a password may legally contain
an unencoded @, and a mask that stops there leaves the tail riding
after the ***). Name and delimiter are preserved: ?password=a@b →
?password=***.
- secret_tokens — the secret-token grammars of
tors.scrub_secrets over the SAME closed
five-grammar set, each span spliced to *** (this family's mask
convention; run scrub_secrets when the token digests are wanted
instead). It runs LAST in the chain, after the conninfo pass: its
spans ride no other rule's anchors and no established rule sees its
masks.
rules=None (the default) runs the full chain in canonical order:
pg_detail_lines → uri_userinfo → the conninfo credential pass →
secret_tokens, each
rule a whole pass over the current text before the next begins (a DETAIL
deletion can eat the @ a userinfo mask anchors on — rule interaction is
why the order is a contract, not a caller choice). The conninfo pass
answers to THREE names (uri_query_creds the [?&] anchor over the
shared five, uri_query_creds_extended the same anchor over the extended
set, libpq_conninfo_creds the libpq keyword lookbehind over the shared
five); any selection of them — including the default chain — runs the
combined pass ONCE, each = matched against the widest key set its
anchor selects, never two sequential substitutions.
[!WARNING] The default chain can leave a credential fragment by design:
scrub("pg://u:p\\nDETAIL:x@h')")is"pg://u:p')"(the DETAIL deletion eats the@, the userinfo mask then has nothing to anchor on, the passwordpsurvives). The order is the documented contract (the scrub a worker applies tostr(exc)/repr(exc)/rendered tracebacks before any of it reaches a log line, a span, or an exported attribute — up to four passes per text, ~24 per failed job across its message/traceback/span texts); do NOT reorder to "fix" the fragment. Safe pattern when credential removal outranks DETAIL handling: runuri_userinfoseparately (e.g.scrub_log_text(text, ["uri_userinfo"]), which gives"pg://u:***@h')"here) — trading the DETAIL deletion for the mask, deliberately and visibly at the call site.A second documented seam: a digit-leading scheme defeats the userinfo anchor everywhere in the family —
scrub_log_text,scrub_log_text(text, ["uri_userinfo"]), andscrub_piiall leave1postgres://user:pass@hostwhole (the shared scheme anchor is\b[a-zA-Z], so1postgresis not a scheme to it; verified against all three spellings). This is the grammar's pinned shape, not a defect; there is currently no tors surface that redacts a digit-prefixed scheme URI — a caller seeing that shape needs its own pre-pass.
rules=[] is the identity; duplicates
dedupe and caller order is irrelevant; an unknown name raises ValueError
naming the accepted set. A pass never rescans its own output.
tors.scrub_log_text(s, rules) is s exactly when no rule fires — including
the *** fixed points, where a rule fires and splices to an equal value:
those return a fresh, equal string. Scrubbing the scrubbed output is a
fixed point by the second pass, with one documented cross-rule exception
to strict idempotence: a password-param replacement deletes a / that was
capping a userinfo user run, so the second pass can find one more
redaction (x://u?pwd=a/b&:pw@h scrubs to x://u?pwd=***&:pw@h, and a
second scrub claims the password too) — the ported chain behaves
identically, and a third scrub is always the fixed point.
tors.scrub_log_text(
"JobError: duplicate key\n"
"DETAIL: Key (idempotency_key)=(customer-4417-a3f2) already exists.\n"
"HINT: unchanged"
)
# "JobError: duplicate key\n\nHINT: unchanged" (blank line left behind)
tors.scrub_log_text(
"connect dsn=postgresql://worker:S3cr3t-x9@db.internal:5432/prod?password=fallback"
)
# "connect dsn=postgresql://worker:***@db.internal:5432/prod?password=***"
tors.scrub_log_text("JobError('duplicate key\\nDETAIL: Key (idempotency_key)=(customer-4417) exists.')")
# "JobError('duplicate key')" (closing quote preserved)
tors.scrub_log_text(
"connect dsn=postgresql://worker:S3cr3t-x9@db.internal:5432/prod",
["uri_userinfo"],
)
# "connect dsn=postgresql://worker:***@db.internal:5432/prod" (one rule alone)
tors.scrub_log_text(
"GET https://api.internal/v1?sv=2020&sig=sha%3Dabc&api_key=kk123&sas_token=tok&x=1"
)
# "GET https://api.internal/v1?sv=2020&sig=***&api_key=***&sas_token=***&x=1"
# (the extended key set: the full chain runs it by default)
tors.scrub_log_text(
"GET https://api.internal/v1?sig=sha%3Dabc&api_key=kk123",
["uri_query_creds"],
)
# "GET https://api.internal/v1?sig=sha%3Dabc&api_key=kk123" (the base rule
# answers only the shared five; the extended names ride with
# "uri_query_creds_extended")
Async: await tors.aio.scrub_log_text(...) runs this under asyncio.to_thread (see Async use).
tors.scrub_secrets
def scrub_secrets(
text: str,
rules: Sequence[
Literal["aws_access_key", "slack_token", "stripe_key", "github_token", "pem_key"]
]
| None = None,
*,
salt: str | None = None,
) -> str: ...
Replace secret-token material — the credential shapes operators hit in
logs that scrub_pii's evidence-backed keys rule deliberately does not
carry — with correlation tokens, in one GIL-released pass. Each span
becomes <head>~<digest>: the head is the match's own non-secret prefix
verbatim (AKIA, xoxb, sk_live_, ghp_ — the half that tells the
operator WHICH credential to rotate), or a constant when the grammar has
none (github for the legacy hex class, PEM for the block span), and
<digest> is the first 12 hex chars of sha256(salt + match) over the
full match. salt=None resolves to tors's documented default
"tors/scrub_secrets/v1" tag — a third domain-separation tag, so a
secret digest never aliases a scrub_pii digest at the default
settings; a KNOWN salt still leaves candidate-list confirmation
possible: the tokens are redaction, not pseudonymization crypto.
# The full shape is assembled at runtime: push protection scans the
# pushed blobs for the contiguous token; the example's key is Amazon's
# own documented spelling (IAM's "Manage access keys" page).
key = "AKIA" + "IOSFODNN7EXAMPLE"
tors.scrub_secrets(f"deploy used {key} and nothing else")
# "deploy used AKIA~d46f248e6407 and nothing else"
token = "xox" + "b-123456789012-1234567890123-abcdefghijklmnop"
tors.scrub_secrets(f"bot token {token} in the log")
# "bot token xoxb~10708077e3b8 in the log"
The five grammars, a closed set (anything else is a ValueError naming
it). Every grammar is a hand-rolled single-pass scanner with anchored
boundaries, the same architecture as scrub_pii: a prefix (or hex-run
head) glued to a preceding key-charset char [A-Za-z0-9_-] is
mid-token and never fires (XAKIA…), unless that char ends a complete
escape sequence (%XX, \uXXXX, …) or an ANSI CSI sequence
(formatting material, not a word), or the position opens a PEM BEGIN
head after a dash run. Exact-width grammars refuse a candidate inside a
LONGER run of their own alphabet (a near-miss is a longer word, never a
match).
aws_access_key—AKIA/ASIA+ exactly 16 chars of[0-9A-Z]. The prefixes are the access-key-ID heads AWS's own docs spell (theAKIA+IOSFODNN7EXAMPLEexample, IAM's "Manage access keys" page); the tail alphabet is uppercase letters and digits — Amazon's example carries bothIandO, so there is no I/O exclusion to implement. The class matches detect-secrets' AWS detector and trufflehog's (\b(?:AKIA|ABIA|ACCA)[A-Z0-9]{16}\b); tors pins the two live/temporary credential prefixes. AWS documents no checksum for access key IDs, so none is validated and none is claimed. False positives: 20-char[0-9A-Z]strings with anAKIA/ASIAhead at a clean boundary — rare outside real key material.slack_token— case-insensitivexox[abprso]-+ one or more digit-run/dash sections + a final alphanumeric run: detect-secrets' Slack detector (xox(?:a|b|p|o|s|r)-(?:\d+-)+[a-z0-9]+under IGNORECASE), pinned verbatim. The classes are ASCII digits and letters only: Python's\dunder the cited pattern would also match Unicode digits (xoxb-١٢٣-…), which the scanner refuses — a documented scanner/oracle divergence. Slack's docs pin the prefixes it documents today (xoxb-bot,xoxp-user) and the dash-separated section structure whose final section is the secret (32 chars now, 6 or 10 before August 2016); trufflehog's stricter two-numeric-sections spelling is a subset, and the looser cited grammar is the one pinned (a redactor's misses are the dangerous direction). Slack'sxapp-app-level prefix is a different head and stays out. False positives: non-Slack strings shapedxox?-digits-digits-alnum, rare outside token-shaped fixtures.stripe_key—(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{24,}, the tail consumed maximally. Stripe's docs pin the six prefixes (pk_live_/rk_live_/sk_live_and the_test_spellings) and no length; trufflehog's detector ([rs]k_live_[a-zA-Z0-9]{20,247}) brackets the documented example key spelling (sk_live_+ 24), and 24+ keeps newer longer keys matching. No checksum is documented by Stripe; none is validated or claimed.pk_keys are not secrets by Stripe's own table and scrub anyway (over-redaction, safe direction); the organization prefixsk_org_(no live/test half in Stripe's docs) stays a documented exclusion. The REPORT says which environment a key belongs to (stripe_live/stripe_test).github_token— two classes under one name. Modern:(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}, exactly 36 — GitHub's token-format post pins the five prefixes, the_separator, and the base62 body (30 random chars plus a 6-char checksum tail). GitHub DEFINES that checksum (a 32-bit CRC32 base62-encoded) but does not publish its exact construction, so tors validates no checksum and claims none: recall-biased by design. Legacy: a maximal hex run of exactly 40 chars at a word boundary — GitHub's post describes its own pre-2021 tokens as "hex-encoded 40 character strings that are indistinguishable from other encoded data like SHA hashes". That legacy class is the maximally recall-biased rule in this family: EVERY clean 40-char hex run matches, SHA-1 digests and git commit ids included; a log stream dense with bare SHA-1s selects it knowingly. The fine-grainedgithub_pat_shape is not here —scrub_pii'sapi_keysrule carries it and the rest of its own evidence-backed set.pem_key— the-----BEGIN <words> PRIVATE KEY-----…-----END <same words> PRIVATE KEY-----block span (RFC 7468's framing), the WHOLE block the redacted span. Both markers required, the label's words equal, the PGPPRIVATE KEY BLOCK-----close accepted the same way — the keys rule's own marker machinery, reused rather than reimplemented. An unterminated BEGIN is a documented non-match. The false-positive rate is negligible: the grammar is self-validating.
rules=None runs every grammar; rules=[] is the identity; each name
selects that grammar. Tokens are fixed points by construction (every
prefix's tail check lands on the ~, never its own alphabet; the digest
hex spells no prefix), so scrub_secrets is strictly idempotent —
stronger than scrub_pii's single re-fire corner.
tors.scrub_secrets(s, rules) is s exactly when no grammar matched.
The report twin: tors.scrub_secrets_report
The same scrub for the
same arguments (report["text"] == scrub_secrets(...) byte-exact) plus
the accounting, scrub_pii_report's shape with all three keys present
every time: redacted (per-kind counts, lowercase names, absent kinds
omitted) and spans (ordered by start, codepoint indices into the
INPUT text, each {"type": ..., "start": ..., "end": ...}). The scan
is one pass, so the spans are input coordinates with no offset mapping:
text[span["start"]:span["end"]] re-derives the matched credential
shape from the input directly. Empty input is the empty accounting.
# The full shapes are assembled at runtime: push protection scans the
# pushed blobs for the contiguous token (the vendors' documented
# example spellings).
live = "sk_live_" + "4eC39HqLyjWDarjtT1zdp7dc"
test = "sk_test_" + "51AbCdEfGhIjKlMnOpQrStUvw"
tors.scrub_secrets_report(f"live {live} test {test}")
# {"text": "live sk_live_~ef9505bb1efc test sk_test_~7e3d266c1b3e",
# "redacted": {"stripe_live": 1, "stripe_test": 1},
# "spans": [{"type": "stripe_live", "start": 5, "end": 37},
# {"type": "stripe_test", "start": 43, "end": 76}]}
In scrub_log_text, the rule name secret_tokens runs these same five
grammars over a log line and splices *** over each span (this
family's mask convention, no digests), last in the chain:
token = "xox" + "b-123456789012-1234567890123-abcdefghijklmnop"
tors.scrub_log_text(f"auth {token} ok", ["secret_tokens"])
# "auth *** ok"
Async: await tors.aio.scrub_secrets(...) runs this under asyncio.to_thread (see Async use). await tors.aio.scrub_secrets_report(...) is the report twin under the same hop.
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)]
Async: await tors.aio.word_bounds(...) runs this under asyncio.to_thread
(see Async use) — the marshalling hold above is exactly what the hop
removes from the event loop.
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.json_is_valid
def json_is_valid(data: bytes | str) -> bool: ...
The RFC 8259 validity gate: True exactly when orjson.loads(data) would
succeed — one linear scan over the raw bytes, no object tree, GIL-released.
Built for the validate-and-discard gate, bytes that are parsed once and
thrown away: on a 64 KiB list-of-small-dicts document, ~95% of a full
orjson.loads is constructing objects nobody reads, and the scan alone is
a 4-5x cheaper pass at these sizes (64 KiB ~40 µs, 1 MiB ~0.7 ms; issue
61's measured prototype table). The scanner is hand-rolled and iterative —
no recursion, no heap, an O(1) fixed stack — so pathological inputs cost the same linear pass.
The acceptance set is orjson 3.x's, not the stdlib's (json.loads's):
where the two disagree, orjson's reading wins, because the gate stands in
front of a consumer whose next step IS orjson.loads. The documented
seams, each pinned in tests/test_json_is_valid.py:
- Float-overflow literals reject:
1e400,-1e400,1e309,2e308,1.7976931348623159e308raiseJSONDecodeErrorin orjson ("number is infinity when parsed as double") where the stdlib hands backinf. The scanner computes the literal's f64 value and rejects a non-finite result; underflow (1e-400→0.0) is finite and accepts. Long-integer literals (20+ digits) ride the same fallback — orjson parses them as doubles — so 3099s (9.99e308) reject where 308 accept; ≤ 19 digits always accept. Caveat: the decision trusts correctly-rounded parsing (Rust'sf64parser), so a literal within one rounding step of ±1.8e308 could in principle disagree with orjson's own float parser; a 40,000-case knife-edge sweep plus the 2,666-input differential corpus found zero such disagreements. - NaN / Infinity / -Infinity reject (no such grammar in RFC 8259; the stdlib accepts them as floats).
- A leading UTF-8 BOM rejects (the stdlib strips it).
- Lone
\ud800-class surrogate escapes reject — a high surrogate escape must be immediately followed by\u+ a low surrogate — and so does a UTF-8-encoded surrogate ("\xed\xa0\x80"); the stdlib builds lone surrogates from both. This is the class that makes a looser validator the unsafe direction for a gate (serde'sIgnoredAny, #61's rejected alternative, accepted both). - Depth cap 1024 (orjson's): the 1025th open container rejects, objects
and arrays counting against one shared cap. It is an answer (
False), not an exception — a validity gate is a boolean question. - Invalid UTF-8 anywhere rejects; raw control characters in strings reject
(
\u0000the escape accepts); trailing garbage, trailing commas, leading zeros, and unterminated strings reject; duplicate keys accept (orjson last-wins). - A
strholding a raw lone surrogate answersFalse, not a raise: such a str has no UTF-8 view to scan, and every consumer's parser (orjson'sloadsincluded) raises on one, so the gate's "would-loads-raise" answer isFalse— the raise-free contract holds on this lane too (the stdlib would accept some of these; orjson's reading wins, as everywhere in this surface). The escape TEXT ("\ud800"spelled out) is ordinary content and rejects through the normal grammar.
Booleans only: no invalid input raises — nothing in the scanner has an
error path. A wrong-TYPE argument (not bytes, not str) raises
TypeError like the bytes-in surface; bytearray/memoryview are
refused with it (a writable buffer mutated by another thread mid-scan
under the released GIL is a data race, not a semantic difference).
GIL behavior: utf8_is_valid's class. A bytes argument is a zero-copy
immutable borrow, the whole scan runs under one py.detach, and the bool
return has no marshalling class at all — the borrow alone is the call's
GIL-held residue, valid and invalid input alike. A str argument pays the
standard str-in borrow first, under the GIL: a zero-copy alias when the
string is pure ASCII or its UTF-8 view is already cached (repeat calls on
the same object: O(1) borrow, then the detached scan), a one-time O(input)
materialization+cache-fill on the first non-ASCII call (encode-parity;
CPython caches the view on the object, and encode reads it but never
fills it). No aio twin, matching utf8_is_valid/utf16_is_valid: a
sub-millisecond scan needs no thread hop (see Async use for the
size guidance).
tors.json_is_valid(b'{"a": [1, 2.5, true, null]}') # True
tors.json_is_valid(b"1e400") # False — orjson raises; the stdlib says inf
tors.json_is_valid(b'"\\ud800"') # False — lone surrogate escape
tors.json_is_valid(b"\xef\xbb\xbf{}") # False — BOM
tors.json_is_valid('{"a": 1}\ud800') # False — raw surrogate in the str, no raise
A str argument holding a lone surrogate (a raw one — not the escape text)
never reaches the scan at all: the str-in borrow cannot materialize its
UTF-8 view and raises UnicodeEncodeError, the standard str-argument
contract every tors str-in function shares.
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, autojunk=False).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.SequenceMatcher(None, a, b, autojunk=False) 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.
A third divergence class needs no repeated flanks at all: difflib's default
autojunk=True junk-handles every element appearing more than len(b)//100 + 1
times once len(b) >= 200, dropping it from matching, so past 200 elements the
default-constructor spelling is not the oracle — SequenceMatcher(None, a, b,
autojunk=False) is, and tors deliberately keeps the un-heuristic'd answer
(tests/test_similarity.py pins the threshold).
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: ...
Async: await tors.aio.replace_many(...) runs this under asyncio.to_thread (see Async use).
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.
Output ceiling. The spliced output's byte size is computed before anything is
allocated (saturating arithmetic over the match spans) and refused past 32 MiB
with a catchable ValueError naming the refused size and the ceiling — the
documents layer's own byte contract (DEFAULT_ANYDOC_INPUT_LIMIT), one
convention for how many bytes is one tors object. The unit is UTF-8 bytes, and no
replacement value is ever truncated mid-character: values are spliced whole or
the call is refused. A sub-ceiling allocation refusal surfaces as the same
catchable ValueError (the reservation is attempted with try_reserve, never an
uncatchable allocator abort). The ceiling applies before the identity contract's
borrowed return, so a replace whose output would exceed it raises even when the
net effect would be the identity — a >32 MiB "identity" replace is the
amplification shape wearing a disguise. The masked spelling below is
length-preserving and has no ceiling (a short key cannot amplify into a long
output there).
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: ...
Async: await tors.aio.replace_many_masked(...) runs this under asyncio.to_thread (see Async use).
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)]
Async: await tors.aio.sentence_bounds(...) runs this under
asyncio.to_thread (see Async use).
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]]: ...
Async: await tors.aio.find_patterns(...) runs this under asyncio.to_thread (see Async use).
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: ...
Async: await tors.aio.count_matches(...) runs this under asyncio.to_thread (see Async use).
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.contains_unescaped / tors.find_unescaped
def contains_unescaped(haystack: bytes, needle: bytes) -> bool: ...
def find_unescaped(haystack: bytes, needle: bytes) -> int: ...
Escape-parity-aware byte search in one GIL-released native pass: an occurrence of
needle at byte offset i counts only when the maximal run of \ immediately
before i has even length (an empty run is even, so an occurrence at offset
0 counts). An odd run means the run's backslash pairs escape each other and the
leftover one escapes the occurrence's first byte — the occurrence is literal
text, not the sequence. find_unescaped returns the offset of the first
unescaped occurrence, -1 when none exists (bytes.find's own sentinel, kept
over Optional[int] deliberately: it is the spelling stdlib callers already
branch on); contains_unescaped is the boolean spelling of the same question.
The motivating case is JSON, and it is byte-ambiguous by construction: a
serializer (orjson is the measured consumer) renders a real NUL codepoint
(U+0000) as the six-byte escape text \u0000 and the literal six-character
text of the same spelling as seven bytes (the backslash itself escaped), and
the second contains the first at offset +1 — so a plain substring test answers
"yes" for both. PostgreSQL settles it downstream: a real NUL is fatal in a
jsonb column (SQLSTATE 22P05), the literal text is fine, so a pipeline that
binds serialized JSON must know which one it holds before the INSERT — reject
the wrong one and you either refuse legal text or ship a value that fails the
bind. The stdlib's only spelling is find-then-re-parse-the-whole-value-and-
recursively-walk-it; the parity rule answers from the raw bytes directly:
tors.find_unescaped(b'"a\\u0000b"', b"\\u0000") # 2: the real NUL escape
tors.find_unescaped(b'"a\\\\u0000b"', b"\\u0000") # -1: the literal text
tors.contains_unescaped(b'"a\\\\u0000b"', b"\\u0000") # False
Contract details, each pinned in tests/test_unescaped_scan.py:
- The offsets are byte offsets, not
strindices: the return indexes thebytesit was handed —haystack[i:i + len(needle)] == needlefor every answer that is not-1. Over multibyte UTF-8 content the byte offset and the decoded text's character offset are different numbers (find_patternsneeded a byte→char mapping for exactly this confusion; this API has none, because the input is bytes and the contract is byte-space end to end). - Rejected hits advance the scan one byte past the hit, not past the whole
match, so self-overlapping needles stay correct: the two-byte needle
b"00"in a haystack of one backslash then000rejects the hit at 1 and finds the overlapping live hit at 2, which a resume-at-match-end scan would skip entirely. - An empty needle raises
ValueError("empty needle")(it would match at every position and has no parity meaning, thefind_patternsempty-pattern rationale); both arguments take exactlybytes(bytearray/memoryview/strraiseTypeError, the bytes-in surface's zero-copy immutable-borrow contract). - No JSON knowledge lives in the functions: parity is the mechanism; "the needle is an escape sequence, so even means live and odd means literal" is the caller's reading of it. Any backslash-escaped grammar (printf format strings, shell quotes, regex sources) can drive the same scan.
GIL behavior: utf8_is_valid's class exactly — two zero-copy PyBytes borrows,
the whole memmem scan plus the per-hit parity walk under one py.detach, and
bool/int returns, so there is no marshalling class at all and no error path
past the empty-needle ValueError (raised under the GIL, before the detach).
The 12 MiB pass sits well under the 10 ms heartbeat floor (memchr-class;
≈0.26 ms no-match and ≈0.70 ms over a 72,520-rejected-hit false-positive
corpus — box- and load-dependent figures from the wall cells in
tests/test_unescaped_scan.py, same order as the ≈0.25/≈0.79 ms inline
band in tests/test_gil_release.py), so the heartbeat cells are
ceiling-only. No aio twin: a sub-millisecond call needs no thread hop.
tors.utf8_byte_len
def utf8_byte_len(s: str) -> int: ...
The UTF-8 byte length of a str: len(s.encode("utf-8")) with the copy taken
out. That expression allocates a full bytes object, measures it, and throws
it away — pure waste whenever only the count is wanted, which is the shape of
every size cap in front of a store. The motivating sites are a write
path's: argument validation checks idempotency-key and scope byte caps on
every enqueue, and a terminal handler re-encodes a serialized result of
up to 64 KiB (MAX_RESULT_BYTES) on every success just to take its
length — a
genuine double pass, the byte count having existed inside the serializer's
output and been discarded by the .decode() that produced the str.
Fresh vs repeat, stated up front (the cold-lane economics the lane table
pins: cold-encode 4.9 ms | first-call 4.7 ms | warm-encode 196 µs |
cached-tors 0.13 µs at 12 MiB): a terminal fresh str per success never
repeats on the same object, so the one-shot lane is encode-parity by
construction — the win there is only the absence of a Python-visible
bytes object, not wall time. The wall-time win is repeat counts on the
same object (O(1) cached borrow vs a fresh alloc+memcpy per encode)
and every ASCII count (zero-copy alias, flat ~0.1 µs). Memory on every
lane is zero-alloc: one field read off the borrowed &str (the chunked
count is the utf16 twin's shape; this twin allocates nothing).
A companion, not a standalone motivation: this ships in the scan family's
binding module as the pinned companion of contains_unescaped/
find_unescaped, same module and same harness patterns — and honest sizing
says it would not stand alone (a short-string encode is a few hundred
nanoseconds; the win is large inputs and hot paths, where the copy is the
cost — the 64 KiB terminal case is ~0.9 µs of pure alloc+memcpy per success,
the lane table's ASCII 64 KiB expression cell).
The implementation is the standard str borrow, not arithmetic over CPython's
internal UCS storage: pyo3's to_str hands the core a Rust &str, whose
len() IS its UTF-8 byte length — one field read, the mechanism dried into
the language instead of hand-rolled (an unsafe PyUnicode_KIND/data walk with
surrogate-pair arithmetic would exist only to avoid one cached materialization).
The cost model that buys, measured — all wall numbers below are
CPython-3.12 calibration-box figures (arm64, release build; re-measure
per target — semantic pins are the contract, timing is not; the full
lane table is in tests/test_performance.py):
- ASCII (serialized JSON with
ensure_ascii=Trueis pure ASCII): compact ASCII data is its own UTF-8, so the borrow is a zero-copy alias and the call is O(1) with no allocation at all — measured flat ~0.1 µs from 1 KiB to 12 MiB, against the expression's alloc+memcpy every call (~0.9 µs at 64 KiB, ~14 µs at 1 MiB, ~180 µs at 12 MiB). - Non-ASCII, first call on the object (a cold UTF-8 cache): CPython
materializes and caches the UTF-8 view on the
strobject (an internal cache, not a Python-visiblebytes), so the first call is O(n) — encode-parity in cost class (measured within ~10-20% of a cold encode: the same encoder pass plus a malloc plus a second memcpy into the permanent cache), with no Python-visible object to allocate and collect. What "cold" means, exactly: the cache is filled by the str-in borrow itself — this call, or any earlier str-in tors call on the same object — and nothing else fills it.encodeshares the cache but only reads it, so the sharing is one-directional: after oneutf8_byte_len, a subsequentlen(s.encode())on the same 12 MiB object dropped from ~4.9 ms to ~184 µs, measured — while a priorlen(s.encode())does not warm this lane at all: the firstutf8_byte_lenafter an encode still pays the full materialization (measured ~3.8-4.8 ms at 12 MiB on fresh objects, the cold class exactly; observed on CPython 3.12 here, expected from the sources on 3.10–3.14 — seedocs/cache-proof.mdfor the per-versionObjects/unicodeobject.clinks — where the encode path reads the cache and only thePyUnicode_AsUTF8AndSizeborrow writes it). Semantic pins (same object, same answer) are the contract; timing is not — the cache is CPython-internal and the wall cells record it, never assert it. - Non-ASCII, repeat calls on the same object: O(1) — strictly better than the expression, which re-copies on every call (measured ~0.1 µs against the warm expression's 1.8 µs at 64 KiB and 196 µs at 12 MiB).
tors.utf8_byte_len("café") # 5: three ASCII bytes + one two-byte é
tors.utf8_byte_len("\U0001f600") # 4: one astral codepoint, four bytes
# the byte-cap gate a terminal handler spells on every success:
if tors.utf8_byte_len(serialized_result) > 64 * 1024:
reject() # over MAX_RESULT_BYTES — no bytes object built to find out
Error parity is exact: a str holding lone surrogates cannot be UTF-8-encoded,
and the borrow raises CPython's own UnicodeEncodeError (pyo3 propagates it
before any tors code runs) — the same exception encode raises, attributes
included (.encoding, .reason, .start/.end, .object, pinned
attribute-for-attribute in tests/test_utf8_byte_len.py); there is no
tors-side error path. The argument takes exactly str (bytes /
bytearray / memoryview / int raise TypeError).
GIL behavior: a single int return, no marshalling class — and one honest
difference from the scan twins: the call's only O(n) work is the borrow
itself, which is GIL-held (a non-ASCII object's first call materializes the
UTF-8 view under the GIL; there is no way to fill an object's cache without
it). At 12 MiB that materialization measures ~5 ms, under the 10 ms heartbeat
interval, so the heartbeat cells are ceiling-only; the linear envelope
(~0.4-0.5 ms of GIL hold per MiB) puts a ~200 MiB non-ASCII string at the
100 ms ceiling — the recorded scale guidance for this one heavy lane. No aio
twin: an O(1)-to-borrow call needs no thread hop.
tors.utf16_byte_len
def utf16_byte_len(s: str) -> int: ...
The UTF-16 byte length of a str: len(s.encode("utf-16-le")) with the
2n copy taken out — 2 bytes per BMP codepoint, 4 per astral codepoint (a
surrogate pair). This is the other half of the len() → bytes pair
utf8_byte_len opens (the maintainer's ask: a util to convert a UTF8/UTF16
python len() into bytes for the API, because backend devs need byte caps
for storage and interop). UTF-16 is the code-unit world of JavaScript,
Java, Windows, and .NET — String.prototype.length counts UTF-16 units,
and an astral emoji is length 2 there — so column caps (NVARCHAR), wire
caps, and interop size checks in that world are UTF-16 bytes, and the
Python spelling of the count allocates and copies the entire 2n bytes
object just to throw it away.
The implementation is the utf8 twin's borrow plus derived arithmetic, no
FFI: the standard str borrow hands the core a Rust &str, and the core
derives the answer from its UTF-8 bytes — UTF-16 bytes =
2 * (#codepoints + #astral codepoints), where over valid UTF-8
#codepoints is the lead-byte count and #astral is the count of 4-byte
lead bytes (matches!(b, 0xF0..=0xF4) — 0xF5..=0xFF never occur in a
&str, so the closed range fails safe) — one pass of byte-class
arithmetic, no allocation, no per-codepoint decoding (the derivation, its
proof against a chars()-based naive count, and the exhaustive boundary
sweep are in src/scan_impl.rs). The corners the identity buys: no astral
codepoints means exactly 2 * len(s) for ALL BMP text — CJK and combining
marks included, where the UTF-8 byte count diverges — pure ASCII means 2 *
the UTF-8 byte count, and every answer is even. The final doubling is
checked arithmetic that refuses instead of wrapping — and it is
unreachable for real inputs on every width: a &str is at most
isize::MAX bytes and #codepoints + #astral never exceeds one per
byte, so the doubled answer is at most 2 * isize::MAX, which fits
usize on 32-bit and 64-bit alike. The OverflowError contract is the
loud refusal if that invariant ever breaks, pinned at synthetic
boundary counts crate-side (the injectable combine unit).
The cost model, measured — all wall numbers below are
CPython-3.12 calibration-box figures (arm64, release build; re-measure
per target — semantic pins are the contract, timing is not; the full
lane table is in tests/test_performance.py):
- Warm lanes, both corpus kinds alike — the scan is
representation-independent and the utf-16 expression pays its 2n
alloc + encode pass on every kind: measured ~2.2 µs at 64 KiB against
the expression's 8.8-10.4 µs, ~34 µs at 1 MiB against 142-147 µs, and
~400 µs at 12 MiB against 1.7-1.8 ms — ratios 0.21-0.26 on every warm
lane. Repeat counts on the same object stay O(1)-borrow plus the
detached scan; memory is zero-alloc on every lane (field read plus the
chunked count, no
bytesobject on any lane). - Non-ASCII, first call on the object (a cold UTF-8 cache): the
borrow materializes and caches the UTF-8 view first (the utf8 twin's
cold class — encode-parity cost, GIL-held, and a prior
encodedoes not warm it; observed on CPython 3.12 here, expected from the sources on 3.10–3.14 — seedocs/cache-proof.md), then the scan runs detached. Measured 376 µs at 1 MiB against the utf-16 expression's own cold 146 µs — the one lane the expression wins, because it never touches UTF-8 at all;utf16_byte_lenis NOT recommended for one-shot counts of fresh non-ASCII strings. The trade buys every subsequent call at 4-5x and the absence of a 2nbytesobject per call. The fresh-object end-to-end bench is themeasured_not_assertedcell intests/test_performance.py.
tors.utf16_byte_len("café") # 8: four BMP codepoints, 2 bytes each
tors.utf16_byte_len("\U0001f600") # 4: one astral codepoint, a surrogate pair
tors.utf16_byte_len("東京") # 4: two BMP codepoints — the UTF-8 count is 6
# a JS/Windows column cap, counted without the 2n copy:
if tors.utf16_byte_len(s) > 280: # over 140 UTF-16 units: NVARCHAR(140)'s budget
reject()
The surrogate lane is REFUSAL PARITY with the replaced expression, measured
and pinned (a first-draft claim that the stdlib's utf-16-le encode accepts
lone surrogates was wrong — the test battery caught it): the strict
encode("utf-16-le") refuses lone surrogates exactly like the utf-8 codec
does ("surrogates not allowed"), so the function refuses the same strings
the expression itself refuses.
⚠ Breaking differences from the replaced expression. The refusal decision is parity; the error SHAPE is not: (1)
.encodingis"utf-8"(the str-in borrow materializing the UTF-8 view is the step that fails) where the expression's error says"utf-16-le"— an encoding-label switch a caller matching on.encodingwill observe; (2) on a multi-surrogate run the borrow's(start, end)names the whole run where the utf-16-le error reports only the first unit — a whole-run span vs a first-unit span; (3)errors="surrogatepass"(one 2-byte unit per lone surrogate, the 3-byte WTF-8 spelling in utf-8) is unsupported — no str-argument tors function offers a surrogate mode, since every one needs the UTF-8 view first. Pinned attribute-for-attribute in tests/test_utf8_byte_len.py, the byte-len family file (including theencode("utf-16")BOM form, which answers the le length plus the 2-byte BOM). The argument takes exactlystr(bytes/bytearray/memoryview/intraiseTypeError) — it counts astr, not decodes bytes; the decode side of UTF-16 isdecode_utf16.
GIL behavior: the borrow is the call's GIL-held residue (a non-ASCII object's first call materializes the UTF-8 view under the GIL — the utf8 twin's class, ~5 ms at 12 MiB, under the 10 ms heartbeat interval), and the detach carries the real O(n) scan (~0.4 ms at 12 MiB, memchr-class) where the utf8 twin's detach is nominal around one field read. The heartbeat cells are ceiling-only; the linear envelope (~0.4-0.5 ms of GIL hold per MiB) puts a ~200 MiB non-ASCII string at the 100 ms ceiling, the twin's recorded scale guidance. No aio twin: the GIL-held residue is the borrow alone, and the scan detaches.
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.
The *_iter objects are the opposite: an iterator returned by e.g.
word_bounds_iter holds a mutable native cursor, belongs to one thread, and must
not be drained concurrently — a concurrent __next__ raises RuntimeError: Already
borrowed (a clean exception, no corruption); the Compiled* classes are the
shareable ones.
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]]: ...
Async: await tors.aio.extract_code_blocks(...) runs this under asyncio.to_thread (see Async use).
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: ...
Async: await tors.aio.strip_code_fences(...) runs this under asyncio.to_thread (see Async use).
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). When a schema is passed, the budget bounds the schema
alignment layer too: the key-remap ladder, the union and type-union branch
retries, scalar coercion, missing-key fill, enum suggestion scoring, and
validation all sample the same clock, with the same soft bound (at most one
property or one union branch past expiry). The enum suggestion loop
reads the clock before every member's comparison and hands the clock's
remaining budget to each jaro-winkler score, so both the many-members axis
and the one-very-long-member axis are bounded: a wide enum of long members
raises TimeoutError within a small multiple of the budget instead of
running the loop to completion, and an expired clock raises — a None
suggestion on an expired clock would mask the timeout as a plain data
miss. The key-remap ladder's fuzzy tier samples the same way — a forced
clock read per property (short window-disjoint key comparisons never
consult a clock internally) plus the remaining budget handed to each
comparison — so the one-unknown-key-over-a-wide-schema axis is bounded
like the enum's, by the same design. 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, one validation pass — the validator crate's error-carrying
validate is the most expensive opaque unit, so the validity gate rides
its boolean API and validate() always reads the clock before it) 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
when a schema is passed, the schema's own PRE-alignment phase — the
repairer's construction: the resolve walk, the root validator's
validator_for compile, the address-set walk, all eager and linear in
the schema's size — runs BEFORE the clock is armed (the budget attaches
to the constructed repairer, mod.rs arms it right after new), so a
wide schema's fixed phase is not deadlineable: ~0.5s at 100,000
properties, whatever the budget. The alignment layer that follows samples
the clock per property, so the phase is the overshoot's whole body for a
wide-schema call (a 5ms budget over a 100k-property schema raises at the
first ladder consult, ~0.5s in); making that phase interruptible is a
design change (the compile is the validator crate's one opaque call), not
a sampling one. The
schema alignment layer shares the same clock throughout, including the
salvage unwrap's nested repair: a salvage=True call inherits the
caller's budget inside the unwrap instead of restarting unbounded. 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; a constrained schema position — anenummember value,const,minimum,maximum,exclusiveMinimum,exclusiveMaximum, ormultipleOf— carrying an integer outside the exact-integer range (i64::MIN..=u64::MAX) refuses the schema outright with aValueErrornaming the position's JSON pointer (e.g./properties/count/minimum): compiled asf64, such a constraint cannot distinguish neighboring integers, so it would silently accept values the schema rejects, and tors refuses to guess rather than validate lossily (in-range integers — the wholei64/u64span — are exact and never refuse; schema floats keep the historical lossy-float reading, and the document's own huge integers are untouched); 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. - Shared-reference schemas: the schema walk visits every path, so a
schema built from shared references (48 nested shared lists, depth 48)
would expand exponentially; the walk caps container visits at the canon
walk's 2,000,000-node ceiling and refuses past it with a catchable
ValueError("Input schema visits too many objects"). A legitimately large FLAT schema of the same node count is unaffected — the cap counts container visits, and a flat schema's cost is linear in the caller's own input. - 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).
Async: await tors.aio.repair_json(...) runs this under asyncio.to_thread (see Async use). The whole repair pass is one detached native pass (a 12 MiB damaged payload repairs in a 122 ms wall, ratio 0.08) whose documented measurements reach seconds and minutes on large inputs, so the tens-of-microsecond hop is noise on MB-scale documents; a KiB-scale LLM snippet repairs in microseconds, prefer the sync spelling there.
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'}]}
Async: await tors.aio.repair_json_loads(...) runs this under asyncio.to_thread (see Async use). It is tors.repair_json's exact pass with the decoded object marshalled out, so the same story holds: the hop is noise next to a multi-MB repair, real overhead next to a microsecond-scale KiB snippet; prefer the sync spelling on snippets, tors.aio on documents.
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', ...}])
Async: await tors.aio.repair_json_diagnostics(...) runs this under asyncio.to_thread (see Async use). Same detached pass as tors.repair_json, with an O(actions) diagnostics list added to the residue, so the guidance is unchanged: prefer the sync spelling on KiB-scale snippets, tors.aio on MB-scale documents.
tors.JsonRepairer
class JsonRepairer:
def __init__(self, *, ensure_ascii: bool = True) -> None: ...
def push(self, chunk: str) -> str: ...
def end(self) -> str: ...
def snapshot(self) -> str: ...
def reset(self) -> None: ...
The stateful incremental repairer for LLM token streams: the same repair
semantics tors.repair_json applies whole-text, applied chunk by chunk as
the tokens arrive. Why it exists: a token stream cannot call
repair_json per chunk. The engine owns the whole input and re-decides it
with lookahead and splices; re-running any whole-text pass per push re-parses
the accumulated text every time, the quadratic shape the
suture/repair-json-stream publish measures at ~15x the wall when the chunk
count quadruples. JsonRepairer is the streaming answer: one left-to-right
state machine (the container stack, the string/escape state, one pending
token), every chunk processed exactly once, nothing ever re-parsed: the total
work is linear in the total bytes at any chunk size (~4x wall per 4x input,
pinned; at a fixed 4KiB chunk size the machine streams 128KiB in 2.55ms and
512KiB in 10.87ms, ratio 4.26, ~2.1x per doubling, and the 128-push stream
costs 0.54x one whole-text repair of the same text). The documented
anti-pattern (a per-chunk re-parse) measures ~16x on the same pin and fails
it.
The contract. push(chunk) feeds one chunk and returns the text THIS
call newly emitted: canonical JSON with the stream's open state still open
(the delta; a pending number or bare word streams out when its run
terminates). snapshot() renders the current state closed: the pending
token resolved, the open string quoted shut, the open containers closed;
the result is loadable JSON at every point of the stream (the live-preview
shape), it never consumes anything, and snapshot() == end() on the same
prefix, always (pinned at every split position of the sweep corpus).
end() finalizes: the truncated-output heal; a second end() returns the
same text; push after end() raises ValueError; reset() restores a
fresh state. push returns the newly emitted text rather than the whole
document so far because a per-push whole-document return is itself
O(stream) marshalling per chunk: the same quadratic-per-chunk class the
linearity pin gates; concatenate the deltas to reconstruct the emitted
stream, and treat snapshot/end as the authority (a late repair that
retracts text — the dangling-member drop, the grouping-paren unwrap, the
empty-element retract, the array lane's stray-... and strictly-empty
item drops and the deferred-escape re-render, truncates or drains the
emitted stream behind the delta cursor).
The deferred-escape strings (the weekly-fuzz crash class): the
whole-text engine's two lanes decode \f and \/ differently, and the
lane that decides is the one a stream cannot know at the string's own
close. The strict lanes (the whole-input json.loads fast path, the
repair parser's suffix probe) decode both through the canonical table;
the repair lane accepts neither: the backslash stays a literal
character in the value and the re-dump re-escapes it (\f to \\f,
\/ to \\/), and the repair lane decides whenever the document is
cut, a paren opened anywhere, or the parse was otherwise repaired
mid-document. A string whose raw source holds one of those escapes (or
an odd backslash run of three or more, which the repair lane's run scan
collapses differently, or a misescaped non-active delimiter) therefore
does not commit its rendering at its own close: the rendering streams
verbatim, but the repairer holds the string's raw source as a
deferred-escape mark and resolves every mark at end()/snapshot()
close time, verbatim when the strict lanes decided (["\f"],
["\f%"], {"\f\/%": 1}, ["\f"]x all keep), re-rendered through the
lib's escape normalization when the repair lane did (["\f%" heals to
["\\f%"], ["\f", 1 to ["\\f", 1], {"\f\/%": 1 to
{"\\f\\/%": 1}, [[ "\f"] to [["\\f"]], ("\f") to "\\f",
[tru, "\f"] to ["tru", "\\f"]). "Keep" means the strict lanes
decided and the mark resolves to the rendering as the canonical decode
spelled it, byte for byte where the escape table agrees with the
re-dump: the {"\f\/%": 1} keep example's emitted text is
{"\f/%": 1} because the canonical table MELTS the escaped solidus
(\/ decodes to a bare / and the re-dump does not re-escape it --
the engine's own emitted bytes), while the \f stands; the mark never
re-writes such a keep, the canonical decode itself spelled it. The mark
flushes only at close
time: every separator keeps the rendering mid-stream, so the
distinction survives the separator (["\f", 1 escapes while
["\f", 1] keeps), and it composes with the strictly-empty item drops,
the paren taint and multi-string documents (each string holds its own
mark); a string still OPEN at the cut is the same class — the cut's
repair lane always decides, so the in-flight content re-renders through
the same escape walk before the escape-tail rstrip (["\f to
["\\f"], ["\/ to ["\\/"]). The walk mirrors the engine's
smart-quote machinery: an ordinary „ in the value pushes the lib's
low smart-quote span, and the odd backslash run's own delimiter then
joins the collapsed backslash as content instead of taking it into the
value (["e„\\\" repairs to ["e\u201e\\\""], where the plain
content collapses to ["e\""]) — „ is the only delimiter that
re-decides this way (”, a \" escape and a check-point " — the
lib's second pop arm, a plain " whose predecessor took the
default-append path — pop the span, spans nest),
and the drain also fires on a held partial escape at the cut
(["\ud83d\u repairs to ["\ufffd\\u"] — the oracle keeping the
\ud83d escape, the documented lone-surrogate divergence). The same
smart-quote span machinery is whole-text-only where it meets a CLOSE
quote: a slow-path string (one holding a backslash) whose value holds
an ordinary „ absorbs its span-active close quotes as content and
runs on (["e„\\\""" repairs to ["e\u201e\\\"\""]), where the
linear stream closes at the quote (["e\u201e\\\""]) and the tail
re-synchronizes — the string re-synchronization class above. An escape
the two lanes decode
identically never defers: the
streaming decode's own table carries the repair lane's
\x-plus-two-hex-digits acceptance (\x41 to A) beside the strict
set.
The escaped own delimiter and the '-delimited spans: the
streaming decode's escape table carries the lib's active-delimiter arm
— an escaped OWN delimiter decodes to the delimiter alone, the
backslash dropped (['a\' repairs to ["a'"], where the invalid-escape
spelling kept both, ["a\\'"]). A '-delimited string always takes
the lib's slow-path string scan (the fast path is "-delimited only),
so the streaming decode carries its low smart-quote span machinery
whole: an ordinary „ pushes (the acc[-1] != "\\" gate included —
a „ hard on a backslash-ending value, the halved even run's own \
or a decoded \u005c, is plain content), an ordinary ” pops, a
\+" escape pops, and a CHECK-POINT " pops — the lib's second pop
arm (_scan_string_body's low-span " arm at the loop tail, examining
the char AFTER a default-appended one; a " whose predecessor took any
other arm — the „ push, the ” pop, an escape decode, a popped-"'s
own literal append — is the loop-top current char and does not pop).
While a span is ACTIVE the string cannot close: the own-delimiter quote
is content (['e„b' repairs to ["e\u201eb'"]). The span-active close
with a follow-up quote/terminator ahead is the engine's
_handle_right_delimiter_candidate lookahead's to decide (the
even-odd delimiter scan closes the string there, ['e„b'c'] to
["e\u201eb", "c'"]) — the linear stream's documented divergence. A
"-delimited string's span-active close quotes keep the string
re-synchronization class above unchanged. And the bareword-key
comma-commit's paren tail ({kf ,"="(kf ,) is the duplicate-key
dict-update class one lib step removed: the lib's whole-text lane
re-parses the paren's dangling bareword as a duplicate member key whose
update overwrites the member's value whole ({"kf": ""}), where the
linear stream keeps both members ({"kf": "=", "kf": ""}).
The repair semantics match the whole-text engine's on the classes a
stream can decide per character: single-quote and curly-quote delimiters
normalize to "; missing commas insert and trailing commas disappear
(deferred separators: the comma emits when the next member starts, so both
are correct with no rewriting); bare words and Python literals repair
(None/True/False to null/true/false, case-insensitively —
TRUE/tRuE/NONE too — other bare words to strings at their own
casing, the strict special floats NaN/Infinity/-Infinity kept at
their exact spellings); numbers follow the engine's run-and-rollback
lanes (1e+ heals to "1e", 1,000 to "1,000", 12abc to
"12abc"); comments skip (top-level ones exactly like the engine);
tuples and groupings split the engine's own way ((1) to 1, (1,) to
[1], {"a": (1, 2)} to {"a": [1, 2]}); a closer matching a frame
below the innermost one closes the levels between ({"a": [1, 2} to
{"a": [1, 2]}). The output is
canonical json.dumps-parity text (the same escape table, ensure_ascii
the same knob), so end() is byte-identical to repair_json on the same
total text for every class above: the chunk-boundary sweep suite
(tests/test_json_repair_streaming.py) streams every split position of
every corpus text through the repairer and diffs the end against the
whole-text engine, the oracle (json-repair, exact-pinned) covering the
repaired-complete case, and hypothesis streams random JSON documents
through random chunkings the same way. The array lane's item drops are
part of that claim: a cut whose open string holds the stray ... run,
and a cut after a whitespace-followed strictly-empty item, both
reproduce the whole-text answer byte for byte (pinned in the machine's
own test module and exercised by the fuzz target's engine-parity gate).
Partial literals, the decided case: there is NO tru -> true
healing. The engine's own partial semantics decide, and the stream
reproduces them exactly: repair_json("tru") is "" (a top-level word is
prose unless the whole input is one strict JSON value) and
repair_json('{"a": tru') is {"a": "tru"} (a bare word in a container is
a string), so a tru arriving at the end of a stream heals to the string
"tru" in context, or to the empty sentinel at a clean top level, and
never to a guessed true. Mid-stream the run is held back as a pending
token (a push returns the text emitted so far, "" here), not passed
through raw. Deciding otherwise would break the end-equals-engine
differential this surface is tested with.
The truncation heal (end/snapshot on a cut-off stream): an open
string closes with " — its content rstrips first (Python's whitespace
set: {"k": "a b heals to {"k": "a b"}, {"k": "a\n to {"k": "a"},
the engine's own escape-tail heal), and a content holding a
repair-lane-divergent escape resolves it first through the repair lane's
own normalization — the cut's repair lane always decides, so the open
string's escapes re-render exactly like a closed deferred-escape
string's mark (["\f to ["\\f"], ["\/ to ["\\/"], {"k": "x\n\f
to {"k": "x\n\\f"} — the rstrip is the tail's, the mid-string
newline-escape stands); a closed string that ENDS the stream
on a newline-run loses the run ({"k": "a\n" to {"k": "a"}, a tab or a
mid-string newline passes); open containers close with their own brackets
— a still-open EMPTY container drops whole at an item position ([[ to
[], [1, [ to [1]) and closes at a member-value position or the root
({"a": [ to {"a": []}), and an array's trailing strictly-empty member
drops with it ([[], [] to [[]], [1, [] to [1]), the cascade
walking up the stack; the array lane's own item drops reproduce too —
the stray ... (an item whose parse is the exact string "..." with
the parse ending on a .: the cut's open string [\r\r"... to [],
a bare number-run [1, ... to [1], the closed ["..."] element
staying) and the strictly-empty item whose next char is not a separator
([[] , to [], [[] , 1 to [1], the comma directly after the
element keeping it — the decision is LOCAL to the element's own array's
closing shape: a trailing separator where a value was required drops it
([[] ,] to [], [1, [] ,] to [1], {"a": [[] ,]} to
{"a": []} — a trailing comma is never strict-valid, so neither fast
path can cover the array and the repair lane's skip fired), a proper
close keeps it through the whole-input json.loads fast path and the
strict-suffix probe ([[] , 1] to [[], 1], [true, [] , 1],
[[] ] to [[]], {"a": ["" , 1]} to {"a": ["", 1]}, trailing
garbage after the valid root included: [[] , 1] x to [[], 1]), a
cut root drops every mark still alive ([[], [[] , 2], 3 to
[[], [2], 3]), a whitespace-held decision followed by anything but a
separator drops with the stall guard eating the whitespace (the char
re-parsing as the next member: [[] x, 1] to ["x", 1]) or the char
itself ([[]x] to []), and the paren taint never defers (a
paren-derived or paren-enclosed element drops on the first char,
whatever it is: [() ] to [], [([] , 1)] to [[1]], keeping only
for a directly-arriving ,/closer: [()] to [[]]); the pending
number/word resolves by the same
rules
that terminate it mid-stream — through the literal table, so the special
floats' spellings heal to strings ([NaN to ["NaN"], where the
complete document keeps the float) and a number-born word drops its
stray leading sign ({"a": -NaN to {"a": "NaN"}) — the rewind-born
word further takes the string lane's entry skip, dropping the run's
leading non-alphanumeric garbage before the literal check ([.t] to
["t"], [.true] to [true], [.5x] to ["5x"], [-.t] to ["t"];
the strict -Infinity spelling keeps its float) — an element that
renders empty retracts whole ([1, - to [1], the engine's falsy-nudge
rule), a missing value after : heals to "", a missing KEY drops the
whole pair ({: 1} to {}, the valid {"": 1} passing through
untouched), and a trailing separator disappears. The object-KEY lane
runs the string semantics too: an unquoted key attempt's leading
non-alphanumeric garbage drops at the commit ({-ab: 1} to
{"ab": 1}, {.5: 1} to {"5": 1}), and a whitespace holds the
attempt for the next char — it commits at its :, else it invalidates
and the key loop retries from the next word ({a b: 1} to {"b": 1});
an attempt that strips to nothing drops the pair whole ({-: 1 to
{}).
Escapes and surrogate
pairs are machine state: a backslash, a \u escape's hex digits, or the
two escapes of a surrogate pair may straddle chunks freely; a lone
surrogate escape decodes to U+FFFD, the engine's documented divergence
from the oracle.
Top-level strings, the mid-stream vs end distinction: a top-level
string is a provisional scalar, exactly like a number or a word: the
pushes emit nothing while it streams, and the snapshot is the end's own
answer (the pinned snapshot() == end() invariant). At end() the
commit equals the whole-text engine's: a completed strict double-quoted
string commits ("hi" to "hi"); a single-quoted one was prose to the
engine ('hi' to ''). Mid-stream the two spellings are held
identically — the distinction is only ever visible in what end()
commits.
Documented divergences (the full list; the output stays valid JSON or
the empty sentinel in every case): the engine's deep string
re-synchronization (it terminates a damaged string at a structural closer:
{"a": "hello} to {"a": "hello"}) and doubled-quote/escape repair are
whole-text-only, the stream closing strings at the delimiter or at end;
the engine's in-container comment consumes the value after it
({"a": /*x*/ 1} to {"a": ""}) where the stream skips the comment and
parses the value; multiple top-level values keep the first (the engine may
array-wrap: compose with repair_json when that matters); the fence
pre-pass is not streamed (feed unwrapped text, or compose with
tors.extract_code_blocks); a missing colon inserts one and keeps the
value where the engine's repair lane heals {"a" 1} to {"a": ""}; the
first-member dangling-key shape heals to {} where the whole-text
engine falls back to an array ({"a" to ["a"]); the strict-vs-repair
split on trailing tails (the engine's REPAIR lane decides by the WHOLE
input: the complete document {"a": "x\n", "b": 1} keeps the string's
newline and [NaN, 1] keeps the float, but the cut {"a": "x\n", "b": 1
strips the newline and [NaN, 1 quotes the float — the stream keeps the
strict spelling mid-document and matches only the tail-of-stream cases:
the string last before the cut, the word still in flight); the paren-with-
colon conversion (the engine turns a colon inside the parenthesized
container into an object: ("a": to {"a": ""}, the stream "a");
the mismatched-closer garbage where the engine's whole-text close-up
re-decides earlier structure (both outputs valid); the word-swallow (a
bare word born at an array position runs past a mismatched closer in the
engine's reparse: [1e} to [1, "e}"], the stream [1, "e"]); the
unquoted-value run's absorption (the engine's unquoted-value lane eats
container chars and internal whitespace into the string and re-decides
literal prefixes: [{"b": undefined},] to [{"b": "undefined},"}] where
the stream keeps [{"b": "undefined"}]; [null x] to [null, "x"]
where the stream gives ["null x"]); the empty-object fallback's body
reparse (an object closing empty over a non-trivial colon-free body
re-parses the body as an array — the stream reproduces it only when the
body recovers nothing: {]} to [] and {"a": {(] to {"a": []} match,
while { x} to the engine's ["x}"] stays {} in the stream); duplicate
object keys (the dict update-in-place: {"a": 1, "a": 2} to {"a": 2},
the stream keeps both members); the unquoted-value }-absorption without
the ws (a non-strict run VALUE at an object's close absorbs the object's
own } when the tail past it holds two chars before the next }:
{"a": b}66 to the engine's {"a": "b}66"}, the stream {"a": "b"}); a
lone high surrogate escape followed by another escape (the engine's
whole-text string-repair lane re-decides the escape run: ["\ud83d\falsee"
to the engine's ["\ufffd\\falsee"], the stream ["\ufffd\falsee"]); the
missing-colon shape with a container where the colon was due (the engine's
object loop consumes the container-open as the colon-substitute and its
repair lane empties the array: {"a" [[] , 1]} to {"a": []}, the stream
{"a": [[], 1]}); the completed-paren taint on a later deferred element
(a paren anywhere kills both fast paths, so the repair lane decided:
[(2), [] , 1] to the engine's [2, 1], the stream [2, [], 1]); the
comma-first tail after a valid root (the suffix probe is gated on
non-comma trailing content: [[] , 1] , to the engine's [1], the
stream [[], 1]); and the
compound missing-key shape no longer diverges (the key lane's string
semantics commit {: 1, : 2} to {"1,": 2} the engine's own way).
Argument contract: push takes exactly a str (TypeError otherwise;
a lone surrogate in it raises UnicodeEncodeError at the boundary, the
standard str-in convention); past the 200-container nesting cap push
raises ValueError with the engine's normalized message (reset() before
reuse); push after end() raises ValueError. The repairer owns mutable
native state: single-thread ownership, one repairer per stream, like a file
object.
GIL: each method's whole machine pass (push: O(chunk); end/
snapshot: the close-time render) runs under one py.detach; the GIL-held
residue is the O(delta) string return of push and the O(document) string
return of end/snapshot, the standard str-out marshalling class.
r = tors.JsonRepairer()
r.push('{"name": "Ada", "tags": ["adm')
r.push('in", "act')
r.snapshot()
# '{"name": "Ada", "tags": ["admin", "act"]}'
r.end()
# '{"name": "Ada", "tags": ["admin", "act"]}'
r2 = tors.JsonRepairer()
r2.push('{"a": [1, 2')
r2.end()
# '{"a": [1, 2]}'
r3 = tors.JsonRepairer()
r3.push('Answer: {"score": 9.')
r3.end()
# '{"score": 9.0}'
r3.reset()
r3.push('[1, 2')
r3.end()
# '[1, 2]'
Async: the sync class is the streaming surface and needs no aio twin:
each push is O(chunk) microseconds (a token, a line), so a per-push
asyncio.to_thread hop would cost more than the work on every call (the
exact shape docs/async.md's KiB-scale guidance warns against), and the
caller's loop is free anyway: the pushes are plain sync calls inside the
async token loop. A whole-text async repair already exists as
await tors.aio.repair_json(...); for a co-scheduled or CPU-heavy stream,
wrap the whole consume loop, not each push, in asyncio.to_thread in the
caller's code (see Async use).
Evidence: src/json_repair/streaming.rs (the machine, its decisions, and
the divergence list in the module docs) behind src/py/json_repair.rs's
JsonRepairer (one py.detach per method); the sweep and linearity pins
live in tests/test_json_repair_streaming.py, the fuzz target replaying
arbitrary chunk sequences in fuzz/fuzz_targets/json_repair_streaming.rs.
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: whole whitespace
clusters only, so the trim never ends the result mid-cluster (a Prepend plus a
no-break space is one cluster; its non-whitespace half keeps it whole):
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."
Async: await tors.aio.truncate_to_bounds(...) runs this under asyncio.to_thread (see Async use).
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 —
64 DISTINCT decoy regions: windows with identical content dedup at admission (they
refine identically), so repetitive filler cannot starve a near match. That eviction
is 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
Async: await tors.aio.is_grounded(...) runs this under asyncio.to_thread (see Async use). The default exact path is a memchr-class containment that finishes in microseconds even on MiB passages, pure overhead to hop; fuzzy=True's windowed diff scan is linear in the source and is the shape a long retrieved passage makes millisecond-scale, where the hop is noise.
tors.highlight
def highlight(
query: str,
text: str,
*,
max_snippets: int = 3,
max_chars: int = 400,
) -> GroundingResult: ...
Snippet-provenance grounding: WHERE in text the query's evidence sits —
the span-level provenance a search UI highlights and a citation deep-links
to. Returns a GroundingResult (a TypedDict): snippets — up to
max_snippets non-overlapping spans in position order, each a
GroundingSnippet (text, start, end, score) whose offsets are
Python str (codepoint) indices into the ORIGINAL text, so
text[start:end] is exactly snippet["text"] for every snippet — and
score, the best snippet's score in [0.0, 1.0] (0.0 when there is no
overlap at all). This is a lexical overlap metric, not a semantic one: it
locates evidence, it does not judge answerability.
The scorer is ROUGE-W F1 (Lin 2004, "ROUGE: A Package for Automatic
Evaluation of Summaries", §3.2): a length-weighted longest common
subsequence over token sequences, recall-oriented ("does this span cover
the query's content") with a superlinear contiguity reward — terms
occurring as a contiguous run outrank the same terms spread through
filler — and the paper's Equation 15 normalization keeping every score in
[0, 1]. BLEU (Papineni et al. 2002) was considered and rejected for this
role: its precision-oriented, reference-anchored objective is gamed by a
one-word window and its 4-gram statistics are too sparse against a short
query.
A qualification that matters when comparing numbers: the WLCS fill runs in
a max-on-match spelling: Lin's Figure 3 forces a match cell to extend
the diagonal run, and tors replaces that forced-diagonal branch with max
against the skip options (Lin 2004, Eq. 15, with the forced-diagonal branch
replaced by max to preserve candidate monotonicity). The deviation is
deliberate: the forced diagonal is NOT monotone in the candidate (a match
cell forced to extend the run can strand accumulated credit when a later
token re-matches, so a text offered MORE source material could score LESS,
which would break the recall twin's contract). The max-on-match spelling
restores monotonicity (extending the text never lowers a coverage score,
pinned under Hypothesis attack), at the cost of compatibility: these scores
are not bit-compatible with the official ROUGE package or the
rouge-score PyPI library (the fills disagree on ~12% of random token
pairs). And because the DP keeps only two rows, it cannot represent
Pareto (value, trailing-run) states: the score is the max-on-match
recurrence, a greedy-run-weighted alignment score, not the literal
weighted-LCS optimum over all monotone matchings (a brute-force oracle
beats it on rare pairs; also pinned). Both deviations are documented
semantics, verified in the test suite, not bugs.
Tokens come from UAX #29 word boundaries (the same segmentation
word_bounds exposes), with one refinement: every character in the
Hiragana and Katakana blocks (U+3040–U+30FF), CJK Extension A
(U+3400–U+4DBF), the CJK Unified Ideographs (U+4E00–U+9FFF), Hangul
syllables (U+AC00–U+D7AF) and the CJK Compatibility Ideographs
(U+F900–U+FAFF) inside a word segment becomes its own token (UAX #29
keeps Katakana and Hangul runs joined, and unspaced CJK morphemes are
the standard IR per-character fallback), so text in those blocks anchors
at the same granularity the query does. The refinement does not reach
the CJK-family blocks outside those ranges: halfwidth Katakana
(U+FF66–U+FF9F), halfwidth Hangul (U+FFA0–U+FFDC) and Hangul jamo
(U+1100–U+11FF) runs stay one token, so such a run matches a query term
only whole: ground_sentences("アイウエオ。", "ウ") scores 0.0 where the
fullwidth twin ground_sentences("アイウエオ。", "ウ") scores 1/3 (one
of the five run tokens matches; the contrast is pinned in
TestCjkFilterCollateral, tests/test_ground_sentences.py).
Matching case-folds and NFC-canonicalizes
(NFD accents match NFC queries); offsets land on token boundaries, which
never split a grapheme cluster, so the round-trip holds through CJK,
accents, ZWJ emoji and astral-plane text alike (pinned across all of
these by tests/test_grounding.py, and fuzzed for by
fuzz_targets/grounding.rs).
Selection: text tokens matching any query term form anchor runs; each run
expands to its UAX #29 sentence when the sentence fits max_chars (the
citation unit the ALCE baselines use — Gao et al. 2023, "Enabling Large
Language Models to Generate Text with Citations"), every span clamps to
max_chars at token boundaries (a snippet always holds at least one
token, even under a budget smaller than that token), the top candidates
are scored and the greedy pass keeps the best-scoring non-overlapping set
(the attribution-report shape: different regions of a chunk support
different parts of an answer — RARR, Gao et al. 2022). The score is a
ranking signal, not an answerability verdict (Joren et al. 2024,
"Sufficient Context": sufficiency is a semantic judgment).
Pathological chunks are bounded: at most the first 16384 text tokens and
128 query terms are scanned, and at most 64 candidates reach the DP —
the DP itself is two reusable rows over the query's terms, never an
n·m matrix. An empty or token-free query, an empty or token-free text,
and max_snippets=0 return the empty result — degenerate input is a
valid answer, never an error; max_chars=0 is a ValueError.
tors.highlight("torque spec", "The pump failed. The bushing torque spec was 42 Nm. Replaced.")
# {'snippets': [{'text': 'The bushing torque spec was 42 Nm. ', 'start': 17,
# 'end': 52, 'score': 0.44444444444444436}], 'score': 0.44444444444444436}
# (the score is exactly 4/9: 2 query terms, a 7-token sentence, one
# contiguous matched run — Equation 15's F1 collapses to 2k/(n+m) under the
# power-law shaping — printed through the DP's f64 arithmetic)
(The snippet's trailing space is the UAX #29 sentence convention
sentence_bounds documents: SB10/SB11 attach a terminator's trailing
space to the preceding sentence — the offsets are the sentence's, exactly
as documented above.)
Async: await tors.aio.highlight(...) runs this under asyncio.to_thread (see Async use). The capped grounding pass measures ~1 ms on a 2k-token chunk (docs/async.md's own thread-hop-territory threshold) and ~5.2-5.5 ms at document scale, so the hop is noise at chunk scale and up; a sentence-length text is a microsecond pass, prefer the sync spelling there.
tors.ground_sentences
def ground_sentences(
text: str,
query: str,
*,
max_chars: int | None = None,
) -> SentenceGrounding: ...
Sentence-level grounding batch: EVERY UAX #29 sentence of text, scored
against query, in position order: the bridge primitive a downstream NLI
verifier (MiniCheck/SummaC style) consumes. Returns a SentenceGrounding
(a TypedDict): sentences, one GroundingSnippet-shaped entry per
sentence (text, start, end, score), whose offsets are the
sentence's exact bounds (the tuples tors.sentence_bounds(text) returns)
as Python str (codepoint) indices into the ORIGINAL text, so
text[start:end] is exactly sentence["text"] for every sentence,
token-free sentences included, and score, the aggregate.
The per-sentence score is the same ROUGE-W F1 the snippet surface ranks
spans with (see tors.highlight above): one tokenization (UAX #29 words,
CJK per character, case-fold + NFC), one shaping, and the same
qualification applies (the monotone max-on-match recurrence, not
bit-compatible with the official ROUGE package or rouge-score). Note the
argument order: ground_sentences(text, query), the OPPOSITE of
highlight(query, text). The citation unit is the
sentence, the unit the attribution literature converged on (ALCE's
snippet-mode baselines measure citation quality per sentence: Gao et al.
2023, "Enabling Large Language Models to Generate Text with Citations"),
and the score is a RANKING signal, not an answerability verdict (Joren et
al. 2024, "Sufficient Context": whether a context suffices to answer is a
semantic judgment no lexical overlap score can make). Consumers needing
the verdict run their NLI model over the top-scored sentences.
The aggregate score is the best sentence's F1 (the MAX, not the mean)
for three reasons: it is the retrieval signal the bridge needs ("does SOME
sentence carry this query's evidence"; the argmax over units is exactly
how ALCE-style citation selection picks the evidence sentence); it mirrors
highlight's own aggregate (the best snippet's score), keeping the
grounding family coherent; and it is stable under irrelevant additions (a
long document with one relevant sentence must not read as ungrounded
because the document is long, which a mean rewards forgetting). The mean is
deliberately not offered: "how much of this document is about the query"
is a different question, and sentence_bounds + a fold in Python
composes it trivially from the per-sentence scores.
An empty or token-free query scores every sentence 0.0 (the
segmentation is the answer's shape; the query only drives scores); an
empty text returns the empty result: degenerate input is a valid answer,
never an error. max_chars bounds each sentence's SCORED window: a
sentence longer than the budget is scored over its leading
token-boundary window (at least one token, highlight's documented
floor), while its reported start/end/text still cover the WHOLE
sentence; the window's exact boundary (where the leading window cuts off)
is an implementation detail and deliberately not exposed: consumers get
the whole-sentence span and the window's score, which is the contract.
None (the default) scores whole sentences, and max_chars=0
is a ValueError. Like every integer parameter in the library,
max_chars takes a plain int (a bool is its 0/1 int value, the
family-wide convention). Pathological chunks are bounded: at most the first
16384 text tokens and 128 query terms are scanned (sentences past the cap
score 0.0, their offsets and text still exact); the DP is two reusable
rows per sentence, never an n·m matrix, and the total work is linear in
the text at a bounded query width.
tors.ground_sentences(
"The pump failed. The bushing torque spec was 42 Nm. Replaced.", "torque spec"
)
# {'sentences': [{'text': 'The pump failed. ', 'start': 0, 'end': 17,
# 'score': 0.0},
# {'text': 'The bushing torque spec was 42 Nm. ', 'start': 17,
# 'end': 52, 'score': 0.44444444444444436},
# {'text': 'Replaced.', 'start': 52, 'end': 61, 'score': 0.0}],
# 'score': 0.44444444444444436}
# (the middle sentence's score is exactly 4/9, the same Equation 15 F1
# highlight's example computes; the aggregate is the max of the three)
Against the installed alternative: the batch call prices the same job
rouge_score's per-pair RougeScorer.score loop does (this document's
1,251 sentences against one query) at ~4-5x under its wall, ~5.7-6.1 ms
per ground_sentences call against ~25-28 ms for the loop, with
highlight at ~5.2-5.5 ms; the metrics differ by design (ROUGE-1/L
there, the ROUGE-W-shaped F1 here, the toy agreement on both sides),
tools/bench_sota.py, bands in Performance.
Async: await tors.aio.ground_sentences(...) runs this under asyncio.to_thread (see Async use). The batch runs the grounding pass over every sentence of the text (measured ~5.7-6.3 ms on a 1,251-sentence document, ~400 ms walls at 3 MiB of chatlog), so the hop is noise at any batch scale; a short paragraph is microsecond-scale, prefer the sync spelling there.
tors.grounding_coverage
def grounding_coverage(source: str, text: str) -> float: ...
Grounding recall / source utilization: what fraction of source's tokens
does text actually utilize, the recall twin of tors.is_grounded
(the precision side: every claim supported). One float in [0.0, 1.0],
the model-free operationalization of TRACe's uTilization metric (Friel,
Belyi & Sanyal 2024, "RAGBench: Explainable Benchmark for
Retrieval-Augmented Generation Systems", §3.2: document utilization =
Len(U_i) / Len(d_i), the length of the utilized context spans over the
context's length; Adherence (is_grounded's lane) is the framework's
precision metric). Where TRACe's U comes from an annotator's span
labels, this is the lexical approximation: U is the token overlap of
text with source.
The measure is ROUGE-W recall, Lin 2004's Equation 15 recall component
(f^-1(WLCS / f(|source|))) over the grounding family's own UAX #29
tokenization, with the same qualification highlight's scorer carries:
the WLCS fill is the monotone max-on-match spelling (Lin's forced-diagonal
branch replaced by max, so adding text never lowers coverage), which is
deliberately NOT bit-compatible with the official ROUGE package or
rouge-score, and it is a greedy-run-weighted alignment score rather than
the literal weighted-LCS optimum, see the full note under
tors.highlight. It is not a difflib-style character coverage, for the
same reasons the snippet surface scores ROUGE-W: one tokenization and one
shaping shared with highlight/ground_sentences, so the precision and
recall surfaces never disagree about what a token is (a case-fold or
NFC/NFD difference matches; a character-level diff would count it
lost); and the weighted-LCS shaping rewards CONTIGUITY (a text quoting a
contiguous passage of the source outscores one scattering the same
tokens through filler, which is exactly the "actually utilized" signal
utilization annotates.
This is a lexical overlap signal, not a semantic one: it measures token
coverage, not whether the information was genuinely used. Identical
text and source are 1.0 (up to f64 rounding of the DP's
accumulation, within 1e-9, pinned); disjoint, token-free, or empty
operands are exactly 0.0 (TRACe's ratio is undefined there, 0/0,
and 0.0 is the conservative reading; pinned in
tests/test_grounding_coverage.py). Cost: the classic weighted-LCS DP,
O(|S|·|T|) time with O(min(|S|, |T|)) memory (two reused rows,
never a materialized n·m matrix); at most the first 16384 tokens of each
operand are scanned, the denominator being the source tokens actually
scanned.
tors.grounding_coverage("the quick brown fox jumps over the lazy dog", "the lazy dog jumps")
# 0.33333333333333337
tors.grounding_coverage("same words both sides", "same words both sides")
# 1.0
tors.grounding_coverage("alpha bravo charlie", "xray yankee zulu")
# 0.0
tors.grounding_coverage("", "text") # either side empty: exactly 0.0
# 0.0
The score normalizes by the SOURCE's token count, so it is asymmetric in
the operands: text holding every source token in one contiguous run
scores 1.0 no matter how much extra material it carries, while the
same pair swapped scores the matched fraction, f^-1(f(3)/f(5)) = 3/5:
tors.grounding_coverage("ba ce di", "ba ce di fo gu")
# 1.0
tors.grounding_coverage("ba ce di fo gu", "ba ce di")
# 0.6
Coverage is monotone in text (extending the text never lowers it) and
deliberately NOT monotone in the query: extending the query with a term
absent from the text lowers the score (the added term dilutes recall),
expected behavior, not a defect.
Async: await tors.aio.grounding_coverage(...) runs this under asyncio.to_thread (see Async use). The weighted-LCS DP is O(|S|·|T|) up to the 16384-token operand caps, milliseconds at page scale and seconds at the caps (~10-50 ms walls measured at 2 MiB + 1 MiB operands), so the hop is noise on document-scale operands; a sentence-or-paragraph pair is a microsecond pass, prefer the sync spelling there.
tors.grounding_report
def grounding_report(
text: str,
sources: list[str],
query: str | None = None,
*,
threshold: float = 0.85,
) -> GroundingReport: ...
WHAT: the production grounding-report composition: per-sentence
grounding verdicts over a list of sources, plus the aggregate verdict.
The pipeline shape is the Deepchecks "Grounded in Context" framework's
(Gerner et al. 2025, "Grounded in Context: Retrieval-Based Method for
Hallucination Detection", arXiv 2504.15771): decompose the output into
statements, score each statement against the context, aggregate the
per-statement scores into one verdict. tors implements the shape with
its lexical layer, the paper's NLI entailment model (its step 5)
deliberately left as the handoff: the verdict here is a lexical-overlap
threshold, the same honest line ground_sentences draws (a lexical
overlap cannot judge entailment; consumers needing that run their NLI
model over the top-scored sentences).
Returns the report dict, every key present every time:
sentences: one entry per UAX #29 sentence oftext, position order, each{"text", "start", "end", "best_source", "score", "grounded"}. The offsets are the sentence's exact bounds (the tuplestors.sentence_bounds(text)returns, Python codepoint indices, sotext[start:end]is exactlysentence["text"]).best_source: the index of the source holding the sentence's best alignment, earliest source winning ties;Nonewhen there is no alignment at all (no sources, or no source shares a token with the sentence),score0.0with it.score: that best alignment's ROUGE-W F1 in[0.0, 1.0], the scoretors.highlight(sentence, source, max_snippets=1, max_chars=400)itself reports (the sentence scored as the query at the family's default snippet budget), so a report sentence's score is exactly reproducible from the primitives.grounded:score >= thresholdAND an alignment exists (no sources grounds nothing, even atthreshold=0.0).thresholddefaults tois_grounded's documented 0.85 and must be in[0.0, 1.0].aggregate:{"grounded_ratio", "grounded", "sentences", "mean_score", "best_score", "coverage", "query_score"}.grounded_ratiois the grounded fraction (0.0for no sentences),grounded/sentencesthe counts,mean_score/best_scorethe per-sentence score mean/max,coveragethe text-level token utilization oftextagainst the newline-joined sources (tors.grounding_coverage(text, "\n".join(sources)): what fraction of the output's own tokens the sources supply, the recall reading that complements the per-sentence precision lens;0.0with no sources), andquery_scorethe optional query'sground_sentencesaggregate (the best sentence's F1 against the query: WHICH grounded sentence reads first;0.0whenqueryisNone. An empty-stringquery=""pins to that same0.0— for the scoring,query=""andquery=Noneare identical).
WHY this shape: the decomposition-then-verification pipeline is
what makes long contexts tractable for a verifier (the paper's own
motivation: an NLI encoder with a 512-token window cannot score a
whole response against a whole context, so each claim gets its own
small premise set); tors's report hands the consumer that same
structure with the lexical scores in place, so the cheap pass ranks
and filters (which sentences, which sources, at what threshold) before
the expensive model runs. The aggregate is deliberately plural (ratio,
counts, mean, best, coverage, query lens) rather than one number: a
single significant ungrounded sentence is invisible in a mean but
blunt in grounded_ratio, while the mean answers "how much of this
text is supported" and coverage catches wholesale fabrication the
per-sentence alignment can miss.
HOW: sources is a list of strings (an empty entry is legal and
aligns nothing). Degenerate inputs are pinned shapes, never errors: an
empty text returns the all-empty report (empty sentence list, every
aggregate key at 0.0); no sources grounds nothing (every
best_source None, grounded False, grounded_ratio and
coverage 0.0).
text = "The pump failed. The bushing torque spec was 42 Nm. Replaced."
tors.grounding_report(text, ["Service log: the bushing torque spec was 42 Nm."], query="torque")
# {'sentences': [{'text': 'The pump failed. ', 'start': 0, 'end': 17,
# 'best_source': 0, 'score': 0.16666666666666669, 'grounded': False},
# {'text': 'The bushing torque spec was 42 Nm. ', 'start': 17,
# 'end': 52, 'best_source': 0, 'score': 0.8750000000000003,
# 'grounded': True},
# {'text': 'Replaced.', 'start': 52, 'end': 61,
# 'best_source': None, 'score': 0.0, 'grounded': False}],
# 'aggregate': {'grounded_ratio': 0.3333333333333333, 'grounded': 1,
# 'sentences': 3, 'mean_score': 0.3472222222222223,
# 'best_score': 0.8750000000000003,
# 'coverage': 0.6363636363636365, 'query_score': 0.25}}
Async: await tors.aio.grounding_report(...) runs this under
asyncio.to_thread (see Async use). The report's cost is
linear in (sentences x sources) highlight passes (each bounded by the
family's query cap and the 400-char budget, the source re-tokenized per
pair), milliseconds at page scale and hundreds of milliseconds at
document scale, so the hop is noise on document-scale inputs; a
one-sentence text is a microsecond pass, prefer the sync spelling
there.
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, autojunk=False).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 valid — the equal ops spell a common subsequence,
so M <= LCS(a, b) (structural, pinned as a size ladder in
tests/test_similarity.py) — and it is maximal on the forced-alignment
classes (identical, empty, pure insert/delete with differing flanks) and
measured exactly maximal through the ladder's 512-char octave, but it is
BOUNDED-maximal, not guaranteed maximal: from the 1024-char octave upward the
bounded middle-snake search can accept a good non-minimal split and score
slightly UNDER the true LCS ratio (measured: 0.7043 vs the true 0.7107 on
3000-char random strings, a ~1% undercount; in the pinned ladder the
undercounts start at 1024, worst observed ratio 0.972 at n=1024 over a
26-symbol alphabet, held above an empirical 0.9 drift-guard floor that is
NOT a contract). There is no lower bound on M past the search's limits;
see src/diff_impl.rs's module docs for
exactly what the engine does and does not guarantee, and the size-ladder
property test in tests/test_similarity.py for the pinned bounded
property. 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.
A third divergence class sits past difflib's autojunk threshold: with the default
autojunk=True — the spelling get_close_matches uses internally and cannot turn
off — any element appearing more than len(b)//100 + 1 times in a b of 200+
elements is junked before matching, so plain difflib's ratio collapses on such
inputs ("y" + "x"*300 vs "x"*300: difflib 0.0) while tors keeps the
un-heuristic'd answer (0.9983…), exact agreement holding with autojunk=False —
the oracle's spelling above (tests/test_similarity.py pins the threshold).
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']
Async: await tors.aio.similarity_ratio(...) and await tors.aio.get_close_matches(...) run under asyncio.to_thread (see Async use). A two-short-string ratio is a microsecond call the hop outruns; the bulk sweep is where the twin pays, measured ~65 ms walls over 13,900 x 664-char candidates with the whole per-candidate Myers sweep detached (worst GIL gaps 10.6-11.3 ms of those 64.2-71.7 ms walls, ratio 0.15-0.18).
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
Async: await tors.aio.levenshtein(...), await tors.aio.jaro(...), and await tors.aio.jaro_winkler(...) run under asyncio.to_thread (see Async use). Each is a quadratic DP over the operand lengths: the hop costs more than the call on the word/name-scale pairs these metrics usually see, and is noise on the multi-KB and adversarial multi-MB operands the two-row DP and deadline_ms exist for.
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). The
overlap is declined the same way — zero overlap for that one transition — when
taking it would not buy new context: if the re-cut from the snapped start would
land a span strictly inside the previous chunk (the same text embedded twice,
the failure mode #83 fixed), the next chunk starts at the previous chunk's end
instead, so a chunk is never contained in its predecessor.
The 2026 systematic chunking study prices this knob end to end: across paired
configurations on Natural Questions, adding 10-20% overlap did not improve
retrieval (|Δ BERTScore| <= 0.004, EM differences <= 0.001) while chunk
count and index size inflated by exactly 1/(1 - r) (Bennani & Moslonka 2026,
"A Systematic Analysis of Chunking Strategies for Reliable Question
Answering", arXiv 2601.14123, Finding F1), so its recommendation is
overlap = 0 unless you have evidence your retriever benefits from boundary
redundancy. That is why every member of this family defaults to overlap=0,
and tors.chunk_overlap_cost returns the inflation factor for any ratio you
are considering. The same study's method finding (sentence/structure-aware
chunking beats token chunking: sentence ≈ semantic > token) is what
boundary="sentence", chunk_by_sentences, and chunk_hierarchical's
default hierarchy implement.
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. (On overlap's documented price:
the 2026 systematic study measured no retrieval gain from 10-20% overlap
against its exact 1/(1-r) index inflation, arXiv 2601.14123, Finding F1;
overlap=0 is this family's default recommendation and
tors.chunk_overlap_cost prices the factor.) 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,
overlap_boundary: Literal["grapheme", "word"] = "grapheme",
) -> 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:
heading → 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", ". ", " ").
The heading level (#63) is what makes this structure-aware for markdown:
its cuts sit at ATX heading lines, so a section's content never merges
across a heading of higher rank. Each cut lands before the heading —
the heading itself rides with the section that follows it (the chunk that
starts at a heading includes the heading text), and the newline run
between a section's content and the next heading is dropped between
chunks, the same convention the paragraph level applies to blank-line
runs (and when a heading follows a blank-line run, its cut is that
paragraph gap's cut: the two levels agree exactly where both can cut;
the heading level's addition is the single-newline case the paragraph
level cannot see). A heading-bearing document under a whole-document
budget comes back as its sections, not one giant chunk: the
final-chunk-runs-untrimmed exception is a size concession, and the
heading level's structural contract outranks it. The level's scope is
deliberately ATX-only for v1, verified against the markdown the
tors.documents engines actually emit — pdf_oxide's
StructType::markdown_prefix writes "# "…"###### ", anydoc's
markdown renderer writes "#".repeat(level) + " ", and
html-to-markdown-rs's HeadingStyle::default() is Atx — so setext
underlines (===/--- runs under a paragraph line) are excluded, as are
every line that is not shaped 1-3 spaces, 1-6 '#', space/tab/EOL:
#no-space, a 7-hash run, an escaped \#, a blockquote's > # x, a
list item's - # x, 4-space indented code, and any mid-line hash are
ordinary content, and a heading-shaped line inside a fenced code block
(`` fences, CommonMark §4.5's own state machine, shared withextract_code_blocks) is code. The level is gated: text with no#byte
anywhere never realizes it (a one-passmemchr` probe is the only cost
heading-free input adds), so heading-free text chunks exactly as the
pre-#63 hierarchy did. Precedence is budget > heading > paragraph >
sentence > word: the heading level bounds sections, the budget still
bounds oversized sections (split at the finer levels), and the
grapheme-safe raw cut stays the unconditional last fallback.
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 accurate levels in at that position, the mix an
all-literal list could not express before. ["\n", None] is
line → heading → 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.
The reserved literal "heading" is the heading level itself — the
explicit opt-in for custom hierarchies: ["heading", None] is heading →
paragraph → sentence → word → raw cut, the markdown-RAG shape (sections
bounded by headings, oversized sections split at accurate sentence/word
boundaries); the word is reserved, so a literal split on "heading" is
not expressible (spell it in different case if you truly need the word).
Cost: every level (each of the 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 carried into the 0.6.0 changelog
(released 2026-09-10); 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. Since #103 that holds at the end of the document too: the
final-chunk shortcut (the whole remainder fits the budget, so the chunk
runs to the end untrimmed) answers the separator-skip question first, so
a window that OPENS on a separator match is never emitted — not even as
the untrimmed final chunk. A trailing separator run therefore survives
only as a suffix of a content-bearing chunk, and an all-separator
document chunks to zero chunks.
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 — and so does an overlap whose
re-cut would land the next chunk strictly inside its predecessor (the same
text twice, no new context): the transition falls back to the zero-overlap
cut instead, so ends always strictly advance. (The knob's documented
default: the 2026 systematic study found no retrieval gain from 10-20%
overlap against its exact 1/(1-r) index inflation, arXiv 2601.14123,
Finding F1; overlap=0 is the family default and
tors.chunk_overlap_cost prices the factor.)
overlap_boundary="word" opts the snap into word-aware tails (#47) for
exactly the embedding-pipeline shape a mid-word tail start is a rough
edge for. The composition order is: grapheme snap, then word snap, then
the decline-the-snap lookahead. The grapheme candidate lands first (never
mid-cluster), then the snap moves further back to the nearest UAX #29
word boundary at or before it — the same word-bounds level the default
hierarchy already builds: realized lazily at the first snap that consults
it, shared with any window that descends to it (never a second walk), and
for a hierarchy with no word level at all (an all-literal custom list)
the same one-off word-bounds level is built at the first snap instead. A
mid-word tail therefore starts at its word's first codepoint; when the
word level has no boundary in the snap-back range (dense CJK runs, Thai
without a dictionary, one long token) the plain grapheme candidate is
kept, and at overlap=0 the mode is accepted and is a no-op (no snap
site ever runs). The word-snapped candidate then goes through the
decline-the-snap lookahead unchanged — it may reach back to or past the
previous chunk's start, in which case the transition degrades to zero
overlap exactly as a grapheme candidate reaching that far would: no chunk
is ever contained in (or duplicated across) its predecessor. Unknown
values raise ValueError naming the closed set ('grapheme', 'word'),
unconditionally at the argument boundary (an irrelevant knob never errors
late).
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. Since #103 the final-chunk exit answers one
extra question — could a separator match open at the exit's start — and a
cheap necessary-condition pre-test over the unrealized level specs keeps
that answer free whenever no separator could possibly open there (a text
that does not begin with one of the literal separators, under the default
hierarchy always: a paragraph-gap cut cannot begin at codepoint 0), so the
whole-document cells below keep their zero-build exit; a text that DOES
open with a separator match pays the hierarchy's first search there (the
skip question needs the level), once, memoized. Since #63 the default
hierarchy adds one more lazily-paid pass: the heading level's #-byte
gate probe (~0.12 ms at 12 MiB, memchr, once per call at the first
heading-site consultation — a level realization, the scan itself, or a
demotion check), which heading-free text answers "no heading can exist"
without ever building the level. 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
md = (
"# Guide\n\nIntro paragraph for the guide.\n\n"
"## Setup\n\nBody of the setup section, long enough to matter.\n\n"
"## Usage\n\nTail section."
)
tors.chunk_hierarchical(md, 10_000)
# [(0, 39), (41, 100), (102, 125)]
# -> the sections come back separate even under a whole-document budget:
# each chunk starts at its own heading ("# Guide...", "## Setup...",
# "## Usage..."); a section wider than max_chars would still split at
# paragraph/sentence/word levels (budget > heading > paragraph > ...).
tors.chunk_hierarchical(md, 10_000, ["heading", None]) == tors.chunk_hierarchical(md, 10_000)
# True: the "heading" sentinel + splice is the default hierarchy spelled out.
tors.chunk_to_budget / tors.chunk_to_offsets
def chunk_to_budget(
text: str,
token_counter: Callable[[str], int],
*,
max_tokens: int,
overlap: int | float = 0,
) -> list[tuple[int, int]]: ...
def chunk_to_offsets(
text: str,
token_offsets: Sequence[tuple[int, int]],
*,
max_tokens: int,
overlap: int | float = 0,
) -> list[tuple[int, int]]: ...
Async: await tors.aio.chunk_to_budget(...) and
await tors.aio.chunk_to_offsets(...) run these under asyncio.to_thread
(see Async use).
Token-budget chunking measured in the caller's own tokens: the packing
primitive for LLM context windows, where the budget is a model's real token
limit and a character proxy (chunk_text) is not good enough. The design is
the segment-then-pack shape semchunk popularized
(isaacus-dev/semchunk) and the
LangChain/LlamaIndex token splitters ship, over tors's own UAX #29
segmenters: cut text at sentence boundaries (a sentence whose own measured
count exceeds max_tokens is re-cut at word boundaries; a single word still
wider than the whole budget goes out whole (a covering chunker cannot split
below its finest boundary), then greedily pack consecutive segments into
chunks whose measured token count fits max_tokens. Returns (start, end)
pairs in Python str index (codepoint) units: text[start:end] is the
chunk. Empty text returns []; text that fits whole returns one chunk.
Two spellings, two measurement shapes:
chunk_to_budgettakes the counter as a Python callable. It is called with one CANDIDATE CHUNK's text per packing decision, never once per boundary (O(segments) calls total, each counting at most one chunk's worth of text), so counters that merge tokens across spaces or boundaries are measured exactly as the emitted chunk will be, which is what makes the per-chunk budget invariant hold for every counter. The counter must return anint>= 1 for every sentence (0, a negative count, or an unreasonably large value raiseValueError: a sentence measuring no tokens makes the budget contract meaningless. A word-count tokenizer (len(text.split())) measures zero any sentence that is a whitespace run, not only whitespace-only text: UAX #29 makes a blank line a sentence of its own (the second\nof\n\nis one), so multi-paragraph text with blank lines raises the same error:"Paragraph one.\n\nParagraph two.", any markdown blank-line document, even a trailing blank line; whitespace-only text is the instance where every sentence is blank. A non-int return raisesTypeError; a counter that raises propagates its exception unchanged. This is theCompiledLemmaDict-style measured exception to the stateless doctrine: a caller-supplied callable inside the packing.chunk_to_offsetstakes the token spans PRE-COMPUTED: a sequence of(start, end)codepoint pairs, one per token, sorted and non-overlapping (HuggingFace tokenizers'Encoding.offsetsis this shape after filtering zero-width spans (HF special tokens emit(0, 0)); gaps are allowed, and untokenized text such as inter-token whitespace measures 0 tokens). A span's token count is the number of token pairs fully contained in it, so the packing is additive and exact with no callback anywhere.
overlap repeats trailing context into the next chunk: an int token count
in [0, max_tokens) or a float ratio in [0, 1) (resolved as
floor(ratio * max_tokens) tokens). The next chunk starts at the trailing
segment boundary whose span back to the closed chunk's end measures at least
the requested overlap: the RAG-retrieval shape where a fact split across a
cut is still whole in the next chunk. The shared content is counter-relative:
the overlap is certified by the same measurement the packing used, so a
counter that certifies a whitespace run as a token can make the overlap a
whitespace run. The overlap is declined for a
transition when it cannot buy new context (a chunk shorter than the
requested overlap, or a re-cut that would land a span strictly inside its
predecessor): that one transition degrades to zero overlap rather than
stall, loop, or emit the same text twice: the same forward-progress
discipline chunk_text's overlap applies. Chunks are non-empty, strictly
increasing in both start and end, cover to the end of the text, and each
fits the budget per the same measurement the packing used; with
overlap=0 they are a contiguous lossless covering partition. (The knob's
documented default, family-wide: the 2026 systematic study found no
retrieval gain from 10-20% overlap against its exact 1/(1-r) index
inflation, arXiv 2601.14123, Finding F1; tors.chunk_overlap_cost prices
the factor.)
max_tokens < 1, an out-of-range overlap (either spelling), or a
mis-shaped token_offsets sequence raise ValueError before any packing
runs; a non-callable token_counter raises TypeError. An int beyond the
i64 range the binding extracts (max_tokens=10**30) raises pyo3's own
OverflowError at extraction instead, the truncate_to_bounds-identical
pattern for every i64-typed size argument here, a clean Python error, never
a panic. Text beyond u32::MAX bytes (4 GiB, the codepoint→byte offset
grid the packing resolves spans through) also raises ValueError before
any work runs, rather than silently truncating offsets.
GIL model, stated honestly because the two spellings differ: chunk_to_budget is NOT
GIL-free: its counter is Python and can only run under the GIL. The
packing core runs under one py.detach and re-attaches the GIL per counter
call, so the GIL is held only while the counter runs (plus O(chunk)
argument construction), released for all native work between measurements.
One measured caveat, because the doctrine forbids false GIL claims: the
loop is schedulable between callbacks when each callback holds the GIL
longer than sys.getswitchinterval() (5ms by default) or the native
windows between them are substantial: a callback that straddles the
switch interval forces CPython's fair GIL handoff (gil_drop_request). A
GIL-held callback SHORTER than the switch interval on a small text
(microsecond detach windows) can starve the loop for the whole call: the
worker drops and re-acquires the GIL faster than the woken loop thread can
take it. tests/test_gil_release.py pins the schedulable band for
super-interval callbacks; for a fast counter on a small text, run the
packing on a thread you control or use the GIL-free spelling below.
chunk_to_offsets is the GIL-free choice for
hot paths: the O(tokens) argument walk under the GIL, then the whole pack
detached end to end, with the family's usual O(chunks) 2-tuple marshalling
after. Like every chunker here, neither makes a retrieval-quality promise:
the cost/benefit study at arXiv:2410.13070
("Is Semantic Chunking Worth the Computational Cost?") found expensive
splitting strategies not consistently worth their cost over simpler ones;
this surface ships the cheap mechanical contract (boundary-safe packing
under an exact token budget) and leaves the strategy to the caller.
Against the installed alternative: chunk_to_offsets packs the same
1 MiB, 200-token-budget, overlap-20 corpus in ~22 ms where
semchunk.chunk (offsets=True, the same whitespace counter) takes
~42-44 ms (~1.9x), the two outputs agreeing on the shared invariant
(every chunk within budget, the text tiled) though not on windows
(tools/bench_sota.py; bands in Performance).
def word_counter(text: str) -> int:
return len(text.split()) # the docs' counter; any tokenizer callable works
text = "One. Two. Three. Four."
tors.chunk_to_budget(text, word_counter, max_tokens=2)
# [(0, 10), (10, 22)] -- "One. Two. " | "Three. Four."
tors.chunk_to_budget(text, word_counter, max_tokens=2, overlap=1)
# [(0, 10), (5, 17), (10, 22)] -- "One. Two. " | "Two. Three. " | "Three. Four."
tors.chunk_to_budget("a b c d e f g h", word_counter, max_tokens=3)
# [(0, 6), (6, 12), (12, 15)] -- a sentenceless run: word-boundary fallback
spans = [(s, e) for s, e in tors.word_bounds(text) if text[s:e].strip()]
tors.chunk_to_offsets(text, spans, max_tokens=4, overlap=2)
# [(0, 10), (5, 17), (10, 22)] -- the same shape, measured from spans
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.chunk_overlap_cost
def chunk_overlap_cost(overlap: float) -> float: ...
WHAT: the chunk family's overlap knob, priced: the index-inflation
factor 1 / (1 - overlap) for an overlap ratio in [0.0, 1.0). The
2026 systematic chunking study (Bennani & Moslonka 2026, "A Systematic
Analysis of Chunking Strategies for Reliable Question Answering",
arXiv 2601.14123, Finding F1) measured that adding 10-20% overlap did
NOT improve retrieval (|Δ BERTScore| <= 0.004, EM differences
<= 0.001) while chunk count and index size inflated by exactly
1 / (1 - r) (their worked example: r = 0.2 is 1.25x more chunks,
ingestion time and storage), and recommended overlap = 0 unless you
have evidence your retriever benefits from boundary redundancy. That is
why every member of the chunk family defaults to overlap=0; this
function prices whatever ratio you are considering anyway.
WHY a function and not a comment: the cost is not linear in the
overlap and not intuitive at the top of the range (0.5 is 2x, 0.9 is
10x, 0.99 is 100x), so a caller tuning overlap by feel against a
storage budget benefits from the exact factor, and the domain's edge
(overlap >= 1 is infinite inflation: a chunker whose overlap equals
its budget never advances, and the index it would build is infinite) is
the asymptote named in the error rather than an inf silently flowing
into a size estimate.
HOW: overlap must be a finite float in [0.0, 1.0); negative
values, values at or past 1.0, and NaN raise ValueError naming the
asymptote. Pure float arithmetic: no GIL release (the work is strictly
less than the argument extraction around it, the simhash_distance
zero-detach class), and no aio twin for the same reason.
tors.chunk_overlap_cost(0.0)
# 1.0
tors.chunk_overlap_cost(0.2)
# 1.25
tors.chunk_overlap_cost(0.5)
# 2.0
tors.chunk_overlap_cost(0.75)
# 4.0
tors.chunk_quality
def chunk_quality(
chunks: Sequence[tuple[int, int]],
text: str,
*,
tau: int = 0,
) -> ChunkQuality: ...
WHAT: the two intrinsic chunk-quality metrics of the adaptive-
chunking study (de Moura Júnior, Lelong & Blangero, "Adaptive Chunking:
Optimizing Chunking-Method Selection for RAG", LREC 2026, arXiv
2603.25333, the ekimetrics/adaptive-chunking reference implementation)
computed over
the caller's own chunk spans: the (start, end) tuples any member of
the chunk family returns, or any hand-built spans. Returns
{"integrity": float, "cohesion": float}, both in [0.0, 1.0], both
keys always present.
integrityis the study's Block Integrity: the fraction of the text's UAX #29 sentences (the suite's own segmentation, the same boundssentence_boundspublishes, standing in for the study's annotated gold blocks) that NO chunk boundary crosses. A boundary crosses a sentence when it falls strictly inside the sentence's span, farther thantaucodepoints (the tolerance, default 0, for segmenter jitter) from BOTH edges. A boundary exactly at a sentence edge is never a crossing; sentence-sized chunks score 1.0 by construction.cohesionis the study's Intra-Chunk Cohesion as tors's dependency-free LEXICAL PROXY: the mean Dice coefficient (shingle_dice's exact quantity,2|A ∩ B| / (|A| + |B|)over the width-3 word-shingle sets, the same UAX #29 tokenization and case-fold+NFC matching form the near-duplicate family uses) between each sentence and its containing chunk, averaged over the (sentence, chunk) pairs that exist (containment is the assignment; a sentence no chunk contains contributes to neither metric, its damage isintegrity's).
The honest caveat, stated because the names invite the comparison: the study's ICC scores sentence-to-chunk similarity with EMBEDDING cosine similarity. tors's version is a different, weaker, but usable signal: it rewards chunks whose sentences share surface vocabulary and penalizes chunks that stitch unrelated sentences together, but it cannot see topical continuity that shares no words (a pronoun-heavy continuation scores low), it inherits the shingle family's empty-set conventions (a sentence shorter than the shingle width has an empty shingle set; two empty sets score 1.0, exactly one empty 0.0), and its numbers are NOT comparable to the paper's. What it buys is that the metrics run with no model, no network, and no dependency beyond the crate's own segmenters and shingle hashes, so a chunking-config sweep can gate on them per document, in milliseconds, the way the study's selector does.
Degenerate shapes, pinned: an empty chunk list or an empty text
gives {"integrity": 1.0, "cohesion": 0.0} (no boundary crosses
anything, vacuously; no sentence-chunk pair exists, the conservative
0/0 reading, the same convention grounding_coverage pins). A span out
of text's range or with start > end raises ValueError; tau < 0
raises ValueError.
text = "The pump failed. The bushing torque spec was 42 Nm. Replaced."
tors.chunk_quality(tors.chunk_by_sentences(text, 1), text)
# {'integrity': 1.0, 'cohesion': 1.0}
tors.chunk_quality([(0, 61)], text)
# {'integrity': 1.0, 'cohesion': 0.31746031746031744}
tors.chunk_quality([(0, 30), (30, 61)], text)
# {'integrity': 0.6666666666666667, 'cohesion': 0.2857142857142857}
tors.chunk_quality([], text)
# {'integrity': 1.0, 'cohesion': 0.0}
(The second call: one chunk covering everything, so nothing is crossed; the middle sentence's shingles all ride inside the chunk's (Dice 2/3) while the flanking sentences' sets are mostly new material to the chunk (Dice 2/7) or empty outright ("Replaced." has fewer tokens than the shingle width: the exactly-one-empty convention scores 0.0), and the mean of 2/7, 2/3, 0 is 0.3174. The third call: the boundary at 30 cuts the middle sentence, span (17, 52), and integrity pays for it.)
Async: await tors.aio.chunk_quality(...) runs this under
asyncio.to_thread (see Async use). The pass is one
segmentation plus one shingle pass per chunk and sentence, linear in
the text, milliseconds at page scale and tens of milliseconds at MiB
scale, so the hop is noise on document-scale inputs; a short paragraph
is a microsecond pass, prefer the sync spelling there.
tors.content_hash
def content_hash(obj: str | int | float | bool | None | list | tuple | dict) -> str: ...
The object content hash: the lowercase-hex SHA-256 of the object's canonical form, where the canonical form is EXACTLY
import json
json.dumps(obj, sort_keys=True, separators=(",", ":"))
with json.dumps's defaults ensure_ascii=True and allow_nan — so the
oracle is the stdlib itself, total and always available:
import hashlib
import json
hashlib.sha256(
json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
tors.content_hash(obj) returns that string, byte-identical — for
surrogate-free input where json.dumps succeeds; where json.dumps
rejects (mixed unsortable keys, over-limit ints, circular references) both
sides raise the same exception type, and lone-surrogate str input is the
documented exclusion below — pinned
differentially against the oracle over every contract below
(tests/test_content_hash.py) and literal-pinned at the byte level
crate-side (src/canon_impl.rs). Deterministic by construction: any dict
key order yields the same hash; the one exception is a dict with distinct
NaN keys: NaN never compares equal to NaN, so their output order is
timsort's own rather than a mathematical property (the delegated-sort
paragraph below), and each ordering still hashes exactly as the
json.dumps oracle spells it, the parity that matters. An equal-value
list and tuple hash identically (tuples serialize as lists,
recursively).
The type contract. Leaves: str (lone-surrogate strings excluded —
they raise UnicodeEncodeError where json.dumps succeeds, the
documented divergence below), int (arbitrary precision), float,
bool, None. Containers: list, tuple, dict. Anything else raises
TypeError naming the type (set, frozenset, bytes, bytearray,
custom classes, plain Enum, views, iterators alike). Circular references
raise ValueError on both sides (json.dumps's own marker semantics: a
shared sibling is fine, only a true cycle raises). One scoped exception-type
divergence: doubly invalid input — a circular dict that also carries a
non-coercible key — raises ValueError from json.dumps (its per-pair
check interleaves coercion and cycle detection) and TypeError from
content_hash (all keys of a dict are coerced before its values are
walked); single-defect inputs raise the same type on both sides.
Dict keys: coercion, then json's own sort order. Non-str keys are
coerced exactly as json.dumps coerces them: 1 -> "1", True ->
"true", None -> "null", 1.0 -> "1.0", nan/inf -> "NaN"/
"Infinity". Keys are sorted BEFORE stringification — all-int keys come
out in numeric order (2 before 10), where the same digits as str keys
sort lexicographically ("10" before "2"). The exotic key shapes — any
float key, big-int keys, mixed int/float, NaN keys, and any
str/int-SUBCLASS key (whose overridden rich comparison json.dumps's
sort honors) — are sorted by delegating to CPython's own list.sort over
the dict's own (key, value) items: the very tuples json.dumps sorts,
the same timsort, so the output order matches the running interpreter
byte-for-byte, including the corners no reimplementation would dare: two
distinct NaN objects legally coexist as dict keys, and their output order
is timsort's behavior, not a mathematical property. Mixed unsortable key
types ({1: ..., "a": ...}) raise the sort's own TypeError,
byte-identical with json.dumps's; non-coercible key types (tuple,
bytes, ...) raise TypeError naming the key type.
Strings: the ensure_ascii escape table. Inside a string the raw
bytes are exactly printable ASCII (U+0020-U+007E) minus " and \ — the
forward slash is never escaped. Everything else is escaped: \", \\,
the five short escapes \b \t \n \f \r (U+0008/9/A/C/D), \u00XX
lowercase for the other controls and for DEL (U+007F is outside printable
ASCII), \uXXXX lowercase for all non-ASCII, and astral codepoints as
surrogate-pair escapes: chr(0x1F600) -> "\ud83d\ude00". Every
codepoint U+0000-U+007F and the BMP/astral boundary codepoints are pinned
as values and as dict keys.
Floats: Python's own repr, never reimplemented. Finite floats are
materialized via Python's float repr during the walk (tors does not
reimplement float formatting — exact by construction, 0.1 is "0.1",
1e16 is "1e+16", 5e-324 is "5e-324", sys.float_info.max is its
full 17-digit spelling). Non-finite values use json's allow_nan
literals: NaN, Infinity, -Infinity.
Ints: arbitrary precision. The i64 fast path covers
-(2**63)..2**63-1 (a storage read plus fixed-buffer decimal digits;
measured 4.8ns per int against 29.3ns for the repr call, ~6x), and
everything beyond falls back to Python's own int->str, so the
interpreter's sys.set_int_max_str_digits limit raises identically on
both sides (ValueError on 3.11+; on 3.10, no limit, both sides hash).
Why the emitter is hand-written. No maintained crate emits this byte
format: serde_json and orjson both output raw UTF-8 for non-ASCII
strings with their own escape rules and no ensure_ascii mode at all.
The contract here is the stdlib's exact wire format, so the emission is
tors's own ~150 lines (src/canon_impl.rs) rather than a dependency that
approximates it. Nothing else is hand-rolled where a crate exists: the
hash is sha2, the hex is const_hex, and every float/int spelling is
Python's own.
The surrogate divergence (documented, pinned). A str holding lone
surrogates — value or key, exact or subclass — raises UnicodeEncodeError
("surrogates not allowed") from the standard str borrow, the crate-wide
boundary every str-in surface here documents, where json.dumps ACCEPTS
lone surrogates (it emits \udXXX escapes for them). Real astral text
(valid surrogate pairs) hashes with full parity. The other divergence
lanes: the walk is iterative, so tors accepts nesting deeper than
json.dumps, which RecursionErrors at an interpreter-version-dependent
depth — bounded by the untrusted-input ceiling (MAX_TOTAL_DEPTH
150k total frames exact+protocol, MAX_WALK_NODES 2M visited objects;
100k exact levels hash, 200k raises RecursionError); and mixed
exact+protocol interleavings diverge by construction (json counts every
container against one C budget, tors counts only protocol frames against
the interpreter budget), pinned as a documented divergence, not parity.
Subclass hooks run to completion under the GIL. A dict subclass's
.items() and a list/tuple subclass's __iter__ are pulled to completion
before the child walk begins; a hook yielding an unbounded stream aborts
with a generic ValueError (the per-container bound is a DoS backstop, its
value deliberately not leaked), bounded never infinite. Treat
content_hash as trusted-input-only for subclass instances with
attacker-controlled hooks — the same posture json.dumps itself has
(it materializes unboundedly and would spin forever). The same
trusted-input-only posture covers depth/breadth beyond a modest envelope
(depth on the order of 10-20k frames, visited objects on the order of
200-500k nodes): the untrusted-input ceiling (MAX_TOTAL_DEPTH 150k total
frames exact+protocol, MAX_WALK_NODES 2M visited objects; 100k exact
levels hash, 200k raises RecursionError) is test-fitted to the pinned
superset lane, not to an adversarial wall/RSS budget — a 149k-deep exact
tree still holds tens of MiB GIL-held before the ceiling fires.
Protocol nesting past ~1000 is unsupported. Subclass containers are
counted against sys.getrecursionlimit() and raise RecursionError at the
cap. On 3.10/3.11 that matches json.dumps's own boundary; on 3.12+ the
stdlib C-stack budget is looser (a legit 2k-deep subclass chain hashes under
json.dumps at ~100k frames on 3.14 while tors raises at ~1000) — a
deliberate conservative divergence, same error class, tighter boundary,
never a silent hash. Exact nesting is the deep lane (100k levels hash);
protocol nesting is the parity lane up to the interpreter limit.
Delegated sorts are bounded. Exotic-key dicts (any float/big-int/
mixed/NaN/subclass key) sort by delegating to CPython's own list.sort
over live (key, value) tuples — O(n log n) Python comparisons plus one
hash entry and one tuple per key, all GIL-held (≈100 MiB for 1M exotic
keys). Past MAX_DELEGATED_SORT_KEYS (100k) keys in one dict, or
MAX_TOTAL_DELEGATED_PAIRS (500k) delegated pairs walked in one call, the
walk refuses with a generic ValueError. Dict subclasses whose keys are
all exact str (or exact int/bool, with no two keys comparing equal —
a True/1 tie sorts by value in the interpreter's timsort) sort natively
in Rust on the same fast paths the exact lane uses, so a 100k-str-key
Counter/OrderedDict/defaultdict hashes without touching the delegated
bounds; subclass keys of any other type keep the delegated lane and its
bounds. The str and int/bool fast paths
take no interpreter sort and cost nothing against these bounds.
GIL model. The object walk and the leaf spellings run under the GIL
(the standard arg-walk class scaled to an object, O(tree): one borrow
plus copy per str, one i64 read per int, one repr call per float); the
canonical-form emission, the SHA-256, AND the owned tree's teardown run
under one py.detach (the tree moves into the detached closure, so no
deep-tree Drop tail holds the GIL after the digest), streaming into the
hasher. At 12 MiB of the records corpus the walk's
worst heartbeat gap measured 23-32ms of 42-60ms walls (the stdlib
spelling holds ~the whole wall: ratio 1.00 inline vs tors's 0.45-0.60;
tests/test_gil_release.py), and the wall race with the full stdlib
expression is a measured dead heat at 64 KiB-12 MiB
(tests/test_performance.py): the value is the GIL release and the
parity guarantees, not raw speed over the C encoder. At 1 MiB the wall
stays within 1.5x the stdlib expression (tests/test_content_hash.py's
wall-ceiling pin); the exotic-key delegated sort (list.sort over live
objects) is the known slow lane versus the str/int fast paths.
a = {"title": "Q3 outage report", "severity": "high", "tags": ["grid", "north"]}
b = {"tags": ["grid", "north"], "severity": "high", "title": "Q3 outage report"}
tors.content_hash(a) == tors.content_hash(b)
# True: equal content, any key order, one dedup key
tors.content_hash(a)
# '558be2127fa557b06ffd3dd0735697e14022546c969c6c37b01cf6278a178cf2'
request = {"model": "guss-9", "messages": [{"role": "user", "text": "Summarize the Q3 report."}]}
tors.content_hash(request)
# 'b0df18f8e089f15fb56fa51c241151213e67b82e7b656de29b1bafedb3785464'
Async: no tors.aio.content_hash twin: a fast one-shot call over an
in-memory object, not a large-input pass worth a thread dispatch (see
Async use).
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.md5_hex / tors.sha1_hex / tors.sha256_hex / tors.sha512_hex / tors.hmac_sha256_hex and the _digest twins (tors.md5_digest / tors.sha1_digest / tors.sha256_digest / tors.sha512_digest / tors.hmac_sha256_digest)
def md5_hex(data: str | bytes) -> str: ... # 32 lowercase hex chars
def sha1_hex(data: str | bytes) -> str: ... # 40
def sha256_hex(data: str | bytes) -> str: ... # 64
def sha512_hex(data: str | bytes) -> str: ... # 128
def hmac_sha256_hex(key: str | bytes, data: str | bytes) -> str: ... # 64
def md5_digest(data: str | bytes) -> bytes: ... # 16 raw digest bytes
def sha1_digest(data: str | bytes) -> bytes: ... # 20
def sha256_digest(data: str | bytes) -> bytes: ... # 32
def sha512_digest(data: str | bytes) -> bytes: ... # 64
def hmac_sha256_digest(key: str | bytes, data: str | bytes) -> bytes: ... # 32
The one-shot hashing primitives a text pipeline keeps reaching for, each
algorithm in two output spellings over ONE digest computation: the
lowercase-hex _hex names and the raw-digest-bytes _digest names. The
hex spellings are the cache-key/ETag/request-ID shapes: webhook signature
verification and API auth (hmac_sha256_hex, the GitHub/Stripe/Slack
HMAC-SHA-256 convention), ETag and Content-MD5 checks against object
stores (md5_hex), quick content compares and legacy-interop digests
(sha1_hex), and dedup-cache keys / content addressing
(sha256_hex/sha512_hex — the same engine finalize's hash tail and
merkle_root's leaves use, without the normalize stage). The digest
spellings serve the call sites that want the bytes themselves: signature
schemes that base64-encode the digest, key-derivation chains that feed a
digest back in as a key, certificate/content thumbprints, advisory-lock
ints sliced off the front — see
Raw digest bytes. Every
algorithm is the maintained RustCrypto implementation (md-5, sha1,
sha2, hmac); nothing is hand-rolled, the same dependency policy as the
rest of the crate. Byte-identical to the stdlib spellings in both output
shapes —
tors.sha256_hex(data) == hashlib.sha256(data).hexdigest(),
tors.sha256_digest(data) == hashlib.sha256(data).digest(),
tors.hmac_sha256_hex(key, data) == hmac.new(key, data,
hashlib.sha256).hexdigest() — and internally one computation:
tors.sha256_hex(data) == tors.sha256_digest(data).hex(). All pinned by
exact differentials over hypothesis corpora plus the primary-source
known-answer vectors (RFC 1321, FIPS 180-4, RFC 4231's every
HMAC-SHA-256 case) in tests/test_hash.py.
md5_hex/md5_digest and sha1_hex/sha1_digest are
checksum/legacy-interop primitives, never security primitives — either
spelling. Both are broken and have been since the 2000s: practical md5
collisions date to 2004, sha1's first public collision to 2017 (Google's
SHAttered). Use them for Content-MD5, S3 ETags, cache-busting,
rsync-style quick compares — never for signatures, certificates, or
password handling. The security side of this surface is
sha256/sha512/hmac_sha256, either spelling. The HMAC key is held
in memory for the call and is not zeroized on return — the same posture
as the stdlib hmac/hashlib spelling, which likewise keeps key
material in ordinary memory.
str input is its UTF-8 bytes, on purpose. hashlib raises TypeError on
str and makes every caller spell s.encode("utf-8") first; tors takes the
str directly — tors.sha256_hex(s) == hashlib.sha256(s.encode("utf-8"))
.hexdigest() — because the str-in convention is crate-wide (normalize,
finalize, the segmentation family). A str holding lone surrogates
therefore raises UnicodeEncodeError at the argument boundary, the same
crate-wide contract. bytes input is exactly bytes: bytearray and
memoryview raise TypeError rather than being copied, the bytes-in
family's immutable-buffer doctrine (b64_encode_bytes,
tests/test_b64.py) — callers holding one wrap it first,
tors.sha256_hex(bytes(buf)), then hash. Any input length is legal, empty included (the
empty-input digests are pinned known-answer vectors); there are no
ValueError paths on this surface.
Stateless one-shot only — streaming/incremental hashing is out of
scope by charter. No hash object, no streaming update surface: every
spelling here hashes its whole input in one call, and tors is stateless
by charter (Design and scope), so a hashlib-style
constructor object is exactly the persistent-handle shape that charter
cuts (the two measured exceptions, CompiledPatterns and
CompiledLemmaDict, exist for per-call re-materialization costs a digest
object doesn't have: hashlib.sha256() construction is O(1)). A caller
hashing a stream hashes chunk digests and combines them (the
merkle_root shape, or a running HMAC chain over chunks); for incremental
feeding, hashlib's object API is the right tool and is not duplicated.
GIL model. The argument borrow (zero-copy for ASCII/cached str, for
bytes always; on the two-argument HMAC spellings the one-time O(input)
UTF-8 materialization applies independently per str argument, so two
non-ASCII str inputs pay two materializations — the measured HMAC wall
cells use bytes key+data, equivalently the ASCII zero-copy lane) runs
under the GIL; the whole digest computation — update
and finalize, plus the O(digest-size) hex formatting on the _hex
spellings — runs under one py.detach. The GIL-held residue is the
marshalling of one short hex string (the _hex names) or one fixed-size
PyBytes of 16/20/32/64 bytes (the _digest names — the
b64_decode bytes-return class). The honest
comparison with hashlib, measured (Apple Silicon, min-of-3):
CPython's hashlib releases the GIL for digest updates of 2048+ bytes
(the _hashopenssl threshold), so at multi-MiB sizes the stdlib is
loop-friendly too and tors's GIL release is uniformity, not a latency
win; below the threshold hashlib holds the GIL but a sub-2048-byte
digest is microseconds, immaterial either way. On raw throughput the
OpenSSL engines (hardware SHA extensions) win or tie at engine-dominated
sizes — sha256 12 MiB ~4-5ms (tors) vs ~4ms (hashlib), ratio ~1.1-1.2x
(absolute figures move with box and load; the band is the statement),
sha1 ~1.06-1.11, md5 and sha512 dead heats — recorded, not hidden. The wall
wins tors can assert are the sizes this surface exists for, where
per-call overhead dominates the engine: hashing a short ASCII str at 0.40-0.54
of hashlib.sha256(s.encode("utf-8")).hexdigest() (the encode is a real
cost the stdlib makes you pay; non-ASCII str pays a one-time O(input)
UTF-8 materialization, so the win narrows there), and HMAC at request-signature sizes at
~0.31 of even the stdlib's fastest one-shot spelling
(hmac.digest(key, data, "sha256").hex()). Full tables:
Performance.
No deadline_ms. Every deadline-bearing primitive in this crate
protects against an adversarial-input superlinear blowup. This surface
has no such shape: hashing cost is linear in the input length, with no
superlinear structure for an adversary to feed — a caller already
controls the one lever that bounds the cost (how many bytes they pass).
No tors.aio twin. The hashing family is absent from
tors.aio._WRAPPED on the module's own size discipline: a one-shot
digest over the inputs this surface exists for (cache keys, request
signatures, webhook bodies) is a microsecond-scale call, and the
asyncio.to_thread hop costs tens of microseconds, more than the call
itself (see Async use). A coroutine that does hash a
multi-MiB payload wraps manually, await asyncio.to_thread(tors.sha256_digest, blob);
the GIL is released for the digest either way, so the wrap buys the loop
its wall clock back and nothing else.
Raw digest bytes: the _digest spellings
The five _digest names return the digest as raw bytes — the same
engines, the same single detach, the same contracts as their _hex
twins (str input is its UTF-8 bytes; exactly-bytes in, so
bytearray/memoryview raise TypeError — wrap first, bytes(buf);
a lone surrogate raises
UnicodeEncodeError at the borrow; any key length legal, empty included;
the key borrowed and validated before the data) —
without the hex tail. One digest computation per call, two output
spellings to choose from; tors.md5_digest(x) is exactly
bytes.fromhex(tors.md5_hex(x)).
The raw bytes are what a second consumer layer wants, the shapes a hex string forces to decode first:
- Base64 webhook signatures. The webhook schemes that sign with
HMAC and base64-encode the digest (not hex) need
hmac_sha256_digest:urlsafe_b64encodeover the raw 32 bytes is the signature, verified withhmac.compare_digest(below). - Key derivation chains. A labelled subkey is a digest fed back in
as an HMAC key —
hmac_sha256_digest(hmac_sha256_digest(root, label), data)— the HKDF-style extract/expand shape; a hex string would have to be decoded before every link. - Content/certificate thumbprints and digest-sliced ints. A
stable identifier for an arbitrary name — an advisory-lock int
(
int.from_bytes(sha256_digest(name)[:8], "big")) or a thumbprint key — is a slice or re-encoding of the raw bytes.
md5_digest and sha1_digest carry their twins' warning verbatim:
checksum/legacy-interop only, never security. And the streaming
boundary above is the whole family's: the _digest spellings are
one-shot too — there is no incremental feeding surface on either
spelling, by charter.
A base64-signature webhook verification, the hmac_sha256_digest
shape (the secret is the bytes after the whsec_ prefix; the signed
content is "{msg_id}.{timestamp}.{payload}"; the verify compare is
hmac.compare_digest, never ==):
import base64
import hmac as hmac_module
import tors
secret = base64.b64decode("whsec_3f9d2a8c".partition("_")[2])
signed_content = (
"msg_5fXn0.1731634200."
'{"event":"invoice.paid","id":"evt_88213","amount":4200}'
).encode("utf-8")
signature = base64.urlsafe_b64encode(tors.hmac_sha256_digest(secret, signed_content))
# b'q8IyxOXFC_mgdZj-GSqtTl2vj3eTIgMM0HQQ-FfRQVw='
hmac_module.compare_digest(
signature,
base64.urlsafe_b64encode(tors.hmac_sha256_digest(secret, signed_content)),
)
# True
A digest-sliced advisory-lock int (the stable-identifier shape: a 64-bit int for an arbitrary resource name):
import tors
lock_id = int.from_bytes(tors.sha256_digest("tenant:42:resource:7")[:8], "big")
# 15284293306093710542
A labelled derivation chain (the extract-then-expand shape: the label derives an intermediate key, the data expands it — both links consume the raw digest bytes):
import tors
root = b"root-key-material"
subkey = tors.hmac_sha256_digest(
tors.hmac_sha256_digest(root, "tors/db-session-key"), "user:42"
)
subkey.hex() # the 32 raw bytes, hex for inspection
# "cc3ccddeaa0718afe52e67b9011b49dfe29ae01571495a75e17eea1f59f6e7b6"
A webhook-verification shape with the hex spelling, for the schemes that
compare hex signatures (the str key and payload spell exactly how
they arrive off the wire; hmac.compare_digest stays the right compare):
import hmac as hmac_module
import tors
secret = "whsec_3f9d2a8c"
payload = '{"event":"invoice.paid","id":"evt_88213","amount":4200}'
expected = tors.hmac_sha256_hex(secret, payload)
# "43ec3b86ee42fdcf9640ded30e94104b4a11b9fd43f1624b30471f7b13e76a12"
hmac_module.compare_digest(expected, tors.hmac_sha256_hex(secret, payload))
# True
An S3/HTTP ETag check (the md5 checksum use; the value is the normalized body's digest, matching what the store computed):
import tors
body = tors.normalize("line one \n\n\n\nline two\r\n")
# "line one\n\nline two"
tors.md5_hex(body)
# "487f5cc2c45cc57e638d9fce8c33d95c"
tors.md5_hex(body) == "487f5cc2c45cc57e638d9fce8c33d95c" # the declared ETag
# True
== is the correct compare here: an ETag is a non-secret checksum, so
a timing side channel has nothing to leak. Secret comparisons (HMAC
signatures, tokens) must use hmac.compare_digest as in the webhook
cells above — never ==.
The _digest twins: the bytes-side pins
The five raw-digest names return a fresh, immutable bytes object of
exactly one length each: md5_digest 16 bytes, sha1_digest 20,
sha256_digest 32, sha512_digest 64, hmac_sha256_digest 32. The
object is built per call (PyBytes::new), never cached or interned, so
handing the digest to a second consumer layer cannot alias tors's
internals. Three parity identities hold for every input and are pinned
differentially in tests/test_hash.py (TestDifferentialParity,
hypothesis corpora plus RFC 1321 / FIPS 180-4 / RFC 4231 known-answer
vectors through both spellings):
tors.md5_digest(x) == hashlib.md5(x).digest()and likewise for sha1/sha256/sha512 (cross-implementation agreement: RustCrypto vs OpenSSL);tors.hmac_sha256_digest(k, x) == hmac.new(k, x, hashlib.sha256).digest(), any key length, empty included (an empty key IS the zero-padded 64-byte key, RFC 2104's padding rule);tors.<alg>_hex(x) == tors.<alg>_digest(x).hex(): one digest computation per call, the hex path consumes the digest path.
The argument contract is the _hex twins' byte for byte, and the error
messages name the argument that failed:
import hashlib
import tors
digest = tors.sha512_digest("payload") # str in: its UTF-8 bytes
len(digest)
# 64
digest == hashlib.sha512(b"payload").digest() # cross-implementation parity
# True
tors.sha512_hex("payload") == digest.hex() # one computation, two spellings
# True
import tors
tors.sha256_digest(bytearray(b"payload"))
# TypeError: data must be str or bytes, not <class 'bytearray'>
A bytes SUBCLASS is accepted (pyo3's PyBytes borrow covers
subclasses); bytearray, memoryview, and everything else that is not
exactly str or bytes raise TypeError with that message shape,
because the GIL-released digest reads an immutable buffer. On the HMAC
spellings the key is borrowed and validated before the data, so when
both arguments are bad the KEY's error is the one that fires:
import hashlib
import hmac as hmac_module
import tors
tors.hmac_sha256_digest(None, bytearray(b"data"))
# TypeError: key must be str or bytes, not <class 'NoneType'>
tors.hmac_sha256_digest(b"", b"payload") == hmac_module.new(b"", b"payload", hashlib.sha256).digest()
# True
tors.hmac_sha256_digest(b"", b"payload") == tors.hmac_sha256_digest(b"\x00" * 64, b"payload")
# True
The empty input is a pinned known answer in every algorithm, the digest of the empty string (FIPS 180-4 / RFC 1321's vectors themselves):
import tors
tors.sha256_digest(b"").hex()
# 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
tors.md5_digest(b"").hex()
# 'd41d8cd98f00b204e9800998ecf8427e'
tors.uuid7_timestamp_ms / tors.uuid_version / tors.uuid_parse
def uuid7_timestamp_ms(value: bytes | str) -> int: ...
def uuid_version(value: bytes | str) -> int: ...
def uuid_parse(value: str) -> bytes: ...
The UUIDv7 field operations: the hex-grammar work (the structural parse
and the canonical encode) delegated to the Rust uuid crate — the uuid-rs
org's, Apache-2.0 OR MIT, no default features, zero transitive
dependencies — with tors's own thin strictness layer on top (generating
IDs is not this surface's job — every producer from uuid.uuid7() to
uuid_utils already does that — reading the standard time-ordered ID's
fields back out is). A store keyed by UUIDv7 IDs ends up reimplementing
these three operations at every site that paginates by recency or buckets
by time: the 48-bit unix-millisecond timestamp (keyset cursors and
time-bucketed queries against an ID column), the version nibble that says
whether that timestamp means anything, and a strict text-to-bytes parse at
the ID-validation boundary. 16 bytes in, integer out — trivial, which is
exactly why it keeps getting reimplemented slightly wrong.
uuid7_timestamp_ms returns the leading six bytes as a big-endian
unix-millisecond timestamp (RFC 9562 section 5.7's unix_ts_ms field):
datetime.datetime.fromtimestamp(ms / 1000, UTC) is the ID's creation
instant. It raises ValueError naming the version actually found when the
version nibble is not 7 — the field is only defined for v7, and the nil
UUID's all-zero field must not silently answer 0.
uuid_version returns the version nibble (byte 6's high half), 0-15, for
any UUID of any variant: the field itself, not an RFC-4122-ness check. The
variant is a different field (byte 8's top two bits) and out of scope; the
stdlib uuid.UUID.version property refuses to answer off the RFC 4122
variant, where this answers the nibble question unconditionally.
uuid_parse is canonical text to the 16 raw bytes, strict, the
validation-primitive direction. The stdlib uuid.UUID accepts strictly
more than this, deliberately not matched here: it parses braced text
({...}), the URN prefix (urn:uuid:...), hyphen-less hex, and uppercase,
while uuid_parse accepts exactly one grammar — 36 ASCII characters
(36 bytes), hyphens at positions 8/13/18/23 (the 8-4-4-4-12 groups),
lowercase hexadecimal elsewhere — raising ValueError naming the problem,
the position
(0-based), and the accepted form otherwise. A caller using this as a gate
wants one grammar, the canonical one every producer emits, not the
stdlib's permissive union; that closed-set strictness is the same contract
b64_decode's validate=True default and the errors=/boundary=
parameters already establish. The divergences are pinned, not accidental:
tests/test_uuid.py proves the stdlib accepts each loose form in the same
test that pins tors rejecting it. The parse mechanics behind the contract:
Uuid::parse_str (the crate's) owns structure and the text-to-bytes
transcode, and tors's strictness layer is one comparison — accepted text
must be byte-equal to its parsed value's re-encoded canonical form, which
is what rejects the crate's (and the stdlib's) loose spellings at exactly
the canonical grammar. The strict contract is not the crate's default,
which is precisely why the layer is tors's own.
The int-out pair takes exactly bytes (16 bytes, ValueError naming the
count otherwise; bytearray/memoryview are TypeError, the bytes-in
surface's borrows-the-argument-copies-the-16-bytes contract: the bytes
spelling borrows the argument, copies the 16 bytes out) or canonical str
(same strict grammar, same errors); uuid_parse takes exactly str. A
str holding lone surrogates fails the borrow itself
(UnicodeEncodeError) before any grammar check runs.
import datetime
import uuid
# any v7 works; this one is fixed so the outputs below are literals
u = uuid.UUID("01977420-dc00-7abc-9def-98765432100f")
tors.uuid_version(u.bytes) # 7
tors.uuid7_timestamp_ms(u.bytes) # 1750000000000
tors.uuid7_timestamp_ms(str(u)) # 1750000000000: canonical text accepted too
datetime.datetime.fromtimestamp(1750000000000 / 1000, datetime.timezone.utc)
# datetime.datetime(2025, 6, 15, 15, 6, 40, tzinfo=datetime.timezone.utc)
tors.uuid_parse(str(u)) == u.bytes # True
tors.uuid_parse(str(u).upper())
# ValueError: UUID text must be lowercase hex: found uppercase 'D' at
# position 9 (the stdlib uuid module accepts uppercase; tors's canonical
# form deliberately does not)
GIL model: the bytes spelling borrows the argument, copies the 16 bytes,
and the bit extraction (field read) runs detached under py.detach; the
str spelling validates and
transcodes under the GIL (the whole input is 36 bytes, smaller than the
call's own marshalling residue — a detached parse would be overhead for
its own sake) with the extraction detached after it, keeping the crate's
GIL-free-core contract uniform across the trio. uuid_parse is that
contract's honest exception: its whole work is the 36-byte parse (there
is no int-out tail to detach) and it runs GIL-held by design, ~70ns a call
(re-measured after the uuid-crate adoption; the crate's const-fn parser
is faster than the hand-rolled scan it replaced).
Every per-call GIL-held residue on this surface is sub-microsecond; the
heartbeat cell in tests/test_gil_release.py pins the batch-loop band.
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.minhash_signature
def minhash_signature(
text: str,
*,
num_perm: int = 128,
shingle_size: int = 3,
seed: int = 0,
method: str = "xxh",
bits: int | None = None,
) -> list[int]: ...
The MinHash signature of text, one GIL-released native pass: the
recall-side near-duplicate complement to the SimHash family. simhash64/
simhash128 are the precision side — one compact fingerprint, cheap to
store and compare, but a weak recall instrument at corpus scale (two
paraphrased documents sit far apart in Hamming space however related
their vocabulary). MinHash is the recall side: num_perm min-hashes per
document, and the fraction of positions on which two signatures agree
estimates the Jaccard similarity of the two documents' shingle sets — the
quantity a corpus-scale ingestion pipeline (100k+ documents) bands into an
LSH table and recalls candidate pairs on (Broder's MinHash, the primitive
behind near-duplicate detection at web scale).
The estimator contract. For shingle sets A and B, each position
i of the two signatures agrees exactly when the shingle achieving the
minimum of h_i over A ∪ B lies in A ∩ B, which happens with
probability J(A, B) = |A ∩ B| / |A ∪ B|. So
E[agreement] = J and the standard error over k = num_perm positions is
sqrt(J(1 - J) / k) — O(1/sqrt(k)), about 0.044 at the default
num_perm = 128 at the worst case J = 0.5, halving with every 4x in
num_perm. The comparison is one expression at the call site:
sum(x == y for x, y in zip(sig_a, sig_b)) / len(sig_a)
or, byte-identically, tors.minhash_jaccard(sig_a, sig_b) (below), which
also carries the b-bit correction when the signatures were compressed with
bits=.
The two engines (method=). The method parameter picks the sweep
that produces the rows, both riding the same shingle set, the same
determinism contract, and the same empty-set sentinel:
"xxh"(the default): the classic k-independent-permutations min-sweep described in this section, byte-identical to every signature produced before the parameter existed."superminhash": Ertl's SuperMinHash (arXiv 1706.05698), the in-place incremental sampling scheme: each element's XXH64 seeds its own SplitMix64 stream, and the algorithm maintains one in-place permutation with a histogram early exit, drawing amortized O(1) instead of k random values per element. The estimator stays the agreement fraction, still unbiased, with the paper's strictly smaller variance for small sets: the alpha(m, u) factor is about 1/2 while the union cardinality u is under the signature size m, so at u = 100 the SuperMinHash RMSE measures ~0.7x the classic engine's at the same k, converging to parity as u >> m (measured over the pinned RMSE grid intests/test_minhash.py).
The two engines' signatures are NOT cross-compatible: the agreement
fraction of a mixed pair estimates nothing, and the SuperMinHash
permutation structure depends on m (no prefix property: k=8 is not
k=128's first 8 rows). Rows are opaque ints, so no cheap runtime check
can catch a mix; mixing signatures across methods, num_perm values, or
seeds is a caller error, and the banding consumer inherits the same rule
(an LSH table must hold signatures from ONE engine at ONE parameter set).
method="superminhash" combined with bits= raises ValueError: the
b-bit estimator's math needs uniform integer rows, and the SuperMinHash
rows are f64 bit patterns of real values in [0, m), not uniform
integers.
b-bit compression (bits=). An int in [1, 63] keeps the LOWEST
bits bits of every row (Li and König, "b-Bit Minwise Hashing", WWW
2010): num_perm full rows compress to a b·num_perm-bit fingerprint.
The default None is the full u64 rows, byte-identical to the unmasked
output. The masked rows' agreement probability is
J + (1 - J)·2^-b (distinct min-hashes collide on their low b bits with
probability 2^-b), so the unbiased estimate is the corrected fraction
(p_hat - 2^-b) / (1 - 2^-b), which minhash_jaccard(..., bits=b)
computes. The accuracy contract: the variance is at most ~3x the full rows' once
b >= log2(1/J) (below that, the chance term dominates the information
the rows carry; measured: at J = 0.5 the RMSE ladder sits at 0.070 for
b=1, 0.048 for b=4, 0.041 for b=8, against 0.043 for full rows at k=128).
For banding, the per-band collision probability changes accordingly:
(J + (1-J)·2^-b)^r per band of r rows instead of J^r -- the
lsh_probability S-curve applies with the effective per-row agreement
J + (1-J)·2^-b substituted for s, and the false-positive floor lifts to
(2^-b)^r per band, negligible at b=8 and above.
Tokenization and shingling. Tokens are the same tokenizer
tf_idf/bm25_rank ride: UAX #29 word segments (word_bounds' own
segmentation), segments made entirely of whitespace skipped, each
lowercased with Unicode-correct case folding — so "Hello, WORLD!" and
"hello, world!" signature identically, and whitespace shape (tabs,
newlines, runs) is invisible. A shingle is shingle_size consecutive
tokens hashed under an injective length-prefixed framing: the window's
token count as one little-endian u64, then per token its UTF-8 byte
length as one little-endian u64 followed by the bytes themselves, the
whole frame fed to XXH64 (seed 0). Length-prefix codes are uniquely
decodable, so the framing is injective by construction — deliberately,
because "no UAX #29 token can contain U+001F" is false: U+001F is not
whitespace, so the segmenter keeps it (tors.word_bounds("a\x1fb") is
the three tokens ["a", "\x1f", "b"]), and UAX #29 WB4 (ignore
Extend/Format/ZWJ) then glues a following combining mark, ZWJ, or SOFT
HYPHEN onto it (tors.word_bounds("\x1f\u0301") is the single token
["\x1f\u0301"]). A token + U+001F + token join would rest on the
narrower (and segmentation-table-sensitive) claim that U+001F never
mixes into a longer segment; the framing rests on nothing the segmenter
can take away. Both directions are pinned in tests/test_minhash.py:
the \x1f-adjacent mark corpus (every combining mark in U+0300–U+036F,
plus ZWJ and SOFT HYPHEN, attaches) and the framing's injectivity over a
separator-bearing window domain. Word shingles,
not character shingles: natural-text near-duplicates preserve word
sequence far more often than exact character spans — a reflowed paragraph
or a swapped word shifts character k-grams wholesale while word k-grams
survive, which is what recall at corpus scale needs.
The pinned arithmetic (the determinism contract). Every element is
fixed, documented arithmetic, platform-independent (integer ops only), so
the same text at the same parameters produces the identical signature
across processes, machines, and platforms within one tors version — the
same stability requirement the simhash family states for its FNV-1a. The
boundary that claim stops at: the signature is a function of the crate's
UAX #29 segmentation tables (unicode-segmentation, pinned in
Cargo.lock) as well as of the frozen arithmetic, so a tors release that
bumps those tables can change signatures — re-fingerprinting every
affected document. This surface's advertised use is persisted signatures
and LSH tables at corpus scale, where a re-fingerprinting upgrade
silently invalidates the table: a caller persisting either across tors
versions must re-baseline on upgrade (within a version nothing varies —
the arithmetic and XXH64 are frozen):
- each shingle is hashed with XXH64, seed 0: the frozen-spec algorithm
(final since xxHash 0.7.0 — the digest for a given seed and byte stream
is part of the spec, not an implementation detail), via
twox-hash; signature[i] = minover shingles of(a_i * x + b_i) mod p, withp = 2^61 - 1(the Mersenne prime, the standard MinHash field) and the(a_i, b_i)pairs derived fromseedby a SplitMix64 stream —a_iin[1, p-1](a zero multiplier would collapse the permutation to a constant),b_iin[0, p-1), two draws per permutation in permutation order (so a smallernum_permyields a prefix of a larger signature at the same seed).randis deliberately not involved: it is not in tors's dependency tree and its stream internals are not a semver-stable contract, while the ~8-line SplitMix64 derivation is golden-pinned on both the Rust and Python sides (tests/test_minhash.py).
This is fixture-grade determinism, not cryptography: nothing here resists an adversary crafting collisions, and nothing needs to.
The empty-shingle-set convention. Empty text, whitespace-only text,
or fewer tokens than shingle_size yields num_perm copies of the u64
MAX sentinel (2**64 - 1): deterministic, seed-invariant, and unable to
collide with any real minimum (the affine outputs live in
[0, 2**61 - 1), a disjoint range). Two empty documents agree at every
position, an empty-vs-nonempty pair at none, and an all-sentinel
signature is a stable digest an LSH table can bucket empty documents
under.
The LSH table is caller state. tors stays stateless
(design.md): this function computes one document's signature
and keeps nothing; the persistent banding table, the candidate store, and
the threshold calibration are the caller's. The one-shot pass over
signatures already in hand is lsh_candidates below: cut every signature
into r-row bands, hash each band to a bucket key, and recall every
bucket-sharing pair in one call — no table to insert into or query
against.
Bounds. num_perm must be in [1, 1024] and shingle_size at least
1; an in-range value outside those bounds raises ValueError naming the
bounds before any work runs, while an int outside the i64 range the
binding extracts (num_perm=10**30) raises pyo3's own OverflowError
at extraction instead — the truncate_to_bounds-identical pattern for
every i64-typed size argument. method must be "xxh" or
"superminhash" (ValueError naming the choices; a non-str is a
TypeError), bits an int in [1, 63] or None (ValueError naming
the range; bool and every other non-int a TypeError, an out-of-i64
int an OverflowError).
seed is any int, reduced mod 2**64 with two's-complement semantics
for negatives (seed=-1 is seed=2**64 - 1). All three int parameters
are accepted through the __index__ protocol (numpy integers and other
int-likes work; the slot is dispatched, never the instance's own
__and__, so masking cannot alter the value) — and __index__ itself
is caller code: it runs exactly once, with its own side effects, and
its own failure propagates unchanged rather than masking as a parameter
error. bool is rejected explicitly in every position (num_perm=True
is a TypeError, not 1), including as an __index__ result; anything
without __index__ (str, float, None, bytes) and an __index__
returning a non-int are TypeError. A non-str
text raises TypeError; text bearing lone surrogates raises
UnicodeEncodeError (the crate-wide str-borrow contract). Cost is
O(tokens × shingle_size) hashing (every step re-hashes the whole live
window under the length-prefixed framing — widths 64/256 on 100 KiB cost
~9/~32 ms against ~2 ms at the default width on the dev box,
macOS/arm64, release) plus O(distinct × num_perm) in the min-sweep, the
dedup-first shape: each distinct shingle hash updates the minima once,
so a repeated-token document rides its handful of distinct shingles
rather than its thousands of occurrences. Resident memory is the live
shingle_size-deep token window — O(min(tokens, shingle_size)) —
plus the distinct-hash set plus the num_perm coefficients: the token
list is streamed, never retained
(the pre-fix shape held the whole Vec<String> across the sweep, a
measured ~77 MB transient at 12 MB of prose: 133 MB peak vs 56 MB after,
same interpreter and corpus baseline). Past 1024 tokens of width the
short stream never materializes at all: a retention-free token count
decides "fewer tokens than the width" first, so a huge shingle_size
over a large text answers the sentinel at the tokenizer transient
(~13.5 MB at width 10⁹ peaks ~30 MB, not the ~145 MB the retaining
shape held, same dev box). There is no
deadline_ms on this call (unlike diff_opcodes): the sweep has no
superlinear shape, only the linear ones above, so the lever is the
caller's own input size — bound it before calling (truncate, chunk, or
max_bytes-gate the read) rather than after. One guard does exist: a
wide fillable shingle window (shingle_size > 1024 with at least
shingle_size tokens) costs (tokens − shingle_size + 1) × shingle_size
token-hashes — work peaking near shingle_size ≈ tokens/2, not at the
huge widths the sentinel short-circuit already absorbs — and past the
sweep budget of 2^26 token-hashes (~0.4 s) the call raises ValueError
naming the bound, the same two-sided pattern as num_perm's cap
(pinned in tests/test_minhash.py). The gate's own token count is
capped at ⌊budget/shingle_size⌋ + shingle_size tokens (≤ ~66.5k for
every admissible width), so a huge stream is rejected in constant time
without a GIL-held walk of the whole input, and the error reports the
minimum provable spend past the cap ("at least N token-hashes"). The gate's own token count is
capped at ⌊budget/shingle_size⌋ + shingle_size tokens (≤ ~66.5k for
every admissible width), so a huge stream is rejected in constant time
without a GIL-held walk of the whole input, and the error reports the
minimum provable spend past the cap ("at least N token-hashes"). Measured (dev box,
macOS/arm64, release): ~13.5 MB of repetitive prose at the default 128
permutations completes in ~0.25 s, while 1 MiB of distinct-rich text
(every token unique, the minhash_signature_distinct bench row and the
Python worst-case cell) costs ~104 ms at k=128 and ~226 ms at k=1024
(criterion medians, 10 samples) — the ~100M-affine-op worst case the
caller bound is calibrated on; the suite's 2.5 s regression ceiling is
~10x the repetitive nominal
(see tests/test_minhash.py), a tripwire, not a target. await
tors.aio.minhash_signature(...) runs the call under asyncio.to_thread
(see Async use).
original = (
"The quarterly oil sample interval for field outages was adjusted after the bushing "
"torque specifications changed. Maintenance windows now close within fourteen days. "
)
edited = (
"The monthly oil sample interval for field outages was adjusted after the insulator "
"torque specifications changed. Maintenance windows now close within fourteen days. "
)
unrelated = "Pack my box with five dozen liquor jugs."
a = tors.minhash_signature(original)
b = tors.minhash_signature(edited)
u = tors.minhash_signature(unrelated)
a[:3]
# [151086443443351341, 59387643775660493, 132191052063639682]
sum(x == y for x, y in zip(a, b)) / len(a)
# 0.6015625: near-duplicates — the two-word swap leaves most 3-word
# shingles intact, so the estimated shingle-set Jaccard stays high
# (exact J here: 0.6429)
sum(x == y for x, y in zip(a, u)) / len(a)
# 0.0: an unrelated text agrees on no positions
tors.minhash_signature("")[:3]
# [18446744073709551615, 18446744073709551615, 18446744073709551615]:
# the empty-shingle-set sentinel
tors.minhash_jaccard
def minhash_jaccard(
sig_a: Sequence[int], sig_b: Sequence[int], *, bits: int | None = None
) -> float: ...
The paired-row Jaccard estimator over two minhash_signature
signatures: bits=None counts rows equal at full width and returns the
agreement fraction (the one-expression estimator above, as a call);
bits=b is the b-bit estimator over b-bit-compressed rows (Li and
König, WWW 2010): masked rows agree with probability
J + (1 - J)·2^-b, so the returned estimate applies the chance
correction (p_hat - 2^-b) / (1 - 2^-b), restoring unbiasedness. The
corrected estimate can land slightly NEGATIVE at true similarity zero (a
finite sample's chance term can exceed the observed agreement) -- that
is the unbiased estimator's honest shape, not a bug; threshold callers
should compare raw agreement fractions instead. The variance is at most ~3x the full rows' once b >= log2(1/J).
Both signatures must be equal-length and non-empty, else ValueError
naming the lengths; row elements ride the strict int convention
lsh_candidates spells (negative: ValueError, past 2**64 - 1:
OverflowError, non-int including bool: TypeError). The estimator
reads the rows it is given: signatures from different engines,
parameters, or seeds mixed in a call are a caller error nothing can
detect (the rows are opaque), so the engine-compatibility contract in
minhash_signature's section is the guard.
full_a = tors.minhash_signature(original)
full_b = tors.minhash_signature(edited)
full_u = tors.minhash_signature(unrelated)
tors.minhash_jaccard(full_a, full_b)
# 0.6015625: the agreement fraction, unbiased (true J 0.6429)
masked_a = tors.minhash_signature(original, bits=8)
masked_b = tors.minhash_signature(edited, bits=8)
len(masked_a)
# 128: still num_perm rows, each now one byte of payload
tors.minhash_jaccard(masked_a, masked_b, bits=8)
# 0.6: the corrected b-bit estimate (chance term removed; true J 0.6429)
tors.minhash_jaccard(full_a, full_u)
# 0.0
O(len(sig_a)) integer comparisons: no detach, no aio twin (the
lsh_probability stay-sync class -- the thread hop costs more than the
call).
tors.lsh_candidates
def lsh_candidates(
signatures: list[list[int]], *, bands: int, rows: int
) -> CandidatePairs: ...
The MinHash LSH banding pass: the near-duplicate candidate generator that
turns a corpus of minhash_signature signatures into candidate pairs in
one stateless call. Broder, Glassman, Manasse, and Zweig, "Syntactic
Clustering of the Web" (WWW 1997) supply the shingling + resemblance
frame minhash_signature estimates; Leskovec, Rajaraman, and Ullman,
"Mining of Massive Datasets", ch. 3 the banding probability model.
Every signature is cut into bands bands of rows rows — every
signature's length MUST equal bands * rows, exactly what
minhash_signature returns at num_perm=bands*rows — each band's rows
hash to one 64-bit bucket key (XXH64, seed 0, over the length-prefixed
little-endian row frame, the crate's one hashing contract, a FIXED seed:
no rng handle, the same signatures band to the same buckets every call,
every process), and every pair of signatures sharing at least one bucket
comes back as a candidate:
sigs = [tors.minhash_signature(t) for t in (original, edited, unrelated)]
tors.lsh_candidates(sigs, bands=32, rows=4)
# {'pairs': [(0, 1)]}: the near-duplicate pair is recalled, the
# unrelated pair sits below the false-positive floor
tors.lsh_candidates([], bands=32, rows=4)
# {'pairs': []}
Returns {"pairs": [(i, j), ...]} with i < j, deduplicated across
bands (a pair sharing several band buckets appears once) and in ascending
(i, j) order. The candidate relation is a function of the signatures'
CONTENT, not their positions: permuting the input permutes the same
relation, pinned in tests/test_lsh.py.
The false-positive contract, stated plainly. Dissimilar signatures CAN become candidates, in exactly two ways, and both are by design:
rowsrows agreeing by chance is the S-curve itself —lsh_probabilitygives a pair its candidate probability at any similarity, so low-similarity pairs leak through wherever the curve is not flat at zero.- Two DIFFERENT row frames colliding in the 64-bit band key is possible at ~k²/2^65 over k distinct keys — negligible, but nonzero, and deliberately NOT engineered away (a second hash pass or a 128-bit key buys nothing against a false-positive budget the chance-agreement channel already dominates).
LSH banding trades precision for recall everywhere it is used; the pair
list is a recall-biased filter to SCORE downstream (shingle_jaccard,
dedup_near_dup), never a verdict. Identical signatures are ALWAYS
candidates (every band collides deterministically) — the curve's
s = 1 end, pinned exactly.
Choosing bands and rows. The shape sets the recall/precision
step: lsh_probability evaluates the S-curve for a candidate shape,
lsh_threshold gives its approximate step location. At
bands=32, rows=4 (the example above) a pair at similarity 0.6 is
recalled with probability ~0.988 while 0.1 sits at ~0.003.
Cost and memory (output-sensitive by construction). One pass:
O(n · num_perm) band hashing (each band's key O(rows), so linear in
the signature data itself) plus pair emission ONLY inside shared
buckets, which is O(output) by definition — nothing compares
signatures that share no bucket, so there is no n² anywhere except
through the output. Resident memory is one band's bucket table (freed
per band) plus the sorted pair accumulator's merge scratch, O(n + pairs). The linear class is pinned
by growth-ratio cells in tests/test_scaling_pins.py, the memory class
by a VmHWM guard in tests/test_lsh.py, and the pass is benchmarked in
benches/lsh.rs.
Bounds. bands and rows must each be at least 1 and every
signature's length exactly bands * rows, each a ValueError naming
the contract before any work runs; the two shape parameters ride the
__index__ protocol (int-likes work, bool rejected). Signature
elements are STRICT, the bytes-element surfaces' convention: exact
non-negative ints within 2**64 - 1 (a negative: ValueError, past the
range: OverflowError, a non-int including bool: TypeError; no
per-element __index__ dispatch, so numpy int-likes convert first).
GIL: the signatures walk (one int extraction per element, the standard
O(n · num_perm) arg-walk class) and the bounds validation under the
GIL, then the whole banding pass under one py.detach, then the
O(pairs) tuple-list marshalling.
Async: await tors.aio.lsh_candidates(...) runs this under asyncio.to_thread (see Async use). Honest caveat, measured: the O(n · num_perm) signature walk is interpreter-side int extraction and stays GIL-held inside the worker thread (ratios 0.47-0.61 at 4k x 128 signatures), so the hop buys the detached banding pass and the caller's concurrency shape, not a fully detached call.
tors.lsh_probability
def lsh_probability(s: float, *, bands: int, rows: int) -> float: ...
The banding S-curve as a pure formula: the probability that two
signatures with Jaccard similarity s share at least one of bands
band buckets of rows rows, 1 - (1 - s**rows)**bands (Mining of
Massive Datasets, ch. 3; datasketch's parameter-tuning docs spell the
same curve). Flat near 0, a steep step around the approximate threshold
(1/bands)**(1/rows) (lsh_threshold), flat near 1 — the curve to
CONSULT when picking a shape for a target similarity:
tors.lsh_probability(0.0, bands=32, rows=4)
# 0.0: the exact zero end — disjoint signatures are candidates only
# through lsh_candidates' false-positive channels
tors.lsh_probability(1.0, bands=32, rows=4)
# 1.0: the exact one end — identical signatures are always candidates
tors.lsh_probability(0.6, bands=32, rows=4)
# 0.9882238254216146
tors.lsh_probability(0.1, bands=32, rows=4)
# 0.003195044956405657
s must be in [0.0, 1.0] (NaN refused), else ValueError; bands
and rows at least 1, else ValueError. The ends are exact and the
curve is monotone in s — both pinned against exact
fractions.Fraction arithmetic and over a grid of sample points in
tests/test_lsh.py. Pure float arithmetic over two integers: no GIL
release (the simhash_distance zero-detach class) and no aio twin —
the thread hop would cost more than the call.
tors.lsh_threshold
def lsh_threshold(*, bands: int, rows: int) -> float: ...
The approximate similarity threshold where the S-curve takes its step,
(1/bands)**(1/rows) — the same formulation Mining of Massive Datasets
ch. 3 and datasketch's docs carry. An APPROXIMATION, not an inversion of
lsh_probability: a pair at this similarity is a candidate with
probability NEAR the curve's midpoint, not exactly 0.5. Pick a shape
here, then verify the actual curve with lsh_probability:
tors.lsh_threshold(bands=16, rows=8)
# 0.7071067811865476
tors.lsh_probability(tors.lsh_threshold(bands=16, rows=8), bands=16, rows=8)
# 0.6439258695482072: near the midpoint, not exactly 0.5
More bands lower the threshold (recall up), more rows raise it, and the
one-band edge sits at (1/1)**(1/r) = 1. Pure float arithmetic over two
integers: no GIL release, no aio twin.
tors.weighted_minhash_signature
def weighted_minhash_signature(
text_or_tokens: str | Sequence[str] | Mapping[str, float],
*,
num_perm: int,
seed: int | None = None,
) -> list[int]: ...
The Consistent Weighted Sampling signature of a token MULTISET (Ioffe,
"Improved Consistent Sampling, Weighted Minhash and L1 Sketching", ICDM
2010; the active-index scheme Shrivastava's NeurIPS 2016 paper restates
as its Algorithm 1; the survey is Wu et al., arXiv 1811.04633): the
frequency-aware complement to minhash_signature. WHAT it adds: the
binary MinHash reads a document as its token SET -- a token repeated
thrice is one element, indistinguishable from a single occurrence --
while the weighted engine reads the COUNTS, and the fraction of
permutations whose (token_hash, active-index) pairs agree estimates
the GENERALIZED (weighted) Jaccard similarity
sum_k min(w_a, w_b) / sum_k max(w_a, w_b). WHY the binary engine
cannot see frequency: its estimator's unbiasedness rests on each
shingle's min-hash being drawn from the SET, so 'aaa' contributes one
element no matter how many times it occurs -- frequency information is
destroyed at the shingle-set step, before any permutation runs.
c = tors.weighted_minhash_signature("waste oil sample", num_perm=128)
d = tors.weighted_minhash_signature("waste waste waste oil oil sample", num_perm=128)
# The binary engine sees the same token set and estimates J = 1.0:
sum(x == y for x, y in zip(
tors.minhash_signature("waste oil sample", shingle_size=1),
tors.minhash_signature("waste waste waste oil oil sample", shingle_size=1),
)) / 128
# 1.0: frequency is invisible to it
tors.minhash_weighted_jaccard(c, d)
# 0.515625: the weighted engine reads the counts (1,1,1) vs (3,2,1),
# whose generalized Jaccard is exactly 0.5
e = tors.weighted_minhash_signature("waste oil sample sludge", num_perm=128)
tors.minhash_weighted_jaccard(c, e)
# 0.765625: true J 0.75 -- closer, as the counts say
The input spellings, all one multiset: a str (tokenized by the crate's
one UAX #29 word stream, the tf_idf/bm25_rank tokenizer, lowercased;
each occurrence a count); a sequence of str (each element ONE
occurrence, no re-tokenization -- a caller's domain-specific segmenter
weighs exactly as given); a mapping of str to a non-negative finite
number (explicit weights, fractional allowed; zero excludes the token;
NaN, infinity, and negatives raise ValueError; bool values raise
TypeError). Returns 2 * num_perm rows, the winner pair
(token_hash, t) per permutation: the token's identity is the crate's
one XXH64 contract over the single-token frame (the shingle_size-1
shingle hash), and t is the integer active index emitted as its f64
bit pattern -- an injective encoding, so fractional-weight signatures
compare exactly too.
The engine and the estimator contract. Per permutation j and token
hash h with weight w: five SplitMix64 draws from the XXH64 frame
[seed, j, h] give r, c ~ Gamma(2, 1) (two open-interval exponentials
each, the paper's distribution) and beta ~ U(0, 1); then
t = floor(ln(w)/r + beta) (the consistency-bearing integer active
index: the weight-independent grid the pair rides, so two documents
naming the same token at different weights still agree exactly when the
token wins both processes), y = exp(r*(t - beta)), z = y*exp(r),
a = c/z; the permutation's sample is the token minimizing a, ties
(measure-zero) breaking to the lowest token hash -- tokens sweep in
ascending-(hash, weight) order, the pinned deterministic order. The
estimator's consistency -- the agreement fraction estimates the
generalized Jaccard -- is Ioffe's theorem, made empirical by the RMSE
cell in tests/test_weighted_minhash.py (measured bias +0.005, RMSE
0.037 at k=128, on the binomial band). There is no prefix property and
no cross-compatibility with the binary engines' signatures: the
generalized Jaccard is a different quantity than the Jaccard, and
minhash_weighted_jaccard is the only estimator that reads these rows.
The determinism contract carries over whole: no random entropy
anywhere (the draws are hash-derived, the seed the only input), so the
same multiset at the same parameters is the same signature across
processes, machines, and platforms within one tors version, the
segmentation-table boundary included. seed=None (the default) IS
seed=0 -- the fixed default seed, not a random draw.
Empty text, an empty list, an empty mapping, or all-zero weights is the
empty-multiset convention: every row the u64 MAX sentinel
(2**64 - 1), seed-invariant, the same stable digest the binary
engine's empty documents bucket under.
Bounds and cost. num_perm must be in [1, 1024] (ValueError
naming the bounds; __index__ accepted, bool rejected; required, no
default -- the weighted signature's row count is 2 * num_perm and the
caller picks it explicitly). seed is any int or None (the
mod-2^64 reduction the binary engine spells). Mapping keys must be
str and sequence elements str (TypeError otherwise); a str input
bearing lone surrogates raises UnicodeEncodeError (the crate-wide
str-borrow contract). Cost is O(tokens) hashing to count the multiset
plus O(num_perm * distinct_tokens) in the ICWS sweep -- linear in
tokens times num_perm (pinned in tests/test_scaling_pins.py). GIL
model: the input walk and validation under the GIL, the count + sweep
pass under one py.detach, then the 2 * num_perm-element int-list
marshalling. await tors.aio.weighted_minhash_signature(...) runs the
call under asyncio.to_thread (see Async use).
tors.minhash_weighted_jaccard
def minhash_weighted_jaccard(
sig_a: Sequence[int], sig_b: Sequence[int]
) -> float: ...
The generalized-Jaccard estimator over two
weighted_minhash_signature signatures: the fraction of permutations
whose (token_hash, active-index) pairs agree (Ioffe's consistency
property). Both signatures must be equal-length, even-length, and
non-empty, else ValueError naming the shape (an odd row count is not
an ICWS signature; the rows are opaque, so signatures mixed across
engines at coincident lengths are the documented undetectable caller
error). The result is in [0.0, 1.0] and estimates
sum_k min(w_a, w_b) / sum_k max(w_a, w_b) over the two token
multisets. O(len(sig_a) / 2) integer comparisons: no detach, no aio
twin (the lsh_probability stay-sync class).
tors.minhash_weighted_jaccard(c, d)
# 0.515625
tors.minhash_weighted_jaccard(c, c)
# 1.0
tors.weighted_minhash_signature("", num_perm=2)
# [18446744073709551615, 18446744073709551615, 18446744073709551615,
# 18446744073709551615]: the empty-multiset sentinel, two rows per
# permutation
tors.simhash_distance
def simhash_distance(a: int, b: int) -> int: ...
The Hamming distance between two simhash fingerprints — the values
tors.simhash64/tors.simhash128 return: the count of bit positions at
which they differ. Zero means an identical fingerprint (the near-dup
gate's "same token multiset" equality: same words, any order, any
whitespace); small distances are the near-duplicate band whose
calibration is corpus-dependent (simhash64's measured anchors and their
caveats apply unchanged — calibrate per deployment).
a = tors.simhash64("the quick brown fox jumps over the lazy dog")
b = tors.simhash64("the quick brown fox jumps over the lazy cat")
tors.simhash_distance(a, b)
# 9: one word changed, the fingerprint moved a few bits, not half the
# width (unrelated-text pairs sit far wider apart)
tors.simhash_distance(a, a)
# 0
Both spellings are supported, but a pair must come from ONE of them:
mixing a 64-bit with a 128-bit fingerprint raises ValueError. The
honest mechanics, since a Python int carries no width metadata: each
argument is classified by magnitude (only a 128-bit fingerprint can be
>= 2**64) and a pair split across that line is refused — which catches
the real caller bug (one simhash64 value against one simhash128
value); a pair that both fit in 64 bits compares correctly either way,
because the arithmetic itself is width-blind (a ^ b).bit_count().
A non-int argument (including bool) raises TypeError; a negative int
raises ValueError (fingerprints are unsigned); an int past 2**128
raises OverflowError. Both arguments ride the __index__ protocol, so
int-likes (numpy integers) work.
GIL: the crate's zero-detach extreme point (the uuid_parse class) — the
whole work is one xor-and-popcount over two borrowed ints, strictly less
than the argument extraction that precedes it. No aio twin.
tors.shingle_jaccard / tors.shingle_dice
def shingle_jaccard(a: str, b: str, *, width: int = 3) -> float: ...
def shingle_dice(a: str, b: str, *, width: int = 3) -> float: ...
The two set-similarity readings of Broder 1997's shingling + resemblance
(the definition near-duplicate detection is about, and the exact quantity
tors.minhash_signature estimates): each text is reduced to its set of
width-token WORD shingles, and the pair is scored as either the Jaccard
index |A ∩ B| / |A ∪ B| or the Dice coefficient
2|A ∩ B| / (|A| + |B|). Dice weights agreement toward the small-set
side (a shared sliver of a huge set moves Dice more than Jaccard), so
dice >= jaccard for every pair.
Word shingles, not character shingles (the same rationale
minhash_signature documents): near-duplicates preserve word sequence —
a paragraph reflowed or a word swapped keeps most of its word k-grams,
where character k-grams shift wholesale. Tokens are the crate's one
real-word tokenizer: UAX #29 word segments (word_bounds' segmentation),
whitespace-only segments skipped — so CJK text (no spaces) still yields
per-ideograph tokens, and emoji/punctuation segments are tokens.
Normalization is the grounding layer's matching form: each token is
lowercased and canonicalized to NFC before hashing, so case and
whitespace shape are invisible ("Hello World" and "hello world" score
1.0), and NFC-equivalent inputs behave identically (NFD "café" and NFC
"café" are the same tokens). Punctuation is NOT invisible: the tokenizer
keeps punctuation-only segments as tokens (simhash's and minhash's own
rule), so a comma lands in the shingle stream — inserting punctuation is
an edit, not a reflow.
The empty-set convention: text with no word tokens (empty,
whitespace-only, or fewer tokens than width) has an EMPTY shingle set;
two empty sets score 1.0 (∅ ⊆ ∅ — two token-free texts are duplicates of
each other, which is what dedup_near_dup(method="shingle") must
conclude too), exactly one empty side scores 0.0.
tors.shingle_jaccard(
"The quarterly oil sample interval for field outages was adjusted",
"the QUARTERLY oil sample interval for field outages was adjusted",
)
# 1.0: case and whitespace shape are invisible
tors.shingle_jaccard("alpha beta gamma delta epsilon zeta",
"alpha beta gamma eta theta iota")
# 0.14285714285714285: the shared head is 1 of 7+ shingles
tors.shingle_dice("alpha beta gamma delta epsilon zeta",
"alpha beta gamma eta theta iota")
# 0.25: the same agreement, small-set-weighted
tors.shingle_jaccard("", "alpha beta gamma")
# 0.0; tors.shingle_jaccard("", "") -> 1.0 (the empty-set convention)
Bounds: width must be >= 1 (ValueError), rides the __index__
protocol (bool rejected), and a width past 1024 over a stream that
fills the window raises the same sweep-budget ValueError
minhash_signature raises (the cost shape is identical: each step
re-hashes the live window). A non-str argument raises TypeError; a
lone surrogate raises UnicodeEncodeError (the crate-wide str-borrow
contract).
GIL: two str borrows and the width validation under the GIL, the whole
tokenize + shingle + set pass under one py.detach, a single float out.
Against the naive pure-Python spelling of the same job (the same UAX #29
tokens, fold, and width-3 shingle tuples, sets and arithmetic in
Python): ~9 ms vs ~15 ms on 100 KiB pairs, the same value out
(~1.7x; Python's stdlib has no word segmenter, so the baseline borrows
only word_bounds, tools/bench_sota.py, bands in
Performance).
Async: await tors.aio.shingle_jaccard(...) and await tors.aio.shingle_dice(...) run under asyncio.to_thread (see Async use). The whole tokenize + shingle + set pass is one detached native pass measuring ~9 ms on 100 KiB pairs, so the hop is noise there and up; a short-string pair is a microsecond call, prefer the sync spelling there.
tors.dedup_near_dup
def dedup_near_dup(
texts: list[str],
*,
threshold: float = 0.9,
method: Literal["simhash", "shingle", "minhash"] = "simhash",
) -> DedupResult: ...
Greedy keep-first near-duplicate dedup over a list of strings — the corpus-preparation step Lee et al. 2021 showed pays off at LLM pretraining scale (https://arxiv.org/abs/2107.06499), and the BigCode / starcode pipelines run as an exact-hash pass followed by near-dup passes with a calibrated threshold. Returns a dict with three keys, all present every time, all indices into the INPUT order:
kept: the representatives, ascending — textiis kept iff no EARLIER kept text is withinthresholdof it. Input order is the tie-break by construction, so the result is deterministic and order- preserving: the first of a duplicate family survives.dropped: the absorbed texts, ascending.groups: a full partition ofrange(len(texts)), in representative order — each group is one kept representative followed by the texts it absorbed (singleton groups are kept texts with no duplicates).kept == [g[0] for g in groups]andsorted(kept + dropped) == list(range(len(texts)))always hold.
corpus = [
"The quarterly oil sample interval was adjusted after the audit.",
"the quarterly oil sample interval was adjusted after the audit",
"The quarterly oil sample interval was extended after the audit.",
"A completely different memo about the parking garage resurfacing.",
]
tors.dedup_near_dup(corpus, threshold=0.8, method="simhash")
# {'kept': [0, 3], 'dropped': [1, 2], 'groups': [[0, 1, 2], [3]]}
tors.dedup_near_dup(corpus, threshold=0.8, method="shingle")
# {'kept': [0, 2, 3], 'dropped': [1], 'groups': [[0, 1], [2], [3]]}
# (the shingle method's exact trigram Jaccard keeps the one-word-edit
# row; the simhash vote still sees the shared bag of words)
tors.dedup_near_dup([])
# {'kept': [], 'dropped': [], 'groups': []}
The three methods (an unknown name raises ValueError naming every
choice) share one normalization policy — the grounding layer's lowercase
+ NFC fold — so switching methods cannot silently change what "same
text" means:
"simhash"(default): a pair is duplicates when the Hamming distance between the 64-bit fingerprints of the folded texts is at mostfloor((1 - threshold) * 64)(threshold 0.9 -> 6 bits). Fastest; the bag-of-words vote makes it blind to word ORDER (a permutation fingerprints identically) and its band is corpus-dependent — thesimhash64calibration caveat applies to the threshold."shingle": duplicates when the EXACT Jaccard index of the 3-token word-shingle sets is at leastthreshold(theshingle_jaccardsemantics, fixed width 3). The precise, slower reading; order-sensitive where simhash is not. The threshold comparison is float: a pair whose exact Jaccard rounds up to exactly the threshold merges (its exact value sits a hair below the threshold's rational value); the error is at most one ulp in the merge direction, never the data-loss direction."minhash": duplicates when the agreement fraction of the two 128-permutationminhash_signaturesignatures (its defaults: shingle_size 3, seed 0) is at leastthreshold— the estimated Jaccard (standard errorsqrt(J(1-J)/128)~ 0.044) for texts too wide to intersect exactly.
Scope — small candidate sets, honestly: the sweep is O(n²) pair
checks by design, with the per-method early exits (the popcount is O(1);
the shingle method skips a pair whose cardinality ratio already fails
the threshold; the MinHash count aborts the moment agreement is
unreachable), holding O(total input) of fingerprint state and NO
persistent index. The LSH banding table a corpus-scale pipeline builds
on these signatures is caller state — docs/design.md's scope cut, the
same boundary bm25_rank sits on. The quadratic wall is pinned with an
explicit budget in tests/test_scaling_pins.py and benchmarked at
n = 100/1k/10k in benches/near_dup.rs; beyond tens of thousands of
candidates, generate candidates from minhash_signature output with
lsh_candidates and score them here.
threshold must be in [0.0, 1.0] (NaN refused), else ValueError; a
non-str element raises TypeError. The empty list gives the empty
result; all-identical input keeps exactly the first text; two token-free
texts (empty, whitespace-only) are duplicates of each other (the empty-
set convention above). The convention's 0.0 side (exactly one token-free
text) is a shingle/minhash special case: under the simhash method
token-free text fingerprints to 0, and at a low enough threshold it can
merge with a real text.
GIL: the list extraction (one str copy per element, the standard
O(total input) class) and the threshold/method validation under the GIL,
then the fingerprint pass AND the pairwise sweep under one py.detach,
then the O(n + groups) index-list marshalling.
Async: await tors.aio.dedup_near_dup(...) runs this under asyncio.to_thread (see Async use). The fingerprint pass and the documented O(n²) pairwise sweep run detached as one pass (12k documents measure ~0.2-0.4 s walls), so the hop is noise on any corpus-shaped list; a handful of short strings is microsecond-scale, prefer the sync spelling there.
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.rank_fuse / tors.ndcg_at_k / tors.mrr / tors.recall_at_k / tors.precision_at_k
def rank_fuse(ranked_lists: list[list[Hashable]], *, k: int = 60, weights: Sequence[float] | None = None) -> list[tuple[Hashable, float]]: ...
def ndcg_at_k(
ranked: list[Hashable],
relevant: set[Hashable] | frozenset[Hashable],
*,
k: int | None = None,
gains: dict[Hashable, float] | None = None,
) -> float: ...
def mrr(ranked: list[Hashable], relevant: set[Hashable] | frozenset[Hashable]) -> float: ...
def recall_at_k(ranked: list[Hashable], relevant: set[Hashable] | frozenset[Hashable], k: int) -> float: ...
def precision_at_k(ranked: list[Hashable], relevant: set[Hashable] | frozenset[Hashable], k: int) -> float: ...
Async: each of the five has an awaitable twin under asyncio.to_thread (see Async use): await tors.aio.rank_fuse(...), await tors.aio.ndcg_at_k(...), await tors.aio.mrr(...), await tors.aio.recall_at_k(...), and await tors.aio.precision_at_k(...). Honest caveat, measured: the fusion dedup walk and the metrics' membership walks are interpreter-side hashing and stay GIL-held inside the worker thread (ratios 0.56-0.62 at 200k entries against the bespoke 0.80 budget for rank_fuse, ceiling-only 0.85-0.91 at 100k ids for ndcg_at_k), so the hop buys the detached arithmetic and the caller's concurrency shape; past ~10^6 total entries the walk alone holds the GIL for 100ms+ and no placement buys it back.
The retrieval-family companions to bm25_rank: rank-space arithmetic over
doc ids. No scores, no tokenization, no index, nothing stateful.
rank_fuse is Reciprocal Rank Fusion (Cormack, Clarke & Büttcher,
"Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning
Methods", SIGIR 2009, https://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf):
given multiple ranked lists of hashable doc ids,
score(d) = sum over lists of 1 / (k + rank(d)) -- ranks 1-based
it consumes RANKS ONLY, never raw scores; the paper's whole point is that
raw scores from different retrieval systems (a BM25 score, a cosine
similarity, a click count) are not comparable while ranks are. With one
shared constant k (default 60, the paper's own, unchanged across its
experiments) damping the top ranks, no single list's #1 swamps the
others' votes. A document absent from a list contributes no vote from it;
a document ranked twice in ONE list votes once, at its first occurrence
(the later entries advance one rank). Returns (id, score) for every
distinct id across all lists, sorted by fused score descending, ties
broken by earliest first appearance across the lists in caller order
(a point the paper leaves open, pinned here as contract). Returned ids are
the original objects; dedup and equality follow Python's own dict/set
semantics (1, True, and 1.0 are the same id).
weights= is weighted RRF (the extension the hybrid-retrieval
engines ship): one optional positive finite float per list, each list's
vote becoming w_i / (k + rank(d)) instead of 1 / (k + rank(d)) —
exactly Elasticsearch's RRF retriever per-child weight ("the weight
that each score of this retriever's top docs will be multiplied in the
RRF formula", rrf_score = w_1 × rrf_score_1 + ..., GA 9.2) and the
same per-source weighting Redis's hybrid ranking applies when it
combines a keyword and a vector leg. Weights live in score space, never
rank space: a weight re-scales one list's votes and touches no rank.
weights=None (the default) is the paper's original fusion EXACTLY:
every weight 1.0 and the outputs are byte-identical to the unweighted
spelling (pinned in tests/test_rank_fusion.py). A duplicate id votes
once per LIST, weighted by THAT list's weight (the dedup-first contract,
extended). A zero, negative, NaN, or infinite weight raises ValueError
(strictly positive finite: Elasticsearch admits zero, tors does not —
a zero-weight list is almost certainly a miscounted retriever list, the
k < 1 class); a length mismatch with ranked_lists raises
ValueError naming both sides; a non-sequence weights (a bare str
included) or a non-numeric entry raises TypeError. Two honesty notes
on that domain: a legal denormal weight can underflow a document's
every vote to exactly 0.0 (5e-324/61 rounds away at any rank) —
the id still appears, as a 0.0-score pair ordered last (score
descending, ties among the 0.0 scores by first appearance), because
emission follows vote existence, not score positivity. And the
sequence protocol is the plain one: bytes are a sequence of ints and
launder to their code points (the int-extraction convention:
weights=b"12" is weights=[49.0, 50.0]); a bare str is refused.
k must be >= 1 (ValueError); ranked_lists must be a non-empty list
of lists (TypeError otherwise; fusing zero lists is a ValueError, the
merkle_root "root of no chunks" precedent: the formula is defined over
one-or-more lists and a zero-list call is almost certainly an upstream
bug) while an individual empty list is legal and contributes no votes,
the "this retriever returned nothing" shape. An unhashable id raises
TypeError (Python's own hash error: a dict cannot key it, the same
wrong-type-entry contract bm25_rank's corpus walk keeps).
The metrics are the standard IR definitions over one ranking:
ndcg_at_k: normalized discounted cumulative gain (Järvelin & Kekäläinen, "Cumulated gain-based evaluation of IR techniques", ACM TOIS 20(4), 2002), in[0.0, 1.0].relevantis a set of relevant ids (binary relevance 1.0);gainsis an optional graded override; the gain of iddisgains[d]when the dict contains it, else1.0whendis inrelevant, else0.0. The DCG uses the paper's log2 discount, rank 1 undiscounted:DCG@k = Σ_{i=1..k} gain_i / log2(i + 1)over the linear gain function (for binary relevance the paper's exponential2^rel − 1variant is identical). The ideal DCG sorts the complete judged pool, every id inrelevant(at its gain) plus everygainskey, descending and discounts the same way.mrr: the reciprocal rank of the first relevant result (1/rank, ranks 1-based;0.0when no ranked result is relevant).recall_at_k:|relevant ∩ ranked[:k]| / |relevant|.precision_at_k:|relevant ∩ ranked[:k]| / min(k, len(ranked)), trec_eval's own convention for a run shorter thank(a system that returned fewer results is not punished for positions it never filled).
Both set formulas assume deduped input: a duplicate counts once, at its
first occurrence (e.g. recall_at_k(["a", "a", "b"], {"a", "b"}, 2) is
1.0, not 0.5).
A duplicated id inside ranked counts once, at its first occurrence
(the same dedup-first contract rank_fuse keeps: a repeat is a malformed
ranking, and counting it twice would inflate precision and push nDCG past
1). k past the ranking's length simply uses every available position
(nDCG clamps k the same way).
Edge-input policy (the family's one contract): empty DATA answers a
well-defined 0.0: an empty ranked, an empty relevant set (no
relevant document exists, so no hit is possible), and the
zero-ideal-DCG case (nothing judged relevant) included. Out-of-range
NUMERICS raise ValueError (k < 1 everywhere k appears; a negative
or non-finite gains value; a zero, negative, or non-finite weights
value). Wrong TYPES raise TypeError
(a non-list ranked/ranked_lists, a non-set relevant (exactly set
or frozenset), a non-dict gains, a non-numeric gains value (the
extraction failure), a non-sequence weights (a bare str included), a
non-numeric weights entry, an unhashable id, whose error is
Python's own; a bool where an int belongs (k=True) or in gains
extracts as its 0/1 value, the int-extraction convention). For
nDCG, legal finite gains can be so large the DCG and
IDCG sums overflow to +inf; the score then saturates instead of
dividing inf/inf (NaN): 1.0 when DCG >= IDCG, 0.0 when a finite
DCG faces an infinite ideal, and a finite ratio clamps to
[0.0, 1.0] (the monotone-total policy, since the ideal pool contains
every ranked gain under the same discount schedule, so an overflowed DCG
can at most match the overflowed ideal). The saturation's error is
two-sided and unbounded within that branch: the 1.0 side can
over-report an imperfect ranking, the 0.0 side can under-report a
near-perfect one all the way to 0.0; both directions apply only when
the gains sit within ~16 orders of magnitude of f64's ceiling.
GIL model: the fusion/dedup walk and the metrics' membership walks
are interpreter-side hashing (Python-object hashing cannot leave the
GIL), and the O(distinct-ids) tuple marshalling rides with them; the
score sweep, sort, and metric arithmetic run under one py.detach. The
GIL-held share is structurally the majority of a fusion call at large
sizes (the content_hash arg-walk class), so inputs past ~10^6 total
entries hold the GIL for 100ms+ in the walk alone: this family is a
reranking-scale primitive (hundreds to thousands of entries per list),
not a whole-corpus one. Measured bands: tests/test_gil_release.py.
Id-shape caveat, honestly stated: those bands were measured on
cheap-to-hash ids (short str ids); hash-expensive ids (10-int tuples
are the measured pathological shape) make each dedup/membership dict
operation slower until the walk is effectively interpreter-bound: the
GIL-held share approaches the call's full wall (measured ratios
0.98-1.00 there) and no thread placement buys it back, the same
id-shape caveat docs/async.md carries for the aio spelling.
Against the installed alternatives (tools/bench_sota.py, same inputs,
agreement checked both directions; bands in
Performance): rank_fuse answers the same RRF
ranx.fusion.rrf computes (top-50 ordering overlap 100%, plain-Python
score recompute to a 3.5e-18 delta) at ~15-20x its wall, and
ndcg_at_k reads the same value sklearn.metrics.ndcg_score and
ranx.evaluate do at ~2x and ~5x theirs.
tors.rank_fuse([
["cat-a", "dog-b", "bird-c"], # a BM25 reranker's top 3
["dog-b", "cat-a"], # a vector search's top 2
["bird-c"], # a keyword filter's hit
])
# [('cat-a', 0.03252247488101534), ('dog-b', 0.03252247488101534),
# ('bird-c', 0.032266458495966696)]
# cat-a and dog-b tie (two votes each); cat-a appeared first and wins
# the tie: earliest first appearance across the lists.
tors.rank_fuse(
[
["cat-a", "dog-b", "bird-c"],
["dog-b", "cat-a"],
["bird-c"],
],
weights=[2.0, 1.0, 1.0], # weighted RRF: the BM25 leg counts double
)
# [('cat-a', 0.04891591750396616), ('dog-b', 0.048651507139079855),
# ('bird-c', 0.04813947436898257)]
# cat-a's weighted votes (2/61 + 1/62) break the tie; bird-c's first-list
# vote carries the 2.0 weight too (2/63 + 1/61).
ranked = ["cat-a", "dog-b", "bird-c", "fish-d"]
relevant = {"cat-a", "bird-c", "whale-e"}
tors.ndcg_at_k(ranked, relevant) # 0.7039180890341347
tors.mrr(ranked, relevant) # 1.0
tors.recall_at_k(ranked, relevant, 2) # 0.3333333333333333
tors.recall_at_k(ranked, relevant, 4) # 0.6666666666666666
tors.precision_at_k(ranked, relevant, 2) # 0.5
tors.ndcg_at_k(ranked, relevant, k=2) # 0.6131471927654584
# (k=2's ideal packs two of the three relevant ids at ranks 1-2; one hit
# at rank 1 scores 1/1.6309...)
tors.ndcg_at_k(ranked, relevant, gains={"cat-a": 3.0, "bird-c": 1.0})
tors.score_fuse
def score_fuse(
scored_lists: list[list[tuple[Hashable, float]]],
*,
method: str = "combmnz",
weights: Sequence[float] | None = None,
k: int | None = None,
) -> list[tuple[Hashable, float]]: ...
Async: await tors.aio.score_fuse(...) runs this under asyncio.to_thread (see Async use). Honest caveat, measured, the same one rank_fuse's line carries: the pair walk (one dict op plus one score extraction per entry, interpreter hashing) and the O(distinct-ids) tuple marshalling are GIL-held inside the worker thread (ratios 0.52-0.74 at 100k entries across the three methods), so the hop buys the detached arithmetic and the caller's concurrency shape; past ~10^6 total entries the walk alone holds the GIL for 100ms+ and no placement buys it back. Evidence: src/py/score_fusion.rs holds the walk under the GIL and detaches the normalization/accumulation/sort; tests/test_gil_release.py::test_score_fuse_in_a_thread_... carries the band.
score_fuse is the score-based sibling of rank_fuse: the same id
table, the same first-appearance tie-break, the same one-pair-per-id
emission, but the input consumes raw similarity scores instead of ranks
-- (id, score) pairs (a BM25 output, a cosine similarity, a click
count), the other half of the fusion problem. Three methods: CombMNZ and the linear pattern are the two score-based
baselines of Cormack, Clarke & Buüttcher's SIGIR 2009 comparison (the
RRF paper's own baseline set,
https://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf, where CombMNZ is
the strongest score-based baseline they report); linear is
Elasticsearch's linear-retriever pattern:
https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/linear-retriever.
method="combmnz"(the default), Fox & Shaw, "Combination of Multiple Searches", TREC-2 1994: withnorm_ithe min-max normalized scores of listi,
score(d) = lists(d) x sum over lists of w_i x norm_i(d)
CombSUM times the number of lists containing d (the MNZ
multiplier: each additional list's hit is a vote of confidence in
the score itself). The strongest score-based
baseline in the Cormack 2009 comparison (their own wording: RRF beats
it in all but one of their topics), hence the default.
- method="borda": the rank-based count,
score(d) = sum over lists of w_i x (n_i - rank_i(d)) / n_i, ranks
1-based over the DEDUPLICATED list (a duplicate folds to its first
occurrence and later ids advance one position, rank_fuse's
contract). The votes are RANK-based deliberately: Borda counts are
defined over positions (each list elects its top document with
n-1 points and its last with 0), and the (n - rank)/n spelling
only rescales that count to [0, 1) so a weight means the same
thing over a 3-document list and a 300-document one. Normalizing
the scores here (min-max, the CombMNZ spelling) would not be Borda:
it would smuggle score magnitudes back into the one method whose
entire point is that only the ordering votes.
- method="linear": the Elasticsearch linear-retriever pattern
(https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/linear-retriever):
score(d) = sum over lists of w_i x norm_i(d) -- no MNZ
multiplier, min-max normalized, the same per-retriever weight
shape weighted RRF carries.
The normalization conventions (pinned in tests/test_score_fusion.py):
norm_i is min-max per list over that list's OWN scores, and the
edges are pinned:
- A zero-range list -- every score equal, a single-entry list
included -- normalizes every entry to the neutral midpoint
0.5. The list carries order information only ("all these docs tie"); the midpoint invents neither a winner (1.0) nor a loser (0.0), and a linear sum carries it without distortion. - Negative scores are legal. Min-max maps ANY finite range onto
[0, 1]((s - min)/(max - min)shifts and rescales; the signs of the raw scores wash out), so a cosine-similarity list (-1..1) and a BM25 list (0..40) normalize to the same interval. The rejected domain is only non-finite scores: a NaN poisons every comparison it touches and an infinite min or max makesmax - minill-defined -- NaN and both infinites raiseValueError, thegainsfinite-domain discipline. - A range that itself overflows (
-1.7e308to+1.7e308: legal finite scores whosemax - minis+inf) saturates instead of dividinginf/inf(NaN): a numerator that overflowed against the infinite range answers exactly1.0(maxitself is the first such numerator; the top of the scale), while finite numerators divide to exactly0.0-- not merely small values: a finite number divided by+infis0.0in float arithmetic, so the overflowed list's internal order collapses to two buckets (1.0for the overflowed maxima,0.0for everything else). The policy is monotone and NaN-free, the same documented approximationndcg_at_k's saturating ratio carries, with the same one-sided cost: near-top scores can over-report as exactly1.0when both score extremes sit within ~16 orders of magnitude of f64's ceiling.
The rest is rank_fuse's discipline carried over unchanged (one
spelling note: k here is an OUTPUT CUTOFF -- the top-k pairs returned;
rank_fuse's k is the RRF rank constant inside the scoring formula.
Same name, different job, each documented on its own call).
scored_lists is a non-empty list of lists of (id, score) pairs
(fusing zero lists raises ValueError, the merkle_root "root of no
chunks" precedent; an individual empty list is legal and contributes
no votes, the "this retriever returned nothing" shape). A duplicate id
folds to its FIRST occurrence per list -- the first score stands, and
a folded-away score does not shape that list's min-max range -- while
the same id in a DIFFERENT list votes again with its own score (the
whole point of fusion). Dedup and equality follow Python's own
dict/set semantics (1, True, and 1.0 are the same id, the first
spelling returned); an unhashable id raises TypeError (Python's own
hash error); a malformed pair (not a 2-element sequence) or a
non-numeric score raises TypeError.
weights= optionally carries one positive finite float per list (the
weighted-RRF philosophy extended to score space: a weight re-scales
one list's contribution, never the vote's shape). weights=None (the
default) is the all-1.0 unweighted fusion EXACTLY, outputs
byte-identical to the unweighted spelling (pinned). A zero, negative,
NaN, or infinite weight raises ValueError (strictly positive
finite, rank_fuse's own domain: a zero-weight list is almost
certainly a miscounted retriever list); a length mismatch with
scored_lists raises ValueError naming both sides; a non-sequence
weights (a bare str included) or a non-numeric entry raises
TypeError. method must be exactly one of "combmnz", "borda",
"linear" (case-sensitive; ValueError naming the accepted set
otherwise). k=None (the default) returns every distinct id; k=N
the top N (k < 1 raises ValueError, truncation clamps to the
distinct-id count).
Returns (id, score) pairs for every distinct id across all lists,
sorted by fused score descending, ties broken by earliest first
appearance across the lists in caller order (rank_fuse's contract,
extended to score space). Emission is vote-existence, not score
positivity: a fused 0.0 (Borda's last place, a list's minimum, an
underflowed denormal weight) still appears, ordered last. Scores are
finite-or-+inf, never NaN (all-nonneg accumulations; the saturating
norm above).
GIL model: one GIL-held walk of every pair (the id table is a
dict, Python-object hashing IS interpreter work, plus one score
extraction per entry -- rank_fuse's arg-walk class, one conversion
heavier), the weights walk and validation when supplied, then the
per-list min-max, normalization, weighted accumulation, MNZ counts,
and sort (plain arithmetic over dedup indices) under one py.detach,
then the O(distinct-ids) (id, score) tuple marshalling. The same
reranking-scale guidance as rank_fuse: hundreds to thousands of
entries per list, not whole-corpus; the id-shape caveat
(hash-expensive ids push the GIL-held share toward 1.0) carries over
verbatim. Measured bands: tests/test_gil_release.py, scaling pins
in tests/test_scaling_pins.py.
tors.score_fuse([
[("cat-a", 1.0), ("dog-b", 0.5)], # a BM25 reranker's (id, score) top 2
[("dog-b", 0.8), ("cat-a", 0.2)], # a vector search's top 2
[("bird-c", 3.0)], # a keyword filter's single hit
])
# [('cat-a', 2.0), ('dog-b', 2.0), ('bird-c', 0.5)]
# cat-a and dog-b tie (norm 1.0 from each of two lists, x2 lists =
# 2.0 each); cat-a appeared first and wins the tie. bird-c rides a
# single-entry (zero-range) list at the neutral 0.5.
tors.score_fuse(
[
[("cat-a", 1.0), ("dog-b", 0.5)],
[("dog-b", 0.8), ("cat-a", 0.2)],
[("bird-c", 3.0)],
],
method="linear", # Elastic's linear retriever: no MNZ boost
)
# [('cat-a', 1.0), ('dog-b', 1.0), ('bird-c', 0.5)]
tors.score_fuse(
[
[("cat-a", 1.0), ("dog-b", 0.5)],
[("dog-b", 0.8), ("cat-a", 0.2)],
[("bird-c", 3.0)],
],
method="borda", # positions vote, magnitudes never
)
# [('cat-a', 0.5), ('dog-b', 0.5), ('bird-c', 0.0)]
# cat-a: (2-1)/2 from list 0 + 0 from list 1; bird-c: (1-1)/1 = 0,
# still emitted (vote existence, ordered last).
tors.score_fuse(
[
[("cat-a", 1.0), ("dog-b", 0.5)],
[("dog-b", 0.8), ("cat-a", 0.2)],
[("bird-c", 3.0)],
],
weights=[2.0, 1.0, 1.0], # weighted CombMNZ: the BM25 leg counts double
)
# [('cat-a', 4.0), ('dog-b', 2.0), ('bird-c', 0.5)]
# cat-a's weighted vote (2.0 x 1.0 from list 0) breaks the 2.0/2.0
# tie; bird-c's single-list 0.5 carries its own list's weight (1.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')
Random generation (random_string / random_hex / random_b62 / random_b64url / uuid4 / uuid7)
The universally hand-rolled helpers — token hex, urlsafe tokens, base62 ids,
UUIDv4/v7 — as fast GIL-free tors primitives. The Python incumbents (secrets,
uuid, uuid_utils) hold the GIL through their formatting passes; tors runs
the whole draw-plus-sample/format pass under one py.detach.
Length-first, uniformly. Every token spelling takes the output length
directly — "I want a base62 id X characters long" is the whole call, which is
how backend callers actually think about ids, keys, and tokens (the
maintainer's framing, and the reason random_hex and random_b64url were
moved off their byte-count spellings). random_hex(32) is a 32-character hex
key; random_b62(22) is a 22-character id; random_b64url(43) is a
43-character urlsafe token; random_string(n, alphabet) is n characters of
whatever alphabet you carry. All four are ONE char-sampling engine:
random_hex/random_b62/random_b64url are exactly random_string over
their fixed alphabets (pinned as literal equality in tests/test_random.py).
The uuids are the family's other engine — bit-structured byte fills handed to
the uuid crate's builders — because a UUID is a field layout, not a string
length.
The security contract, stated first because it is the one way to misuse this family. Two spellings, never interchangeable:
- Unseeded (the default): fresh bytes from the operating system's CSPRNG
on every call —
getrandom/getentropythroughrand'sOsRng, drawn per call. There is no process or thread RNG state anywhere in tors, so afork()child cannot inherit and replay a parent's stream — fork-safe, matching stdlibsecrets' own per-call semantics exactly. This is the spelling safe for keys, tokens, and secrets. - Seeded (
seed=): a deterministic ChaCha20 stream keyed by the seed. The output is a pure function of (seed, arguments), fully predictable from the seed — a reproducible-test/fixture tool, NEVER safe for secrets, keys, or tokens: any adversary who learns the seed can reproduce the stream (and 64-bit seeds are brute-forceable). A seeded stream replays exactly across processes, threads, and forks by design — same seed, same output — which is why it is never safe for secrets. rand_core's own docs say the same about the derivation ("not suitable for cryptography ... the input size is only 64 bits"). Use the unseeded spelling for anything an adversary must not guess.
The seeded mode is deterministic cross-platform (ChaCha20 plus Lemire
sampling plus the crate formatters are platform-independent arithmetic), so
seeded outputs are safe to pin as test fixtures; tests/test_random.py
pins them two ways — against committed literals, and against an
independent Python re-implementation of the documented construction
(rand_core's PCG32 seed derivation, the RFC 8439 ChaCha block, Lemire's
nearly-divisionless draw).
Sampling has no modulo bias: all four token spellings (the
random_string engine and its hex/b62/b64url delegations) draw alphabet
indices with Lemire's nearly-divisionless method (reject exactly the draws
whose product low-half falls below 2^64 mod n, leaving every output the
same number of preimages — uniform by construction; a plain x % n does not
have this property). The uuids sample raw bytes, uniform by construction.
The committed goldens pin determinism (same seed, same output), never
unbiasedness on their own — a modulo transcription reproduces short
goldens without ever hitting its < 2^-58-per-draw rejection region; the
unbiasedness claim is owned by the 300k-draw chi-square/exact-count pins
in tests/test_random.py (whose digests a % transcription fails in
full) and the crate-side reject-path unit.
Errors follow the repo taxonomy, uniformly across the four token spellings:
a negative length raises ValueError naming the parameter and the accepted
form; an empty alphabet raises ValueError; non-str alphabet and non-int
length raise TypeError, as does a seed that is neither None nor int-like
(an int instance — bools and IntEnums ride along — or any __index__
object: the same int-like convention length and every size param in the
crate accept; an __index__ that itself raises surfaces its own error); an
alphabet holding
lone surrogates raises UnicodeEncodeError (the repo-wide str contract).
0 is legal everywhere and returns "" (secrets.token_hex(0)'s own
shape). There is no size cap inside the argument: length is Py_ssize_t
(2^63 - 1 on 64-bit — anything wider raises OverflowError at extraction),
and memory is the only bound inside that — a catchable MemoryError: an
impossible length is refused before any allocation is attempted
(try_reserve), the exact shape 'x' * n and secrets.token_hex(n) give
for the same request — never a process abort.
No tors.aio twins: these are fast CPU/syscall calls, not the
detached-transform input class the async surface exists for
(Async use).
len(tors.random_hex(32)) # the unseeded spelling: fresh OS draw
# 32 # every call — never a pinned literal
tors.random_hex(32, seed=42) # the deterministic spelling: pin it in tests
# '861225d7151bf9b14a3617ab9b534d19'
tors.random_string
def random_string(length: int, alphabet: str, *, seed: int | SupportsIndex | None = None) -> str: ...
length characters sampled uniformly (Lemire, no modulo bias at any
alphabet size) from any non-empty alphabet str, in one GIL-released pass.
Multibyte characters are sampled as characters: the output is always exactly
length characters over the alphabet's own characters.
Duplicate characters are weighted, not deduplicated — each position is an
independent draw over the alphabet's character positions, so "aaab"
yields a with probability 3/4: dedupe the alphabet first for
uniform-over-distinct-characters. Sampling is over Unicode scalar values,
not grapheme clusters: a combining mark in the alphabet samples
independently of its base character. The alphabet is materialized fresh on
every call (no cross-call cache, by the fork-safe no-state discipline);
cost is O(alphabet) to materialize plus O(length) draws. Bulk callers
reusing one huge alphabet across many calls should prefer the
stdlib. The only size ceiling is the argument itself (Py_ssize_t) and
memory beyond it (u64-based sampling handles any alphabet a str can
hold).
tors.random_string(12, "abcdef", seed=42)
# 'dcabacecacae'
tors.random_hex
def random_hex(length: int, *, seed: int | SupportsIndex | None = None) -> str: ...
length lowercase hex characters — exactly
random_string(length, "0123456789abcdef"), one engine — the hex-key
spelling, by output length: a 32-char key is random_hex(32).
Odd lengths are legal and first-class: a 31-char hex id is a real shape, and
random_hex produces it directly (the old byte-count spelling could not ask
for it at all). Even lengths are what digest-shaped keys want — every 2
characters are exactly one byte, so random_hex(2 * n) is byte-exact
material. secrets.token_hex(n) and random_hex(2 * n) are the same uniform
distribution over 2n-char hex strings — different draws, never different
contracts; the 2 * is the whole mapping (the stdlib thinks in bytes, this
spelling in characters).
tors.random_hex(32, seed=42)
# '861225d7151bf9b14a3617ab9b534d19'
tors.random_b62
def random_b62(length: int, *, seed: int | SupportsIndex | None = None) -> str: ...
Exactly random_string(length, BASE62_CHARS) — one engine, delegated — over
[0-9A-Za-z]: the URL-safe, case-sensitive, human-transcribable id
spelling.
tors.random_b62(22, seed=0)
# '1yrBtE6FUlG59Zjj3K2vVn'
tors.random_b64url
def random_b64url(length: int, *, seed: int | SupportsIndex | None = None) -> str: ...
length characters uniform over the 64-character RFC 4648 §5 urlsafe
alphabet (A-Za-z0-9-_; + and / never), every position unconstrained —
exactly random_string(length, B64URL_CHARS), one engine. The
opaque-token spelling: a 43-char urlsafe token (the JWT-signature shape) is
random_b64url(43), and secrets.token_urlsafe(32) formats into the same
43-character length class.
The boundary, stated honestly: this is the token contract, NOT "a valid
base64 encoding of N random bytes." An encoding's final character is
constrained (an encoding of 32 bytes can only ever show 16 distinct final
characters at length 43 — the final char carries the final byte's low 4
bits, shifted into place; the 31-byte encoding is the 4-distinct case, its
final char carrying just the low 2 bits; this spelling shows all 64), and
lengths that are not valid base64 output lengths (41, 45, ...) are legal
here — a uniform token has no alignment constraint. There is no padded=
parameter: padding is an encoding concept, not a token concept, and =
never appears. Callers who want encodable random material should take
random_hex of even length — byte-exact via hex.
tors.random_b64url(43, seed=7)
# 'Bi8SLaf0s_a4pi-vqthbTaOstZjDweDcEC5hW7S_CNp'
tors.uuid4
def uuid4(*, seed: int | SupportsIndex | None = None) -> str: ...
An RFC 4122 version-4 UUID string (36 chars, lowercase, hyphens at
8/13/18/23) from one 16-byte entropy fill — the stdlib uuid.uuid4()
spelling, GIL-released, with the family's optional deterministic mode.
Uniqueness is probabilistic (122 random bits), the same guarantee
uuid.uuid4() carries.
tors.uuid4(seed=42)
# '7848b5d7-11bc-4883-9963-17a3f9c90269'
tors.uuid7
def uuid7() -> str: ...
An RFC 9562 version-7 UUID string: 48-bit Unix-epoch milliseconds + version
7 + variant + 74 random bits (12-bit rand_a + 62-bit rand_b) from one
OS draw — the sortable-id spelling. No seed= parameter, by design: the
timestamp is external state, so a seeded uuid7 would still vary with the
clock — the deterministic tool is uuid4(seed=...).
The uniqueness boundary, stated honestly: probabilistically unique
(birthday bound over 74 random bits within a millisecond, distinct
timestamps across milliseconds), NOT counter-monotonic. Two calls within
one millisecond are ordered by their random bits, not by call order; a
backwards clock step flows straight into the timestamp. uuid_utils'
strict-monotonic counter is a different product promise — its engine keeps
process-local counter state (and pays no syscall per call, which is why it
benchmarks faster; see Performance), where tors draws
fresh OS entropy per call for the same fork-safety the rest of this family
carries. The caller-visible contract is the timestamp itself:
u = tors.uuid7()
u
# '01a09927-e261-7153-8d74-283f6d488162'
int(u[:8] + u[9:13], 16) # the call's Unix epoch milliseconds
# 1789275923041
tors.uuid4_bytes / tors.uuid7_bytes
def uuid4_bytes(*, seed: int | SupportsIndex | None = None) -> bytes: ...
def uuid7_bytes() -> bytes: ...
The uuids' 16 raw bytes — the same buffers uuid4/uuid7 format, version
and variant fields set, no canonical formatting — because the surveyed
consumers re-wrap the canonical str back into bytes anyway: id-assignment
call sites construct UUID(bytes=...) from the str's decoded bytes, and
timestamp slicing takes .hex()[:12]. The str spellings force those sites
into a format-then-reparse roundtrip (36-char format, hyphen strip, hex
re-decode); the bytes spellings are one native draw and the field layout,
no roundtrip:
u = uuid.UUID(bytes=tors.uuid7_bytes()) # the re-wrap shape, native
u.version, u.variant
# (7, RFC_4122)
tors.uuid7_bytes().hex()[:12] # the timestamp-slice shape
# '01a09927e261' # the call's Unix epoch milliseconds, hex
Contracts: uuid4_bytes(seed=s) yields exactly the 16 bytes whose
canonical hyphenated form is uuid4(seed=s) — one construction, two return
spellings, pinned as literal equality in tests/test_random.py (the seeded
bytes are pure functions of the seed, pinned against the same independent
oracle). uuid7_bytes()[:6] big-endian is the call's Unix-epoch
milliseconds (int.from_bytes(b[:6], "big") — the u[:8] + u[9:13]
field's own bytes), the same 5-second clock tolerance uuid7's timestamp
contract carries. The seed contract and security warning are uuid4's own
(any int-like, reduced mod 2^64; a seeded stream is fully predictable —
never for secrets), and uuid7_bytes takes no seed= for uuid7's own
reason. Fixed 16 bytes always: there is no length argument, so the token
spellings' memory bound does not exist here. No tors.aio twins (the
family's own rule: fast CPU/syscall calls).
tors.first_invalid_charset
def first_invalid_charset(
items: Sequence[str], *, first: str | None = None, rest: str
) -> int: ...
Batch codepoint-set validation for identifier-style rules, one GIL-released
pass per batch: the index into items of the first item not built entirely
from the caller's two sets — first the set of codepoints allowed at
position 0, rest the set allowed at every position after it (and at
position 0 too when first is None, the uniform spelling: one set
everywhere) — or -1 when every item passes. An empty batch answers -1
even under spellings where every item would offend (first="",
rest=""): vacuously valid, no items to offend. An empty item is an offender,
wherever it sits. first="" allows nothing at position 0 (every item
offends); rest="" allows nothing after position 0 (under the uniform
spelling every item offends; with a non-empty first, only single-codepoint
items drawn from first can pass). The scan short-circuits at the first
offender — no promise about work done past it, though the argument walk does
validate the whole sequence up front (a bad entry anywhere raises at the
boundary, past a first offender or not). The answer is always an item index,
never a position within an item. The argument walk is bounded, the same DoS
backstop content_hash's protocol walk carries: a sequence that yields past
the walk's ceiling (1M items; legitimate batches sit orders of magnitude
below it) aborts with a ValueError naming nothing, instead of holding the
GIL while an unbounded __iter__ (never trusted: the walk iterates, it does
not consult len) grows the batch until the process dies.
The sets are data, not patterns: plain strings of permitted codepoints,
membership per codepoint (never per byte; duplicate codepoints in a spelling
are harmless and their order is irrelevant). No ranges, no escapes, no
classes — an ^[a-z_][a-z0-9_]*$-style rule is spelled by listing the
codepoints — and no Unicode-category classes (\w), which would need
property tables: the charter boundary already drawn for lexical data
(design and scope). A rule that needs \w is not expressible;
a rule that needs specific codepoints is, whatever their script.
Membership is per scalar value, with no normalization: precomposed é
(U+00E9, one codepoint) and decomposed e + U+0301 (two codepoints) are
different inputs and get different verdicts — first_invalid_charset(["é"],
rest="é") is -1 while first_invalid_charset(["é"], rest="é")
is 0, correctly per the per-codepoint engine. When NFC and NFD forms
must agree, normalize (tors.normalize / tors.nfc) before validating.
Normalization does not fold confusables (visually similar but distinct
codepoints, e.g. Latin A vs Cyrillic А, stay distinct under NFC); allow-list
exactly the codepoints you mean.
Membership is per codepoint, not per grapheme cluster: a ZWJ family emoji (5 codepoints), a flag pair (2 regional indicators), or a base letter plus a combining mark (2 codepoints) each pass only when every constituent codepoint is in the set — one grapheme can still offend.
Batch-only by design, and honestly so. The motivating consumer (an
enqueue path) validates identifier-shaped strings with anchored regexes at
84–950 ns per call, each under a single py.detach round trip — so a
per-item tors call (~0.25 µs measured, against ~0.08 µs for one compiled
regex match) is slower than the regex it would replace. The win exists
only when one call covers the batch: one detach, one pass over all items.
Measured on the dev box (min-of-7): the crossover is at single-digit item
counts (~2x at a 10-item batch), ~5x at 100 items (2.0 µs vs the per-item
regex loop's 9.7 µs), ~7x at 1000 (14.0 µs vs 96.7 µs). The Rust core alone
measures ~64 ns fixed (the two set builds) plus ~5.4 ns per item
(post-#[inline] band, first_invalid_charset group, benches/search.rs).
Do not call this once
per string; call it on the batch.
import string
ident_first = string.ascii_letters + "_"
ident_rest = ident_first + string.digits
tors.first_invalid_charset(["job_42", "queue_eu", "9bad"], first=ident_first, rest=ident_rest)
# 2 ("9bad": a digit is not allowed at position 0)
tors.first_invalid_charset(["job_42", "queue_eu"], first=ident_first, rest=ident_rest)
# -1 (every item passes)
tors.first_invalid_charset(["worker:01", "tag name"], rest=ident_rest + "-:.")
# 1 (first=None: one set everywhere; "tag name" carries a space)
Argument contract: items is a sequence of str — a list, a tuple, or
any Sequence (a bare str raises TypeError, as do non-sequences: dict,
set, generators, scalars; a non-str entry raises TypeError); first and
rest must be exactly str, both keyword-only, rest required, first
defaulting to None; lone surrogates raise UnicodeEncodeError at the
argument boundary (the standard str-in boundary, paid by every item and both
set arguments). Error precedence on both-bad calls is pyo3 left-to-right
extraction order: first beats rest, and set-argument conversion errors
beat the items-walk entry errors. The contract is proven against a pure-Python membership-loop
oracle plus the equivalent anchored regexes rebuilt from the same set halves
over hypothesis-generated inputs (tests/test_first_invalid_charset.py).
GIL model: one GIL-held walk of the items sequence (the standard str-in
borrow class, O(items) handles; the one-time O(input) UTF-8 materialization
applies per non-ASCII item object on first extraction, so a batch of
never-before-touched non-ASCII items holds the GIL for that
materialization — unmeasured for this function: no dedicated non-ASCII cell,
same str-in materialization class as every other function), then the set builds and
the whole batch scan under one py.detach, then a single int return — no
marshalling class at all. The set builds are O(set) over the ASCII spelling
plus O(set log set) over the non-ASCII tail (sort + dedup, inside the
detach): negligible for the few-dozen-codepoint ASCII rules this validator
exists for (the ~64 ns fixed band above, which includes both per-call set
builds — hoist huge set spellings to module constants: define once, reuse
the same string), a real sort for a 10k-codepoint
non-ASCII spelling whose sort cost is deferred (correctness pinned,
unmeasured beyond the ASCII band). At realistic batch sizes the whole call sits far under
the 10 ms ping floor, so the GIL cell is ceiling-only (the utf8_is_valid
class), pinned in tests/test_gil_release.py. The core is a trivial one-pass
membership walk (one-bit-per-codepoint ASCII mask plus a sorted non-ASCII
tail; no index arithmetic, no unsafe), which is why it ships no
cargo-fuzz target: the hypothesis differentials over arbitrary Unicode
cover its input space. The Rust core band is the one above — ~64 ns fixed
(the two set builds) plus ~5.4 ns per item, post-#[inline]
(first_invalid_charset group, benches/search.rs).
Building rejection messages: tors.first_invalid_offender
def first_invalid_offender(
items: Sequence[str], *, first: str | None = None, rest: str
) -> tuple[int, int, str] | None: ...
The integration survey's finding made the detail a function: the
consumer's rejection UX names the losing CHARACTER and POSITION (its
per-character messages), and an item index alone cannot. The offender
spelling is the same scan answering that question: it returns
(item_index, char_position, offending_char) for the first offending
item's FIRST offending position, None when every item passes.
char_positionis a codepoint index within the item — the family's data model is codepoints (membership is per codepoint), so positions count codepoints, never UTF-8 byte offsets: in"🦀x"thexsits at position 1 (its byte offset would be 4).offending_charis that codepoint as a 1-charstr, a 4-byte astral codepoint included.char_positionmay land inside a grapheme cluster — the flag-partial(0, 1, "🇷")underrest="🇫"is one grapheme but offends at codepoint 1 — so do not slice the item at that position for display (it would split the grapheme); build messages from(item, char), which the tuple already carries.- The empty item reports
(i, 0, ""): an empty item is an offender with no codepoint at position 0 to name, so the char field is empty exactly when the item is — a message builder branches on it. - The consistency invariant with the int spelling is structural — the
Rust core's ONE scan returns the stop detail and each spelling projects
it — so
first_invalid_offender(...)isNoneexactly whenfirst_invalid_charset(...)answers-1, and when notNonethe tuple's first field IS the int spelling's answer. The differential battery pins it against the reference oracle extended to return the detail (tests/test_first_invalid_charset.py). - A
== -1/!= -1test ported from the int spelling does not transfer: a tuple never equals-1, so an!= -1invalidity guard fires on every batch and an== -1validity guard never does, both silently. The clean spelling isis None/is not None, pinned on both branches (clean AND offending) in tests/test_first_invalid_charset.py.
import string
queue_first = string.ascii_letters + string.digits + "_"
queue_rest = queue_first + ".-"
names = ["jobs_eu", "foo:eu", "queue_us"]
offender = tors.first_invalid_offender(names, first=queue_first, rest=queue_rest)
# (1, 3, ":") ("foo:eu": ":" is not allowed at position 3)
def rejection(names, offender):
if offender is None:
return None
item, position, char = offender
if not char: # the empty item: no offending character to name
return f"queue name {names[item]!r} is invalid: it is empty"
return f"queue name {names[item]!r} is invalid: {char!r} at position {position} is not allowed"
rejection(names, offender)
# "queue name 'foo:eu' is invalid: ':' at position 3 is not allowed"
The argument contract is the int spelling's exactly — the same shared
walk, so every boundary refusal (a bare str, non-sequences, non-str
entries, non-str first/rest, lone surrogates; the
walk-validates-the-whole-list-first precedence) raises the same exception
with the byte-identical message, pinned by raising both spellings on the
same bad input and comparing them. Error precedence on both-bad calls is
pyo3 left-to-right extraction order: first beats rest, and set-argument
conversion errors beat the items-walk entry errors. No new error classes.
One engine, one scan, and the int path pays nothing for the detail: the
walk stops at the first offending codepoint, and at that stop point it
already holds the position and the codepoint — the detail is free by
construction, the only addition to the shared walk being the position
counter (one integer add per codepoint, a dead field in the int
projection). The scan is #[inline], so each projection compiles away
the fields it drops: without the inline the multi-word stop state
crosses a call boundary and the int spelling paid ~10% on the criterion
group's small cells for detail it discarded (measured); with it the band
is back — the cells above are the post-extension numbers. The int
spelling's performance cells (the wall race, the criterion group, the
GIL ceiling cell) carry the family's contract unchanged, and this
spelling adds no cells of its own: same engine, same walk, same
one-detach batch pass, the only delta being the return — one small
tuple, built only when an offender is found.
Common alphabets
The scope question, answered up front: is it worthwhile adding any other
charset validators — b62, b64, hex, UUID? No new validators. Named wrapper
functions (is_valid_b62, is_valid_b64url, ...) would each delegate to
the same core — N wrappers of zero performance gain, pure API surface and
maintenance cost — so the generic first_invalid_charset stays the single
engine. What is worth shipping is the data: the alphabets long enough
that re-spelling them at every call site invites silent transcription
errors (a wrong 62-character set still validates something). Those ship
as pinned module constants — data, not code:
| constant | alphabet | length | for |
|---|---|---|---|
tors.CHARSET_B62 |
0-9 A-Z a-z |
62 | base62 ids |
tors.CHARSET_B64URL |
A-Z a-z 0-9 - _ — RFC 4648 §5, unpadded |
64 | JWT segments, url-safe tokens |
tors.CHARSET_HEX_LOWER |
0-9 a-f |
16 | lowercase hex digests |
tors.CHARSET_HEX_UPPER |
0-9 A-F |
16 | uppercase hex digests |
tors.CHARSET_HEX_MIXED |
the 22-codepoint union of the two hex alphabets | 22 | case-insensitive hex digests |
The first=None uniform spelling (one set at every position) makes each
constant a single argument:
ids = ["7xK9mQ2pZv", "0Zz8", "bad!"]
tors.first_invalid_charset(ids, rest=tors.CHARSET_B62)
# 2 ("bad!": "!" is outside the base62 alphabet)
segments = ["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", "eyJzdWIiOiIxMjM0NTY3ODkwIn0="]
tors.first_invalid_charset(segments, rest=tors.CHARSET_B64URL)
# 1 (the second segment is padded — the OUT case below)
Three shapes are deliberately OUT, each for a structural reason:
- Standard/padded base64. The
=padding is positionally structured — terminal only. A flat charset cannot express "positions0..len-2from the alphabet, positionlen-1optionally=", and a charset that admitted=would wrongly accept mid-string padding.tors.b64_decodeIS the strict base64 validator — decode-as-validation, which raises on misplaced padding, wrong lengths, and non-alphabet codepoints alike, is the right tool. That is whyCHARSET_B64URLis the unpadded alphabet and the padded segment above is an offender, not a pass. - UUID. The hyphens at fixed positions 8/13/18/23 are structure, not
charset;
first_invalid_charsetcannot express them. There is no strict UUID validator in this release — hyphens are structure, tracked on the uuid7-helpers branch — not a flat alphabet. - Digits.
"0123456789"is trivially spelled with zero typo risk; not worth a name.
The constants' contents are contract, pinned byte-exact with the
length/uniqueness/subset algebra that makes the family coherent
(tests/test_first_invalid_charset.py); the stub carries their type (str)
and is held to tors.__all__ by the same drift guard as every function
(tests/test_pyi_drift.py).
Module data: tors.CHARSET_B62 / tors.CHARSET_B64URL / tors.CHARSET_HEX_LOWER / tors.CHARSET_HEX_MIXED / tors.CHARSET_HEX_UPPER / tors.KEY_FAMILIES
tors.CHARSET_B62: str
tors.CHARSET_B64URL: str
tors.CHARSET_HEX_LOWER: str
tors.CHARSET_HEX_MIXED: str
tors.CHARSET_HEX_UPPER: str
tors.KEY_FAMILIES: tuple[str, ...]
Six published module data constants: the five pinned alphabets the
batch validators consume and the canonical key-family tuple the
credential scrubber consumes. They are data, not functions, on purpose
(the Common alphabets subsection under
first_invalid_charset records the scope decision): named per-alphabet
validator wrappers would each delegate to the same core for zero
performance gain, so the constants kill the only real friction, the
transcription risk of re-spelling a 62-character alphabet (a wrong
62-character set still validates something) or re-enumerating a
closed family set by hand, at every call site. Being data, they carry
no argument contract, no GIL story (nothing runs), and no tors.aio
twin (aio wraps functions; a constant read is not a call to hide).
All six sit in tors.__all__, and the stub is held to it by the same
drift guard as every function (tests/test_pyi_drift.py).
The five alphabets are plain str literals defined in the package's
__init__.py, and their CONTENT is contract, pinned byte-exact in
tests/test_first_invalid_charset.py; the stub carries the type only,
so the live module stays the single spelling of each alphabet:
import tors
tors.CHARSET_B62
# '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
tors.CHARSET_B64URL
# 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
tors.CHARSET_HEX_LOWER
# '0123456789abcdef'
tors.CHARSET_HEX_UPPER
# '0123456789ABCDEF'
tors.CHARSET_HEX_MIXED
# '0123456789abcdefABCDEF'
(len(tors.CHARSET_B62), len(tors.CHARSET_B64URL),
len(tors.CHARSET_HEX_LOWER), len(tors.CHARSET_HEX_MIXED), len(tors.CHARSET_HEX_UPPER))
# (62, 64, 16, 22, 16)
CHARSET_B64URL is RFC 4648 §5's url-safe alphabet, UNPADDED: = is
positionally structured, not a codepoint a flat charset can admit
(b64_decode is the strict base64 validator; the Common alphabets
subsection has the full reasoning). CHARSET_HEX_MIXED is the
22-codepoint union of the two hex spellings, for validators that must
accept either case. Membership is per scalar codepoint with no
normalization, order and duplicates in a spelling irrelevant. Each
constant feeds first_invalid_charset/first_invalid_offender as the
rest= set (or first= for position 0); the uniform first=None
spelling makes it a single argument:
import tors
digests = ["487f5cc2c45cc57e638d9fce8c33d95c", "487F5CC2C45CC57E638D9FCE8C33D95C"]
tors.first_invalid_charset(digests, rest=tors.CHARSET_HEX_LOWER)
# 1 (the uppercase digest offends the lowercase alphabet)
tors.first_invalid_charset(digests, rest=tors.CHARSET_HEX_MIXED)
# -1 (both pass the case-insensitive union)
KEY_FAMILIES is different in one respect: it is exported from the
Rust core, built from the same single source (pii_impl's
KEY_FAMILY_NAMES) the api_keys scanner's grammar table and the
families= unknown-name error are spelled from, so the tuple, the
accepted names, and the error message cannot drift apart. It is the
fourteen key-family names in the KeyFamily discriminant order:
import tors
tors.KEY_FAMILIES
# ('openai', 'anthropic', 'google', 'fireworks', 'modal', 'github',
# 'minted', 'jwt', 'aws', 'xai', 'gcp_oauth', 'pem', 'azure', 'gitlab')
tuple(f for f in tors.KEY_FAMILIES if f != "jwt")[:3]
# ('openai', 'anthropic', 'google')
Its consumer is scrub_pii/scrub_pii_report's families= parameter
(see that section's Key-family selection): families=None is every
family this version knows, the set grows when new families land
(semver-visible, so stability-seeking callers list names explicitly),
and an unknown name is refused with an error that names the accepted
set, which is the tuple itself:
import tors
tors.scrub_pii("ssn 123-45-6789", families=["ssn"])
# ValueError: families must be one of ('openai', 'anthropic', 'google', 'fireworks', 'modal', 'github', 'minted', 'jwt', 'aws', 'xai', 'gcp_oauth', 'pem', 'azure', 'gitlab'), not "ssn"
tors.__version__
The installed distribution's version, read once at import from its
metadata (importlib.metadata.version("tors"), the same source
pip/uv report, so they cannot disagree; pinned by
tests/test_package_version.py). It is never a second literal in the
source that release tooling cannot bump. Where the distribution
metadata is absent (a source tree imported off-path) it answers
"unknown" rather than lying. Declared on the typed surface as plain
str (the value is import-computed, not a constant the stub could
lie about).
import tors
tors.__version__
# '0.15.1'
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; under the default the metered anydoc, office_oxide, and HTML lanes refuse during the read at the 32 MiB ceiling, and the pdf lane reads under the 512 MiB backstop); 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 two-phase doctrine: the read is
bounded from the start, a prefix sniff picks the lane, and then the metered
lanes — the ones that amplify input into resident
memory: anydoc, office_oxide, and HTML (~23x input; the HTML converter holds
the whole input and output at once, a 48 MiB doctype HTML peaked at 1118 MiB)
— refuse during the read at the 32 MiB default ceiling, while the
pdf lane reads under the finite 512 MiB backstop (a memory-safety
floor, not a lane policy). The lane is unknowable before the container sniff,
which is why only the explicit budget can be checked fully pre-read. Over the
ceiling, the call raises ValueError naming the ceiling 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)
reads the pdf lane under the finite 512 MiB backstop (a memory-safety floor,
raised with an explicit max_bytes=); the 32 MiB default ceiling is the
amplified lanes' (anydoc, office_oxide, HTML) during-the-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