Add exact batched all-pairs Levenshtein distances - #195
Merged
Merged
Conversation
levenshtein_distance prices one pair of strings with a pure Python dynamic program, so a caller that needs the whole cross product of two collections pays the interpreter cost of every matrix cell. graphtage.batch_distance answers the same question for a whole batch, behind a registry of interchangeable backends. The python backend wraps levenshtein_distance and is the oracle and the small-batch fallback. The numpy backend advances every pair through one row of its matrix per array pass, turning the horizontal dependency into a running minimum so that numpy.minimum.accumulate can compute a row in one pass. On an Apple M3 Max running Python 3.14 and numpy 2.5.3 it answers 160,000 pairs of 15 to 30 character strings in 0.47 s against the scalar path's 7.0 s. The batch is oriented shorter-string-first, split by str against bytes, and bucketed by length before it reaches the kernel. Without bucketing, one long string among short ones pads every row to its length, which measures slower than the scalar path it replaces. Nothing calls this yet; wiring it into the engine is separate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every test names the failure it prevents, and each was confirmed to fail against a deliberately broken module before being kept. Fifteen mutations were tried; every one is caught: - a substitution costing 2 instead of 1 - reading a pair out one column early, or at its chunk's padded width - encoding str as UTF-16 code units, which splits astral characters - dropping the length bucketing, or the shorter-string-first orientation - comparing a str to a bytes rather than refusing - scattering a kind's sub-batch back to the wrong positions - short-circuiting an empty string to 0 instead of the other's length - losing either deduplication index on the way back to the inputs - ignoring the GRAPHTAGE_BATCH_BACKEND environment variable - squeezing a zero-length axis out of the result - re-registering a backend name over an existing one The length skew test bounds the numpy backend against the python backend measured in the same run rather than against a wall clock, so it reports a bucketing regression without depending on how fast the machine is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 15, 2026
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 this adds
A new module,
graphtage/batch_distance.py, that computes exact Levenshtein distances for a wholecollection of string pairs at once, behind a registry of interchangeable backends.
Nothing in Graphtage calls this module. It is a pure addition, and reviewing it needs no context
beyond the file itself. Wiring it into the matching engine is a separate change.
Two backends ship:
pythonwraps the existinggraphtage.levenshtein.levenshtein_distance. It is the oracle theother backends are checked against and the fallback for batches below any threshold.
numpyis the default above 32 pairs.numpy>=1.26is already a dependency, so this adds none.GRAPHTAGE_BATCH_BACKENDpins the choice, which is what makes the benchmark below and the paritytests possible. An explicit
backend=argument wins over the environment variable, which wins overthe automatic choice.
How the numpy backend works
The horizontal dependency
cur[j] = min(base[j], cur[j - 1] + 1)is what normally stops a row ofthe Levenshtein matrix from being vectorized. Subtracting
jfrom both sides turns it into arunning minimum of
base[j] - j, whichnp.minimum.accumulatecomputes in one pass. A batch thencosts as many array passes as its strings are long, rather than as many interpreter steps as it has
matrix cells. Each pair is read out in the row where
ireaches the length of its first string, atthe column given by the length of its second, so pairs of different lengths share one batch.
Three things happen before the kernel sees a batch:
gives the fewest and widest array passes.
strandbytespairs are separated, and a pair that mixes the two is rejected rather thananswered.
ord('a')andb'a'[0]are both 97 while'a' == b'a'isFalse, so there is noanswer that satisfies both readings.
strencodes to oneuint32per code point andbytestoone
uint16per byte, so the two cannot be concatenated without an explicit cast.long string among short ones pads every row of the batch to its length.
Pairs are deduplicated on both sides and indexed back through two dictionaries, and pairs that are
equal or have an empty side are answered before the kernel.
Measured throughput
Apple M3 Max, macOS 26.6.2, Python 3.14.0, numpy 2.5.3. Random lowercase strings of 15 to 30
characters, timed through
all_pairsend to end, including deduplication, bucketing and encoding.numpypythonWhere the two backends meet depends on string length:
numpyovertakespythonat about 9 pairs of8 to 25 character strings, but not until about 50 pairs of 2 to 5 character ones. The threshold is
set at 32 pairs, which covers the shorter case; either backend answers a batch that small in well
under a millisecond.
The chunk budget matters more than it looks. At 8 MiB the rows stop fitting in cache and throughput
drops by about a third, so it is set to 256 KiB.
What the tests pin
test/test_batch_distance.pyasserts these properties:levenshtein_distancereturns, over a corpus of empty strings,single characters, repeated strings, a 200 character string and a near copy of it, non-ASCII text
with a combining mark, and characters outside the Basic Multilingual Plane.
by code point and the per-character lattice the engine already builds.
chunk.
pythonbackend measured in the same run, so it reports a regression without depending on machine speed.
all_pairs([], []),all_pairs(['a'], [])andall_flat([], [])keep their zero-length axes.GRAPHTAGE_BATCH_BACKEND, gives the same answer, and a batch thatwould go to
numpygoes topythonwhen the environment says so.strcannot be compared to abytes, and a call holding both kinds gives each the answer itwould get on its own.
Each test names the specific failure it prevents, and each was confirmed to fail against a
deliberately broken module before being kept. Fifteen mutations were tried and all fifteen are
caught, including the silent ones: a substitution cost of 2, an off-by-one in the readout column,
reading at the chunk's padded width, UTF-16 instead of UTF-32 encoding, no bucketing, no
shorter-string-first orientation, no
stragainstbytescheck, a misplaced scatter out of akind's sub-batch, an empty string short-circuiting to 0, either deduplication index dropped, the
environment variable ignored, and a zero-length axis squeezed out of the result.
Checks
pytest -q: 211 passed, 34 subtests passed.ruff check graphtage test docs bindist: clean.make -C docs html SPHINXOPTS="-W --keep-going": clean.batch_distanceis in thefrom . import ...line ingraphtage/__init__.py, sodocs/build_api.pypicks it up. Thegenerated
.rstfiles are covered bydocs/.gitignore, so there is nothing to commit for them.🤖 Generated with Claude Code