Select optimal Levenshtein paths in EditDistance - #132
Merged
Merged
Conversation
Collaborator
Author
|
Note on CI: an earlier run's Resolved: after rebasing onto 06e0417, all checks pass, including |
`EditDistance._best_match` picked a predecessor cell by comparing the accumulated costs of the three neighbors alone. Canonical Wagner-Fischer compares each neighbor's accumulated cost *plus* the cost of the edit that transitions from it, so the previous comparison was only a proxy and picked the wrong predecessor whenever the transition costs differed. The guard that accepted the diagonal also required its bounds to be strictly less than both border edits. `Range.__lt__` orders lexicographically on `(upper_bound, lower_bound)`, so two equal definitive ranges compare `False` in both directions and the guard could never fire on a tie. Control then fell through to a branch that never compared the insertion against the removal, so the cheaper diagonal was discarded and the worse border could be selected. Together these meant a substitution was never chosen for strings: with `insert_remove_penalty=0` a `Match` between two distinct single-character `StringNode`s costs 1 and always ties with the insertion and the removal. Graphtage's string distance was the indel (LCS) distance, not Levenshtein. Replace both defects with a single scored comparison over the three candidates, keyed on `(path cost, number of edits)`. The secondary key keeps the behavior added in a24764c, preferring one substitution over an insertion paired with a removal of equal cost. Ties on both keys are now broken by a fixed direction order that is documented in the method docstring: diagonal, then border insertion, then border removal. Reconstruction walks the matrix backwards, so preferring the insertion emits the removal earlier in the forward edit sequence, which matches the convention of listing deletions before additions. Drop the `make_distinct` call. It was vestigial: instrumentation found the diagonal edit already definitive at all 45 call sites of the reproducer, and the two border edits are always `ConstantCostEdit`s whose `tighten_bounds()` is a permanent no-op. It is also insufficient for the new comparison, which needs exact costs rather than non-overlapping intervals, so the replacement `_exact_cost` helper tightens each candidate to a definitive bound and raises `ValueError` if it cannot. That guarantees the matrix never accumulates an `Infinity` upper bound into its `numpy` `uint64` cost array. Re-baseline the expectations that encoded the suboptimal behavior: * `test_string_diff_printing`: "abcdef" -> "azced" cost 5 -> 3, and the rendered ANSI output changes from three removals plus two insertions to two substitutions plus one removal and one insertion. * `test_string_diff_remove_insert_reordering`: "abcdefg" -> "abhijfg" rendered three insertions followed by three removals for a cost of 6; it now renders three substitutions for a cost of 3. Renamed to `test_string_diff_substitution_run`, because the sequence it exercises no longer contains any `Insert` or `Remove` edit, and the cost is now asserted. * `test_small_diff`: the "test": "foo" -> "bar" key/value pair edit cost 6 -> 3. * `test_list_diff`: [0, 1, 2, 3, 4, 5] -> [1, 2, 3, 4, 5] is unchanged, since the shared-suffix optimization reduces it to a single removal before the matrix is built. The test now asserts that outcome deliberately: exactly one removal, and a total cost of 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`levenshtein_distance` returned the value of the last cell that its loops happened to visit. When the target string was empty the inner loops never ran, so the function returned `dist[0][0]`, which is 0. `LeafNode.edits` uses this value as the cost of a `Match`, so matching any node against an empty string looked free. Index the bottom right cell directly instead. `EditDistance.bounds()` never became definitive when the shared prefix and suffix consumed both sequences, for example when two identical strings are compared. `is_complete()` stays `False` because the 1x1 edit matrix holds only the `None` origin cell, and `tighten_bounds()` returns `False` immediately, so the bounds were stuck at `[0, 2n]` and violated the tightening protocol. Return `Range(0, 0)` for that case: both sequences are the same length and every edit is a zero-cost match. Neither defect was reachable through `TreeNode.diff`, which short-circuits equal leaves before building a matrix, but both are reachable through the public `levenshtein_distance` and `string_edit_distance` entry points. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`levenshtein_distance` is the canonical dynamic-programming implementation and had no test coverage at all, even though the iterative `EditDistance` matrix is supposed to compute the same metric. Add a property test that runs `string_edit_distance` to a definitive bound and compares it against the reference for 200 random pairs. The generator uses a four-letter alphabet and strings of up to ten characters on purpose. That maximizes the number of matrix cells where a substitution ties with an insertion paired with a removal, which is the case that produced #89. The test fails on the previous `_best_match` implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_ordered_list_reorder_costs` landed in #131 after this branch was cut, and it recorded the cost that the previous `_best_match` produced. Reversing an ordered list of three integers cost 4, as two insertions and two removals with the middle element matched. Two substitutions cost 1 each, so the optimum is 2 and the edit sequence is now three matches: 1 -> 3, 2 -> 2, and 3 -> 1. The test still contrasts with `test_reorder_is_free`, which asserts that the same reordering costs 0 for an `UnorderedListNode`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ESultanik
force-pushed
the
89-optimal-edit-distance
branch
from
September 7, 2026 21:23
114acda to
e10eed7
Compare
This was referenced Sep 9, 2026
pull Bot
pushed a commit
to bbhunter/graphtage
that referenced
this pull request
Sep 9, 2026
Pull request trailofbits#132 changed the edit tie-break order to emit removals before insertions, and DictNode.from_dict sorts keys when it builds a tree, so every JSON example printed something other than what it claimed. Each block is now the verbatim output of the command above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa
pull Bot
pushed a commit
to bbhunter/graphtage
that referenced
this pull request
Sep 9, 2026
PR trailofbits#132 put removals before insertions in the edit tie-break order, so every output block on this page was stale. Rerun the whole session and paste the real output. The last element of the pydiff example is now a `Replace` rather than an insert and remove pair. Delete the stray `from_node.diff(to_node)` line, which used two names the session never defines, and correct `instanceof` to `isinstance`. Read the default printer through `printer.get_default_printer()` rather than the `printer.DEFAULT_PRINTER` module attribute, and use the `p` the examples already bind. Add sections on `BuildOptions`, `pydiff.diff()`, `pydiff.build_tree()`, and diffing Python source through `pydiff.ast_to_tree`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa
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.
Summary
EditDistance._best_matchchose the wrong predecessor cell in the Levenshtein matrix, sographtagereported edit costs above the optimum. Two separate defects caused it.Defect 1: the comparison omitted transition costs. The method compared only the accumulated costs already stored in the three neighboring cells. Canonical Wagner-Fischer compares
costs[r-1][c-1] + substitution_cost,costs[r][c-1] + removal_cost, andcosts[r-1][c] + insertion_cost. "Cheapest predecessor" is not the same as "cheapest path" whenever the transition costs differ, which is almost always.Defect 2: the bounds guard could not fire on a tie. Accepting the diagonal required its bounds to be strictly less than both border edits.
Range.__lt__orders lexicographically on(upper_bound, lower_bound), so two equal definitive ranges compareFalsein both directions. On a tie, control fell through to a branch that never compared the insertion against the removal, so the cheaper diagonal was discarded and the worse of the two borders could win.Together these meant a substitution was never selected for strings.
string_edit_distanceusesinsert_remove_penalty=0, so aMatchbetween two distinct single-characterStringNodes costs 1 and always ties with the insertion and the removal. Graphtage's string distance was the indel (LCS) distance, not the Levenshtein distance it documents.Before and after
The reproducer from #89:
["de", 1]->[2]"abcdef"->"azced""abcdefg"->"abhijfg""kitten"->"sitting"Across 200 random short-string pairs, 150 previously returned a cost above the optimum. A new property test now checks
string_edit_distanceagainst thelevenshtein_distancereference already in the module.The rendered output changes accordingly. For
"abcdefg"->"abhijfg":Canonical tie-break rule
The three candidates are scored on
(path cost, number of edits along the path). The secondary key keeps the behavior added in a24764c and prefers one substitution over an insertion paired with a removal of equal cost.Ties on both keys are broken by direction, in this fixed order:
Reconstruction walks the matrix backwards, so preferring the insertion during traversal places the removal earlier in the forward edit sequence, which matches the convention of listing deletions before additions. Over 600 random pairs, exactly one alignment differs between this order and the diagonal-removal-insertion order, and it differs only in that ordering.
This rule is documented in the
_best_matchdocstring. It is part of the output contract: changing it changes the edit sequence for inputs with several optimal alignments.Re-baselined expectations
test_string_diff_printing"abcdef"->"azced"costtest_string_diff_printingtest_string_diff_substitution_run"abcdefg"->"abhijfg"test_small_diff"test": "foo"->"bar"key/value pair costtest_list_diff[0, 1, 2, 3, 4, 5]->[1, 2, 3, 4, 5]0test_ordered_list_reorder_costs[1, 2, 3]->[3, 2, 1]costtest_string_diff_remove_insert_reorderingis renamed totest_string_diff_substitution_run. Its edit sequence no longer contains anyInsertorRemoveedit, so the old name no longer described what it exercises. Its cost is now asserted as well.test_ordered_list_reorder_costsarrived with #131 after this branch was cut and recorded the old cost. Reversing a three-element ordered list previously cost 4, as two insertions and two removals with the middle element matched. Two substitutions at 1 each make the optimum 2, so the sequence is now three matches: 1 -> 3, 2 -> 2, 3 -> 1. The test still contrasts withtest_reorder_is_free, which asserts 0 for anUnorderedListNode.test_list_diffis unchanged because the shared-suffix optimization reduces that pair to a single removal before any matrix is built. The test now asserts that outcome deliberately: exactly one removal, and a total cost of 1.Other changes
make_distinctis dropped from_best_match. Instrumentation found the diagonal edit already definitive at all 45 call sites of the reproducer, and both border edits are alwaysConstantCostEdits whosetighten_bounds()is a permanent no-op. It is also insufficient for the new comparison, which needs exact costs rather than non-overlapping intervals. A_exact_costhelper replaces it: it tightens each candidate to a definitive bound and raisesValueErrorif it cannot, which guarantees the matrix never accumulates anInfinityupper bound into itsnumpyuint64cost array.make_distinctitself stays ingraphtage.bounds, wheregraphtage.matchingstill uses it.Two adjacent defects surfaced by the new property test:
levenshtein_distancereturned the last cell its loops happened to visit. With an empty target string the inner loops never ran and it returneddist[0][0], which is 0.LeafNode.editsuses this value as aMatchcost, so matching any node against an empty string looked free.EditDistance.bounds()never became definitive when the shared prefix and suffix consumed both sequences, for example when comparing two identical strings.is_complete()staysFalsebecause the 1x1 matrix holds only theNoneorigin cell, andtighten_bounds()returnsFalseimmediately, so the bounds stayed at[0, 2n]and violated the tightening protocol.Neither was reachable through
TreeNode.diff, which short-circuits equal leaves, but both are reachable through the publiclevenshtein_distanceandstring_edit_distanceentry points.Verification
UnorderedListNoderendering againstListNoderendering and do not diff anything, so they are unaffected and pass. Keep forced color when stdout is redirected #130's change tocolorama.init()does not reachPrinter(ansi_color=True, out_stream=StringIO()), and the re-baselined ANSI byte sequences are byte-identical on the rebased tree.flake8 graphtage test --select=E9,F63,F7,F82is clean.ruff checkreports no new findings on the changed files.sphinx-buildsucceeds, with the same 5 pre-existing warnings as onmaster.WARNINGproduce zero records, the same asmaster.Closes #89
🤖 Generated with Claude Code