Skip to content

Build the string edit lattice only when it is rendered - #197

Merged
ESultanik merged 3 commits into
masterfrom
lazy-string-edit-lattice
Sep 15, 2026
Merged

ESultanik merged 3 commits into
masterfrom
lazy-string-edit-lattice

Conversation

@ESultanik

Copy link
Copy Markdown
Collaborator

StringNode.edits returned a StringEdit whose __init__ eagerly called string_edit_distance. That built a
ListNode of one TreeNode per character and ran the full lazy EditDistance lattice, an (n+1)×(m+1) matrix of
live Edit objects, to arrive at a plain integer Levenshtein distance.

WeightedBipartiteMatcher.edges is dense, so an N-by-M match paid for N*M lattices although it renders at most
min(N, M) of them. The laziness never earned its cost either: EditDistance._best_match prices every cell
through _exact_cost, whose while not definitive and tighten_bounds() loop runs to completion, so nesting
forces full evaluation rather than enabling pruning.

What changed

  • StringEdit derives from the existing ConstantCostEdit instead of AbstractEdit. Its cost comes from a new
    exact_string_distance helper, so its bounds are definitive on construction and tighten_bounds returns
    False. StringEdit.bounds and StringEdit.tighten_bounds are gone.
  • StringEdit.edit_distance is now a lazy property. The name is unchanged, and its two readers,
    StringFormatter.print_StringEdit and YAMLStringFormatter.write_start_quote, are unchanged. The lattice is
    built for the edits a diff renders and for nothing else.
  • graphtage.levenshtein.exact_string_distance answers the same question arithmetically: equal operands cost
    nothing, an empty operand costs the length of the other, and a shared prefix and suffix are stripped before
    levenshtein_distance runs on the remainder. It accepts str and bytes in either combination, which is what
    StringNode wraps. It is a single seam, so a batch or cached backend can be substituted behind it later.
  • LeafNode.edits uses the same helper, so non-string leaves share that seam.

The edit script still comes from the same unchanged EditDistance, so the tie-break contract that #196 pinned is
preserved by construction. test_edit_script_tie_break_is_stable, test_shared_prefix_biases_the_alignment,
test_edit_script_realizes_the_reported_cost, and TestRenderedDiffs pass unmodified.

Performance

Wall clock for pydiff.build_tree(a).diff(pydiff.build_tree(b)).edited_cost(), Python 3.14 on an Apple laptop.
Each shape uses random lowercase strings with a fixed seed.

shape master this PR speedup
dict 40×40, all keys differ 3.76 s 0.05 s 71×
list of 14 lists of 6 strings 3.77 s 0.11 s 33×
list of 150 distinct strings 13.76 s 0.30 s 46×
dict 60×60, all keys and values differ 13.96 s 0.12 s 113×
dict 60 keys, 12-char keys, 40-char values 42.91 s 0.57 s 76×

Counted directly on an 8-by-8 dict diff in which no key survives: master builds 128 lattices, this branch builds
16, one per string it renders.

pytest -q goes from 231 to 250 passing.

Differential

The offline corpus is 24 cases: the three shapes above, nested containers, dicts whose values are mutations of
each other, mixed leaf types, bytes values, non-ASCII strings, shared affixes, empty and single-character
strings, a small-alphabet corpus that maximizes ties, four unordered shapes that route through MultiSetEdit,
and one document in each of JSON, JSON5, YAML, XML, HTML, CSV, TOML, INI, and plist. Each case records the total
edit cost and the rendered output.

  • Total edit cost: unchanged in 24 of 24. It never increased, and it never decreased.
  • Rendered output changed in 4 of 24. All four are the unordered shapes: a set of 20 strings, a set of 30
    near-miss strings, a set of 12 tuples, and a dict against a set.

The four are exactly the cases that reach WeightedBipartiteMatcher. It prices its edges with
bounds().upper_bound after only partial tightening through make_distinct, so master hands
scipy.optimize.linear_sum_assignment a weight matrix with some entries over-stated. With exact weights, scipy
selects a different assignment among the ones that tie. Solving the assignment problem independently over a cost
matrix built from levenshtein_distance confirms that master's matchings on these shapes were already optimal in
total, which is why the cost did not move; only which optimum is chosen did.

Ordered containers and all nine file formats render byte-identically.

The corpus is not committed. Keeping the old algorithm alive in the suite to compare against would be worse than
having no comparison at all.

Tests

test/test_levenshtein.py, TestExactStringDistance: cross-checks exact_string_distance against
levenshtein_distance over randomized str, bytes, and non-ASCII corpora, and pins the cases its
short-circuits decide.

test/test_graphtage.py, TestStringEdit: bounds definitive on construction, tighten_bounds returns False,
the lattice stays unbuilt until edit_distance is read, the lattice's final cost equals the reported cost,
printing a StringNode builds no lattice, and a dense 8-by-8 match builds no more lattices than it renders. Plus
test_a_sixty_key_dict_diff_is_fast, which runs a 60-key diff with every key and value different inside
run_with_time_limit(seconds=30).

test/test_multiset.py, TestMatchingOptimality: solves the assignment problem a second time with
scipy.optimize.linear_sum_assignment over a cost matrix built directly from levenshtein_distance, and asserts
that the pairs MultiSetEdit._matcher.matching chose cost the same. A matching optimal under exact costs is by
construction no worse than anything the old path could produce, so this needs no copy of the old algorithm.

Break verification

Each test was verified against a deliberately broken tree.

break tests that failed
exact_string_distance returns max(len(s), len(t)) on the remainder 4 TestExactStringDistance methods, 4 TestMatchingOptimality methods, 3 TestStringEdit methods, and the pre-existing test_string_diff_printing
the shared-suffix scan may run back over the shared prefix test_shared_prefix_and_suffix_do_not_overlap and 3 randomized agreement tests
exact_string_distance coerces its operands with str() test_agrees_on_random_bytes, test_str_never_equals_bytes, test_the_lattice_agrees_with_the_reported_cost, and the pre-existing test_str_versus_bytes
StringEdit builds its lattice in __init__ again test_the_lattice_is_not_built_until_it_is_read, test_printing_a_string_node_builds_no_lattice, test_a_diff_builds_a_lattice_only_for_what_it_renders (128 lattices against a bound of 32)
StringEdit.bounds and tighten_bounds delegate to the lattice, as on master the three above plus test_bounds_are_definitive_on_construction, test_tighten_bounds_returns_false, and test_a_sixty_key_dict_diff_is_fast

The time limit was also checked against the unmodified origin/master sources directly: the 60-key diff trips
the 30-second limit there and finishes in 0.57 s here.

TestMatchingOptimality does not fail on origin/master, because master's matchings on these corpora, though
different, were already cost-optimal. It is verified by the first break in the table rather than by the
before/after comparison, and it is written as an absolute invariant for that reason.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F2sHz5c5TvMs9tFn2HhwaC

ESultanik and others added 3 commits September 15, 2026 16:10
Computing a plain Levenshtein distance through EditDistance materializes one TreeNode per character and one
live Edit per matrix cell. Callers that only need the cost have no way to ask for it.

Add a helper that answers the same question arithmetically: equal strings cost nothing, an empty operand costs
the length of the other, and a shared prefix and suffix are stripped before the canonical dynamic program runs
on what is left. It accepts str and bytes in either combination, matching what StringNode wraps, and is the
single seam where a batch or cached backend can later be substituted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
StringEdit.__init__ called string_edit_distance eagerly, so every candidate pair of strings a diff considered
paid for a full lazy EditDistance lattice. WeightedBipartiteMatcher.edges is dense, so an N-by-M match built
N*M lattices although only min(N, M) of them are ever printed.

Derive the cost from exact_string_distance instead and make StringEdit a ConstantCostEdit, whose bounds are
definitive on construction and whose tighten_bounds returns False. Move the lattice behind a lazy property that
keeps the public edit_distance name, so StringFormatter.print_StringEdit and YAMLStringFormatter build it for
the edits they render and nothing else does.

The edit script still comes from the same unchanged EditDistance, so the tie-break contract that
test/test_levenshtein.py and test/test_json.py pin is preserved by construction. Point LeafNode.edits at the
same helper so non-string leaves share the seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The change that makes StringEdit a ConstantCostEdit has three separable ways to go wrong, and nothing in the
suite covered any of them.

TestExactStringDistance cross-checks exact_string_distance against levenshtein_distance over randomized str,
bytes, and non-ASCII corpora, plus the cases its short-circuits decide: equal operands, empty operands, a
prefix that runs into the suffix, and a mixed str/bytes pair.

TestStringEdit pins that the bounds are definitive on construction, that tighten_bounds returns False, that the
lattice stays unbuilt until edit_distance is read, that its final cost equals the reported cost, and that
neither printing a StringNode nor costing a dense 8-by-8 match builds more lattices than the diff renders. A
60-key dict diff inside run_with_time_limit guards the speedup itself: it takes over 30 seconds when StringEdit
prices itself through the lattice and under a second when it does not.

TestMatchingOptimality solves the assignment problem a second time with scipy over a cost matrix built directly
from levenshtein_distance, and asserts that the pairs WeightedBipartiteMatcher chose cost the same. That is an
absolute statement about the matching, so it needs no copy of the old algorithm to compare against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2sHz5c5TvMs9tFn2HhwaC
@ESultanik
ESultanik merged commit 63965c3 into master Sep 15, 2026
12 checks passed
@ESultanik
ESultanik deleted the lazy-string-edit-lattice branch September 15, 2026 21:51
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