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.
- agent_tracegraph-0.2.0.dist-info/METADATA +380 -0
- agent_tracegraph-0.2.0.dist-info/RECORD +28 -0
- agent_tracegraph-0.2.0.dist-info/WHEEL +4 -0
- agent_tracegraph-0.2.0.dist-info/entry_points.txt +2 -0
- agent_tracegraph-0.2.0.dist-info/licenses/LICENSE +191 -0
- tracegraph/__init__.py +38 -0
- tracegraph/adapters/__init__.py +13 -0
- tracegraph/adapters/base.py +22 -0
- tracegraph/adapters/langgraph_checkpoint.py +804 -0
- tracegraph/adapters/otlp_spans.py +703 -0
- tracegraph/adapters/phoenix_export.py +218 -0
- tracegraph/analysis/__init__.py +55 -0
- tracegraph/analysis/ahu.py +329 -0
- tracegraph/analysis/diagnose.py +676 -0
- tracegraph/analysis/explain.py +46 -0
- tracegraph/analysis/patterns.py +628 -0
- tracegraph/artifact.py +192 -0
- tracegraph/cli.py +1242 -0
- tracegraph/input_validation.py +19 -0
- tracegraph/model.py +225 -0
- tracegraph/normalize.py +248 -0
- tracegraph/py.typed +0 -0
- tracegraph/review_candidates.py +136 -0
- tracegraph/sqlite_snapshot.py +65 -0
- tracegraph/store/__init__.py +22 -0
- tracegraph/store/base.py +43 -0
- tracegraph/store/in_memory.py +94 -0
- tracegraph/store/ladybug.py +505 -0
|
@@ -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"])
|