tree_edit.py python
312 lines 10.1 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago
1 """LCS-based tree edit algorithm for labeled ordered trees.
2
3 Implements a correct tree diff that produces ``InsertOp``, ``DeleteOp``,
4 ``ReplaceOp``, and ``MoveOp`` entries for labeled ordered trees.
5
6 Algorithm
7 ---------
8 The diff proceeds top-down recursively:
9
10 1. Compare root nodes by ``content_id``. Different content_id → ``ReplaceOp``
11 on the root node.
12 2. Diff the children sequences using the same LCS algorithm as
13 :mod:`~muse.core.diff_algorithms.lcs`:
14
15 - Matched child pairs (same ``content_id``) → recurse into subtree.
16 - Unmatched inserts → ``InsertOp`` (entire subtree added).
17 - Unmatched deletes → ``DeleteOp`` (entire subtree removed).
18 - Paired insert+delete of same ``content_id`` at different positions →
19 ``MoveOp``.
20
21 This approach is O(nm) per tree level where n, m are child counts. It does
22 not find the globally minimal edit script (Zhang-Shasha is optimal), but it
23 is correct: every change is accounted for, and applying the script to the base
24 tree produces the target tree. For the bounded tree sizes typical of domain
25 objects (scenes, tracks, ASTs ≲ 10k nodes), this is more than adequate for
26 Zhang-Shasha optimisation is a drop-in replacement once needed.
27
28 ``TreeNode`` is defined here and re-exported by the package ``__init__``.
29
30 Public API
31 ----------
32 - :class:`TreeNode` — labeled ordered tree node (frozen dataclass).
33 - :func:`diff` — ``TreeNode`` × ``TreeNode`` → ``StructuredDelta``.
34 """
35
36 from __future__ import annotations
37
38 import logging
39 from dataclasses import dataclass
40 from typing import Literal
41
42 from muse.core.schema import TreeSchema
43 from muse.domain import (
44
45 DeleteOp,
46 DomainOp,
47 InsertOp,
48 MoveOp,
49 ReplaceOp,
50 StructuredDelta,
51 )
52
53 type _DeleteMap = dict[str, tuple[int, "TreeNode"]]
54
55 logger = logging.getLogger(__name__)
56
57
58 # ---------------------------------------------------------------------------
59 # TreeNode — the unit of tree-edit comparison
60 # ---------------------------------------------------------------------------
61
62
63 @dataclass(frozen=True)
64 class TreeNode:
65 """A node in a labeled ordered tree for domain tree-edit algorithms.
66
67 ``id`` is a stable unique identifier for the node (e.g. UUID or path).
68 ``label`` is the human-readable name (e.g. element tag, node type).
69 ``content_id`` is the SHA-256 of this node's own value — excluding its
70 children. Two nodes are considered the same iff their ``content_id``\\s
71 match; a different ``content_id`` triggers a ``ReplaceOp``.
72 ``children`` is an ordered tuple of child nodes.
73 """
74
75 id: str
76 label: str
77 content_id: str
78 children: tuple[TreeNode, ...]
79
80
81 # ---------------------------------------------------------------------------
82 # Internal helpers
83 # ---------------------------------------------------------------------------
84
85
86 def _subtree_nodes(node: TreeNode) -> list[TreeNode]:
87 """Return all nodes in *node*'s subtree (postorder)."""
88 result: list[TreeNode] = []
89
90 def _visit(n: TreeNode) -> None:
91 for child in n.children:
92 _visit(child)
93 result.append(n)
94
95 _visit(node)
96 return result
97
98
99 def _lcs_children(
100 base_children: tuple[TreeNode, ...],
101 target_children: tuple[TreeNode, ...],
102 ) -> list[tuple[Literal["keep", "insert", "delete"], int, int]]:
103 """LCS shortest-edit script on two sequences of child nodes.
104
105 Comparison is by ``id`` — children with the same id are matched (a "keep"),
106 even if their ``content_id`` differs. A kept pair that has a different
107 ``content_id`` will produce a ``ReplaceOp`` when recursed into by
108 :func:`_diff_nodes`.
109
110 Unmatched children produce insert / delete ops.
111
112 Returns a list of ``(kind, base_idx, target_idx)`` triples.
113 """
114 n, m = len(base_children), len(target_children)
115 base_ids = [c.id for c in base_children]
116 target_ids = [c.id for c in target_children]
117
118 dp: list[list[int]] = [[0] * (m + 1) for _ in range(n + 1)]
119 for i in range(n - 1, -1, -1):
120 for j in range(m - 1, -1, -1):
121 if base_ids[i] == target_ids[j]:
122 dp[i][j] = dp[i + 1][j + 1] + 1
123 else:
124 dp[i][j] = max(dp[i + 1][j], dp[i][j + 1])
125
126 result: list[tuple[Literal["keep", "insert", "delete"], int, int]] = []
127 i, j = 0, 0
128 while i < n or j < m:
129 if i < n and j < m and base_ids[i] == target_ids[j]:
130 result.append(("keep", i, j))
131 i += 1
132 j += 1
133 elif j < m and (i >= n or dp[i][j + 1] >= dp[i + 1][j]):
134 result.append(("insert", i, j))
135 j += 1
136 else:
137 result.append(("delete", i, j))
138 i += 1
139
140 return result
141
142
143 def _diff_nodes(
144 base: TreeNode,
145 target: TreeNode,
146 *,
147 domain: str,
148 address: str,
149 ) -> list[DomainOp]:
150 """Recursively diff two tree nodes, returning a flat op list."""
151 ops: list[DomainOp] = []
152 node_addr = f"{address}/{base.id}" if address else base.id
153
154 # Root node comparison
155 if base.content_id != target.content_id:
156 ops.append(
157 ReplaceOp(
158 op="replace",
159 address=node_addr,
160 position=None,
161 old_content_id=base.content_id,
162 new_content_id=target.content_id,
163 old_summary=f"{base.label} (prev)",
164 new_summary=f"{target.label} (new)",
165 )
166 )
167
168 if not base.children and not target.children:
169 return ops
170
171 # Diff children via LCS
172 script = _lcs_children(base.children, target.children)
173
174 raw_inserts: list[tuple[int, TreeNode]] = [] # (target_idx, node)
175 raw_deletes: list[tuple[int, TreeNode]] = [] # (base_idx, node)
176
177 for kind, bi, ti in script:
178 if kind == "keep":
179 # Recurse into the matched child pair
180 ops.extend(
181 _diff_nodes(
182 base.children[bi],
183 target.children[ti],
184 domain=domain,
185 address=node_addr,
186 )
187 )
188 elif kind == "insert":
189 raw_inserts.append((ti, target.children[ti]))
190 else:
191 raw_deletes.append((bi, base.children[bi]))
192
193 # Move detection: paired insert+delete of the same node id at different positions.
194 # Node identity is tracked by id, not content_id, so a repositioned node
195 # is detected as a move even if its content also changed.
196 delete_by_id: _DeleteMap = {}
197 for bi, node in raw_deletes:
198 if node.id not in delete_by_id:
199 delete_by_id[node.id] = (bi, node)
200
201 consumed_ids: set[str] = set()
202 for ti, node in raw_inserts:
203 nid = node.id
204 if nid in delete_by_id and nid not in consumed_ids:
205 from_idx, _ = delete_by_id[nid]
206 if from_idx != ti:
207 ops.append(
208 MoveOp(
209 op="move",
210 address=node_addr,
211 from_position=from_idx,
212 to_position=ti,
213 content_id=node.content_id,
214 )
215 )
216 consumed_ids.add(nid)
217 continue
218 # True insert — recursively add the entire subtree's nodes
219 for sub_node in _subtree_nodes(node):
220 ops.append(
221 InsertOp(
222 op="insert",
223 address=node_addr,
224 position=ti,
225 content_id=sub_node.content_id,
226 content_summary=f"{sub_node.label} added",
227 )
228 )
229
230 for bi, node in raw_deletes:
231 if node.id in consumed_ids:
232 continue
233 # True delete — recursively remove the entire subtree's nodes
234 for sub_node in _subtree_nodes(node):
235 ops.append(
236 DeleteOp(
237 op="delete",
238 address=node_addr,
239 position=bi,
240 content_id=sub_node.content_id,
241 content_summary=f"{sub_node.label} removed",
242 )
243 )
244
245 return ops
246
247
248 # ---------------------------------------------------------------------------
249 # Top-level diff entry point
250 # ---------------------------------------------------------------------------
251
252
253 def diff(
254 schema: TreeSchema,
255 base: TreeNode,
256 target: TreeNode,
257 *,
258 domain: str,
259 address: str = "",
260 ) -> StructuredDelta:
261 """Diff two labeled ordered trees, returning a ``StructuredDelta``.
262
263 Produces ``ReplaceOp`` for node relabels, ``InsertOp`` / ``DeleteOp``
264 for subtree insertions and deletions, and ``MoveOp`` for repositioned
265 subtrees (detected as paired delete+insert of the same content).
266
267 Args:
268 schema: The ``TreeSchema`` declaring node type and diff algorithm.
269 base: Root of the base (ancestor) tree.
270 target: Root of the target (newer) tree.
271 domain: Domain tag for the returned ``StructuredDelta``.
272 address: Address prefix for generated op entries.
273
274 Returns:
275 A ``StructuredDelta`` with typed ops and a human-readable summary.
276 """
277 # Fast path: identical trees
278 if base.content_id == target.content_id and base.children == target.children:
279 return StructuredDelta(
280 domain=domain,
281 ops=[],
282 summary=f"no {schema['node_type']} changes",
283 )
284
285 ops = _diff_nodes(base, target, domain=domain, address=address)
286
287 n_replace = sum(1 for op in ops if op["op"] == "replace")
288 n_insert = sum(1 for op in ops if op["op"] == "insert")
289 n_delete = sum(1 for op in ops if op["op"] == "delete")
290 n_move = sum(1 for op in ops if op["op"] == "move")
291
292 parts: list[str] = []
293 if n_replace:
294 parts.append(f"{n_replace} relabelled")
295 if n_insert:
296 parts.append(f"{n_insert} added")
297 if n_delete:
298 parts.append(f"{n_delete} removed")
299 if n_move:
300 parts.append(f"{n_move} moved")
301 summary = ", ".join(parts) if parts else f"no {schema['node_type']} changes"
302
303 logger.debug(
304 "tree_edit.diff: +%d -%d ~%d r%d ops on %r",
305 n_insert,
306 n_delete,
307 n_move,
308 n_replace,
309 address,
310 )
311
312 return StructuredDelta(domain=domain, ops=ops, summary=summary)
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago