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,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}")