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,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