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,46 @@
|
|
|
1
|
+
"""``explain`` — the causal-chain primitive, reads the RAW layer only.
|
|
2
|
+
|
|
3
|
+
Per the layer contract, root-cause work reads ``CAUSED_BY`` (every real predecessor),
|
|
4
|
+
never the derived single-parent tree. ``explain`` walks backward from a step (typically
|
|
5
|
+
a failure) and surfaces the full set of real causes, explicitly flagging any step whose
|
|
6
|
+
causality is *lossy* under the tree projection — so a reader is never misled into
|
|
7
|
+
trusting the single ``TREE_PARENT`` as "the" cause.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from tracegraph.model import Step
|
|
15
|
+
from tracegraph.store.base import GraphStore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Explanation:
|
|
20
|
+
target: Step
|
|
21
|
+
#: Raw causal ancestors of ``target``, nearest cause first.
|
|
22
|
+
chain: list[Step] = field(default_factory=list)
|
|
23
|
+
#: step_ids within {target} ∪ chain whose true causality was collapsed by the
|
|
24
|
+
#: single-parent projection (had more than one CAUSED_BY).
|
|
25
|
+
lossy_steps: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def is_lossy(self) -> bool:
|
|
29
|
+
return bool(self.lossy_steps)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def explain(
|
|
33
|
+
store: GraphStore, step_id: str, *, steps: dict[str, Step] | None = None
|
|
34
|
+
) -> Explanation:
|
|
35
|
+
"""Build the raw causal chain leading to ``step_id``.
|
|
36
|
+
|
|
37
|
+
``steps`` lets a caller that already holds the trace's step map (e.g. the CLI
|
|
38
|
+
explaining many matches against one store) skip re-materializing the whole trace
|
|
39
|
+
per call — ``store.trace()`` can be a full graph pull on the LadybugDB backend.
|
|
40
|
+
"""
|
|
41
|
+
if steps is None:
|
|
42
|
+
steps = store.trace().steps_by_id()
|
|
43
|
+
target = steps[step_id]
|
|
44
|
+
chain = store.ancestors(step_id)
|
|
45
|
+
lossy = [s.step_id for s in [target, *chain] if s.projection_lossy]
|
|
46
|
+
return Explanation(target=target, chain=chain, lossy_steps=lossy)
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""Cross-trace pattern matching over the **raw** causal graph.
|
|
2
|
+
|
|
3
|
+
The headline "graph queries, not columns" capability: find a causal *sequence* of steps
|
|
4
|
+
(e.g. "a tool step that errored", "plan immediately followed by a failing tool") across many
|
|
5
|
+
traces at once. Reads the raw ``CAUSED_BY`` layer — patterns are about real causality, so they
|
|
6
|
+
must see every real predecessor, not the lossy single-parent projection.
|
|
7
|
+
|
|
8
|
+
A ``PathPattern`` is a backend-neutral spec: an ordered list of ``StepPredicate``. The matcher
|
|
9
|
+
here is pure-Python (zero optional deps); the *same spec* also compiles to standard Cypher
|
|
10
|
+
(see :func:`compile_to_cypher`), which is what the optional LadybugDB backend runs. The compiler is
|
|
11
|
+
deliberately kept here, next to the spec, so the spec is self-describing — "this is what a
|
|
12
|
+
``PathPattern`` means as a Cypher query" — and importable without the optional dependency.
|
|
13
|
+
|
|
14
|
+
Beyond a strictly-contiguous chain, a predicate can declare two cross-step relationships that
|
|
15
|
+
make the marquee "tool X → retry → tool X → failure" pattern expressible:
|
|
16
|
+
|
|
17
|
+
* ``gap`` — the predicate is reached from the previous one through a *variable-length* causal
|
|
18
|
+
span (``(lo, hi)`` ``CAUSED_BY`` hops, ``hi=None`` meaning unbounded), not strict adjacency.
|
|
19
|
+
This is the "→ retry →": some intervening steps, not necessarily one.
|
|
20
|
+
* ``same_name_as`` — a back-reference requiring this step's ``name`` to equal the name matched
|
|
21
|
+
by an *earlier* predicate. This is the "the **same** tool X both times".
|
|
22
|
+
|
|
23
|
+
The pure-Python matcher is the **system of record** for every pattern. A bounded gap compiles
|
|
24
|
+
to a faithful ``CAUSED_BY*lo..hi`` Cypher query; an *unbounded* (or over-cap) gap cannot be
|
|
25
|
+
expressed within LadybugDB's 30-hop variable-length ceiling, so :func:`compile_to_cypher` raises
|
|
26
|
+
:class:`UncompilablePattern` rather than silently truncating — and the LadybugDB backend falls back
|
|
27
|
+
to a whole-graph pull plus this same matcher (see ``store/ladybug.py``). Nothing is ever silently
|
|
28
|
+
dropped.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections import deque
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
|
|
36
|
+
from tracegraph.model import EdgeType, NormalizedTrace, Step, StepKind, StepStatus
|
|
37
|
+
|
|
38
|
+
#: LadybugDB 0.18.1 caps the upper bound of any variable-length relationship pattern
|
|
39
|
+
#: at 30 hops (``[:CAUSED_BY*2..1000000]`` raises "Upper bound of rel exceeds maximum: 30").
|
|
40
|
+
#: 30 is therefore the largest gap upper bound that compiles to *faithful* Cypher. This is
|
|
41
|
+
#: a verified backend limit, not an arbitrary choice — do NOT raise it without retesting the
|
|
42
|
+
#: supported LadybugDB range. See store/ladybug.py:ancestors and the
|
|
43
|
+
#: ``ladybug-varlength-hop-cap`` design note. Gaps larger than this (or unbounded) are matched by
|
|
44
|
+
#: the pure-Python system of record and refused by the compiler (UncompilablePattern).
|
|
45
|
+
MAX_GAP = 30
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UncompilablePattern(ValueError):
|
|
49
|
+
"""A pattern the pure-Python matcher accepts but Cypher cannot express faithfully.
|
|
50
|
+
|
|
51
|
+
Raised by :func:`compile_to_cypher` for a gap that is unbounded or whose upper bound
|
|
52
|
+
exceeds LadybugDB's 30-hop variable-length cap (:data:`MAX_GAP`). The backend must catch this
|
|
53
|
+
and fall back to the pure-Python matcher (the system of record) rather than emit a query
|
|
54
|
+
that silently truncates — so an uncompilable pattern degrades to *slower*, never to *wrong*.
|
|
55
|
+
It subclasses ``ValueError`` so existing ``except ValueError`` callers stay correct.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class StepPredicate:
|
|
61
|
+
"""Matches a step on any combination of name / kind / status (unset fields are wildcards).
|
|
62
|
+
|
|
63
|
+
Two optional fields express *cross-step* relationships (resolved by the matcher/compiler,
|
|
64
|
+
never inside :meth:`matches`, which stays a pure single-step test):
|
|
65
|
+
|
|
66
|
+
* ``gap`` — ``(lo, hi)`` variable-length ``CAUSED_BY`` hops connecting this predicate to the
|
|
67
|
+
previous one instead of strict adjacency. ``lo``/``hi`` count *relationships*: ``(1, 1)``
|
|
68
|
+
is strict adjacency (0 intervening steps), ``(2, …)`` means "≥1 intervening step". ``hi``
|
|
69
|
+
may be ``None`` for an unbounded span. ``None`` (the default) is strict adjacency.
|
|
70
|
+
* ``same_name_as`` — the index of an *earlier* predicate whose matched ``name`` this step's
|
|
71
|
+
``name`` must equal (a back-reference; both names must be non-null, mirroring Cypher's
|
|
72
|
+
``NULL = NULL`` → ``NULL`` semantics).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
name: str | None = None
|
|
76
|
+
name_prefix: str | None = None
|
|
77
|
+
error_contains: str | None = None
|
|
78
|
+
kind: StepKind | None = None
|
|
79
|
+
status: StepStatus | None = None
|
|
80
|
+
same_name_as: int | None = None
|
|
81
|
+
gap: tuple[int, int | None] | None = None
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
# Only single-field, index-free invariants here (a frozen dataclass __post_init__ can
|
|
85
|
+
# read but not see sibling predicates). Cross-predicate checks live in _validate_backrefs.
|
|
86
|
+
if self.gap is not None:
|
|
87
|
+
lo, hi = self.gap
|
|
88
|
+
if lo < 1:
|
|
89
|
+
raise ValueError(f"gap lower bound must be >= 1, got {lo}")
|
|
90
|
+
if hi is not None and hi < lo:
|
|
91
|
+
raise ValueError(f"gap upper bound {hi} is below lower bound {lo}")
|
|
92
|
+
if self.same_name_as is not None and self.same_name_as < 0:
|
|
93
|
+
raise ValueError("same_name_as must be a non-negative predicate index")
|
|
94
|
+
if self.name is not None and self.name_prefix is not None:
|
|
95
|
+
raise ValueError("name and name_prefix are mutually exclusive")
|
|
96
|
+
if self.name_prefix == "" or self.error_contains == "":
|
|
97
|
+
raise ValueError("name_prefix and error_contains must be non-empty when set")
|
|
98
|
+
|
|
99
|
+
def matches(self, step: Step) -> bool:
|
|
100
|
+
if self.name is not None and step.name != self.name:
|
|
101
|
+
return False
|
|
102
|
+
if self.name_prefix is not None and not (step.name or "").startswith(self.name_prefix):
|
|
103
|
+
return False
|
|
104
|
+
if self.error_contains is not None and self.error_contains not in (step.error_msg or ""):
|
|
105
|
+
return False
|
|
106
|
+
if self.kind is not None and step.kind is not self.kind:
|
|
107
|
+
return False
|
|
108
|
+
if self.status is not None and step.status is not self.status:
|
|
109
|
+
return False
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
def __str__(self) -> str:
|
|
113
|
+
parts = []
|
|
114
|
+
if self.name is not None:
|
|
115
|
+
parts.append(f"name={self.name}")
|
|
116
|
+
if self.name_prefix is not None:
|
|
117
|
+
parts.append(f"name^={self.name_prefix}")
|
|
118
|
+
if self.error_contains is not None:
|
|
119
|
+
parts.append(f"error~={self.error_contains}")
|
|
120
|
+
if self.kind is not None:
|
|
121
|
+
parts.append(f"kind={self.kind.value}")
|
|
122
|
+
if self.status is not None:
|
|
123
|
+
parts.append(f"status={self.status.value}")
|
|
124
|
+
if self.same_name_as is not None:
|
|
125
|
+
parts.append(f"name=#{self.same_name_as}") # "same name as predicate N"
|
|
126
|
+
return "{" + ", ".join(parts) + "}" if parts else "{any}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class PathPattern:
|
|
131
|
+
"""A sequence of predicates matched against consecutive steps along a causal path.
|
|
132
|
+
|
|
133
|
+
By default each predicate matches a *direct* causal child of the previous one (strict
|
|
134
|
+
adjacency). A predicate carrying a ``gap`` is instead reached through a variable-length
|
|
135
|
+
causal span — see :class:`StepPredicate`. The first predicate's ``gap`` is meaningless
|
|
136
|
+
(nothing precedes it) and ignored.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
steps: tuple[StepPredicate, ...]
|
|
140
|
+
description: str = ""
|
|
141
|
+
pattern_id: str | None = None
|
|
142
|
+
pattern_version: int | None = None
|
|
143
|
+
review_eligible: bool = True
|
|
144
|
+
|
|
145
|
+
def __post_init__(self) -> None:
|
|
146
|
+
if (self.pattern_id is None) != (self.pattern_version is None):
|
|
147
|
+
raise ValueError("pattern_id and pattern_version must be set together")
|
|
148
|
+
if self.pattern_version is not None and self.pattern_version <= 0:
|
|
149
|
+
raise ValueError("pattern_version must be a positive integer")
|
|
150
|
+
|
|
151
|
+
def __str__(self) -> str:
|
|
152
|
+
if not self.steps:
|
|
153
|
+
return ""
|
|
154
|
+
out = [str(self.steps[0])]
|
|
155
|
+
for p in self.steps[1:]:
|
|
156
|
+
if p.gap is None:
|
|
157
|
+
out.append(" → " + str(p))
|
|
158
|
+
else:
|
|
159
|
+
lo, hi = p.gap
|
|
160
|
+
hi_s = "" if hi is None else str(hi)
|
|
161
|
+
out.append(f" →[gap {lo}..{hi_s}]→ " + str(p))
|
|
162
|
+
return "".join(out)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class Match:
|
|
167
|
+
trace_id: str
|
|
168
|
+
step_ids: list[str]
|
|
169
|
+
labels: list[str] = field(default_factory=list)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _children(nt: NormalizedTrace) -> dict[str, list[str]]:
|
|
173
|
+
"""cause_id -> [effect_id, ...] in execution order (CAUSED_BY points effect -> cause)."""
|
|
174
|
+
out: dict[str, list[str]] = {}
|
|
175
|
+
for e in nt.edges_of(EdgeType.CAUSED_BY):
|
|
176
|
+
out.setdefault(e.dst, []).append(e.src)
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
#: Precomputed per-trace lookup tables — ``(steps_by_id, children)`` — shared across the
|
|
181
|
+
#: matcher functions so a caller running several patterns over one trace (e.g. the diagnose
|
|
182
|
+
#: preset sweep) builds them once instead of once per pattern.
|
|
183
|
+
TraceIndex = tuple[dict[str, Step], dict[str, list[str]]]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def build_index(nt: NormalizedTrace) -> TraceIndex:
|
|
187
|
+
return nt.steps_by_id(), _children(nt)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _validate_backrefs(preds: tuple[StepPredicate, ...]) -> None:
|
|
191
|
+
"""A ``same_name_as`` must reference a *strictly earlier* predicate.
|
|
192
|
+
|
|
193
|
+
``__post_init__`` can't see a predicate's own position, so the forward/self/out-of-range
|
|
194
|
+
check lives here (called by both the matcher and the compiler). Raising loudly beats a
|
|
195
|
+
silent no-match or a ``KeyError`` deep in traversal.
|
|
196
|
+
"""
|
|
197
|
+
for i, p in enumerate(preds):
|
|
198
|
+
if p.same_name_as is not None and p.same_name_as >= i:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"predicate {i}'s same_name_as={p.same_name_as} must point to a strictly "
|
|
201
|
+
f"earlier predicate (0..{i - 1})"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def find_matches(
|
|
206
|
+
nt: NormalizedTrace, pattern: PathPattern, *, index: TraceIndex | None = None
|
|
207
|
+
) -> list[list[str]]:
|
|
208
|
+
"""All causal paths whose steps satisfy the predicates in order.
|
|
209
|
+
|
|
210
|
+
Strict-adjacency predicates advance to a direct causal child; a predicate with a ``gap``
|
|
211
|
+
advances through a variable-length ``CAUSED_BY`` span. Back-references (``same_name_as``)
|
|
212
|
+
constrain a step's ``name`` to a previously-matched one. Returns one step id per predicate
|
|
213
|
+
(gap-interior steps are *not* included), so the match shape is independent of gap width.
|
|
214
|
+
|
|
215
|
+
The result is deterministically ordered by ``(seq, step_id)`` per column and de-duplicated
|
|
216
|
+
on the full path tuple — matching the LadybugDB backend's row sort and ``RETURN DISTINCT`` so the
|
|
217
|
+
two backends agree on a fan-in graph where one endpoint is reachable by several gap paths.
|
|
218
|
+
|
|
219
|
+
Performance: two-step unbounded patterns such as ``tool-repeat-failure-heuristic`` take an
|
|
220
|
+
endpoint-anchored fast path — see
|
|
221
|
+
:func:`_match_two_step_unbounded`. A naive forward walk from every tool would be O(n²) on the
|
|
222
|
+
deep-linear traces this project targets; the fast path is output-optimal. Every other shape
|
|
223
|
+
uses :func:`_find_matches_forward`, which is linear for bounded/strict patterns (a bounded gap
|
|
224
|
+
is capped at :data:`MAX_GAP` hops). The fast path returns *exactly* the same matches as the
|
|
225
|
+
forward matcher (which remains the semantic reference); a randomized differential test pins
|
|
226
|
+
that equality.
|
|
227
|
+
"""
|
|
228
|
+
if not pattern.steps:
|
|
229
|
+
return []
|
|
230
|
+
_validate_backrefs(pattern.steps)
|
|
231
|
+
if index is None:
|
|
232
|
+
index = build_index(nt)
|
|
233
|
+
preds = pattern.steps
|
|
234
|
+
# Fast path for a two-step unbounded shape. A gap on p0 is deliberately
|
|
235
|
+
# ignored by the PathPattern contract because no predicate precedes it, so it must not disable
|
|
236
|
+
# this optimization. The forward matcher is correct here too but O(n²) when many steps match
|
|
237
|
+
# p0; the endpoint-anchored variant is O(n·k).
|
|
238
|
+
if (
|
|
239
|
+
len(preds) == 2
|
|
240
|
+
and preds[1].gap is not None
|
|
241
|
+
and preds[1].gap[1] is None
|
|
242
|
+
):
|
|
243
|
+
return _match_two_step_unbounded(index, preds[0], preds[1])
|
|
244
|
+
return _find_matches_forward(index, pattern)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _name_ok(pred: StepPredicate, step: Step, bound: dict[int, str | None]) -> bool:
|
|
248
|
+
"""Resolve a ``same_name_as`` back-reference against the names bound by earlier predicates.
|
|
249
|
+
|
|
250
|
+
Cypher's ``NULL = NULL`` is ``NULL`` (the row is dropped); mirror that — a nameless step can
|
|
251
|
+
never satisfy a back-reference, on either side of the comparison — so the two backends agree.
|
|
252
|
+
"""
|
|
253
|
+
if pred.same_name_as is None:
|
|
254
|
+
return True
|
|
255
|
+
ref = bound.get(pred.same_name_as)
|
|
256
|
+
return ref is not None and step.name is not None and step.name == ref
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _find_matches_forward(index: TraceIndex, pattern: PathPattern) -> list[list[str]]:
|
|
260
|
+
"""The general matcher: walk every predicate forward over the causal graph.
|
|
261
|
+
|
|
262
|
+
This is the semantic reference for :func:`find_matches` — every other path must reproduce it
|
|
263
|
+
exactly. Linear for strict/bounded patterns; only an *unbounded* gap (which the dispatcher
|
|
264
|
+
routes elsewhere for the common 2-step shape) can make it quadratic.
|
|
265
|
+
"""
|
|
266
|
+
steps, children = index
|
|
267
|
+
preds = pattern.steps
|
|
268
|
+
results: list[list[str]] = []
|
|
269
|
+
seen: set[tuple[str, ...]] = set()
|
|
270
|
+
|
|
271
|
+
# Recursion depth here == predicate index (len(preds), ~2-4), NOT trace depth — safe on the
|
|
272
|
+
# deep-linear traces this project sees. The variable-length gap walk below is an explicit
|
|
273
|
+
# deque (heap, not stack), so a 30-hop span over a 2000-step trace never grows the call stack.
|
|
274
|
+
def advance(node_id: str, idx: int, acc: list[str], bound: dict[int, str | None]) -> None:
|
|
275
|
+
step = steps[node_id]
|
|
276
|
+
pred = preds[idx]
|
|
277
|
+
if not pred.matches(step) or not _name_ok(pred, step, bound):
|
|
278
|
+
return
|
|
279
|
+
acc2 = acc + [node_id]
|
|
280
|
+
if idx + 1 == len(preds):
|
|
281
|
+
key = tuple(acc2)
|
|
282
|
+
if key not in seen: # == RETURN DISTINCT: collapse multiple gap paths to one row
|
|
283
|
+
seen.add(key)
|
|
284
|
+
results.append(acc2)
|
|
285
|
+
return
|
|
286
|
+
bound2 = {**bound, idx: step.name}
|
|
287
|
+
nxt = preds[idx + 1]
|
|
288
|
+
if nxt.gap is None:
|
|
289
|
+
for child in children.get(node_id, []):
|
|
290
|
+
advance(child, idx + 1, acc2, bound2)
|
|
291
|
+
return
|
|
292
|
+
lo, hi = nxt.gap
|
|
293
|
+
unbounded = hi is None
|
|
294
|
+
# BFS forward over CAUSED_BY (cause -> effect). De-dup on (node, distance) so a diamond
|
|
295
|
+
# doesn't blow up exponentially while still visiting every distinct path *length* — an
|
|
296
|
+
# endpoint reachable only at a longer-than-min distance within [lo, hi] is not missed.
|
|
297
|
+
frontier: deque[tuple[str, int]] = deque((c, 1) for c in children.get(node_id, []))
|
|
298
|
+
visited_states: set[tuple[str, int]] = set()
|
|
299
|
+
while frontier:
|
|
300
|
+
cur, dist = frontier.popleft()
|
|
301
|
+
if (cur, dist) in visited_states:
|
|
302
|
+
continue
|
|
303
|
+
visited_states.add((cur, dist))
|
|
304
|
+
if dist >= lo and (unbounded or dist <= hi):
|
|
305
|
+
advance(cur, idx + 1, acc2, bound2)
|
|
306
|
+
if unbounded or dist < hi:
|
|
307
|
+
for c in children.get(cur, []):
|
|
308
|
+
frontier.append((c, dist + 1))
|
|
309
|
+
|
|
310
|
+
for sid in steps:
|
|
311
|
+
advance(sid, 0, [], {})
|
|
312
|
+
results.sort(key=lambda row: tuple((steps[sid].seq, sid) for sid in row))
|
|
313
|
+
return results
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _match_two_step_unbounded(
|
|
317
|
+
index: TraceIndex, p0: StepPredicate, p1: StepPredicate
|
|
318
|
+
) -> list[list[str]]:
|
|
319
|
+
"""Endpoint-anchored matcher for ``[p0, p1-with-unbounded-gap]`` (the marquee shape).
|
|
320
|
+
|
|
321
|
+
A match is ``(a, e)`` where ``a`` satisfies ``p0``, ``e`` satisfies ``p1`` (incl. its
|
|
322
|
+
back-reference to ``a``), and ``e`` is reachable from ``a`` by a causal path of length
|
|
323
|
+
``>= lo`` (the gap's lower bound; the upper bound is unbounded). "A path of length ``>= lo``
|
|
324
|
+
exists" is exactly "the *longest* path ``a -> e`` is ``>= lo``", so we work backward from each
|
|
325
|
+
``p1`` endpoint, computing the longest causal distance to it for every ancestor in one
|
|
326
|
+
reverse-topological pass. Cost is ``O(|endpoints| * (n + edges))`` — output-optimal, and
|
|
327
|
+
``O(n)`` for the realistic case where failing tools are rare. (A forward walk from every
|
|
328
|
+
``p0`` match would be ``O(n²)`` on a deep-linear trace; this avoids re-traversing the shared
|
|
329
|
+
downstream suffix.) Produces exactly what :func:`_find_matches_forward` would.
|
|
330
|
+
"""
|
|
331
|
+
steps, children = index # children: cause -> [effect, ...]
|
|
332
|
+
parents: dict[str, list[str]] = {} # effect -> [cause, ...]
|
|
333
|
+
for cause, effects in children.items():
|
|
334
|
+
for eff in effects:
|
|
335
|
+
parents.setdefault(eff, []).append(cause)
|
|
336
|
+
|
|
337
|
+
lo = p1.gap[0] # type: ignore[index] (dispatcher guarantees p1.gap is (lo, None))
|
|
338
|
+
results: list[list[str]] = []
|
|
339
|
+
for e in (sid for sid, st in steps.items() if p1.matches(st)):
|
|
340
|
+
# Ancestors of e that p0 could match (backward reachability over CAUSED_BY).
|
|
341
|
+
ancestors: set[str] = set()
|
|
342
|
+
stack = [e]
|
|
343
|
+
while stack:
|
|
344
|
+
node = stack.pop()
|
|
345
|
+
for cause in parents.get(node, []):
|
|
346
|
+
if cause not in ancestors:
|
|
347
|
+
ancestors.add(cause)
|
|
348
|
+
stack.append(cause)
|
|
349
|
+
# Longest causal distance to e for each ancestor: process effects before causes (seq
|
|
350
|
+
# descending), so a node's children already have their longest-to-e when we reach it.
|
|
351
|
+
longest: dict[str, int] = {e: 0}
|
|
352
|
+
for a in sorted(ancestors, key=lambda nid: steps[nid].seq, reverse=True):
|
|
353
|
+
best = max(
|
|
354
|
+
(longest[c] + 1 for c in children.get(a, []) if c in longest),
|
|
355
|
+
default=0,
|
|
356
|
+
)
|
|
357
|
+
if best:
|
|
358
|
+
longest[a] = best
|
|
359
|
+
ename = steps[e].name
|
|
360
|
+
for a in ancestors:
|
|
361
|
+
if longest.get(a, 0) < lo or not p0.matches(steps[a]):
|
|
362
|
+
continue
|
|
363
|
+
# Back-reference (p1.same_name_as can only be 0 == p0 here): names must match, both
|
|
364
|
+
# non-null — same NULL semantics as _name_ok / the compiled `IS NOT NULL` guard.
|
|
365
|
+
if p1.same_name_as is not None and not (
|
|
366
|
+
ename is not None and steps[a].name is not None and ename == steps[a].name
|
|
367
|
+
):
|
|
368
|
+
continue
|
|
369
|
+
results.append([a, e])
|
|
370
|
+
results.sort(key=lambda row: tuple((steps[sid].seq, sid) for sid in row))
|
|
371
|
+
return results
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def search(traces: list[NormalizedTrace], pattern: PathPattern) -> list[Match]:
|
|
375
|
+
"""Run a pattern over many traces; return every match with readable step labels."""
|
|
376
|
+
matches: list[Match] = []
|
|
377
|
+
for nt in traces:
|
|
378
|
+
index = build_index(nt)
|
|
379
|
+
steps = index[0]
|
|
380
|
+
for path in find_matches(nt, pattern, index=index):
|
|
381
|
+
labels = [steps[i].name or steps[i].kind.value for i in path]
|
|
382
|
+
matches.append(Match(trace_id=nt.trace.trace_id, step_ids=path, labels=labels))
|
|
383
|
+
return matches
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@dataclass(frozen=True)
|
|
387
|
+
class CompiledQuery:
|
|
388
|
+
"""A Cypher query equivalent to a :class:`PathPattern`.
|
|
389
|
+
|
|
390
|
+
``cypher`` uses parameter placeholders (``$name``) — never string-interpolated user data —
|
|
391
|
+
so the same compiled artifact is safe to run against any Cypher engine without
|
|
392
|
+
rebuilding per call. ``result_vars`` lists the RETURN column aliases in pattern order:
|
|
393
|
+
each row's columns are the matched step ids for predicate 0, 1, … in turn.
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
cypher: str
|
|
397
|
+
params: dict[str, str]
|
|
398
|
+
result_vars: tuple[str, ...]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _where_clauses(idx: int, pred: StepPredicate, params: dict[str, str]) -> list[str]:
|
|
402
|
+
"""Translate a predicate into ``s{idx}.<field> = $param`` clauses, recording the params.
|
|
403
|
+
|
|
404
|
+
Only fields the predicate actually constrains become clauses (mirroring the pure-Python
|
|
405
|
+
matcher's "unset = wildcard" rule). Enum values are stored as their ``.value`` strings,
|
|
406
|
+
matching how LadybugDB (and any other backend) serializes a step row. A ``same_name_as`` adds a
|
|
407
|
+
column-to-column equality (not a param) plus a NOT-NULL guard so the backends agree on
|
|
408
|
+
nameless steps (Cypher ``NULL = NULL`` is ``NULL`` → row dropped, matching the matcher).
|
|
409
|
+
"""
|
|
410
|
+
clauses: list[str] = []
|
|
411
|
+
if pred.name is not None:
|
|
412
|
+
key = f"s{idx}_name"
|
|
413
|
+
params[key] = pred.name
|
|
414
|
+
clauses.append(f"s{idx}.name = ${key}")
|
|
415
|
+
if pred.name_prefix is not None:
|
|
416
|
+
key = f"s{idx}_name_prefix"
|
|
417
|
+
params[key] = pred.name_prefix
|
|
418
|
+
clauses.append(f"s{idx}.name STARTS WITH ${key}")
|
|
419
|
+
if pred.error_contains is not None:
|
|
420
|
+
key = f"s{idx}_error_contains"
|
|
421
|
+
params[key] = pred.error_contains
|
|
422
|
+
clauses.append(f"s{idx}.error_msg CONTAINS ${key}")
|
|
423
|
+
if pred.kind is not None:
|
|
424
|
+
key = f"s{idx}_kind"
|
|
425
|
+
params[key] = pred.kind.value
|
|
426
|
+
clauses.append(f"s{idx}.kind = ${key}")
|
|
427
|
+
if pred.status is not None:
|
|
428
|
+
key = f"s{idx}_status"
|
|
429
|
+
params[key] = pred.status.value
|
|
430
|
+
clauses.append(f"s{idx}.status = ${key}")
|
|
431
|
+
if pred.same_name_as is not None:
|
|
432
|
+
clauses.append(f"s{idx}.name IS NOT NULL")
|
|
433
|
+
clauses.append(f"s{idx}.name = s{pred.same_name_as}.name")
|
|
434
|
+
return clauses
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _rel(pred: StepPredicate) -> str:
|
|
438
|
+
"""The compiled relationship reaching ``pred`` from the previous node.
|
|
439
|
+
|
|
440
|
+
Strict adjacency is a single backward ``CAUSED_BY`` hop (byte-identical to the pre-gap
|
|
441
|
+
output). A bounded gap becomes a variable-length ``*lo..hi`` span. An unbounded or
|
|
442
|
+
over-cap gap can't be expressed within LadybugDB's 30-hop ceiling, so we refuse rather than
|
|
443
|
+
emit a query that silently truncates.
|
|
444
|
+
"""
|
|
445
|
+
if pred.gap is None:
|
|
446
|
+
return "<-[:CAUSED_BY]-"
|
|
447
|
+
lo, hi = pred.gap
|
|
448
|
+
if hi is None:
|
|
449
|
+
raise UncompilablePattern(
|
|
450
|
+
f"unbounded gap (lo={lo}) cannot be compiled within LadybugDB's {MAX_GAP}-hop "
|
|
451
|
+
"variable-length cap; the pure-Python matcher is the system of record for it"
|
|
452
|
+
)
|
|
453
|
+
if hi > MAX_GAP:
|
|
454
|
+
raise UncompilablePattern(
|
|
455
|
+
f"gap upper bound {hi} exceeds LadybugDB's {MAX_GAP}-hop variable-length cap; "
|
|
456
|
+
"the pure-Python matcher is the system of record for it"
|
|
457
|
+
)
|
|
458
|
+
return f"<-[:CAUSED_BY*{lo}..{hi}]-"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def compile_to_cypher(pattern: PathPattern) -> CompiledQuery:
|
|
462
|
+
"""Compile a :class:`PathPattern` to a parameterized Cypher query.
|
|
463
|
+
|
|
464
|
+
Edge direction note: the model stores ``CAUSED_BY`` as effect → cause, but a
|
|
465
|
+
``PathPattern`` reads cause → effect (predicate 0 is matched first, predicate 1 is its
|
|
466
|
+
causal effect, etc). The compiled MATCH therefore walks the relationship *backwards*
|
|
467
|
+
(``s0 <-[:CAUSED_BY]- s1``), so the row order matches the pure-Python matcher's
|
|
468
|
+
``find_matches`` exactly.
|
|
469
|
+
|
|
470
|
+
A predicate carrying a ``gap`` compiles to a variable-length span; because a var-length
|
|
471
|
+
match yields one row *per path*, any gap forces ``RETURN DISTINCT`` so a fan-in graph
|
|
472
|
+
collapses to one row per endpoint tuple — exactly what the pure-Python matcher's de-dup
|
|
473
|
+
produces. A ``same_name_as`` compiles to a column-equality back-reference.
|
|
474
|
+
|
|
475
|
+
Raises :class:`UncompilablePattern` (a ``ValueError`` subclass) when a gap is unbounded or
|
|
476
|
+
exceeds :data:`MAX_GAP` — the caller (the LadybugDB store) must then fall back to the pure-Python
|
|
477
|
+
matcher rather than run a query that would silently truncate. Raises plain ``ValueError`` on
|
|
478
|
+
an empty pattern — that's a caller bug, not a query that returns nothing.
|
|
479
|
+
"""
|
|
480
|
+
if not pattern.steps:
|
|
481
|
+
raise ValueError("cannot compile an empty PathPattern")
|
|
482
|
+
_validate_backrefs(pattern.steps)
|
|
483
|
+
|
|
484
|
+
n = len(pattern.steps)
|
|
485
|
+
nodes = [f"(s{i}:Step)" for i in range(n)]
|
|
486
|
+
# cause → effect along the pattern == effect → cause along CAUSED_BY, reversed in MATCH.
|
|
487
|
+
match = nodes[0] + "".join(_rel(pattern.steps[i]) + nodes[i] for i in range(1, n))
|
|
488
|
+
|
|
489
|
+
params: dict[str, str] = {}
|
|
490
|
+
clauses: list[str] = []
|
|
491
|
+
for i, pred in enumerate(pattern.steps):
|
|
492
|
+
clauses.extend(_where_clauses(i, pred, params))
|
|
493
|
+
|
|
494
|
+
# A variable-length match returns one row per path; DISTINCT collapses those to one row per
|
|
495
|
+
# endpoint tuple, matching the pure-Python matcher's de-dup. Strict-only patterns can't
|
|
496
|
+
# produce duplicate tuples (no repeated CAUSED_BY edges), so they keep the bare RETURN —
|
|
497
|
+
# preserving the exact compiled string for the existing presets.
|
|
498
|
+
# A gap belongs to the relationship reaching its predicate. Predicate 0 has no incoming
|
|
499
|
+
# relationship, so its gap is contractually ignored and must not add a spurious DISTINCT.
|
|
500
|
+
has_gap = any(p.gap is not None for p in pattern.steps[1:])
|
|
501
|
+
ret = "RETURN DISTINCT " if has_gap else "RETURN "
|
|
502
|
+
|
|
503
|
+
parts = [f"MATCH {match}"]
|
|
504
|
+
if clauses:
|
|
505
|
+
parts.append("WHERE " + " AND ".join(clauses))
|
|
506
|
+
parts.append(ret + ", ".join(f"s{i}.step_id AS s{i}" for i in range(n)))
|
|
507
|
+
return CompiledQuery(
|
|
508
|
+
cypher="\n".join(parts),
|
|
509
|
+
params=params,
|
|
510
|
+
result_vars=tuple(f"s{i}" for i in range(n)),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
#: Named preset patterns shipped with the CLI.
|
|
515
|
+
PRESETS: dict[str, PathPattern] = {
|
|
516
|
+
"error": PathPattern(
|
|
517
|
+
(StepPredicate(status=StepStatus.ERROR),),
|
|
518
|
+
"any step that errored",
|
|
519
|
+
pattern_id="error",
|
|
520
|
+
pattern_version=1,
|
|
521
|
+
),
|
|
522
|
+
"tool-failure": PathPattern(
|
|
523
|
+
(StepPredicate(kind=StepKind.TOOL, status=StepStatus.ERROR),),
|
|
524
|
+
"a tool step that errored",
|
|
525
|
+
pattern_id="tool-failure",
|
|
526
|
+
pattern_version=1,
|
|
527
|
+
),
|
|
528
|
+
"plan-then-tool-failure": PathPattern(
|
|
529
|
+
(
|
|
530
|
+
StepPredicate(name="plan"),
|
|
531
|
+
StepPredicate(kind=StepKind.TOOL, status=StepStatus.ERROR),
|
|
532
|
+
),
|
|
533
|
+
"a 'plan' step immediately followed (causally) by a failing tool",
|
|
534
|
+
pattern_id="plan-then-tool-failure",
|
|
535
|
+
pattern_version=1,
|
|
536
|
+
),
|
|
537
|
+
"timeout": PathPattern(
|
|
538
|
+
(StepPredicate(status=StepStatus.ERROR, error_contains="timeout"),),
|
|
539
|
+
"a step that failed with a bounded timeout status message",
|
|
540
|
+
pattern_id="timeout",
|
|
541
|
+
pattern_version=1,
|
|
542
|
+
),
|
|
543
|
+
"repeated-agent-failure": PathPattern(
|
|
544
|
+
(
|
|
545
|
+
StepPredicate(kind=StepKind.AGENT, status=StepStatus.ERROR),
|
|
546
|
+
StepPredicate(
|
|
547
|
+
kind=StepKind.AGENT,
|
|
548
|
+
status=StepStatus.ERROR,
|
|
549
|
+
same_name_as=0,
|
|
550
|
+
gap=(1, None),
|
|
551
|
+
),
|
|
552
|
+
),
|
|
553
|
+
"the same agent failed again later on the causal path",
|
|
554
|
+
pattern_id="repeated-agent-failure",
|
|
555
|
+
pattern_version=1,
|
|
556
|
+
),
|
|
557
|
+
"gate-failure-after-success": PathPattern(
|
|
558
|
+
(
|
|
559
|
+
StepPredicate(kind=StepKind.AGENT, status=StepStatus.OK),
|
|
560
|
+
StepPredicate(
|
|
561
|
+
kind=StepKind.TOOL,
|
|
562
|
+
status=StepStatus.ERROR,
|
|
563
|
+
name_prefix="gate:",
|
|
564
|
+
),
|
|
565
|
+
),
|
|
566
|
+
"a successful agent attempt immediately followed by a failing gate",
|
|
567
|
+
pattern_id="gate-failure-after-success",
|
|
568
|
+
pattern_version=1,
|
|
569
|
+
),
|
|
570
|
+
# Official retry diagnosis requires an explicit producer-authored marker. The adjacency is
|
|
571
|
+
# intentionally strict: producers own this three-node causal contract rather than asking the
|
|
572
|
+
# consumer to guess across interposed steps. Mere repetition is retained below as a query-only
|
|
573
|
+
# heuristic and cannot create governance review candidates.
|
|
574
|
+
"tool-retry-failure": PathPattern(
|
|
575
|
+
(
|
|
576
|
+
StepPredicate(kind=StepKind.TOOL),
|
|
577
|
+
StepPredicate(kind=StepKind.CHAIN, name_prefix="retry:"),
|
|
578
|
+
StepPredicate(
|
|
579
|
+
kind=StepKind.TOOL,
|
|
580
|
+
status=StepStatus.ERROR,
|
|
581
|
+
same_name_as=0,
|
|
582
|
+
),
|
|
583
|
+
),
|
|
584
|
+
"an explicitly linked retry of the same tool that fails",
|
|
585
|
+
pattern_id="tool-retry-failure",
|
|
586
|
+
pattern_version=2,
|
|
587
|
+
),
|
|
588
|
+
"tool-repeat-failure-heuristic": PathPattern(
|
|
589
|
+
(
|
|
590
|
+
StepPredicate(kind=StepKind.TOOL),
|
|
591
|
+
StepPredicate(
|
|
592
|
+
kind=StepKind.TOOL,
|
|
593
|
+
status=StepStatus.ERROR,
|
|
594
|
+
same_name_as=0,
|
|
595
|
+
gap=(2, None),
|
|
596
|
+
),
|
|
597
|
+
),
|
|
598
|
+
"the same tool appears again later and fails; retry causality is only inferred",
|
|
599
|
+
pattern_id="tool-repeat-failure-heuristic",
|
|
600
|
+
pattern_version=1,
|
|
601
|
+
review_eligible=False,
|
|
602
|
+
),
|
|
603
|
+
# Deprecated query-only compatibility preset from before explicit retry causality.
|
|
604
|
+
"tool-retry-failure-near": PathPattern(
|
|
605
|
+
(
|
|
606
|
+
StepPredicate(kind=StepKind.TOOL),
|
|
607
|
+
StepPredicate(
|
|
608
|
+
kind=StepKind.TOOL,
|
|
609
|
+
status=StepStatus.ERROR,
|
|
610
|
+
same_name_as=0,
|
|
611
|
+
gap=(2, MAX_GAP),
|
|
612
|
+
),
|
|
613
|
+
),
|
|
614
|
+
f"the same tool repeats within {MAX_GAP} causal hops and fails "
|
|
615
|
+
"(query-only heuristic; no explicit retry evidence)",
|
|
616
|
+
pattern_id="tool-retry-failure-near",
|
|
617
|
+
pattern_version=1,
|
|
618
|
+
review_eligible=False,
|
|
619
|
+
),
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
_mismatched_preset_ids = [
|
|
623
|
+
(name, pattern.pattern_id)
|
|
624
|
+
for name, pattern in PRESETS.items()
|
|
625
|
+
if name != pattern.pattern_id
|
|
626
|
+
]
|
|
627
|
+
if _mismatched_preset_ids:
|
|
628
|
+
raise RuntimeError(f"PRESETS keys must equal pattern_id: {_mismatched_preset_ids!r}")
|