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,505 @@
|
|
|
1
|
+
"""Optional LadybugDB-backed graph store — the Cypher cache the spec advertises.
|
|
2
|
+
|
|
3
|
+
This is the **accelerator**, never the system of record. The JSON artifact remains
|
|
4
|
+
authoritative (see :mod:`tracegraph.artifact`); a ``LadybugStore`` is rebuilt from it on load
|
|
5
|
+
and can be thrown away. The point of this backend is to *prove* the ``PathPattern`` spec
|
|
6
|
+
compiles to standard Cypher — equivalence with the pure-Python matcher is what makes
|
|
7
|
+
the abstraction honest — so the bulk of the file is a faithful schema and a thin query
|
|
8
|
+
runner, not a separate analysis library.
|
|
9
|
+
|
|
10
|
+
In-memory by default (``ladybug.Database(":memory:")``) so it matches the InMemoryStore's
|
|
11
|
+
ephemerality. Pass ``path=`` to persist. Database caches are version-local and rebuildable;
|
|
12
|
+
durable persistence is the JSON artifact, not the DB directory.
|
|
13
|
+
|
|
14
|
+
Requires the ``[cypher]`` extra (``pip install agent-tracegraph[cypher]``). The import will fail
|
|
15
|
+
cleanly with the ModuleNotFoundError raised by ``ladybug`` itself; the package's default install
|
|
16
|
+
never touches this module.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections import deque
|
|
22
|
+
import json
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import ladybug
|
|
27
|
+
|
|
28
|
+
from tracegraph import artifact
|
|
29
|
+
from tracegraph.analysis.patterns import (
|
|
30
|
+
PathPattern,
|
|
31
|
+
UncompilablePattern,
|
|
32
|
+
compile_to_cypher,
|
|
33
|
+
)
|
|
34
|
+
from tracegraph.analysis.patterns import find_matches as py_find_matches
|
|
35
|
+
from tracegraph.model import (
|
|
36
|
+
Edge,
|
|
37
|
+
EdgeOrigin,
|
|
38
|
+
EdgeType,
|
|
39
|
+
NormalizedTrace,
|
|
40
|
+
RawTrace,
|
|
41
|
+
Step,
|
|
42
|
+
StepEvidence,
|
|
43
|
+
StepKind,
|
|
44
|
+
StepSource,
|
|
45
|
+
StepStatus,
|
|
46
|
+
Trace,
|
|
47
|
+
)
|
|
48
|
+
from tracegraph.normalize import normalize, validate_normalized
|
|
49
|
+
|
|
50
|
+
# Property layout for the Step node table. Kept beside the schema so adding a model field
|
|
51
|
+
# is a single-source change; the constructor reads this list to bind ``$params`` for INSERT.
|
|
52
|
+
_STEP_FIELDS: tuple[str, ...] = (
|
|
53
|
+
"step_id",
|
|
54
|
+
"trace_id",
|
|
55
|
+
"seq",
|
|
56
|
+
"ts",
|
|
57
|
+
"kind",
|
|
58
|
+
"source",
|
|
59
|
+
"name",
|
|
60
|
+
"status",
|
|
61
|
+
"error_msg",
|
|
62
|
+
"evidence_json",
|
|
63
|
+
"projection_lossy",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_TRACE_FIELDS: tuple[str, ...] = (
|
|
67
|
+
"trace_id",
|
|
68
|
+
"source_kind",
|
|
69
|
+
"run_id",
|
|
70
|
+
"thread_id",
|
|
71
|
+
"status",
|
|
72
|
+
"causal_fidelity",
|
|
73
|
+
"links_preserved",
|
|
74
|
+
"decision_evidence_json",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
_SCHEMA_DDL: tuple[str, ...] = (
|
|
78
|
+
# Step: STRING for the enum columns so the Cypher compiler can compare against
|
|
79
|
+
# ``StepKind.value`` / ``StepStatus.value`` directly (Ladybug has no native enums).
|
|
80
|
+
"CREATE NODE TABLE Step ("
|
|
81
|
+
"step_id STRING, trace_id STRING, seq INT64, ts STRING, "
|
|
82
|
+
"kind STRING, source STRING, name STRING, status STRING, "
|
|
83
|
+
"error_msg STRING, evidence_json STRING, projection_lossy BOOLEAN, "
|
|
84
|
+
"PRIMARY KEY (step_id))",
|
|
85
|
+
"CREATE NODE TABLE Trace ("
|
|
86
|
+
"trace_id STRING, source_kind STRING, run_id STRING, thread_id STRING, status STRING, "
|
|
87
|
+
"causal_fidelity STRING, links_preserved BOOLEAN, decision_evidence_json STRING, "
|
|
88
|
+
"PRIMARY KEY (trace_id))",
|
|
89
|
+
"CREATE REL TABLE CAUSED_BY (FROM Step TO Step, origin STRING)",
|
|
90
|
+
"CREATE REL TABLE TREE_PARENT (FROM Step TO Step)",
|
|
91
|
+
"CREATE REL TABLE BELONGS_TO (FROM Step TO Trace)",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class LadybugStore:
|
|
96
|
+
"""Implementation of :class:`~tracegraph.store.base.GraphStore` backed by LadybugDB."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, *, path: str | Path | None = None) -> None:
|
|
99
|
+
# ":memory:" is Ladybug's in-process sentinel; matches InMemoryStore's no-files default.
|
|
100
|
+
db = ladybug.Database(":memory:" if path is None else str(path))
|
|
101
|
+
try:
|
|
102
|
+
conn = ladybug.Connection(db)
|
|
103
|
+
except BaseException:
|
|
104
|
+
# A failed connection leaves no store for anyone to close — release the
|
|
105
|
+
# database here or it stays open for the process lifetime.
|
|
106
|
+
db.close()
|
|
107
|
+
raise
|
|
108
|
+
self._db = db
|
|
109
|
+
self._conn = conn
|
|
110
|
+
self._closed = False
|
|
111
|
+
self._trace: Trace | None = None
|
|
112
|
+
# Read caches over the DB contents, invalidated by the corresponding upsert. The
|
|
113
|
+
# store is an accelerator over an immutable-once-loaded trace, so repeated queries
|
|
114
|
+
# (ancestors per failure, find_matches per preset) shouldn't re-dump the graph.
|
|
115
|
+
self._steps_cache: list[Step] | None = None
|
|
116
|
+
self._causes_cache: dict[str, list[str]] | None = None
|
|
117
|
+
self._trace_cache: NormalizedTrace | None = None
|
|
118
|
+
#: True iff the most recent find_matches() could not compile to faithful Cypher
|
|
119
|
+
#: (unbounded/over-cap gap) and ran the pure-Python matcher instead. Lets the CLI
|
|
120
|
+
#: tell the user the accelerator deferred — the results are identical either way.
|
|
121
|
+
self.fell_back_to_python = False
|
|
122
|
+
|
|
123
|
+
# --- lifecycle ---
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
"""Release the underlying connection and database. Idempotent.
|
|
127
|
+
|
|
128
|
+
The store holds a live LadybugDB connection; a caller creating many stores (one per
|
|
129
|
+
trace in a directory sweep) must close each one or the process accumulates open
|
|
130
|
+
databases. Using the store after close raises ``RuntimeError``.
|
|
131
|
+
"""
|
|
132
|
+
if self._closed:
|
|
133
|
+
return
|
|
134
|
+
self._closed = True
|
|
135
|
+
self._steps_cache = None
|
|
136
|
+
self._causes_cache = None
|
|
137
|
+
self._trace_cache = None
|
|
138
|
+
try:
|
|
139
|
+
self._conn.close()
|
|
140
|
+
finally:
|
|
141
|
+
# The database must be released even if the connection close raises.
|
|
142
|
+
self._db.close()
|
|
143
|
+
|
|
144
|
+
def __enter__(self) -> "LadybugStore":
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
148
|
+
self.close()
|
|
149
|
+
|
|
150
|
+
def _check_open(self) -> None:
|
|
151
|
+
if self._closed:
|
|
152
|
+
raise RuntimeError("LadybugStore is closed")
|
|
153
|
+
|
|
154
|
+
# --- construction ---
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_trace(cls, nt: NormalizedTrace, *, path: str | Path | None = None) -> "LadybugStore":
|
|
158
|
+
"""Load a normalized trace. Validates both edge layers at the boundary (same as
|
|
159
|
+
InMemoryStore.from_trace) so a corrupt artifact can't enter the DB."""
|
|
160
|
+
validate_normalized(nt)
|
|
161
|
+
store = cls(path=path)
|
|
162
|
+
try:
|
|
163
|
+
store.init_schema()
|
|
164
|
+
# Deep-copy the header, matching InMemoryStore.from_trace. Holding the caller's
|
|
165
|
+
# object by reference let a later mutation of the input change what trace()
|
|
166
|
+
# returns while the persisted Trace row still held the original values, so the
|
|
167
|
+
# store and its own database disagreed about the same trace.
|
|
168
|
+
store._trace = nt.trace.model_copy(deep=True)
|
|
169
|
+
store._insert_trace(nt.trace)
|
|
170
|
+
store.upsert_nodes(nt.steps)
|
|
171
|
+
store.upsert_edges(nt.edges)
|
|
172
|
+
except BaseException:
|
|
173
|
+
# Don't leak a live DB/connection behind a failed constructor — the caller
|
|
174
|
+
# never receives the store, so it could never close it.
|
|
175
|
+
store.close()
|
|
176
|
+
raise
|
|
177
|
+
return store
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def from_raw(cls, raw: RawTrace, *, path: str | Path | None = None) -> "LadybugStore":
|
|
181
|
+
return cls.from_trace(normalize(raw), path=path)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def load_artifact(cls, path: str | Path) -> "LadybugStore":
|
|
185
|
+
# ``path`` here is the **artifact** JSON, not a Ladybug DB directory — the artifact
|
|
186
|
+
# is the system of record, so loading by definition goes through it.
|
|
187
|
+
return cls.from_trace(artifact.load(path))
|
|
188
|
+
|
|
189
|
+
# --- GraphStore protocol ---
|
|
190
|
+
|
|
191
|
+
def init_schema(self) -> None:
|
|
192
|
+
for ddl in _SCHEMA_DDL:
|
|
193
|
+
self._conn.execute(ddl)
|
|
194
|
+
|
|
195
|
+
def upsert_nodes(self, steps: list[Step]) -> None:
|
|
196
|
+
"""Insert a batch of steps in one UNWIND statement.
|
|
197
|
+
|
|
198
|
+
The batch is **atomic**: if any row fails (e.g. a duplicate ``step_id`` primary
|
|
199
|
+
key), LadybugDB rolls the whole statement back and no rows land. This is a
|
|
200
|
+
deliberate contract change from the earlier per-row loop, which left the
|
|
201
|
+
already-inserted prefix behind on failure — all-or-nothing is the behavior a
|
|
202
|
+
rebuildable accelerator cache actually wants (a half-loaded trace is never
|
|
203
|
+
queryable as if complete). Same contract for :meth:`upsert_edges`.
|
|
204
|
+
"""
|
|
205
|
+
self._check_open()
|
|
206
|
+
if not steps:
|
|
207
|
+
return
|
|
208
|
+
# One UNWIND per batch instead of one statement per step — bulk load is a single
|
|
209
|
+
# round-trip through the query engine.
|
|
210
|
+
placeholders = ", ".join(f"{f}: r.{f}" for f in _STEP_FIELDS)
|
|
211
|
+
self._conn.execute(
|
|
212
|
+
f"UNWIND $rows AS r CREATE (:Step {{{placeholders}}})",
|
|
213
|
+
{"rows": [_step_params(s) for s in steps]},
|
|
214
|
+
)
|
|
215
|
+
self._steps_cache = None
|
|
216
|
+
self._trace_cache = None
|
|
217
|
+
|
|
218
|
+
def upsert_edges(self, edges: list[Edge]) -> None:
|
|
219
|
+
self._check_open()
|
|
220
|
+
if not edges:
|
|
221
|
+
return
|
|
222
|
+
# One UNWIND per edge type (the relationship label and target table differ per
|
|
223
|
+
# type), preserving each type's insertion order within its batch. The per-type
|
|
224
|
+
# statements run inside one transaction so a mixed batch keeps the same
|
|
225
|
+
# all-or-nothing contract as upsert_nodes — a failure on a later type must not
|
|
226
|
+
# leave the earlier types committed.
|
|
227
|
+
by_type: dict[EdgeType, list[Edge]] = {}
|
|
228
|
+
for e in edges:
|
|
229
|
+
by_type.setdefault(e.type, []).append(e)
|
|
230
|
+
self._conn.execute("BEGIN TRANSACTION")
|
|
231
|
+
try:
|
|
232
|
+
for edge_type, batch in by_type.items():
|
|
233
|
+
self._insert_edge_batch(edge_type, batch)
|
|
234
|
+
# COMMIT belongs inside the try: a failing COMMIT outside it would leave the
|
|
235
|
+
# transaction open on this connection, and every later execute() on the store
|
|
236
|
+
# would silently run inside that dangling transaction.
|
|
237
|
+
self._conn.execute("COMMIT")
|
|
238
|
+
except BaseException:
|
|
239
|
+
self._conn.execute("ROLLBACK")
|
|
240
|
+
raise
|
|
241
|
+
self._causes_cache = None
|
|
242
|
+
self._trace_cache = None
|
|
243
|
+
|
|
244
|
+
def ancestors(self, step_id: str) -> list[Step]:
|
|
245
|
+
"""Raw CAUSED_BY ancestors of ``step_id``, nearest-cause first.
|
|
246
|
+
|
|
247
|
+
Implementation: pull single-hop CAUSED_BY adjacency for the whole graph, then BFS in
|
|
248
|
+
Python. The BFS preserves the in-memory store's edge-order semantics on fan-in
|
|
249
|
+
(Cypher gives no row-order guarantee, so we'd otherwise diverge on multi-parent
|
|
250
|
+
steps). We deliberately do NOT use a variable-length match here: a whole-graph pull
|
|
251
|
+
avoids backend-specific path bounds and guarantees complete RCA at any depth.
|
|
252
|
+
"""
|
|
253
|
+
self._check_open()
|
|
254
|
+
steps_by_id = {s.step_id: s for s in self._load_all_steps()}
|
|
255
|
+
if step_id not in steps_by_id:
|
|
256
|
+
raise KeyError(f"unknown step {step_id!r}")
|
|
257
|
+
adjacency = self._cause_adjacency(steps_by_id)
|
|
258
|
+
|
|
259
|
+
out: list[Step] = []
|
|
260
|
+
seen: set[str] = {step_id}
|
|
261
|
+
queue: deque[str] = deque(adjacency.get(step_id, []))
|
|
262
|
+
while queue:
|
|
263
|
+
cur = queue.popleft()
|
|
264
|
+
if cur in seen:
|
|
265
|
+
continue
|
|
266
|
+
seen.add(cur)
|
|
267
|
+
# Copy: the Step model is not frozen and these instances live in the shared
|
|
268
|
+
# per-store cache — a caller mutating a result must not corrupt later reads.
|
|
269
|
+
out.append(steps_by_id[cur].model_copy(deep=True))
|
|
270
|
+
queue.extend(adjacency.get(cur, []))
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
def export_artifact(self, path: str | Path) -> None:
|
|
274
|
+
artifact.save_atomic(self.trace(), path)
|
|
275
|
+
|
|
276
|
+
def trace(self) -> NormalizedTrace:
|
|
277
|
+
"""Reconstruct the loaded trace from the DB by re-running :func:`normalize`.
|
|
278
|
+
|
|
279
|
+
We **only** pull the raw layer (steps + CAUSED_BY) from LadybugDB and let normalize()
|
|
280
|
+
re-derive ``BELONGS_TO``/``TREE_PARENT``/``projection_lossy``. That collapses two
|
|
281
|
+
round-trip invariants into one source of truth: LadybugStore is byte-stable iff
|
|
282
|
+
``normalize()`` is deterministic (it is). The TREE_PARENT and BELONGS_TO rows that
|
|
283
|
+
live in the DB are still useful for ad-hoc Cypher against the derived layer; they
|
|
284
|
+
just aren't load-bearing for artifact round-trip.
|
|
285
|
+
"""
|
|
286
|
+
self._check_open()
|
|
287
|
+
if self._trace is None:
|
|
288
|
+
raise RuntimeError("store has no trace loaded")
|
|
289
|
+
if self._trace_cache is not None:
|
|
290
|
+
# Deep copy: the model is not frozen, so handing out the cached instance
|
|
291
|
+
# would let one caller's mutation corrupt every later read (and the
|
|
292
|
+
# pure-Python fallback matcher). A copy is still far cheaper than the
|
|
293
|
+
# whole-graph dump + re-normalize it replaces.
|
|
294
|
+
return self._trace_cache.model_copy(deep=True)
|
|
295
|
+
raw_steps = [
|
|
296
|
+
s.model_copy(update={"projection_lossy": False}) for s in self._load_all_steps()
|
|
297
|
+
]
|
|
298
|
+
caused_by_pairs = _collect(
|
|
299
|
+
self._conn.execute(
|
|
300
|
+
"MATCH (a:Step)-[e:CAUSED_BY]->(b:Step) "
|
|
301
|
+
"RETURN a.step_id, b.step_id, e.origin"
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
raw_edges = [
|
|
305
|
+
Edge(
|
|
306
|
+
type=EdgeType.CAUSED_BY,
|
|
307
|
+
src=src,
|
|
308
|
+
dst=dst,
|
|
309
|
+
origin=EdgeOrigin(origin) if origin else None,
|
|
310
|
+
)
|
|
311
|
+
for src, dst, origin in caused_by_pairs
|
|
312
|
+
]
|
|
313
|
+
self._trace_cache = normalize(
|
|
314
|
+
RawTrace(trace=self._trace, steps=raw_steps, causal_edges=raw_edges)
|
|
315
|
+
)
|
|
316
|
+
return self._trace_cache.model_copy(deep=True)
|
|
317
|
+
|
|
318
|
+
# --- pattern matching (the reason LadybugDB earns its weight) ---
|
|
319
|
+
|
|
320
|
+
def find_matches(self, pattern: PathPattern) -> list[list[str]]:
|
|
321
|
+
"""Run a :class:`PathPattern` as compiled Cypher.
|
|
322
|
+
|
|
323
|
+
Returns the **same list, in the same order**, as
|
|
324
|
+
:func:`tracegraph.analysis.find_matches` on the in-memory store. Pure-Python's
|
|
325
|
+
traversal visits outer steps in canonical step order and expands each child level
|
|
326
|
+
in canonical CAUSED_BY order — both of which, after :func:`normalize` canonicalization,
|
|
327
|
+
sort to ``(seq, step_id)`` per position. So we reproduce that ordering on the rows
|
|
328
|
+
Ladybug returns. (Cypher itself gives no row-order guarantee; without this sort the
|
|
329
|
+
backends would silently disagree.)
|
|
330
|
+
|
|
331
|
+
Honest fallback: a pattern with an unbounded (or over-30-hop) gap cannot be expressed
|
|
332
|
+
within the compiler's verified variable-length cap, so ``compile_to_cypher`` raises
|
|
333
|
+
:class:`~tracegraph.analysis.UncompilablePattern`. Rather than emit a query that would
|
|
334
|
+
silently truncate, we fall back to a whole-graph pull (:meth:`trace`) plus the *same*
|
|
335
|
+
pure-Python matcher — equivalence by identity, exactly the load-then-traverse posture
|
|
336
|
+
:meth:`ancestors` already uses for the same cap. The accelerator degrades to *slower*,
|
|
337
|
+
never to *wrong*; ``fell_back_to_python`` records that it happened.
|
|
338
|
+
"""
|
|
339
|
+
self._check_open()
|
|
340
|
+
self.fell_back_to_python = False
|
|
341
|
+
if not pattern.steps:
|
|
342
|
+
# Match the pure-Python convention so callers can be backend-agnostic; the
|
|
343
|
+
# compile step would raise, but raising here would break that contract.
|
|
344
|
+
return []
|
|
345
|
+
try:
|
|
346
|
+
q = compile_to_cypher(pattern)
|
|
347
|
+
except UncompilablePattern:
|
|
348
|
+
self.fell_back_to_python = True
|
|
349
|
+
return py_find_matches(self.trace(), pattern)
|
|
350
|
+
rows = _collect(self._conn.execute(q.cypher, q.params))
|
|
351
|
+
steps_by_id = {s.step_id: s for s in self._load_all_steps()}
|
|
352
|
+
rows.sort(key=lambda row: tuple((steps_by_id[sid].seq, sid) for sid in row))
|
|
353
|
+
return [list(row) for row in rows]
|
|
354
|
+
|
|
355
|
+
# --- internals ---
|
|
356
|
+
|
|
357
|
+
def _insert_trace(self, t: Trace) -> None:
|
|
358
|
+
placeholders = ", ".join(f"{f}: ${f}" for f in _TRACE_FIELDS)
|
|
359
|
+
stmt = f"CREATE (:Trace {{{placeholders}}})"
|
|
360
|
+
self._conn.execute(stmt, _trace_params(t))
|
|
361
|
+
|
|
362
|
+
def _insert_edge_batch(self, edge_type: EdgeType, edges: list[Edge]) -> None:
|
|
363
|
+
# BELONGS_TO targets a Trace node; the causal/tree edges stay Step→Step.
|
|
364
|
+
target_label = "Trace" if edge_type is EdgeType.BELONGS_TO else "Step"
|
|
365
|
+
relationship = (
|
|
366
|
+
f"[:{edge_type.value} {{origin: r.origin}}]"
|
|
367
|
+
if edge_type is EdgeType.CAUSED_BY
|
|
368
|
+
else f"[:{edge_type.value}]"
|
|
369
|
+
)
|
|
370
|
+
stmt = (
|
|
371
|
+
"UNWIND $rows AS r "
|
|
372
|
+
f"MATCH (src:Step), (dst:{target_label}) "
|
|
373
|
+
f"WHERE src.step_id = r.src AND dst.{'trace_id' if target_label == 'Trace' else 'step_id'} = r.dst "
|
|
374
|
+
f"CREATE (src)-{relationship}->(dst) "
|
|
375
|
+
"RETURN count(*)"
|
|
376
|
+
)
|
|
377
|
+
rows: list[dict[str, Any]] = []
|
|
378
|
+
for e in edges:
|
|
379
|
+
row: dict[str, Any] = {"src": e.src, "dst": e.dst}
|
|
380
|
+
if edge_type is EdgeType.CAUSED_BY:
|
|
381
|
+
row["origin"] = e.origin.value if e.origin else None
|
|
382
|
+
rows.append(row)
|
|
383
|
+
result = _collect(self._conn.execute(stmt, {"rows": rows}))
|
|
384
|
+
created = result[0][0] if result else 0
|
|
385
|
+
if created != len(rows):
|
|
386
|
+
# UNWIND+MATCH silently skips a row whose endpoint doesn't exist; that would
|
|
387
|
+
# break the batch's all-or-nothing contract, so surface it as an error — the
|
|
388
|
+
# surrounding transaction in upsert_edges rolls the whole batch back.
|
|
389
|
+
raise ValueError(
|
|
390
|
+
f"{len(rows) - created} of {len(rows)} {edge_type.value} edge(s) "
|
|
391
|
+
"reference unknown endpoints"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _cause_adjacency(self, steps_by_id: dict[str, Step]) -> dict[str, list[str]]:
|
|
395
|
+
"""Whole-graph single-hop CAUSED_BY adjacency (effect -> cause), cached per store.
|
|
396
|
+
|
|
397
|
+
Same load-then-traverse pattern as trace()/find_matches, reproducing
|
|
398
|
+
InMemoryStore.ancestors exactly at any depth. Per-node cause order matches the
|
|
399
|
+
InMemoryStore's CAUSED_BY insertion order, which after :func:`normalize`
|
|
400
|
+
canonicalization is ``(effect.seq, cause.seq, src, dst)`` — Cypher gives no
|
|
401
|
+
row-order guarantee, so we re-sort here.
|
|
402
|
+
"""
|
|
403
|
+
if self._causes_cache is None:
|
|
404
|
+
rows = _collect(
|
|
405
|
+
self._conn.execute(
|
|
406
|
+
"MATCH (effect:Step)-[:CAUSED_BY]->(cause:Step) "
|
|
407
|
+
"RETURN effect.step_id, cause.step_id"
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
adjacency: dict[str, list[str]] = {}
|
|
411
|
+
for effect_id, cause_id in rows:
|
|
412
|
+
adjacency.setdefault(effect_id, []).append(cause_id)
|
|
413
|
+
for causes in adjacency.values():
|
|
414
|
+
causes.sort(key=lambda cid: (steps_by_id[cid].seq, cid))
|
|
415
|
+
self._causes_cache = adjacency
|
|
416
|
+
return self._causes_cache
|
|
417
|
+
|
|
418
|
+
def _load_all_steps(self) -> list[Step]:
|
|
419
|
+
if self._steps_cache is None:
|
|
420
|
+
rows = _collect(
|
|
421
|
+
self._conn.execute(
|
|
422
|
+
"MATCH (s:Step) RETURN " + ", ".join(f"s.{f}" for f in _STEP_FIELDS)
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
self._steps_cache = [_step_from_row(r) for r in rows]
|
|
426
|
+
return self._steps_cache
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# --- helpers (module-level so they're easy to unit-test if needed) ---
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _step_params(s: Step) -> dict[str, Any]:
|
|
433
|
+
"""Bind a Step to the names declared in ``_STEP_FIELDS`` (enums → ``.value`` strings)."""
|
|
434
|
+
return {
|
|
435
|
+
"step_id": s.step_id,
|
|
436
|
+
"trace_id": s.trace_id,
|
|
437
|
+
"seq": s.seq,
|
|
438
|
+
"ts": s.ts,
|
|
439
|
+
"kind": s.kind.value,
|
|
440
|
+
"source": s.source.value,
|
|
441
|
+
"name": s.name,
|
|
442
|
+
"status": s.status.value,
|
|
443
|
+
"error_msg": s.error_msg,
|
|
444
|
+
"evidence_json": (
|
|
445
|
+
json.dumps(s.evidence.model_dump(mode="json"), sort_keys=True)
|
|
446
|
+
if s.evidence is not None
|
|
447
|
+
else None
|
|
448
|
+
),
|
|
449
|
+
"projection_lossy": s.projection_lossy,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _trace_params(t: Trace) -> dict[str, Any]:
|
|
454
|
+
return {
|
|
455
|
+
"trace_id": t.trace_id,
|
|
456
|
+
"source_kind": t.source_kind,
|
|
457
|
+
"run_id": t.run_id,
|
|
458
|
+
"thread_id": t.thread_id,
|
|
459
|
+
"status": t.status.value,
|
|
460
|
+
"causal_fidelity": t.causal_fidelity.value,
|
|
461
|
+
"links_preserved": t.links_preserved,
|
|
462
|
+
"decision_evidence_json": json.dumps(
|
|
463
|
+
[item.model_dump(mode="json") for item in t.decision_evidence], sort_keys=True
|
|
464
|
+
),
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _step_from_row(row: list[Any]) -> Step:
|
|
469
|
+
"""Inverse of ``_step_params``: a SELECT row in ``_STEP_FIELDS`` order back to a Step."""
|
|
470
|
+
by_name = dict(zip(_STEP_FIELDS, row, strict=True))
|
|
471
|
+
return Step(
|
|
472
|
+
step_id=by_name["step_id"],
|
|
473
|
+
trace_id=by_name["trace_id"],
|
|
474
|
+
seq=by_name["seq"],
|
|
475
|
+
ts=by_name["ts"],
|
|
476
|
+
kind=StepKind(by_name["kind"]),
|
|
477
|
+
source=StepSource(by_name["source"]),
|
|
478
|
+
name=by_name["name"],
|
|
479
|
+
status=StepStatus(by_name["status"]),
|
|
480
|
+
error_msg=by_name["error_msg"],
|
|
481
|
+
evidence=(
|
|
482
|
+
StepEvidence.model_validate(json.loads(by_name["evidence_json"]))
|
|
483
|
+
if by_name["evidence_json"]
|
|
484
|
+
else None
|
|
485
|
+
),
|
|
486
|
+
projection_lossy=by_name["projection_lossy"],
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _collect(result: Any) -> list[list[Any]]:
|
|
491
|
+
"""Drain a Ladybug QueryResult into a plain list of rows, then release it.
|
|
492
|
+
|
|
493
|
+
Every read path funnels through here, so leaving the result objects to the garbage
|
|
494
|
+
collector accumulated one live handle per query for the lifetime of the store — visible
|
|
495
|
+
on a directory sweep, where a single store answers many ancestors()/find_matches() calls.
|
|
496
|
+
"""
|
|
497
|
+
rows: list[list[Any]] = []
|
|
498
|
+
try:
|
|
499
|
+
while result.has_next():
|
|
500
|
+
rows.append(result.get_next())
|
|
501
|
+
finally:
|
|
502
|
+
close = getattr(result, "close", None)
|
|
503
|
+
if callable(close):
|
|
504
|
+
close()
|
|
505
|
+
return rows
|