Wire the batched Levenshtein kernel into the diff engine - #198
Merged
Merged
Conversation
The module could price a whole cross product of strings in one vectorized batch, but every caller in Graphtage asks for one pair at a time through exact_string_distance, so nothing reached it. Add cost(a, b), which answers a single pair either from a block that a caller priced in advance or by computing it. Represent the cache as a block rather than a dict keyed on pairs of strings: a row index, a column index, and a numpy.int32 matrix. The cross product of two collections of four hundred strings is about 640 KB that way against tens of megabytes of tuples and hashes. preprice_product builds a dense block from two collections and preprice builds a sparse one from a list of pairs, for a caller whose pairs are not a whole rectangle. Cells that no pair fills hold a sentinel, so a pair nobody asked about is reported as absent rather than as a distance of zero. Keep a bounded stack of blocks, searched newest first, and drop the oldest past the limit. Distances are deterministic, so an eviction costs a recomputation and nothing else, and that is what keeps the structure bounded. Install a block by replacing the stack rather than mutating it, so a reader never sees a partial update and a lost race costs only the block it was installing. Misses are deliberately not recorded: doing so would grow the cache with the number of distinct pairs a diff asks about, which is the whole cross product. The miss path keeps the shared prefix and suffix strip that exact_string_distance had, and hands a pair to a vectorized backend when its matrix is large enough for the array setup to pay for itself even with nothing to amortize it over. Measured on an Apple M-series laptop, the scan overtakes the scalar dynamic program at about 52 by 52 characters and is 2.4 times faster by 128 by 128. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2sHz5c5TvMs9tFn2HhwaC
exact_string_distance is the single seam every string cost goes through, and it was 81 to 84 percent of the remaining time of a diff after the string edit lattice was made lazy. Point it at batch_distance.cost, which answers the same number and reads a pre-priced block when one holds the pair. The import is deferred because batch_distance imports levenshtein_distance, the same way bounds.make_distinct defers its own. That alone changes nothing, because the answer has to be in a block before the question is asked, and exact_string_distance is asked one pair at a time. EditDistance faces a whole cross product: each cell of its matrix holds from_seq[column - 1].edits(to_seq[row - 1]), and a leaf's edit knows its cost as soon as it is constructed. Collect the leaves of both sequences in the constructor and price the product before the first cell is built. The collection happens after the shared prefix and suffix strip, so stripped elements are not priced, and the block outlives the constructor because cells are created lazily across many tighten_bounds calls. Add a keyword-only preprice argument and have string_edit_distance pass False. The character level lattice is itself an EditDistance, so an implicit guard would still cost a pass over every cell of every string a diff renders, in exchange for a batch of single-character pairs that the equality short circuit settles anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2sHz5c5TvMs9tFn2HhwaC
WeightedBipartiteMatcher builds a dense edge matrix, so matching two unordered collections costs every pair in their cross product. Collect the strings those edits will compare and price them in one batch before the matcher is constructed, using the candidate pair count the size warning already computes as the threshold. Projecting a node pair follows what the edits themselves compare: two leaves give one pair, two key/value pairs give their keys and their values, because KeyValuePairEdit always matches key to key and value to value, and two containers give nothing, because the edit between them prices its own cross product. A pair whose sides are a str and a bytes is left out, since a batch declines to choose between the two readings of its symbols. Nothing is priced under the wrong key: a block is indexed by string values and holds only the distance between them, so a projection that does not match what an edit later asks for costs a cache miss rather than a wrong answer. The auto_match_keys loop decided which key/value pairs share a key and constructed their edits in the same pass, which put the first cost before the batch. Split it into _match_by_key, which decides the pairing and takes what it consumes out of both multisets, and a second pass that builds the edits, preserving the multiplicity of a pair that appears more than once on either side. Raise MATCHING_SIZE_WARNING_THRESHOLD from 400 to 250,000. Four hundred pairs is now a few milliseconds; 250,000 pairs is about a second on an Apple M-series laptop, which is where a warning about a long wait earns its place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2sHz5c5TvMs9tFn2HhwaC
StringEdit knows its cost the moment it is constructed, so a matching over leaves hands make_distinct a set of edges it has nothing to do with. It finds that out only after building an interval tree over every one of them and popping the largest, which on a 300 by 300 matching costs about 0.9 seconds of interval tree inserts to reach a loop that breaks on its first iteration. Check for that case first and skip the call. The shortcut is exactly equivalent: make_distinct tightens a bound only when it is not finite, and a definitive bound is finite. _edges_are_distinct is still set and the method still returns True the first time, so repeat_until_tightened terminates as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2sHz5c5TvMs9tFn2HhwaC
Every one of these was written against a deliberate break in the code it covers, and each was confirmed to fail before it was confirmed to pass. TestCostOracle covers cost and the blocks it reads: agreement with levenshtein_distance both with and without a block, that a block is actually consulted rather than merely installed, that eviction past the stack limit leaves every answer correct, that a miss is not recorded, that an unfilled cell of a sparse block is reported as absent rather than as zero, that a str and a bytes never answer for one another, that an oversized rectangle falls back to the scalar path, and that one large pair is scanned while a small one is not. TestPrePricedDiffs renders nine document shapes twice, once pre-priced and once with pre-pricing disabled, and requires the rendered diff and the total edit cost to match. The shapes cover ordered and unordered lists, ragged lists, dictionaries with and without shared keys, bytes, non-ASCII text, multi-line strings, and nested containers. A second test renders each shape once per backend. A third watches the module level levenshtein_distance that batch_distance does not use, which catches exact_string_distance being left wired straight to it: that break installs blocks nothing reads and changes no output at all. TestPrePricing covers the preprice argument in both directions, and TestMakeEdgesDistinct covers the shortcut, including a differential against the implementation it replaced over twenty random weight matrices. One thing here cannot be tested by its effect. A block is keyed by string values and holds only the distance between them, so a projection that does not match what an edit later asks for produces a cache miss, never a wrong answer. test_pre_pricing_does_not_change_a_diff asserts that each shape installs a block at all, which catches a projection that collects nothing, but a projection that collects the wrong strings is a lost optimization that no assertion about output can see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2sHz5c5TvMs9tFn2HhwaC
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.
graphtage/batch_distance.pyhas been able to price a whole cross product of strings in one vectorized batchsince #195, and nothing called it. This wires it into the engine.
After #197 made
StringEditaConstantCostEdit,levenshtein_distancewas 71 to 76 percent of the remainingtime of a diff of 300 strings, reached through the single seam of
exact_string_distance. That function pricesone pair, so a batch has to be filled before it is called. Two places know a cross product is coming and can fill
it:
MultiSetEdit.__init__andEditDistance.__init__.Results
Documents are JSON lists and dictionaries of random lowercase words of 6 to 24 characters, each element mutated by
one substitution, deletion, or insertion. The unordered shapes are built with
ignore_list_order, which routesthem through
MultiSetEditand the bipartite matcher. Measured on an Apple M3 Max, Python 3.14.0.The long-string shape is the largest win because its pairs are big enough that the scan beats the scalar dynamic
program on the count of array passes rather than only on interpreter overhead. The 100 element unordered shape is
the smallest: 9,000 pairs of short strings is not much for a batch to amortize over, and what remains is the
matcher rather than the distances.
In the profile of the 300 element ordered shape,
levenshtein_distanceno longer appears at all, where it was 76percent before. What is left is spread across the edit machinery itself: the largest single entry is
EditDistance._best_matchat 11 percent, followed by_exact_cost,Rangearithmetic, andisinstance.Design
A cost oracle in
batch_distance.cost(a, b)answers one pair, from a pre-priced block when one holds itand by computing it otherwise.
preprice_product(S, T)andpreprice(pairs)fill blocks.A block is a row index, a column index, and a
numpy.int32matrix, not adictkeyed on pairs of strings. For a400 by 400 product that is about 640 KB against tens of megabytes of tuples and hashes. Cells that no pair filled hold
a sentinel, so a sparse block reports an unasked pair as absent rather than as a distance of zero.
The stack holds at most 8 blocks and is searched newest first. Evicting the oldest is always safe because the
values are deterministic, so an eviction costs a recomputation. Misses are deliberately not recorded: recording
them would grow the cache with the number of distinct pairs a diff asks about, which is the whole cross product,
and nothing would ever evict it. Installing a block replaces the stack rather than mutating it, so a reader never
sees a partial update and a lost race costs only the block it was installing.
The seam.
exact_string_distancecallsbatch_distance.costthrough a function-local import, becausebatch_distanceimportslevenshtein_distance;bounds.make_distinctalready defers an import the same way. Themiss path keeps the shared prefix and suffix strip and then chooses between the scalar dynamic program and a
one-pair vectorized scan by the size of the matrix.
Populator 1,
MultiSetEdit.__init__. Projects each candidate node pair onto the strings its edit will compare:two leaves give one pair, two key/value pairs give their keys and their values, and two containers give nothing.
The
auto_match_keysloop interleaved deciding which key/value pairs share a key with constructing their edits,which put the first cost before the batch; it is split into
_match_by_keyand a second pass that builds theedits, preserving the multiplicity of a pair that repeats.
Populator 2,
EditDistance.__init__. Collects the leaves of both sequences after the shared prefix and suffixstrip and prices the product. A new keyword-only
prepriceargument defaults toTrue, andstring_edit_distancepassesFalse: the character-level lattice is itself anEditDistance, and any implicitguard would still cost a pass over every cell of every string a diff renders.
WeightedBipartiteMatcher._make_edges_distinct.StringEditknows its cost at construction, somake_distincthas nothing to tighten, but it built an interval tree over every edge before finding that out.On the 300 element unordered shape that was 0.87 seconds of a 4.62 second profile. The shortcut is exactly
equivalent:
make_distincttightens a bound only when it is not finite, and a definitive bound is finite.Cell edits are not replaced with
Matchobjects anywhere.StringNode.editsstill returns aStringEdit, so asubstituted element still renders a character-level diff.
Thresholds
PREPRICE_MIN_PAIRS = 32. Diffing an ordered list of n strings, minimum of 15 runs, in milliseconds:The crossing is between 25 and 36 pairs, which is where
NUMPY_MIN_PAIRS = 32already sits: below it a batch isanswered by the
pythonbackend, so a block buys the cost of vectorizing without the vectorization. The sweepover the threshold itself agrees, in milliseconds:
PREPRICE_MIN_PAIRSVECTORIZED_MIN_CELLS = 4096, which is 64 by 64 characters. Microseconds per pair, 20 random pairs per row:The crossing is near 52 by 52 characters; 64 by 64 sits just above it.
PREPRICE_MAX_CELLS = 4 * 1024 * 1024is a safety valve rather than a tuned number. The pairs a caller offersneed not fill the rectangle their distinct sides span, and a batch whose rectangle would need more than 16 MB is
left to the scalar path.
NUMPY_MAX_CHUNK_BYTESis unchanged at 256 KiB, which is inside the measured optimal band.MATCHING_SIZE_WARNING_THRESHOLDgoes from 400 to 250,000. Four hundred pairs is now a few milliseconds. Diffingunordered collections after this change:
250,000 pairs is about 1.1 seconds, which is where a warning about a long wait earns its place.
Differential
Not committed. 100 document pairs, being 10 shapes rendered into all 10 formats (JSON, JSON5, XML, HTML, YAML,
TOML, INI, CSV, plist, pickle), diffed in three modes each: default,
--only-edits, and--html. The shapes areordered lists, shuffled lists, ragged lists, dictionaries with and without shared keys, nested containers,
non-ASCII text including combining marks and astral characters, multi-line strings, long strings of 180 to 320
characters, and a list mixing strings with integers, floats, booleans, and null. Bytes reach the tree through the
plist and pickle documents.
stdout was captured and hashed, with stderr discarded so that the progress bar's timing noise does not enter the
comparison.
GRAPHTAGE_BATCH_BACKEND=pythonGRAPHTAGE_BATCH_BACKEND=numpy900 comparisons, zero differences. No edit cost changed. The oracle is exact and
exact_string_distancewasalready exact, so the costs are identical rather than merely non-increasing, and the rendered output is
byte-identical rather than merely equal in cost.
Tests
pytest -q: 272 passed, 1770 subtests.ruff check graphtage test docs bindistis clean, and the documentationstill builds under
-W --keep-going.The #196 contract tests pass unchanged:
test_edit_script_tie_break_is_stable,test_shared_prefix_biases_the_alignment,test_edit_script_realizes_the_reported_cost, andTestRenderedDiffs.Every new test was written against a deliberate break in the code it covers, and each was confirmed to fail before
it was confirmed to pass. Thirteen breaks, thirteen caught:
test_cost_matches_levenshtein_distance_from_a_blocktest_pre_pricing_a_flat_list_leaves_unasked_pairs_absenttest_eviction_does_not_change_answerstest_misses_are_not_memoizedtest_pairs_that_mix_str_and_bytes_are_left_to_the_scalar_pathtest_one_big_pair_goes_to_a_vectorized_backendtest_an_oversized_rectangle_is_not_pre_pricedmake_distinctis skipped for indefinite edges tootest_indefinite_edges_still_reach_make_distincttest_repeated_key_value_pairs_match_as_many_times_as_they_repeattest_pre_pricing_does_not_change_a_difftest_a_character_lattice_is_not_pre_pricedtest_a_sequence_of_leaves_is_pre_pricedexact_string_distancebypasses the oracletest_a_pre_priced_diff_reads_its_costs_out_of_the_blockOne thing here cannot be tested by its effect. A block is keyed by string values and holds only the distance
between them, so a projection that does not match what an edit later asks for produces a cache miss, never a wrong
answer.
test_pre_pricing_does_not_change_a_diffasserts that each shape installs a block at all, which catches aprojection that collects nothing, but a projection that collects the wrong strings is a lost optimization that no
assertion about output can see. The same applies to collecting before rather than after the shared prefix and
suffix strip: it prices pairs nobody asks for, and costs time rather than correctness.
Surprises
Pre-pricing pays off from about 36 pairs, an order of magnitude lower than expected. The batch does not have to be
large; it only has to be large enough for the
numpybackend to accept it.The interval tree in
make_distinctwas a bigger share of the unordered path than expected: 0.87 seconds of a4.62 second profile, spent entirely on reaching a loop that breaks on its first iteration.
One caveat worth recording: pre-pricing in a constructor is eager, while the matrix it serves is built lazily. If
a parent stops tightening an
EditDistanceafter a few diagonals, the pre-priced product is partly wasted. Thebatch is roughly 15 times faster per pair, so the break-even is a parent that builds under a fifteenth of the
matrix. The nested shapes in the table are the case where that could bite, because each cell of the outer matrix
is an inner
EditDistancethat pre-prices its own product; they are 1.8 to 3.2 times faster, so it does not bitethere.
PREPRICE_MIN_PAIRSkeeps smaller nested sequences out of the question entirely.🤖 Generated with Claude Code
https://claude.ai/code/session_01F2sHz5c5TvMs9tFn2HhwaC