agent-tracegraph 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ """Body-free ingestion of trace JSON exported by the official Phoenix CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Any
10
+
11
+ from tracegraph.adapters.otlp_spans import OTLPSpanAdapter, _span_array
12
+ from tracegraph.input_validation import loads, object_field
13
+ from tracegraph.model import CausalFidelity, RawTrace
14
+
15
+ _SAFE_ATTRIBUTES = {
16
+ "graph.node.id",
17
+ "graph.node.parent_id",
18
+ "llm.token_count.prompt",
19
+ "llm.token_count.completion",
20
+ "llm.token_count.total",
21
+ "llm.token_count.input",
22
+ "llm.token_count.output",
23
+ "llm.cost.prompt",
24
+ "llm.cost.completion",
25
+ "llm.cost.total",
26
+ "llm.cost.input",
27
+ "llm.cost.output",
28
+ "llm.cost.currency",
29
+ }
30
+
31
+
32
+ def _first(mapping: dict[str, Any], *names: str) -> Any:
33
+ for name in names:
34
+ value = mapping.get(name)
35
+ if value is not None:
36
+ return value
37
+ return None
38
+
39
+
40
+ def _nanos(value: Any) -> str | None:
41
+ if value is None:
42
+ return None
43
+ if isinstance(value, (int, float)):
44
+ return str(int(value))
45
+ text = str(value)
46
+ if text.isdigit():
47
+ return text
48
+ try:
49
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
50
+ except ValueError:
51
+ return None
52
+ return str(int(parsed.timestamp() * 1_000_000_000))
53
+
54
+
55
+ _OPERATION_NAME = re.compile(r"[A-Za-z0-9_.:/#@-]{1,200}")
56
+
57
+
58
+ def _safe_operation_name(value: Any) -> str | None:
59
+ if value is None:
60
+ return None
61
+ # Operation identifiers are useful, but natural-language span names can themselves be
62
+ # prompts. Keep identifier-shaped names only; reports fall back to the normalized kind.
63
+ clean = str(value).strip()
64
+ return clean if _OPERATION_NAME.fullmatch(clean) else None
65
+
66
+
67
+ def _safe_evaluation_text(value: Any) -> str | None:
68
+ if value is None:
69
+ return None
70
+ clean = " ".join(str(value).split())
71
+ return clean[:200] or None
72
+
73
+
74
+ def _attrs(span: dict[str, Any]) -> dict[str, Any]:
75
+ from tracegraph.adapters.otlp_spans import _attributes
76
+ raw = _attributes(span)
77
+ safe = {key: raw[key] for key in _SAFE_ATTRIBUTES if key in raw}
78
+ kind = _first(span, "span_kind", "spanKind") or raw.get("openinference.span.kind")
79
+ if kind is not None:
80
+ safe["openinference.span.kind"] = kind
81
+ return safe
82
+
83
+
84
+ def _evaluations(span: dict[str, Any]) -> list[dict[str, Any]]:
85
+ out: list[dict[str, Any]] = []
86
+ for annotation in span.get("annotations") or []:
87
+ if not isinstance(annotation, dict):
88
+ continue
89
+ result = annotation.get("result") or {}
90
+ if not isinstance(result, dict):
91
+ result = {}
92
+ name = annotation.get("name")
93
+ if not name:
94
+ continue
95
+ label = _first(result, "label") or annotation.get("label")
96
+ score = _first(result, "score")
97
+ if score is None:
98
+ score = annotation.get("score")
99
+ try:
100
+ score = float(score) if score is not None else None
101
+ except (TypeError, ValueError):
102
+ score = None
103
+ if score is not None and not math.isfinite(score):
104
+ score = None
105
+ out.append(
106
+ {
107
+ "name": _safe_evaluation_text(name),
108
+ "label": _safe_evaluation_text(label),
109
+ "score": score,
110
+ }
111
+ )
112
+ return out
113
+
114
+
115
+ def _trace_objects(document: Any) -> list[dict[str, Any]]:
116
+ if isinstance(document, list):
117
+ candidates = document
118
+ elif isinstance(document, dict) and isinstance(document.get("traces"), list):
119
+ candidates = document["traces"]
120
+ elif isinstance(document, dict) and isinstance(document.get("data"), list):
121
+ candidates = document["data"]
122
+ elif isinstance(document, dict) and isinstance(document.get("spans"), list):
123
+ candidates = [document]
124
+ else:
125
+ raise ValueError("Phoenix export must be a trace object or an array of trace objects")
126
+ if not all(isinstance(item, dict) and isinstance(item.get("spans"), list) for item in candidates):
127
+ raise ValueError("every Phoenix trace must be an object with a spans array")
128
+ return candidates
129
+
130
+
131
+ class PhoenixExportAdapter:
132
+ """Convert Phoenix CLI trace DTOs through the shared OTLP causal builder."""
133
+
134
+ def __init__(self, document: Any) -> None:
135
+ self._traces = _trace_objects(document)
136
+ self._ids: list[str] = []
137
+ self._by_id: dict[str, dict[str, Any]] = {}
138
+ for trace in self._traces:
139
+ trace_id = self._trace_id(trace)
140
+ if trace_id in self._by_id:
141
+ raise ValueError(f"duplicate Phoenix traceId {trace_id!r}")
142
+ self._ids.append(trace_id)
143
+ self._by_id[trace_id] = trace
144
+
145
+ @classmethod
146
+ def from_json(cls, text: str) -> "PhoenixExportAdapter":
147
+ return cls(loads(text))
148
+
149
+ @classmethod
150
+ def from_file(cls, path: str | Path) -> "PhoenixExportAdapter":
151
+ return cls.from_json(Path(path).read_text(encoding="utf-8"))
152
+
153
+ def discover(self) -> list[str]:
154
+ return list(self._ids)
155
+
156
+ def ingest(self, trace_id: str) -> RawTrace:
157
+ try:
158
+ trace = self._by_id[trace_id]
159
+ except KeyError as exc:
160
+ raise KeyError(f"no Phoenix trace {trace_id!r}") from exc
161
+ spans = [self._span(trace_id, span) for span in trace["spans"]]
162
+ document = {"resourceSpans": [{"scopeSpans": [{"spans": spans}]}]}
163
+ return OTLPSpanAdapter(
164
+ document,
165
+ source_kind="phoenix_cli",
166
+ links_as_causes=False,
167
+ include_error_messages=False,
168
+ preserve_unset_status=True,
169
+ causal_fidelity=CausalFidelity.PARENT_ONLY,
170
+ links_preserved=False,
171
+ ).ingest(trace_id)
172
+
173
+ @staticmethod
174
+ def _trace_id(trace: dict[str, Any]) -> str:
175
+ declared = _first(trace, "traceId", "trace_id")
176
+ span_ids = set()
177
+ for span in trace["spans"]:
178
+ if not isinstance(span, dict):
179
+ raise ValueError("spans[] must be an object")
180
+ context = object_field(span.get("context"), "spans[].context")
181
+ sid = _first(context, "trace_id", "traceId")
182
+ if sid is not None and not isinstance(sid, str):
183
+ raise ValueError("spans[].context.trace_id must be a string")
184
+ span_ids.add(sid)
185
+ span_ids.discard(None)
186
+ if declared is None and len(span_ids) == 1:
187
+ declared = next(iter(span_ids))
188
+ if not isinstance(declared, str) or not declared:
189
+ raise ValueError("Phoenix trace has no usable traceId")
190
+ if span_ids and span_ids != {declared}:
191
+ raise ValueError(f"Phoenix trace {declared!r} contains spans from another trace")
192
+ return declared
193
+
194
+ @staticmethod
195
+ def _span(trace_id: str, span: Any) -> dict[str, Any]:
196
+ if not isinstance(span, dict):
197
+ raise ValueError(f"Phoenix trace {trace_id!r} contains a non-object span")
198
+ context = object_field(span.get("context"), "spans[].context")
199
+ span_id = _first(context, "span_id", "spanId") or _first(span, "id", "span_id", "spanId")
200
+ if not isinstance(span_id, str) or not span_id:
201
+ raise ValueError(f"Phoenix trace {trace_id!r} contains a span with no id")
202
+ status = _first(span, "status_code", "statusCode")
203
+ return {
204
+ "traceId": trace_id,
205
+ "spanId": span_id,
206
+ "parentSpanId": _first(span, "parent_id", "parentId"),
207
+ "name": _safe_operation_name(span.get("name")),
208
+ "startTimeUnixNano": _nanos(_first(span, "start_time", "startTime")),
209
+ "endTimeUnixNano": _nanos(_first(span, "end_time", "endTime")),
210
+ "status": {
211
+ "code": status,
212
+ # Read transiently for status normalization; Phoenix imports never persist it.
213
+ "message": _first(span, "status_message", "statusMessage"),
214
+ },
215
+ "attributes": _attrs(span),
216
+ "events": _span_array(span, "events"),
217
+ "_tracegraph_evaluations": _evaluations(span),
218
+ }
@@ -0,0 +1,55 @@
1
+ """Analysis layer. Each analysis declares which graph layer it reads.
2
+
3
+ | analysis | layer |
4
+ |--------------------|----------------------------|
5
+ | explain / RCA | raw CAUSED_BY |
6
+ | diff (AHU) | derived TREE_PARENT |
7
+ | inspect render | derived TREE_PARENT |
8
+ """
9
+
10
+ from tracegraph.analysis.ahu import (
11
+ TreeDiff,
12
+ canonical,
13
+ default_label,
14
+ diff,
15
+ is_isomorphic,
16
+ structure_only,
17
+ )
18
+ from tracegraph.analysis.explain import Explanation, explain
19
+ from tracegraph.analysis.diagnose import AnalysisReport, analyze, dumps as dump_analysis
20
+ from tracegraph.analysis.patterns import (
21
+ MAX_GAP,
22
+ PRESETS,
23
+ CompiledQuery,
24
+ Match,
25
+ PathPattern,
26
+ StepPredicate,
27
+ UncompilablePattern,
28
+ compile_to_cypher,
29
+ find_matches,
30
+ search,
31
+ )
32
+
33
+ __all__ = [
34
+ "Explanation",
35
+ "explain",
36
+ "AnalysisReport",
37
+ "analyze",
38
+ "dump_analysis",
39
+ "TreeDiff",
40
+ "canonical",
41
+ "default_label",
42
+ "diff",
43
+ "is_isomorphic",
44
+ "structure_only",
45
+ "MAX_GAP",
46
+ "PRESETS",
47
+ "CompiledQuery",
48
+ "Match",
49
+ "PathPattern",
50
+ "StepPredicate",
51
+ "UncompilablePattern",
52
+ "compile_to_cypher",
53
+ "find_matches",
54
+ "search",
55
+ ]
@@ -0,0 +1,329 @@
1
+ """Structural regression diff over the **derived** ``TREE_PARENT`` forest.
2
+
3
+ Two runs are compared as rooted trees. Isomorphism uses the Aho–Hopcroft–Ullman (AHU)
4
+ canonical form — each node's canonical value is its label plus the *sorted* canonical values
5
+ of its children — which is exact on a forest (the whole reason we project the
6
+ multi-parent causal graph down to a single-parent tree for diffing).
7
+
8
+ The public canonical values remain **nested tuples**, not strings: ``(label, (child_canon, ...))``. Tuples
9
+ preserve structure, so — unlike string concatenation — a label containing
10
+ ``(``, ``)`` or ``,`` can never forge a false match.
11
+
12
+ Diff and equality intern (label, sorted child IDs) in a table shared by both trees,
13
+ avoiding recursive tuple hashing/comparison. Sorting adds O(d log d) per sibling set.
14
+
15
+ Per the layer contract this reads ``TREE_PARENT`` only. ``CAUSED_BY`` (the raw layer) is for
16
+ ``explain``/RCA, never for structural diff.
17
+
18
+ Note on ``diff``: the boolean ``identical`` is exact (AHU). The "diverges at …" explanation is
19
+ a readable *heuristic* localization (it pairs unmatched children only on structural/label
20
+ evidence, never positionally), not a proven minimal tree-edit script; on trees with several
21
+ changed siblings it may point at a plausible-but-not-unique node, but it will not invent a
22
+ correspondence between two genuinely unrelated siblings.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections import Counter
28
+ from dataclasses import dataclass, field
29
+ from functools import cmp_to_key
30
+ from typing import Callable
31
+
32
+ from tracegraph.model import EdgeType, NormalizedTrace, Step
33
+ from tracegraph.normalize import validate_tree
34
+
35
+ #: How a node is identified for structural comparison. Default = node name, falling back to
36
+ #: kind. Pass ``structure_only`` to compare pure topology (shape) ignoring labels.
37
+ LabelFn = Callable[[Step], str]
38
+
39
+ #: A collision-free canonical value: ``(label, (sorted child canons...))``.
40
+ Canon = tuple
41
+
42
+
43
+ def default_label(step: Step) -> str:
44
+ return step.name or step.kind.value
45
+
46
+
47
+ def structure_only(step: Step) -> str: # noqa: ARG001 - intentionally ignores the node
48
+ return ""
49
+
50
+
51
+ def _children(nt: NormalizedTrace) -> dict[str, list[str]]:
52
+ """parent_id -> [child_id, ...] from TREE_PARENT (which points child -> parent)."""
53
+ out: dict[str, list[str]] = {}
54
+ for e in nt.edges_of(EdgeType.TREE_PARENT):
55
+ out.setdefault(e.dst, []).append(e.src)
56
+ return out
57
+
58
+
59
+ def _roots(nt: NormalizedTrace, children: dict[str, list[str]]) -> list[str]:
60
+ has_parent = {e.src for e in nt.edges_of(EdgeType.TREE_PARENT)}
61
+ return [s.step_id for s in nt.steps if s.step_id not in has_parent]
62
+
63
+
64
+ def _require_forest(nt: NormalizedTrace, side: str) -> None:
65
+ """Reject a derived layer that is not a forest, *before* walking it.
66
+
67
+ ``_roots`` treats "has no TREE_PARENT edge" as "is a root", which makes three malformed
68
+ shapes dangerous, and only a pre-check catches all of them:
69
+
70
+ * a **disconnected** cycle leaves its whole component rootless and therefore unvisited,
71
+ so the comparison silently ran on a subset — two traces could be reported identical
72
+ while one held an entire component the other lacked;
73
+ * a cycle **reachable** from a root (a self-edge, say) is walked forever, so a check
74
+ placed after traversal never runs at all;
75
+ * duplicate step ids make any count-based check unreliable.
76
+
77
+ ``validate_tree`` already rejects unknown endpoints, multi-parent steps and cycles; a
78
+ trace that passes it has every node reachable from a root, because following parents is
79
+ then a terminating walk. Unique ids are checked here since ``validate_tree`` does not.
80
+ """
81
+ ids = [step.step_id for step in nt.steps]
82
+ if len(ids) != len(set(ids)):
83
+ # One pass, not `ids.count()` per id: rejecting a large malformed trace should not
84
+ # itself be quadratic.
85
+ duplicates = sorted(sid for sid, n in Counter(ids).items() if n > 1)
86
+ raise ValueError(f"{side}: duplicate step_id in trace: {', '.join(duplicates)}")
87
+ try:
88
+ validate_tree(nt)
89
+ except ValueError as exc:
90
+ raise ValueError(
91
+ f"{side}: the derived TREE_PARENT layer is not a forest ({exc}), so a structural "
92
+ "comparison would silently ignore or endlessly revisit part of it."
93
+ ) from exc
94
+
95
+
96
+ def _compare(left: Canon, right: Canon) -> int:
97
+ # Tuple-compatible lexical order without Python's recursive tuple comparison.
98
+ stack = [(left, right)]
99
+ while stack:
100
+ a, b = stack.pop()
101
+ if a is b:
102
+ continue
103
+ if isinstance(a, str):
104
+ if a != b:
105
+ return -1 if a < b else 1
106
+ continue
107
+ common = min(len(a), len(b))
108
+ if len(a) != len(b):
109
+ stack.append(("" if len(a) < len(b) else "x", "x" if len(a) < len(b) else ""))
110
+ stack.extend((a[i], b[i]) for i in range(common - 1, -1, -1))
111
+ return 0
112
+
113
+
114
+ def _intern(steps, children, roots, label, table):
115
+ result = {}
116
+ stack = [(root, False) for root in roots]
117
+ while stack:
118
+ node, ready = stack.pop()
119
+ if ready:
120
+ signature = (label(steps[node]), tuple(sorted(result[c] for c in children.get(node, []))))
121
+ result[node] = table.setdefault(signature, len(table))
122
+ else:
123
+ stack.append((node, True))
124
+ stack.extend((child, False) for child in children.get(node, []))
125
+ return result
126
+
127
+
128
+ def _subtree_canons(
129
+ steps: dict[str, Step], children: dict[str, list[str]], roots: list[str], label: LabelFn
130
+ ) -> dict[str, Canon]:
131
+ """Canonical AHU tuple per node id (iterative post-order over the whole forest).
132
+
133
+ Iterative on purpose: agent traces are deep and near-linear, so a recursive post-order
134
+ overflows Python's recursion limit at ~500 nodes. The two-phase stack visits each node
135
+ once and computes its canon only after every child's canon already exists.
136
+ """
137
+ canon: dict[str, Canon] = {}
138
+ for root in roots:
139
+ stack: list[tuple[str, bool]] = [(root, False)]
140
+ while stack:
141
+ nid, ready = stack.pop()
142
+ if ready:
143
+ kids = tuple(sorted((canon[c] for c in children.get(nid, [])), key=cmp_to_key(_compare)))
144
+ canon[nid] = (label(steps[nid]), kids)
145
+ else:
146
+ stack.append((nid, True))
147
+ for c in children.get(nid, []):
148
+ stack.append((c, False))
149
+ return canon
150
+
151
+
152
+ def canonical(nt: NormalizedTrace, label: LabelFn = default_label) -> Canon:
153
+ """Sorted nested root tuples; use is_isomorphic for recursion-safe deep equality."""
154
+ _require_forest(nt, "trace")
155
+ children = _children(nt)
156
+ steps = nt.steps_by_id()
157
+ roots = _roots(nt, children)
158
+ canon = _subtree_canons(steps, children, roots, label)
159
+ return tuple(sorted((canon[r] for r in roots), key=cmp_to_key(_compare)))
160
+
161
+
162
+ def is_isomorphic(a: NormalizedTrace, b: NormalizedTrace, label: LabelFn = default_label) -> bool:
163
+ _require_forest(a, "A")
164
+ _require_forest(b, "B")
165
+ table = {}
166
+ ca, cb = _children(a), _children(b)
167
+ ra, rb = _roots(a, ca), _roots(b, cb)
168
+ aa = _intern(a.steps_by_id(), ca, ra, label, table)
169
+ bb = _intern(b.steps_by_id(), cb, rb, label, table)
170
+ return sorted(aa[r] for r in ra) == sorted(bb[r] for r in rb)
171
+
172
+
173
+ @dataclass
174
+ class TreeDiff:
175
+ identical: bool
176
+ changes: list[str] = field(default_factory=list)
177
+
178
+ def __bool__(self) -> bool: # truthy when there IS a difference
179
+ return not self.identical
180
+
181
+
182
+ def diff(a: NormalizedTrace, b: NormalizedTrace, label: LabelFn = default_label, *, display_label: LabelFn | None = None) -> TreeDiff:
183
+ """Compare two runs structurally. ``identical`` is exact; ``changes`` is a heuristic localization.
184
+
185
+ Aligns children by canonical subtree (so identical subtrees match regardless of order),
186
+ descends through *corresponding* mismatched pairs (paired only on real structural/label
187
+ evidence, never positionally) to pinpoint the deepest divergence, and reports genuinely
188
+ unmatched subtrees as present-only-in-A / present-only-in-B. Iterative throughout, so it
189
+ handles the deep-linear traces LangGraph produces without overflowing the stack.
190
+ """
191
+ _require_forest(a, "A")
192
+ _require_forest(b, "B")
193
+ sa, sb = a.steps_by_id(), b.steps_by_id()
194
+ ca, cb = _children(a), _children(b)
195
+ ra, rb = _roots(a, ca), _roots(b, cb)
196
+ table = {}
197
+ canA = _intern(sa, ca, ra, label, table)
198
+ canB = _intern(sb, cb, rb, label, table)
199
+ show = display_label or label
200
+
201
+ def render(node, steps, children):
202
+ parts = []
203
+ stack = [(node, False)]
204
+ while stack:
205
+ item, literal = stack.pop()
206
+ if literal:
207
+ parts.append(item)
208
+ continue
209
+ parts.append(show(steps[item]) or "·")
210
+ kids = children.get(item, [])
211
+ if kids:
212
+ parts.append("(")
213
+ stack.append((")", True))
214
+ for i in range(len(kids) - 1, -1, -1):
215
+ stack.append((kids[i], False))
216
+ if i:
217
+ stack.append((", ", True))
218
+ return "".join(parts)
219
+
220
+ def path_text(path):
221
+ parts = []
222
+ while path is not None:
223
+ path, name = path
224
+ parts.append(name)
225
+ return " > ".join(reversed(parts))
226
+ # Same comparison as is_isomorphic(), reusing the canons computed above.
227
+ if tuple(sorted(canA[r] for r in ra)) == tuple(sorted(canB[r] for r in rb)):
228
+ return TreeDiff(identical=True)
229
+
230
+ changes: list[str] = []
231
+
232
+ def align(a_ids: list[str], b_ids: list[str]) -> tuple[list[str], list[str]]:
233
+ """Pop A/B ids whose canonical subtrees match; return the leftovers on each side."""
234
+ bucket: dict[int, list[str]] = {}
235
+ for k in b_ids:
236
+ bucket.setdefault(canB[k], []).append(k)
237
+ a_only: list[str] = []
238
+ for k in a_ids:
239
+ pool = bucket.get(canA[k])
240
+ if pool:
241
+ pool.pop() # identical subtree on both sides -> matched, nothing to report
242
+ else:
243
+ a_only.append(k)
244
+ b_only = [k for pool in bucket.values() for k in pool]
245
+ return a_only, b_only
246
+
247
+ def pair_leftovers(
248
+ a_ids: list[str], b_ids: list[str]
249
+ ) -> tuple[list[tuple[str, str]], list[str], list[str]]:
250
+ """Pair unmatched subtrees that genuinely correspond, so "diverges at" never asserts
251
+ a false A<->B correspondence between unrelated siblings.
252
+
253
+ Correspondence requires real evidence — identical *non-empty* child structure (a
254
+ relabel of this node, surfaced as "diverges at"), then identical root label (a node
255
+ with changed descendants, localized by descending). Everything else is honest
256
+ add/remove. There is deliberately NO positional fallback: one unmatched leaf per side
257
+ with nothing in common is an add+remove, not a relabel — and "one leftover each" is
258
+ not itself evidence (the parent may simply have had several children, all but one of
259
+ which aligned away), so we never pair on sibling position alone. That positional
260
+ guess was the bug this replaced.
261
+ """
262
+ pairs: list[tuple[str, str]] = []
263
+ a_left, b_left = list(a_ids), list(b_ids)
264
+
265
+ def greedy(key_a: Callable[[str], object], key_b: Callable[[str], object]) -> None:
266
+ nonlocal a_left, b_left
267
+ index: dict[object, list[str]] = {}
268
+ for k in b_left:
269
+ kb = key_b(k)
270
+ if kb is not None:
271
+ index.setdefault(kb, []).append(k)
272
+ still_a, used_b = [], set()
273
+ for k in a_left:
274
+ ka = key_a(k)
275
+ pool = index.get(ka) if ka is not None else None
276
+ if pool:
277
+ bk = pool.pop()
278
+ used_b.add(bk)
279
+ pairs.append((k, bk))
280
+ else:
281
+ still_a.append(k)
282
+ a_left = still_a
283
+ b_left = [k for k in b_left if k not in used_b]
284
+
285
+ def child_canons(
286
+ canon_map: dict[str, int], child_map: dict[str, list[str]], k: str
287
+ ) -> object:
288
+ kids = child_map.get(k, [])
289
+ return tuple(sorted(canon_map[c] for c in kids)) if kids else None
290
+
291
+ greedy( # identical non-empty child structure == a relabel of this node
292
+ lambda k: child_canons(canA, ca, k),
293
+ lambda k: child_canons(canB, cb, k),
294
+ )
295
+ greedy(lambda k: label(sa[k]), lambda k: label(sb[k])) # same label, changed kids
296
+ return pairs, a_left, b_left
297
+
298
+ def localize(
299
+ a_ids: list[str], b_ids: list[str], here, root_level: bool
300
+ ) -> list[tuple[str, str]]:
301
+ """Report unpairable leftovers as add/remove; return the genuine pairs to descend."""
302
+ pairs, a_left, b_left = pair_leftovers(a_ids, b_ids)
303
+ prefix = path_text(here) if a_left or b_left else ""
304
+ tail = "root subtree" if root_level else "subtree"
305
+ for k in a_left:
306
+ scope = "only in A" if root_level else f"only in A under {prefix}"
307
+ changes.append(f"{scope}: {tail} {render(k, sa, ca)}")
308
+ for k in b_left:
309
+ scope = "only in B" if root_level else f"only in B under {prefix}"
310
+ changes.append(f"{scope}: {tail} {render(k, sb, cb)}")
311
+ return pairs
312
+
313
+ # Iterative worklist: a recursive descent would overflow on a deep-linear divergence.
314
+ worklist = [
315
+ (an, bn, None) for an, bn in localize(*align(ra, rb), here=None, root_level=True)
316
+ ]
317
+ while worklist:
318
+ an, bn, path = worklist.pop()
319
+ la, lb = label(sa[an]), label(sb[bn])
320
+ if la != lb:
321
+ changes.append(f"diverges at {path_text(path) or '(root)'}: A={show(sa[an])!r} B={show(sb[bn])!r}")
322
+ continue
323
+ here = (path, show(sa[an]))
324
+ a_only, b_only = align(ca.get(an, []), cb.get(bn, []))
325
+ worklist.extend(
326
+ (ca_id, cb_id, here) for ca_id, cb_id in localize(a_only, b_only, here, False)
327
+ )
328
+
329
+ return TreeDiff(identical=False, changes=changes or ["trees differ"])