From ecf634830cbbfbd990028a6600860f39ed539eaa Mon Sep 17 00:00:00 2001 From: Evan Sultanik Date: Tue, 15 Sep 2026 16:10:59 -0400 Subject: [PATCH 1/3] Add exact_string_distance beside levenshtein_distance 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) --- graphtage/levenshtein.py | 41 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/graphtage/levenshtein.py b/graphtage/levenshtein.py index d920e9f..98be1ac 100644 --- a/graphtage/levenshtein.py +++ b/graphtage/levenshtein.py @@ -30,7 +30,7 @@ log = logging.getLogger(__name__) -def levenshtein_distance(s: str, t: str) -> int: +def levenshtein_distance(s: str | bytes, t: str | bytes) -> int: """Canonical implementation of the Levenshtein distance metric. Args: @@ -64,6 +64,45 @@ def levenshtein_distance(s: str, t: str) -> int: return dist[rows - 1][cols - 1] +def exact_string_distance(s: str | bytes, t: str | bytes) -> int: + """Computes the Levenshtein distance between two strings without building an edit script. + + :class:`EditDistance` computes the same number, but it does so by materializing one + :class:`graphtage.TreeNode` per character and one live :class:`graphtage.Edit` per cell of the Levenshtein + matrix. Callers that only need the cost, such as :class:`graphtage.StringEdit` and + :meth:`graphtage.LeafNode.edits`, go through this function instead and leave the lattice unbuilt. + + Both arguments may be :class:`str` or :class:`bytes`, in either combination. Indexing :class:`bytes` yields + :class:`int` byte values, which never compare equal to a :class:`str` character, so a mixed pair costs one + per aligned position exactly as the lattice charges for it. + + Args: + s: the string from which to match. + t: the string to which to match. + + Returns: + int: The Levenshtein edit distance metric between the two strings. + + """ + if s == t: + return 0 + elif not s: + return len(t) + elif not t: + return len(s) + # Stripping a shared prefix and suffix quadratically reduces the size of the matrix that + # levenshtein_distance builds. It cannot change the answer: characters that align with themselves are + # always free, and no optimal alignment crosses them. + overlap = min(len(s), len(t)) + prefix = 0 + while prefix < overlap and s[prefix] == t[prefix]: + prefix += 1 + suffix = 0 + while suffix < overlap - prefix and s[len(s) - suffix - 1] == t[len(t) - suffix - 1]: + suffix += 1 + return levenshtein_distance(s[prefix:len(s) - suffix], t[prefix:len(t) - suffix]) + + class EditDistance(SequenceEdit): """An edit that computes the minimum sequence of sub-edits necessary to transform one node to another. From a3cc82dbbf950187c8bea68a260d700f91a14998 Mon Sep 17 00:00:00 2001 From: Evan Sultanik Date: Tue, 15 Sep 2026 16:11:06 -0400 Subject: [PATCH 2/3] Build the string edit lattice only when it is rendered 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) --- graphtage/graphtage.py | 54 +++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/graphtage/graphtage.py b/graphtage/graphtage.py index 651aee5..545db0a 100644 --- a/graphtage/graphtage.py +++ b/graphtage/graphtage.py @@ -8,8 +8,16 @@ from typing import Any, Generic, TypeVar from .bounds import Range -from .edits import AbstractCompoundEdit, AbstractEdit, EditCollection, Insert, Match, Remove, Replace -from .levenshtein import EditDistance, levenshtein_distance +from .edits import ( + AbstractCompoundEdit, + ConstantCostEdit, + EditCollection, + Insert, + Match, + Remove, + Replace, +) +from .levenshtein import EditDistance, exact_string_distance from .multiset import MultiSetEdit from .printer import NULL_PRINTER, Back, Fore, NullANSIContext, Printer from .sequences import FixedLengthSequenceEdit, SequenceEdit, SequenceNode @@ -73,7 +81,7 @@ def calculate_total_size(self) -> int: def edits(self, node: TreeNode) -> Edit: if isinstance(node, LeafNode): - return Match(self, node, levenshtein_distance(str(self.object), str(node.object))) + return Match(self, node, exact_string_distance(str(self.object), str(node.object))) elif isinstance(node, ContainerNode): return Replace(self, node) @@ -672,30 +680,48 @@ def __iter__(self) -> Iterator[KeyValuePairNode]: return iter(self._children.values()) -class StringEdit(AbstractEdit): - """An edit returned from a :class:`StringNode`""" +class StringEdit(ConstantCostEdit): + """An edit returned from a :class:`StringNode`. - __slots__ = ('edit_distance',) + The cost of the edit is the Levenshtein distance between the two strings, which + :func:`graphtage.levenshtein.exact_string_distance` computes arithmetically at construction time. The + character-level lattice that renders the edit is built only when :attr:`StringEdit.edit_distance` is read, + which the formatters do for the edits they actually print. A diff costs many more pairs of strings than it + renders. + + """ + + __slots__ = ('_edit_distance',) def __init__( self, from_node: 'StringNode', to_node: 'StringNode' ): - self.edit_distance = string_edit_distance(from_node.object, to_node.object) + self._edit_distance: EditDistance | None = None super().__init__( from_node=from_node, - to_node=to_node + to_node=to_node, + cost=exact_string_distance(from_node.object, to_node.object) ) - def __repr__(self) -> str: - return f"{self.__class__.__name__}(from_node={self.from_node!r}, to_node={self.to_node!r})" + @property + def edit_distance(self) -> EditDistance: + """The character-level edit lattice for this edit, constructed on first access. - def bounds(self) -> Range: - return self.edit_distance.bounds() + The lattice is the only source of the edit script, so the sequence of character matches, insertions, and + removals that a formatter renders is unaffected by the cost having been computed separately. - def tighten_bounds(self) -> bool: - return self.edit_distance.tighten_bounds() + Returns: + EditDistance: The lattice over the characters of the two strings. + + """ + if self._edit_distance is None: + self._edit_distance = string_edit_distance(self.from_node.object, self.to_node.object) + return self._edit_distance + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(from_node={self.from_node!r}, to_node={self.to_node!r})" def print(self, formatter: GraphtageFormatter, printer: Printer): """`StringEdit` does not implement :meth:`graphtage.tree.Edit.print`. From fc9da212f4b9bad70d6af5f2ff9626521e779770 Mon Sep 17 00:00:00 2001 From: Evan Sultanik Date: Tue, 15 Sep 2026 16:22:00 -0400 Subject: [PATCH 3/3] Pin the cost, the laziness, and the optimality of string edits 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) Claude-Session: https://claude.ai/code/session_01F2sHz5c5TvMs9tFn2HhwaC --- test/test_graphtage.py | 130 +++++++++++++++++++++++++++++++++++++++ test/test_levenshtein.py | 73 +++++++++++++++++++++- test/test_multiset.py | 87 ++++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 1 deletion(-) diff --git a/test/test_graphtage.py b/test/test_graphtage.py index 19654b4..77163af 100644 --- a/test/test_graphtage.py +++ b/test/test_graphtage.py @@ -260,3 +260,133 @@ def test_rendering_closes_the_quote(self): def test_rendering_escapes_unprintable_bytes(self): self.assertEqual('b"he~~\\x00~~++\\xff++o"', self.render(b"he\x00o", b"he\xffo")) + + +class StringEditDistanceCounter: + """Counts how many character-level lattices :class:`graphtage.StringEdit` builds inside a block.""" + + def __init__(self): + self.count = 0 + self._original = graphtage.graphtage.string_edit_distance + + def __enter__(self) -> 'StringEditDistanceCounter': + def counted(s1, s2): + self.count += 1 + return self._original(s1, s2) + + graphtage.graphtage.string_edit_distance = counted + return self + + def __exit__(self, *args): + graphtage.graphtage.string_edit_distance = self._original + + +class TestStringEdit(TestCase): + """Covers the two halves of :class:`graphtage.StringEdit`: its cost and its edit script. + + The cost is computed arithmetically at construction; the lattice that produces the script is built only when + something reads :attr:`graphtage.StringEdit.edit_distance`. These tests pin that split, because a + :class:`graphtage.StringEdit` that eagerly builds its lattice is correct but quadratically slower, and a + lattice whose cost disagrees with the reported cost renders a diff that does not add up to its own price. + + """ + + @staticmethod + def edit(from_str: str | bytes, to_str: str | bytes) -> graphtage.StringEdit: + return graphtage.StringEdit(graphtage.StringNode(from_str), graphtage.StringNode(to_str)) + + def test_bounds_are_definitive_on_construction(self): + """A caller that never tightens must still see the exact cost. + + :class:`graphtage.matching.WeightedBipartiteMatcher` prices its edges with ``bounds().upper_bound`` + after only partial tightening, so an edit whose initial upper bound is an over-estimate feeds the + assignment problem a weight that is too large. + + """ + edit = self.edit("kitten", "sitting") + self.assertTrue(edit.bounds().definitive()) + self.assertEqual(graphtage.Range(3, 3), edit.bounds()) + self.assertEqual(graphtage.Range(3, 3), edit.initial_bounds) + + def test_tighten_bounds_returns_false(self): + """There is nothing left to tighten, so the loops that drive edits to completion terminate at once.""" + edit = self.edit("kitten", "sitting") + self.assertFalse(edit.tighten_bounds()) + self.assertEqual(graphtage.Range(3, 3), edit.bounds()) + + def test_the_lattice_is_not_built_until_it_is_read(self): + """Constructing the edit must not build the lattice; reading the property must, exactly once.""" + edit = self.edit("/usr/local/bin", "/usr/lib/bin") + self.assertIsNone(edit._edit_distance) + lattice = edit.edit_distance + self.assertIsNotNone(edit._edit_distance) + self.assertIs(lattice, edit.edit_distance) + + def test_the_lattice_agrees_with_the_reported_cost(self): + """The script a formatter renders has to add up to the cost the edit reported to the matcher.""" + for from_str, to_str in ( + ("/usr/local/bin", "/usr/lib/bin"), + ("kitten", "sitting"), + ("aabc", "bcb"), + ("hello", "hellp"), + ("", "abc"), + ("abc", ""), + ("abcd", "abcd"), + (b"he\x00o", b"he\xffo"), + ): + with self.subTest(from_str=from_str, to_str=to_str): + edit = self.edit(from_str, to_str) + lattice = edit.edit_distance + while lattice.tighten_bounds(): + pass + self.assertEqual(edit.bounds().upper_bound, lattice.bounds().upper_bound) + + def test_printing_a_string_node_builds_no_lattice(self): + """``print_StringNode`` constructs two ``StringEdit``s per node only to ask whether it is quoted.""" + out_stream = StringIO() + with StringEditDistanceCounter() as counter: + graphtage.StringNode("/usr/local/bin").print(Printer(ansi_color=False, out_stream=out_stream)) + self.assertEqual(0, counter.count) + self.assertEqual('"/usr/local/bin"', out_stream.getvalue()) + + def test_a_diff_builds_a_lattice_only_for_what_it_renders(self): + """A dense N-by-N match costs N*N pairs of key/value nodes but renders at most N of them. + + No key survives, so the bipartite matcher prices every one of the 64 candidate pairs, each of which + holds a key string and a value string. Eagerly building a lattice per candidate is what made a diff of + a few dozen strings take seconds. The bound here is deliberately loose; it only has to stay far below + the 128 lattices that the candidates would otherwise account for. + + """ + size = 8 + from_obj = {f"from-key-{i}": f"/usr/local/share/value-{i}" for i in range(size)} + to_obj = {f"to-key-{i}": f"/opt/local/share/value-{i * 3}" for i in range(size)} + out_stream = StringIO() + with StringEditDistanceCounter() as counter: + diffed = graphtage.json.build_tree(from_obj).diff(graphtage.json.build_tree(to_obj)) + graphtage.json.JSONFormatter.DEFAULT_INSTANCE.print( + Printer(ansi_color=False, quiet=True, out_stream=out_stream), diffed + ) + self.assertLessEqual(counter.count, 4 * size) + + def test_a_sixty_key_dict_diff_is_fast(self): + """Guards the whole point of computing string edit costs without the lattice. + + Every key and every value differs, so the bipartite matcher costs 3600 pairs of key/value nodes, and + therefore 7200 pairs of strings. Building a lattice for each of those takes the better part of a + minute; deriving the cost arithmetically takes under a second. The limit is generous so that a slow + runner cannot flake it. + + """ + rng = random.Random(60) + + def words(count: int, length: int) -> list[str]: + return [''.join(rng.choices('abcdefghijklmnopqrstuvwxyz', k=length)) for _ in range(count)] + + from_obj = dict(zip(words(60, 12), words(60, 40), strict=True)) + to_obj = dict(zip(words(60, 12), words(60, 40), strict=True)) + with run_with_time_limit(seconds=30): + edit = graphtage.json.build_tree(from_obj).edits(graphtage.json.build_tree(to_obj)) + while edit.tighten_bounds(): + pass + self.assertTrue(edit.bounds().definitive()) diff --git a/test/test_levenshtein.py b/test/test_levenshtein.py index 06f2b1c..411b274 100644 --- a/test/test_levenshtein.py +++ b/test/test_levenshtein.py @@ -5,10 +5,11 @@ from graphtage import EditDistance, string_edit_distance from graphtage.edits import Edit, Insert, Match, Remove -from graphtage.levenshtein import levenshtein_distance +from graphtage.levenshtein import exact_string_distance, levenshtein_distance LETTERS: str = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' SMALL_ALPHABET: str = 'abcd' +NON_ASCII: str = 'αβγδεζηθ🙂é́' def render_script(distance: EditDistance) -> list[str]: @@ -281,3 +282,73 @@ def test_edit_script_realizes_the_reported_cost(self): bounds = distance.bounds() self.assertTrue(bounds.definitive(), f"{pair} has bounds {bounds!s}") self.assertEqual(cost, bounds.upper_bound, pair) + + +class TestExactStringDistance(TestCase): + """Covers :func:`graphtage.levenshtein.exact_string_distance`. + + The function short-circuits equal and empty operands and strips a shared prefix and suffix before calling + :func:`graphtage.levenshtein.levenshtein_distance`. Every one of those steps is an opportunity to return a + number that is not the Levenshtein distance, and the number it returns is the cost of a + :class:`graphtage.StringEdit`, so a wrong answer silently changes which nodes a diff matches. + + """ + + def assert_agrees(self, s, t): + expected = levenshtein_distance(s, t) + pair = f"{s!r} -> {t!r}" + self.assertEqual(expected, exact_string_distance(s, t), pair) + self.assertEqual(expected, exact_string_distance(t, s), f"{t!r} -> {s!r}") + + def test_agrees_on_random_strings(self): + """A small alphabet maximizes the number of pairs with a shared prefix or suffix to strip.""" + for _ in trange(500): + s = ''.join(random.choices(SMALL_ALPHABET, k=random.randint(0, 12))) + t = ''.join(random.choices(SMALL_ALPHABET, k=random.randint(0, 12))) + self.assert_agrees(s, t) + + def test_agrees_on_random_bytes(self): + """:class:`StringNode` wraps ``bytes`` as well as ``str``, and indexing ``bytes`` yields ``int``.""" + for _ in trange(500): + s = bytes(random.choices(range(0, 8), k=random.randint(0, 12))) + t = bytes(random.choices(range(0, 8), k=random.randint(0, 12))) + self.assert_agrees(s, t) + + def test_agrees_on_random_non_ascii(self): + """Astral characters and combining marks are single positions to both implementations.""" + for _ in trange(200): + s = ''.join(random.choices(NON_ASCII, k=random.randint(0, 10))) + t = ''.join(random.choices(NON_ASCII, k=random.randint(0, 10))) + self.assert_agrees(s, t) + + def test_empty_operands(self): + self.assertEqual(0, exact_string_distance('', '')) + self.assertEqual(0, exact_string_distance(b'', b'')) + self.assertEqual(3, exact_string_distance('', 'abc')) + self.assertEqual(3, exact_string_distance('abc', '')) + self.assertEqual(3, exact_string_distance(b'', b'abc')) + self.assertEqual(3, exact_string_distance(b'abc', b'')) + + def test_shared_prefix_and_suffix_do_not_overlap(self): + """An operand that is wholly a prefix of the other must not have its characters counted twice.""" + self.assertEqual(1, exact_string_distance('aa', 'aaa')) + self.assertEqual(2, exact_string_distance('aaa', 'aaaaa')) + self.assertEqual(4, exact_string_distance('a', 'aaaaa')) + self.assertEqual(1, exact_string_distance('ab', 'aab')) + self.assertEqual(0, exact_string_distance('aaaa', 'aaaa')) + + def test_str_never_equals_bytes(self): + """A mixed pair costs one per aligned position, which is what the character lattice charges.""" + self.assertEqual(5, exact_string_distance('hello', b'hello')) + self.assertEqual(5, exact_string_distance(b'hello', 'hello')) + self.assertEqual(1, exact_string_distance('a', b'a')) + + def test_agrees_with_the_lattice(self): + """The lattice is what renders the edit, so the reported cost has to be the cost of its script.""" + for _ in trange(100): + s = ''.join(random.choices(SMALL_ALPHABET, k=random.randint(0, 10))) + t = ''.join(random.choices(SMALL_ALPHABET, k=random.randint(0, 10))) + lattice = string_edit_distance(s, t) + while lattice.tighten_bounds(): + pass + self.assertEqual(lattice.bounds().upper_bound, exact_string_distance(s, t), f"{s!r} -> {t!r}") diff --git a/test/test_multiset.py b/test/test_multiset.py index d8a8500..b5f58f8 100644 --- a/test/test_multiset.py +++ b/test/test_multiset.py @@ -1,7 +1,13 @@ +import random +import string from unittest import TestCase +import numpy as np +from scipy.optimize import linear_sum_assignment + import graphtage from graphtage.builder import BasicBuilder +from graphtage.levenshtein import levenshtein_distance from graphtage.multiset import MultiSetEdit from graphtage.pydiff import diff @@ -56,3 +62,84 @@ def test_set_versus_set(self): def test_dict_with_set_value(self): self.assertMultiSetEdit({"a": {1, 2}}, {"a": {1, 3}}, 1) + + +class TestMatchingOptimality(TestCase): + """Checks that the matching a diff of two string sets produces is optimal under exact edit costs. + + :class:`graphtage.matching.WeightedBipartiteMatcher` prices its edges with ``bounds().upper_bound`` after + only partial tightening, so it can hand :func:`scipy.optimize.linear_sum_assignment` a weight matrix that + over-states some pairs and solve a different problem than the one it meant to. Nothing else in the suite + checks the weights the matcher actually used, only that the cost it reports is self-consistent. + + The assignment problem is solved here a second time, from a cost matrix built directly with + :func:`graphtage.levenshtein.levenshtein_distance`, and the two totals must agree. That is an absolute + statement about the matching rather than a comparison against any particular earlier implementation. + + """ + + def assert_matching_is_optimal(self, from_strings: set[str], to_strings: set[str]): + """Asserts that the matcher's chosen pairs cost what an independent optimal assignment costs. + + Args: + from_strings: the set to match from. + to_strings: the set to match to. + + """ + edit = BasicBuilder().build_tree(from_strings).edits(BasicBuilder().build_tree(to_strings)) + self.assertIsInstance(edit, MultiSetEdit) + while edit.tighten_bounds(): + pass + matcher = edit._matcher + from_nodes, to_nodes = list(matcher.from_nodes), list(matcher.to_nodes) + self.assertTrue(from_nodes) + self.assertTrue(to_nodes) + costs = np.array( + [[levenshtein_distance(f.object, t.object) for t in to_nodes] for f in from_nodes], + dtype=np.int64 + ) + rows, columns = linear_sum_assignment(costs) + matching = matcher.matching + self.assertEqual(min(len(from_nodes), len(to_nodes)), len(matching)) + self.assertEqual( + int(costs[rows, columns].sum()), + sum(levenshtein_distance(f.object, t.object) for f, (t, _) in matching.items()) + ) + + @staticmethod + def words(rng: random.Random, count: int, length: int) -> set[str]: + return {''.join(rng.choices(string.ascii_lowercase, k=length)) for _ in range(count)} + + def test_unrelated_strings(self): + rng = random.Random(1) + for _ in range(5): + self.assert_matching_is_optimal(self.words(rng, 12, 10), self.words(rng, 12, 10)) + + def test_near_misses(self): + """Pairs that differ in two characters make the difference between assignments small and easy to miss.""" + rng = random.Random(2) + originals = self.words(rng, 14, 12) + mutated = {w[:5] + ''.join(rng.choices(string.ascii_lowercase, k=2)) + w[7:] for w in originals} + self.assert_matching_is_optimal(originals, mutated) + + def test_shared_affixes(self): + """A shared prefix and suffix is what the exact cost helper strips, so it has to stay exact.""" + rng = random.Random(3) + self.assert_matching_is_optimal( + {f"/usr/local/lib/{w}/bin" for w in self.words(rng, 10, 6)}, + {f"/usr/local/lib/{w}/bin" for w in self.words(rng, 10, 6)} + ) + + def test_rectangular_matching(self): + """More elements on one side than the other leaves some unmatched, which scipy also handles.""" + rng = random.Random(4) + self.assert_matching_is_optimal(self.words(rng, 8, 9), self.words(rng, 15, 9)) + self.assert_matching_is_optimal(self.words(rng, 15, 9), self.words(rng, 8, 9)) + + def test_small_alphabet(self): + """A small alphabet maximizes the number of assignments that tie, which is where a bias shows up.""" + rng = random.Random(5) + self.assert_matching_is_optimal( + {''.join(rng.choices('abcd', k=8)) for _ in range(12)}, + {''.join(rng.choices('abcd', k=8)) for _ in range(12)} + )