Conversation
_MAX_GRAPH_SIZE only limits edges added from a single position in one pass, not the total size the ambiguity graph accumulates across a long, persistently ambiguous stretch of text. _bfs_paths_graph also copied the whole path-so-far (path + [pos]) on every BFS step, so resolving one such graph cost O(V x path length) instead of O(V + E). Together these made word_tokenize() quadratic in text length for input like "กรรมกร" repeated many times: 48,000 characters took over 3 seconds. Rebuild the path once, from a predecessor map, instead of copying it at every step (_bfs_paths_graph -> _bfs_shortest_path). Also track pos_list membership in a set for an O(1) check instead of scanning the list. Checked against the unmodified tokenizer on the full bundled word list, pairwise word concatenations, and known edge cases: token output is unchanged; the adversarial case is 6-13x faster and scales close to linearly again. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLAScAmHFQoCy5oDHoGoo1
|
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



What do these changes do
Fixes a quadratic-time blowup in
word_tokenize()(engine="newmm", thedefault) for text that stays ambiguous over a long stretch. It rebuilds a
BFS path once, from a predecessor map, instead of copying it at every BFS
step, and tracks the position queue's membership in a set instead of
scanning a list.
Related to #893 and #326, both closed: those fixed the exponential/hang
case in this same BFS ambiguity resolution, but left the quadratic-time
case (still slow, no longer hanging) described below.
What was wrong
_onecut()builds a graph of possible word boundaries while a position isambiguous (more than one dictionary match leads forward from it), and
resolves it with BFS once only one candidate boundary is left. Two things
compound:
_MAX_GRAPH_SIZE = 50(added for Report: newmm bug #893, "newmm BFS path explosion")only limits how many edges are added from a single position in one
pass (the
breakinside thefor word in custom_dict.prefixes(...)loop). It does not bound how large the graph grows across many
positions while the text stays ambiguous. For text with many
overlapping dictionary matches over a long stretch, the graph keeps
accumulating one batch at a time and can reach the size of the whole
stretch before the first resolution.
_bfs_paths_graph()copied the whole path-so-far at every BFS step:queue.append((pos, path + [pos])).path + [pos]builds a new list,copying everything already in
path. Once the graph above has grownlarge, resolving it this way costs O(V x path length) instead of the
O(V + E) the comment above the function claims.
Together, tokenizing text with a long run of ambiguous, overlapping
dictionary matches — e.g.
"กรรมกร" * 8000(48,000 characters; "กรรมกร"has several dictionary substrings: กร, รม, มก, กรรม, กรรมกร) — made
word_tokenize()quadratic in text length:Doubling the input roughly quadruples the time. Traced directly: for
"กรรมกร" * 3000(18,000 characters), the graph grows to 18,022 edgesacross 17,998 nodes before it resolves for the first (and only) time in
that text.
Any service that tokenizes text it does not control (a search box, a
chat message, an uploaded document) is exposed to this: input of a few
tens of thousands of characters, not an unusually large size, can cost
seconds of CPU per request and get worse for longer input, which is a
low-effort denial-of-service vector against the default tokenizer.
newmm-safe(safe_mode=True) already limits the damage as a sideeffect of chunking text into ~140-character windows before tokenizing
each one, which keeps the graph from growing past the chunk. But
safe_modeis off by default, and its docstring only mentions using it"for very long texts (hundreds of kilobytes or more)" — nothing about
this being a mitigation for persistently ambiguous text well under that
size.
How this fixes it
_bfs_paths_graph()(renamed_bfs_shortest_path()) now records onepredecessor per visited node during the BFS traversal (O(1) per step,
same as before) and rebuilds the path by walking predecessors backward
once, only when the goal is reached, instead of on every step:
The traversal order and the path returned are unchanged — same BFS,
same first-reached (shortest) path — only how the path is built changes.
The function now returns
list[int]directly rather than aGenerator[list[int], None, None], since the only caller didnext(_bfs_paths_graph(...))to get exactly one path; the queue is acollections.deque(popleft(), O(1)) instead of a plain list(
pop(0), O(n)).Separately,
end_pos_candidate not in pos_listscanned the wholeposition list (used as a heap) on every candidate. A
pos_setmirrorspos_list's contents (added and discarded alongside every push/pop) sothis check is O(1) instead of O(n). Tested this in isolation: on its own
it made little difference next to the path-copying fix above, but it is
still a correctness-preserving O(n) -> O(1) change worth keeping.
_MAX_GRAPH_SIZE's deeper limitation (it does not bound total graph sizeacross positions) is not addressed here — doing that safely needs
picking a resolution strategy for a graph that is deliberately cut off
mid-ambiguity, which risks changing tokenization output for the text
that triggers it and needs care and testing on its own. This change
brings the cost of resolving whatever graph does accumulate back down to
what the existing cutoff assumed it would be.
Verification
Compared against the unmodified tokenizer (
newmm.pyfromdev) on:thai_words()(62,106), through_onecut()directlyDANGER_TEXT_*/LONG_TEXTsamples, empty string, every single Thaiconsonant
segment()andsegment(..., safe_mode=True), not just_onecut()_onecut()output differs from unmodified tokenizersegment()/safe_mode=Trueoutput differs"".join(word_tokenize(text)) != textPerformance, same adversarial pattern, before vs. after (median of 3 runs
at the smaller sizes; single measured run at 96,000/192,000 — both take
long enough on the unfixed tokenizer that the quadratic trend is already
unambiguous):
New test:
TokenizeTestCase.test_newmm_persistent_ambiguity_performancein
tests/core/test_tokenize.py, alongside the existingtest_newmm_ambiguous_performance(#893) regression test that this onecomplements rather than replaces (that one already passed before this
fix; this one did not, at the sizes tested above).
Your checklist for this pull request
Ruff passes and mypy reports no errors in
newmm.py.tests.core.test_tokenize(including the two new/existing performanceregression tests) and
tests.core.test_tokenize_thread_safetypass — thefix does not introduce shared mutable state (
pos_setis a localvariable per
_onecut()call, same lifetime as thegraph/pos_listitalready had). Ruff's formatting flags on
test_tokenize.pyarepre-existing on unmodified
devand unrelated to this change (confirmedby running the same check against the unmodified file).
🤖 Generated with Claude Code
https://claude.ai/code/session_01MLAScAmHFQoCy5oDHoGoo1