Skip to content

Add: Exact Levenshtein search for immutable dictionaries - #336

Draft
grouville wants to merge 18 commits into
ashvardanian:main-devfrom
grouville:levenshtein-index
Draft

Add: Exact Levenshtein search for immutable dictionaries#336
grouville wants to merge 18 commits into
ashvardanian:main-devfrom
grouville:levenshtein-index

Conversation

@grouville

@grouville grouville commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

This PR adds exact bounded Levenshtein search for immutable string dictionaries. It targets applications that build a dictionary once and query it many times, returning every matching original ID and exact distance within an inclusive bound.

It addresses #243. That issue points to Levenshtein automata as a faster alternative to scanning every string. The implementation started there, then kept the automaton only where it helps. A small bound can reject most dictionary prefixes early. A wide bound reaches most branches, so carrying automaton state through the whole tree becomes more expensive than scanning the dictionary directly. The public API stays the same while the index chooses the exact path for each query.

What this adds

import stringzillas as szs

index = szs.LevenshteinIndex([b"book", b"back", b"book", b"boon"], max_distance=2)
query_ids, dictionary_ids, distances = index([b"cook", b"book"], bound=1)

assert sorted(zip(query_ids.tolist(), dictionary_ids.tolist(), distances.tolist())) == [
    (0, 0, 1),
    (0, 2, 1),
    (1, 0, 0),
    (1, 2, 0),
    (1, 3, 1),
]

The index owns a copy of the dictionary. IDs always refer to the original input order, so duplicate strings remain separate results. Search returns every match within bound; result order is unspecified.

The same operation is available as:

  • levenshtein_index and levenshtein_index_utf8 in C++;
  • opaque index and reader handles in C;
  • LevenshteinIndex and LevenshteinIndexUTF8 in Python.

The C interface follows the existing StringZillas batch model. It accepts sequence, 32-bit tape, and 64-bit tape collections, supports caller-owned result allocation and output sizing, and can search on one or several CPU cores.

How search is selected

Query Exact path
k=0 Hash lookup followed by a complete string check
k=1..2, when deletion records stay small Shared deletion variants followed by exact verification
Longer strings or selective larger bounds Compressed prefix-tree traversal with bounded distance state
Wide bounds One query mask reused while the owned dictionary is scanned with bit-parallel distance

The automatic builder first estimates the deletion records required for the whole dictionary. At k=1..2, it uses that index only when the estimate stays below 80 records per word on average. Otherwise it builds the prefix tree for the whole dictionary. C++ callers may give an explicit word-length cutoff when they want a mixed index. The value 80 is an empirical memory guard, not a claim that one plan is best for every workload.

The deletion and tree paths are lossless candidate filters. Every candidate is verified against the owned dictionary text before it is returned. Hash collisions, repeated deletion variants, and cached tree states can add work, but cannot change the answer. Construction also happens in a temporary index, so an allocation or validation failure leaves the previous index unchanged.

The deletion filter follows the lossless observation in Improved Fast Similarity Search in Dictionaries. This is not a direct implementation of that paper. It combines deletion filtering with a compressed prefix tree, cached distance steps, and a direct bit-parallel crossover path.

Text, ownership, and concurrency

The byte index treats each byte as one symbol. The UTF-8 index validates and decodes the dictionary once, validates every query, and measures edits between Unicode codepoints. It does not normalize text, fold case, apply locale rules, or count grapheme clusters.

The index is immutable after construction. C++ keeps mutable search memory in independent readers, so several readers can safely share one index. C and Python keep that memory behind the handle; concurrent callers should use separate handles. The implementation is CPU-only. GPU device scopes are rejected instead of silently running elsewhere.

Where this fits

This API is for repeated complete retrieval over a fixed dictionary. It fits spelling candidates, catalogue matching, and other services that need every dictionary entry inside a known edit radius.

Auditing existing fuzzy-search projects was useful because most did not actually have that contract. Some use normalized similarity, weighted edits, token scores, dense pairwise matrices, or only the nearest result. Replacing those calls with this index would change behavior. The audit therefore narrowed the API rather than producing a misleading production migration claim.

Existing boundaries

  • The maximum bound is chosen when the index is built. A query may use that bound or a smaller one.
  • The dictionary cannot be updated in place and the index has no persistent on-disk format.
  • Search returns all bounded matches. It does not yet provide nearest or top-N search, including the unbounded case mentioned in the issue.
  • Distance is unit-cost plain Levenshtein. Weighted distance, normalized similarity, Damerau transpositions, token similarity, and ranking belong to different contracts.
  • This is not the dense all-pairs within(k) experiment. That work was removed because it solved a separate problem without a concrete caller.

Correctness and failure behavior

Final reviewed head: 19b7faed60051867cb3bf8b1367d3da84bc1b209.

The C++ suite compares 2,548,910 exhaustive dictionary memberships with an independent dynamic program. It covers duplicate IDs, embedded zero bytes, every search path, bounds through 40, and strings around the 64, 128, and 512-symbol block boundaries. Separate tests cover C, Python, UTF-8 validation, failed rebuilds, allocator failures, and independent readers.

Source review after the first draft found three real failure-path bugs:

  • search allocation failure could look like a valid missing match;
  • a retried Python output allocation could release the same arrays twice on an overflow branch;
  • queries= was rejected even though it was a documented argument.

All three are fixed with regression coverage. Focused AddressSanitizer and UndefinedBehaviorSanitizer runs pass. The complete C++20, C++23, C, and Python tests pass. Every current GitHub check is green across Linux, macOS, Windows, WebAssembly, and the QEMU architecture matrix.

The public batch runner also writes a canonical result file. StringZilla, RapidFuzz, and the exact SymSpell compatibility runner produced the same query ID, original dictionary ID, and distance records at k=1 and k=2. The StringZilla and RapidFuzz files also match through k=10 in the separate wide-bound sweep.

Current performance evidence

StringWars #10 contains the query generator, public runners, exact result format, and comparison rules. RapidFuzz is the correctness oracle, not the closest indexed competitor. SymSpell is directly comparable only through its exact compatibility mode on unique lowercase dictionaries. FST, Tantivy, Lucene, and native SymSpell return different information and are reported separately.

The latest local check used 213,557 unique English words, 10,000 mixed queries, one pinned Intel Core i5-9300H AVX2 core, and the exact PR head above. Five warm, pre-sized repetitions measured 6.50 ms at k=1 and 87.91 ms at k=2. The exact-output SymSpell runner measured 65.44 and 718.72 ms. The complete result files matched.

Those numbers are preliminary. The shared host had unrelated load, runner order was not randomized, and later StringZilla-only repetitions varied materially at k=2. They support the architecture and show a strong indexed result, but they are not yet the final speedup claim.

A separate 100-query crossover stress test found StringZilla faster than the exact-output RapidFuzz scan at every bound through k=10. The advantage fell from 10.1x at k=3 to 1.4x at k=10, where 10.6 million of 21.4 million possible pairs matched and result writing dominated. This is evidence that the wide-bound fallback removes the earlier performance cliff, not evidence that k=10 is a normal bound for short English words.

Why this PR is still a draft

There is no known correctness blocker and the project CI is green. It remains a draft for two reasons:

  • The API and its boundaries should be agreed before treating a new immutable index as a settled public surface.
  • The revised StringWars protocol still needs its final controlled run: at least 20 measured repetitions, randomized runner order, quiet pinned CPUs, raw outputs, memory measurements, and separate AVX2 and AVX-512 evidence.

The intended claim is deliberately narrow: exact CPU retrieval of every original dictionary entry and distance inside a small Levenshtein bound. It is not a claim to be the fastest fuzzy-search library for every result contract, language, bound, or workload.

Follow-ups

  • Add nearest and top-N search, with unbounded search as an explicit separate contract.
  • Add persistence and evaluate updates through a separate mutable index.
  • Revisit a per-length automatic planner if real dictionaries show that the current whole-dictionary estimate leaves useful mixed plans unused.
  • Evaluate SIMD dense scanning only after the portable crossover path has controlled evidence across architectures.

Suggested review order

  • Commits 1 and 2 establish owned dictionary IDs, exact lookup, and bounded deletion filtering.
  • Commits 3 through 6 add the compressed prefix tree, cached distance steps, and automatic construction policy.
  • Commits 7 and 8 add UTF-8 behavior and document the retained search paths.
  • Commits 9 and 10 expose the C and Python APIs. Commit 11 adds the exhaustive suite.
  • Commits 12 through 16 contain the failure-path fixes, behavior notes, and low-bound query optimization.
  • Commits 17 and 18 add the wide-bound crossover path and its boundary coverage.

@grouville
grouville force-pushed the levenshtein-index branch 3 times, most recently from 908dbc1 to 3cede5b Compare August 16, 2026 05:48
@grouville grouville changed the title Add: Exact bounded Levenshtein search and dictionary indexes Add: Exact Levenshtein search for immutable dictionaries Aug 16, 2026
@grouville
grouville marked this pull request as ready for review August 16, 2026 06:57
@grouville
grouville marked this pull request as draft August 16, 2026 07:42
Start the bounded Levenshtein index with its simplest useful case: distance zero.

The index copies the input strings into one owned buffer and preserves their zero-based input IDs. Duplicate strings remain separate entries, and lookup returns every matching ID. Results are intentionally unordered so later search paths do not need to sort their output.

Exact lookup hashes complete strings and uses a prefix directory to narrow each query to a small range. The full string is still compared before a match is returned, so hash collisions cannot change the answer.

For dictionaries with at most 2^20 entries, each record stores a 12-bit hash suffix and a 20-bit ID in four bytes. Larger dictionaries keep the full hash and ID in eight bytes. The directory chooses between a dense offset table and a ranked bitmap according to their actual sizes.

This final layout is introduced now because the bounded index in the next commit creates many more records. On the 370,105-word English benchmark, replacing the dense directory with this ranked layout reduced persistent index storage from 149.3 to 34.4 MB at k=1 and from 211.2 to 134.0 MB at k=2. Warm query medians changed only from 1.934 to 1.948 ms and from 42.052 to 41.555 ms. The complete result streams remained identical.

Building happens in a temporary index. Allocation or size failures therefore leave an existing index untouched. The initial public contract accepts only bound zero; later commits extend the same index without changing these ownership and result rules.

The test covers duplicate IDs, misses, rejected nonzero bounds, and preservation of the previous index after a failed rebuild. The benchmark numbers above explain the retained storage layout; this checkpoint itself adds no bounded-search speed claim.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
Extend the immutable dictionary from exact lookup to complete Levenshtein search at bounds one and two.

For each dictionary word, the builder hashes the original word and every distinct string produced by deleting one or two symbols. A query produces the same variants for its requested bound. Any two strings within that bound share at least one such variant, including strings of different lengths because all deletion counts from zero through the bound are stored.

The shared variant is only a candidate filter. Every candidate is checked against the owned dictionary word with plain Levenshtein distance before it is returned. Bound one uses a direct single-edit check. Bound two uses a 64-bit bit-parallel verifier for byte strings through 64 symbols and a narrow dynamic-programming row otherwise. Hash collisions and repeated deletion variants can add work but cannot add, remove, or change a match.

One generation counter per dictionary ID prevents the same candidate from being verified twice during a query. The counters, temporary hashes, verifier rows, and output belong to the caller scratch object. The built index remains immutable and can be shared by concurrent readers.

On the pinned 370,105-word English workload with 10,000 queries, warm single-core medians were 1.948 ms at k=1 and 41.555 ms at k=2. The closest indexed baseline tested, SymSpell-Rust 6.8.3 with every result converted back to plain Levenshtein semantics, took 20.931 ms and 455.857 ms. That is 10.74x and 10.97x slower. Complete sorted ID and distance streams were byte-identical to the pinned RapidFuzz oracle.

The same source compiled for portable x86-64, Haswell/AVX2, and native AVX-512 on the AMD host measured 2.015/48.678 ms, 1.920/42.115 ms, and 1.948/41.555 ms at k=1/k=2. This shows an algorithmic gain, not an AVX-512-specific one. A separate Intel AVX2 run also matched RapidFuzz byte for byte.

The expanded test compares every result against a simple dynamic-programming reference over exhaustive binary strings through length five. It covers all three bounds, duplicate IDs, embedded zero bytes, the long-string verifier, rejected bounds, scratch reuse, and failed rebuilds.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
Deletion lookup grows with the number of ways symbols can be removed. That is effective for short strings at bounds one and two, but it becomes too large for long strings or larger bounds.

Add a second exact path that stores common prefixes once. Each edge points into the owned dictionary buffer instead of copying its text. Runs with no branch are kept as one edge, which makes the tree much smaller for long strings.

A query carries a narrow row of edit distances while it walks the tree. A branch stops as soon as every value in that row is above the requested bound. Reaching the end of a dictionary word returns its original ID and exact distance. Duplicate words remain separate terminal IDs.

The builder takes an explicit length cutoff in this checkpoint. At bounds one and two, words through the cutoff use deletion lookup and longer words use the tree. Above two, the complete dictionary is in the tree. The automatic choice is intentionally left for a later commit so its policy can be reviewed separately.

Compressing paths matters on the measured long-string datasets. On the 100,000-string DNA corpus, it reduced tree storage from 194.5 MB to 6.0 MB and build time from about 97 ms to 19 ms. On the Wikipedia URL corpus, it reduced tree storage from 27.6 MB to 4.8 MB and build time from about 18 ms to 10 ms. These measurements explain the stored shape; the faster cached traversal is added separately.

The simple traversal was already exact but only 4.16x and 1.68x faster than the RapidFuzz full scan at k=3 and k=4 on the English workload. Keeping it as this checkpoint makes the correctness path clear and also shows why the next optimization is needed.

The test checks every result against a plain dynamic-programming reference through bound four. It covers a complete-tree search, a mixed lookup-and-tree search, duplicate IDs, long strings, embedded zero bytes, rejected bounds, and preservation of the old index after a failed rebuild.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
Tree branches often reach the same edit-distance row and then consume the same next symbol. Recomputing that step for every branch repeats work.

For queries through 15 symbols, store the complete clipped distance row in one 64-bit value using four bits per column. The next row, its smallest distance, and its final distance are cached for each row-and-symbol pair reached during the query.

The cache belongs to the caller scratch object, so the immutable dictionary stays safe to share. A generation number makes reuse cheap without clearing every entry before every query. If the cache fills, the step is computed normally, so cache pressure cannot change the result.

A 32,768-entry cache with short linear probing is retained because a smaller 4,096-entry direct cache was 5 to 9 percent slower in the measured English runs. This commit changes only how repeated steps are computed. The complete-tree and mixed-tree reference tests from the previous commit now exercise this path without changing their expected answers.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
A complete distance row no longer fits in one 64-bit value when the query exceeds 15 symbols. Most of that row cannot affect an answer within a small bound.

For bounds through seven, keep only the moving window of columns that can still match. Its values fit in one 64-bit value using the same four-bit cells as the short-query path. The tree depth is included in the cache key because it determines which query columns the window represents.

Queries with larger bounds continue to use the simple narrow-row traversal from the prefix-tree commit. This keeps one clear general path instead of extending the packed form beyond the range it can represent safely.

Together, the two cached-step commits reduced the pinned English medians to 5.460 seconds at k=3 and 16.217 seconds at k=4. The pinned RapidFuzz full scan took 57.038 and 70.840 seconds, so this path was 10.45x and 4.37x faster. Both implementations returned 3,158,139 and 26,600,296 matches, and their complete sorted result streams were byte-identical.

The existing long-string queries now exercise this moving-window path through bounds zero to four. Their results still match the independent dynamic-programming reference.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
Replace the temporary 64-symbol default with a simple estimate made before allocation.

The builder counts the maximum number of deletion records the dictionary would create. It uses deletion lookup when that total is at most 80 records per word on average. Above that limit it uses the prefix tree for the complete dictionary. An explicit length cutoff remains available for callers that know their workload.

This is a memory guard as well as a speed choice. It never removes a word from both paths. Bounds above two always have the complete prefix tree, while bounds zero to two use the selected lookup path and exact checks from the earlier commits.

The 80-record limit is an empirical policy, not a universal constant. It selected deletion lookup for English at k=1 and k=2, deletion then tree for Wikipedia URLs, and tree for DNA strings at both bounds. Their measured StringZilla times were 1.95/41.6 ms, 15.9/502.6 ms, and 12.9/107.5 ms at k=1/k=2. Those three shapes are why the policy is kept simple and visible.

The test now checks both automatic decisions and the explicit mixed cutoff. All three plans continue to match the independent distance reference.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
Keep byte distance and text distance as separate types. The existing index continues to treat every byte as one symbol. The new UTF-8 index validates and decodes the dictionary once, then measures edits between Unicode codepoints.

Queries are validated and decoded into their reader scratch before search. Malformed or truncated UTF-8 returns an error instead of silently replacing input. A failed dictionary rebuild preserves the previous valid index.

The UTF-8 wrapper reuses the same deletion and tree engines over 32-bit codepoints, so it keeps the same IDs, duplicates, exact distances, automatic path choice, and concurrent-reader rules.

On the ASCII English corpus, where byte and codepoint answers are identical, the UTF-8 path measured 2.083 ms at k=1 and 58.245 ms at k=2. The byte path measured 1.948 and 41.555 ms. Validation, decoding, and the wider owned dictionary increased query time by about 7 and 40 percent, and dictionary storage from 6.46 to 16.94 MB.

A separate valid non-ASCII audit mapped the English corpus onto two-byte Unicode characters and produced byte-identical result streams against RapidFuzz codepoint distance. The natural Simplified Chinese corpus was also checked against RapidFuzz, including 343,237,926 returned matches at k=2.

The test covers multibyte substitutions, duplicate UTF-8 entries, malformed queries, malformed dictionaries, and preservation of the previous index after invalid input.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
The first seven commits introduce the search paths one at a time. Add a short map inside the final core so a reviewer does not need to reconstruct that history while reading the complete file.

The comments mark the owned data and per-search memory, the candidate and exact-check boundary, compressed tree construction, the three tree walkers, the hash directory, and the automatic path choice.

This changes no code or result.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
Add the public C engine around the immutable byte and UTF-8 indexes. It accepts the three StringZillas collection layouts, copies the dictionary with the caller allocator, and uses the existing device scopes to search a query batch on one or several CPU cores.

Matches are returned in three caller-owned arrays containing query IDs, dictionary IDs, and exact distances. A zero-capacity call reports the required length. A short buffer reports the same length and leaves its partial contents unspecified, so callers can resize and repeat without depending on internal storage.

The engine keeps one grow-only scratch area per worker. Its one-core path avoids the scheduler and atomics, while a ForkUnion scope reserves one contiguous output block per query. One bulk caller uses an engine at a time, matching the other stateful StringZillas C engines.

The old shared match-struct header is no longer needed because no internal buffer crosses the C boundary. The C test covers duplicates, capacity negotiation, parallel-ready capabilities, byte and UTF-8 errors, and balanced use of a custom allocator.

On the local Intel AVX2 machine, the public API searched 213,557 words for 10,000 queries in 7.45 ms at k=1 and 66.60 ms at k=2 on one core. Four physical cores took 2.11 and 17.99 ms. Complete result files matched RapidFuzz in both modes.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
Add LevenshteinIndex and LevenshteinIndexUTF8 as small wrappers over the public C batch engine. Construction accepts existing StringZilla collections or any iterable of string-like values. Calling an index accepts a query collection, an optional bound, and an optional DeviceScope.

The result is three compact NumPy arrays containing query IDs, dictionary IDs, and exact distances. The wrapper reserves eight matches per query, then repeats once with the required capacity only when that estimate is too small. It never exposes internal scratch memory.

Both classes use vectorcall and the same collection export paths as the existing batch engines. Their capability masks stay visible through __capabilities__. Per-object and device locks protect the reusable C engine in free-threaded Python.

The tests cover duplicate dictionary IDs, byte and Unicode semantics, malformed inputs, rejected bounds, result dtypes, existing Strs input, and a two-core DeviceScope. The rebuilt extension passes all six StringZillas Python tests, with only the three expected CUDA skips.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
Move the Levenshtein index checks into the normal StringZillas C++ and CUDA test launchers instead of maintaining a standalone executable. This makes every supported build compile and run the same core test.

The test compares 2,016,010 dictionary memberships with a small dynamic-programming reference. It covers bounds zero through fifteen, duplicate values, embedded zero bytes, short and long strings, automatic and explicit path choices, all three tree walkers, and 32-bit Unicode symbols.

It also checks malformed UTF-8, failed rebuilds, and concurrent reads of one immutable C++ index with independent scratch objects. The C and Python commits keep their own public API tests, while competitor adapters and timing code remain in StringWars.

The final C++20 and C++23 suites pass in Release. The index phase also passes under AddressSanitizer and UndefinedBehaviorSanitizer; later fingerprint tests still report their existing signed-overflow warnings outside this change.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
The long-string verifier grows two reusable distance rows on demand. It previously returned the same value for an allocation failure and a rejected candidate. A valid match could therefore disappear while the search still reported success.

Return the allocation status separately from the distance and carry it through both deletion-record layouts. Ordinary rejected candidates keep the existing distance sentinel, while memory failures now reach C++ callers and the C and Python wrappers as bad_alloc.

Add a failing allocator test around the 65-byte verification path. It confirms that the failed search returns bad_alloc, then confirms that the same index returns the exact match once allocation is available again.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
Python starts with room for eight matches per query and repeats the search when that estimate is too small. The retry released its first three NumPy arrays but left their local pointers unchanged. If the required length was too large for NumPy, common cleanup decremented the released objects a second time.

Clear each pointer as it is released so every later error path can use the common cleanup safely.

Add coverage for a query returning twenty duplicate dictionary IDs, which exercises the resize and retry path. Also cover empty dictionaries and empty query batches while touching the same output handling.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
The vectorcall parser required one positional argument before reading keywords. As a result, queries= was always rejected even though it is the documented argument name.

Allow the required query collection to arrive either positionally or by keyword. Keep the existing duplicate checks for queries, bound, and device, and report a direct missing-argument error when neither form is supplied.

The Python test now covers a keyword-only query collection, the missing-query error, and a duplicate positional and keyword value.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
State the parts of the first API contract that are easy to assume incorrectly.

The UTF-8 index counts decoded codepoints. It does not normalize text, fold case, or combine codepoints into grapheme clusters. The C and Python engines are CPU-only and allow one call per index at a time, while that call may use several CPU cores.

Also describe the current automatic planner precisely. It chooses one path for the complete dictionary. The C++ length cutoff is the explicit way to combine deletion lookup for short words with prefix-tree search for long words.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
The deletion index stores one record for each distinct residual hash, so construction still sorts and removes duplicates before those records become persistent.

Queries do not need the same work. A reader already tracks which dictionary IDs were checked during the current search. Repeated query hashes can revisit a bucket, but they cannot verify or return one candidate twice.

Keeping those hashes avoids sorting every query neighborhood. On the balanced 10,000-query English workload, the warm single-core AVX2 median improved from about 8.0 to 6.9 ms at distance 1 and from about 76.0 to 61.3 ms at distance 2. This restores a narrow 10.3x and 11.8x lead over the exact-output SymSpell adapter on the same machine.

The C++20 and C++23 suites each checked 2,016,010 memberships. The C and Python tests passed, a focused ASan/UBSan run passed, and the complete distance-1 and distance-2 result files remained byte-identical to RapidFuzz.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
The prefix tree is efficient while an edit bound rejects most branches. Once the bound becomes wide relative to the query, it approaches a full tree walk and carries a distance state through nearly every edge. That caused a sharp slowdown around distance seven on short English words.

Add a direct Myers scan for that regime. It builds the query masks once and reuses them across the owned dictionary. Queries through 64 symbols use one machine word. Longer queries carry the same exact recurrence across 64-symbol blocks. Byte queries use a direct 256-row mask table, while wider symbols use sorted sparse rows.

The public API does not change. Exact lookup and deletion lookup remain the paths for distances zero through two. Selective larger bounds keep the prefix tree. The reader chooses the dense scan only for small byte dictionaries or when the edit bound is wide enough to amortize the number of query blocks. Unicode switches later because sparse mask lookup costs more.

On the 213,557-word English dictionary and 100 fixed queries, the adaptive path took 0.625, 0.702, 0.760, 0.822, and 0.840 seconds at distances 6 through 10. RapidFuzz took 3.023, 3.093, 3.548, 4.871, and 6.241 seconds while producing the same result checksums. The previous tree-only path had the large-bound performance cliff this fallback removes.

The scan uses O(total candidate symbols * ceil(query symbols / 64)) work and O(alphabet rows * ceil(query symbols / 64)) reader memory. All allocations remain in the caller-owned reusable scratch and propagate failure through the existing status result.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
The adaptive search now has two exact paths above distance two, so the tests must prove both the choice and the result rather than only exercising one implementation.

Extend the Unicode exhaustive sweep through distance eight, where sparse query masks enter the dense path. Keep the dedicated tree corpus above 64 symbols so its packed-band and general row implementations remain covered after the planner change.

Add byte cases immediately around 64, 128, and 512-symbol block boundaries, plus Unicode cases around 64 and 128 symbols. Every returned ID and distance is compared with the reference dynamic program at several bounds. Duplicate IDs and result ordering retain the existing contract.

The dense path also receives an allocator fault test. Failure to grow its reusable masks or state must return bad_alloc instead of a partial successful result.

The C++20 suite now checks 2,548,910 exhaustive memberships plus the new block-boundary cases. The focused ASan and UBSan run passed, the C surface passed, and all six CPU Python tests passed with the three CUDA-only tests skipped.

Signed-off-by: Guillaume de Rouville <guillaume.derouville@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant