agent-tracegraph 0.5.0__tar.gz → 0.6.1__tar.gz

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.
Files changed (40) hide show
  1. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/CHANGELOG.md +208 -0
  2. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/PKG-INFO +58 -7
  3. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/README.md +56 -5
  4. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/pyproject.toml +5 -2
  5. agent_tracegraph-0.6.1/src/tracegraph/_errors.py +35 -0
  6. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/adapters/langgraph_checkpoint.py +37 -8
  7. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/adapters/otlp_spans.py +4 -1
  8. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/analysis/check.py +2 -0
  9. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/analysis/diagnose.py +61 -5
  10. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/analysis/memo.py +4 -1
  11. agent_tracegraph-0.6.1/src/tracegraph/analysis/motifs.py +272 -0
  12. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/analysis/patterns.py +95 -48
  13. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/artifact.py +40 -30
  14. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/cli.py +153 -8
  15. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/graph_ref.py +18 -8
  16. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/model.py +49 -0
  17. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/store/ladybug.py +31 -6
  18. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/.gitignore +0 -0
  19. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/LICENSE +0 -0
  20. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/SECURITY.md +0 -0
  21. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/contracts/analysis-report.schema.json +0 -0
  22. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/contracts/check-report.schema.json +0 -0
  23. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/contracts/phoenix-runtime.json +0 -0
  24. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/contracts/review-candidates.schema.json +0 -0
  25. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/__init__.py +0 -0
  26. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/_atomic_write.py +0 -0
  27. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/adapters/__init__.py +0 -0
  28. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/adapters/base.py +0 -0
  29. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/adapters/phoenix_export.py +0 -0
  30. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/analysis/__init__.py +0 -0
  31. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/analysis/ahu.py +0 -0
  32. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/analysis/explain.py +0 -0
  33. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/input_validation.py +0 -0
  34. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/normalize.py +0 -0
  35. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/py.typed +0 -0
  36. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/review_candidates.py +0 -0
  37. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/sqlite_snapshot.py +0 -0
  38. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/store/__init__.py +0 -0
  39. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/store/base.py +0 -0
  40. {agent_tracegraph-0.5.0 → agent_tracegraph-0.6.1}/src/tracegraph/store/in_memory.py +0 -0
@@ -6,6 +6,214 @@ The **artifact** schema version and the **report**/**review-candidate** JSON sch
6
6
  separate contracts from this version number; changes to any of them are called out
7
7
  explicitly.
8
8
 
9
+ ## 0.6.1 - 2026-10-20
10
+
11
+ ### Dependencies
12
+
13
+ - `langgraph-checkpoint-sqlite` now requires `>=3.1.1` (was `>=2`), the first release with the
14
+ fix for GHSA-47pj-3jcm-6whg, where store namespace prefixes could match across segment
15
+ boundaries. Tracegraph reads checkpoints through `SqliteSaver` and never uses the store; the
16
+ floor keeps an install from resolving a flagged version. The source, commands and JSON
17
+ contracts are the same as 0.6.0, but the install range is narrower: an environment that pins
18
+ `langgraph-checkpoint-sqlite==3.1.0` resolves with 0.6.0 and not with 0.6.1.
19
+
20
+ ## 0.6.0 - 2026-10-20
21
+
22
+ ### Added
23
+
24
+ - **`tracegraph summarize <preset> <artifacts...>`** (#60) groups a preset's matches across an
25
+ artifact batch by observed shape, so an operator can pick the recurring failure shape to look at
26
+ first. A group's signature (`motif-signature/v1`) is the matched steps' kind, status, source and
27
+ safe-v1 name, the `CAUSED_BY` origins between them, and the trace's source kind, causal
28
+ fidelity, link setting and whether it discloses heuristically chosen edges (per trace, not per
29
+ edge). It has no ids, error text or artifact file paths; an identifier-shaped name that looks
30
+ like a path is kept, as in `analyze` reports. Each group reports its match count and trace count
31
+ separately, plus up to three representatives (artifact digest and step ids). Only matches
32
+ confirmed by `check`'s rule are grouped; the rest are counted as unconfirmed. Fields that are
33
+ not known are kept as unknown. A coverage section always reports skipped artifacts, traces that
34
+ may hide a match, disclosed capture gaps, traces with no capture record and unconfirmed matches.
35
+ `--out` writes the `tracegraph.motif-summary` schema-v1 JSON with the same input protection as
36
+ `export-review-candidates`. Only strictly adjacent presets are accepted; the three gap presets
37
+ exit 2. Memory holds one trace and one entry per group, never the matches. A 160-wide retry fan
38
+ (25,600 matches) adds about 27 KiB over a 40-wide one, where collecting the matches adds about
39
+ 2 MiB. The issue suggested validating demand first; this is the minimal version.
40
+
41
+ ### Changed
42
+
43
+ - **`query --limit` no longer holds every match of the trace it is reading** (#79). Each
44
+ trace's matches were collected and sorted in full before the cap was applied, so one trace
45
+ with millions of matches took about 1 GiB at `--limit 1`. The matchers now produce matches
46
+ one at a time and `query` keeps only those still under the cap. The footer's total stays
47
+ exact, so every match is still counted. Measured on macOS/arm64 at `--limit 1` with
48
+ synthetic traces: a 2500-step chain of failing `x` agents (`repeated-agent-failure`, 3.1M
49
+ matches), and 1500 tools feeding one `retry:x` step that feeds 1500 failing tools
50
+ (`tool-retry-failure` and `tool-retry-failure-near`, 2.25M matches each):
51
+
52
+ | trace, preset | memory backend | `--backend ladybug` |
53
+ | --- | --- | --- |
54
+ | chain, `repeated-agent-failure` | 976 → 70 MiB, 5.5 → 2.8 s | 1226 → 308 MiB (Python fallback) |
55
+ | fan, `tool-retry-failure` | 1097 → 74 MiB, 2.8 → 1.6 s | 1753 → 757 MiB (Cypher) |
56
+ | fan, `tool-retry-failure-near` | 927 → 74 MiB, 3.2 → 2.1 s | 1470 → 613 MiB (Cypher) |
57
+
58
+ The Cypher rows stay high because LadybugDB buffers the full result set; an empty store
59
+ already takes about 300 MiB. The matches selected, their order, the footer total, exit codes
60
+ and error precedence are unchanged, and `find_matches` returns the same list for every caller.
61
+ Time still grows with the match
62
+ count. A mode that stops early and reports no total was considered and not built.
63
+ `LadybugStore` gains `first_matches(pattern, n)`; `tracegraph.analysis.patterns` gains
64
+ `iter_matches` and `first_matches`.
65
+
66
+ ### Fixed
67
+
68
+ - **Boundaries that promise `ValueError` no longer leak an error from describing the one they
69
+ caught** (#109). Naming a caught exception runs caller code: `str(exc)` calls its `__str__`,
70
+ and a metaclass can make the type's `__name__` a property. When that raised, its error
71
+ escaped instead: from LangGraph ingest when the saver's listing or a supplied `schedule_graph`
72
+ fails, and from `--graph` when the module fails to import. The validated copy that
73
+ `artifact.dumps()` and `analyze()` use since #82 and #93 (both in this release) named its
74
+ caught exception the same way and is hardened too. These now describe the exception through
75
+ one internal guard (`tracegraph._errors`) that falls back to fixed text, and uses only a name or
76
+ message that is a plain `str`.
77
+ Ordinary exceptions are described exactly as before. `artifact.from_obj()` on a schema-v1
78
+ payload also refuses, as `ValueError`, an edge sequence that raises `SystemExit` or another
79
+ `BaseException` (the current schema already did for `SystemExit`), or whose type check
80
+ raises; `KeyboardInterrupt` still propagates.
81
+
82
+ - **`--graph` loads a module file whose path is not valid UTF-8** (#110). On POSIX such a path
83
+ arrives surrogate-escaped, and naming the module from its strict UTF-8 encoding refused it:
84
+ `ingest --graph` exited 2 with a `UnicodeEncodeError` for a file it could import. Such a path
85
+ is now named from its file-system bytes, under its own prefix so it cannot take another
86
+ file's name. Every path that loaded before keeps its module name.
87
+
88
+ - **Cost totals and their baseline delta are exact** (#111). They were computed at `Decimal`'s
89
+ default precision of 28 significant digits and rounded without a note, while the total was
90
+ still reported as complete: a cost of `0.12345678901234567890123456789` was totalled as
91
+ `0.1234567890123456789012345679`, and `10000000000000000000000000000` plus `0.01` as
92
+ `10000000000000000000000000000`. They are now computed at a precision of 160, which is exact
93
+ for any sum of costs within the 64-character bound up to about 10^32 steps. Past that, the total
94
+ (or only the delta) is withheld with a note instead of rounded. Ordinary costs report the same
95
+ digits as before (`1.00 + 1.00` is still `2.00`).
96
+
97
+ - **A cost too long to write out is no longer summed or stored** (#100). A few characters of
98
+ exponent stand for a number of any size. A model-valid `total_cost` of `"1e1000000"` made
99
+ `analyze` fail with `decimal.Overflow`, an `ArithmeticError` rather than the promised
100
+ `ValueError`, with or without a baseline; `"9e999999"` did not overflow but wrote a
101
+ million-digit total into the report. OTLP ingest wrote such a value out in full, so a 10-byte
102
+ attribute `"1e99999999"` became a 100,000,000-character `total_cost` in the artifact. A cost is
103
+ now read only if, written out in full, it is at most 64 characters (`model.MAX_COST_CHARS`),
104
+ checked from its digits and exponent without writing it out (`model.cost_is_bounded(Decimal)`).
105
+ `analyze` withholds the total
106
+ with its own note, as for an unreadable cost; OTLP ingest treats a longer cost as unreadable,
107
+ so the total and component costs are disclosed as `otlp.unreadable_total_cost` and
108
+ `otlp.unreadable_component_cost` as before, and a readable alias is still used. Artifacts are
109
+ not refused at load. The bound is on each cost read, not on the total: summed exactly (#111),
110
+ two 64-character costs can make a 127-character total, and a million of them 133.
111
+
112
+ - **A lone surrogate in a producer string no longer ends a command in a traceback** (#101). JSON
113
+ can carry one (`"\ud800"`), and the artifact loads and validates, but strict UTF-8 refused it
114
+ in several places: `analyze` (and `check` and the new `summarize`, which share the helper)
115
+ hashing a name into its `redacted:` alias; `ingest-otlp` without `--out` hashing the trace id
116
+ into the default file
117
+ name; `export-incident-memo` hashing the run id; `inspect`, `explain` and `query` writing the
118
+ name to the terminal; and LangGraph ingest hashing a routing channel into a candidate task id.
119
+ The three hashes now encode with `surrogatepass`, so every other string hashes to the same
120
+ alias, file name and digest as before. The LangGraph candidate is skipped, because LangGraph
121
+ could not have produced a task id from it. The CLI escapes what its output streams cannot
122
+ encode instead of failing.
123
+
124
+ - **CLI output prints an emoji code in a producer string as written** (#102). The CLI's Rich
125
+ consoles kept emoji substitution on, and escaping markup does not turn it off, so a step named
126
+ `:ok:` printed as 🆗 in `query`, `inspect`, `explain` and `analyze` while the artifact and the
127
+ JSON reports held `:ok:`. Both consoles now have it off, so the new `summarize` prints the same
128
+ way. No text the CLI prints itself used an emoji code.
129
+
130
+ - **`artifact.from_obj()` on a schema-v1 payload raises only `ValueError`** (#103). When reading
131
+ a caller-supplied lazy edge sequence failed, the migration formatted the exception into its
132
+ message, so an exception whose `__str__` raises escaped as that error instead. The message no
133
+ longer formats the exception at all (its type's name can run caller code too, through a
134
+ metaclass); the original stays on the chain as `__cause__`. `loads()` and the CLI parse JSON
135
+ into lists, so neither could reach this.
136
+
137
+ - **`analyze()`, `check.evaluate()` and `build_incident_memo()` raise only `ValueError` on a
138
+ trace the model refuses** (#93). On a trace changed in place after it was built they raised a
139
+ raw `TypeError` or `AttributeError` (`seq = "zero"`, `steps = None`, `trace = None`, a step
140
+ `kind = "bogus"`, an integer `error_msg`), mostly from analysis code past their structural
141
+ check, and the memo rendered a step `status = "maybe"` without complaint. Each now analyzes the
142
+ copy the model validates, the same one `artifact.dumps()` and the writers use, so a value the
143
+ model coerces (`"0"` for a `seq`) is analyzed as it would be read and the caller's trace is
144
+ left unchanged. `analyze()` and the memo still accept structurally sound but non-canonical
145
+ traces; `check.evaluate()` still requires the canonical form. The validated copy does not change
146
+ the report for a valid trace; cost totals change separately in this release (#100, #111). That
147
+ copy is now made from a Python-mode dump: the JSON-mode dump
148
+ turned a NaN in `trace.run_id` or `trace.links_preserved` into `null`, which validated, so
149
+ `dumps()` and both writers erased the value instead of refusing it. It no longer depends on
150
+ the warnings filter either: under `-W error` a coercible value used to be refused through
151
+ pydantic's serializer warning. Cost on a 6000-step chain, best of 7, measured in PR #95 against
152
+ its parent `2eaa403` (which already had #82): `analyze()` 0.25–0.27 s
153
+ → 0.30–0.32 s, with a baseline 0.57–0.58 s → 0.69–0.70 s, `check.evaluate()` 0.65–0.67 s →
154
+ 0.78–0.79 s, `build_incident_memo()` 0.016 s → 0.044 s.
155
+
156
+ - **`artifact.dumps()` raises only `ValueError`** (#82). A trace changed in place after it was
157
+ built (pydantic does not revalidate on attribute assignment) could make it raise a raw
158
+ `TypeError` (`seq = "zero"`, `steps = None`, `edges = None`) or write a field the readers
159
+ reject (`status = "maybe"`, an integer `step_id`). `dumps()` now serializes a copy the model
160
+ has validated, as the writers already did, and raises `ValueError` for anything the model
161
+ refuses or cannot serialize: pydantic's `ValidationError` for a mistyped field, a
162
+ serialization error (also a `ValueError`) for a value it cannot dump at all, such as
163
+ `trace = None`. A value the model coerces (`"0"` for a `seq`) is written as it would be read.
164
+ A lazy sequence that fails while being serialized, through `dumps()` or either writer, is now
165
+ reported as a `ValueError` too, instead of whatever it raised. `dumps()` still checks fields,
166
+ not structure: a dangling edge or a stale trace status is written. Checking structure there
167
+ would refuse the structurally sound but non-canonical traces that `analyze()` digests, such as
168
+ one with a stale trace status (`analyze()` itself refuses a dangling edge). Unknown-field flags
169
+ without a capture record now reach a `dumps()`
170
+ caller as `ValidationError`, as they already did for the writers. A valid trace is
171
+ serialized byte for byte as before. Cost on a 6000-step chain, measured in PR #94 (its helper
172
+ was changed again by #93): `dumps()` 0.15 s → 0.20 s;
173
+ `analyze()` digests the current trace through it, a baseline comparison and `check` digest
174
+ both.
175
+
176
+ - **An error already in the parent graph is no longer recorded again around a subgraph** (#85).
177
+ A LangGraph checkpoint is an error where the error channel holds one and none of the
178
+ checkpoints it was built from did, but the check read only a checkpoint's causal parents, and
179
+ three shapes hid the earlier error from it. A subgraph's `__start__` was compared only with
180
+ the subgraph's input checkpoint, which holds its values as pending writes. A persistent
181
+ subgraph (`checkpointer=True`) entered again was compared with its previous invocation, not
182
+ with the outer checkpoint that launched it. And the parent-graph checkpoint after a subgraph
183
+ was compared only with the subgraph's exit, so a subgraph whose state has no error channel, or
184
+ that cleared its own copy, made the outer error look new. One failure was recorded two or more
185
+ times: three with nested subgraphs, or when the subgraph cleared its copy. The check now also
186
+ reads each checkpoint's declared parents, and for a `__start__`, its input checkpoint's
187
+ declared parent and its launch parent (`metadata.parents`). A former duplicate written by a
188
+ node that rewrote the still-set channel is disclosed as `langgraph.rewritten_error_channel`
189
+ instead, with its stderr line, as for any such write since #47; `check` reads that gap as
190
+ before.
191
+
192
+ - **A fork copy is no longer a second failure.** `update_state(..., as_node="__copy__")`
193
+ copies a checkpoint and runs no node, but it is linked to the copied checkpoint's parent, so
194
+ copying the checkpoint where an error first appeared (in the root graph, or a subgraph's
195
+ `__start__`) recorded that error again. A fork now never records an error.
196
+
197
+ For both: steps, edges, names and kinds are unchanged; what changes is which steps are errors
198
+ (with their `error_msg`) and the disclosure above. An error first written inside a subgraph, a
199
+ `Send` payload error below a clean state, and an error a caller's `update_state` writes are
200
+ recorded as before. Artifacts change only for histories that held such a duplicate. The
201
+ schema stays v7, and the committed fixtures are byte-identical. One shape still records the
202
+ error twice: a fork copy of a subgraph's *input* checkpoint, then resumed (#90).
203
+
204
+ ### Known limitations
205
+
206
+ - Resuming after a fork copy of a subgraph's input checkpoint
207
+ (`update_state(..., as_node="__copy__")` on it) records an error again at the subgraph's next
208
+ start: one the parent graph held (#90) or one a `Send` payload carried (#96). Four ways of
209
+ recognizing such a copy were tried. Three classified the fork by its content: an unread
210
+ `__start__` value, a `__start__` version absent from `versions_seen`, and pending writes from
211
+ LangGraph's `__start__` task. The fourth matched it to an input checkpoint with identical
212
+ `channel_values`, `channel_versions` and `versions_seen`, and compared its run with the
213
+ original's. Each hid a genuine failure on a plain `Pregel` graph, so none shipped. A
214
+ `Pregel` input's first step can run a user node, and a no-op `update_state(...,
215
+ as_node="__input__")` can write an input identical to a loop checkpoint.
216
+
9
217
  ## 0.5.0 - 2026-10-18
10
218
 
11
219
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-tracegraph
3
- Version: 0.5.0
3
+ Version: 0.6.1
4
4
  Summary: Time-travel debugging and causal RCA for LangGraph agent traces, on a causal graph.
5
5
  Project-URL: Homepage, https://github.com/memtomem/tracegraph
6
6
  Project-URL: Repository, https://github.com/memtomem/tracegraph
@@ -19,7 +19,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
19
  Classifier: Topic :: Software Development :: Debuggers
20
20
  Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.12
22
- Requires-Dist: langgraph-checkpoint-sqlite>=2
22
+ Requires-Dist: langgraph-checkpoint-sqlite>=3.1.1
23
23
  Requires-Dist: langgraph-checkpoint>=4.1
24
24
  Requires-Dist: pydantic<3,>=2
25
25
  Requires-Dist: rich>=13
@@ -110,6 +110,7 @@ tracegraph presets # list cross-trace query patterns
110
110
  tracegraph query tool-failure A.json B.json # find a causal pattern across many traces
111
111
  tracegraph query tool-retry-failure *.json # explicit retry marker, then same tool failing
112
112
  tracegraph query tool-failure --backend ladybug A.json B.json # use the optional Cypher accelerator
113
+ tracegraph summarize tool-retry-failure traces/ -o motifs.json # group matches by observed shape
113
114
 
114
115
  # export versioned, body-free governance evidence (an empty match set is a valid report)
115
116
  tracegraph export-review-candidates tool-retry-failure *.json -o candidates.json
@@ -130,7 +131,7 @@ Frequently-used options, beyond `--out` / `--json-out`:
130
131
  | `--explain` | `query` | Print each match's raw causal chain back to its root cause. |
131
132
  | `--structure` | `diff` | Compare topology only, ignoring labels. |
132
133
  | `--backend` | `query` | Use the optional LadybugDB Cypher accelerator instead of the pure-Python matcher. |
133
- | `--strict` | `query`, `export-review-candidates` | Fail with exit 2 when a directory holds a `*.json` file that is not a valid artifact, instead of skipping it with a warning. Use it in CI gates, where a skipped corrupt run would otherwise read as "no match". |
134
+ | `--strict` | `query`, `summarize`, `export-review-candidates` | Fail with exit 2 when a directory holds a `*.json` file that is not a valid artifact, instead of skipping it with a warning. Use it in CI gates, where a skipped corrupt run would otherwise read as "no match". |
134
135
  | `--rule` | `check` | Rule to evaluate, repeatable; there is no default rule set. |
135
136
 
136
137
  ### Regression check
@@ -304,6 +305,37 @@ Every shipped preset has a stable `pattern_id` and positive integer `pattern_ver
304
305
  as `pattern-id@vN` by `presets` and `query`. Increment the version when match semantics or
305
306
  review-export eligibility changes; wording-only description edits keep the current version.
306
307
 
308
+ `summarize` groups one preset's matches across the same file/directory batches. It is meant for
309
+ deciding which recurring failure shape to look at first, without tallying `query` output by hand.
310
+ A group is one observed shape. Two matches share a group when, for every matched step, the kind,
311
+ status, source and safe-v1 name agree, and for every edge between them the `CAUSED_BY` origin
312
+ agrees, under the same source kind, causal fidelity and link setting, and the same answer to
313
+ whether the trace discloses causal edges chosen heuristically at ingest (`motif-signature/v1`).
314
+ That last answer is per trace: an OTLP export records a parent picked by timing with the same
315
+ origin as an unambiguous one, and says only that some edge was picked that way, not which. The
316
+ answer is `disclosed` whenever a recognized heuristic-edge gap is present, else `not_disclosed`.
317
+ It is `unknown` for a trace with no capture record, or with a gap code this build does not know
318
+ and no recognized heuristic-edge gap. It is `not_applicable` for a single-step preset, which has
319
+ no edge.
320
+ Trace and step ids, error text and artifact file paths are never part of it. Names pass the same
321
+ safe-v1 rule as `analyze` reports. An identifier-shaped name is kept even when it looks like a path or an
322
+ address, and anything else becomes `redacted:<sha256>`. An operation whose name changes from run
323
+ to run gets one group per name. Each group counts its matches and, separately, the traces they
324
+ come from, and lists up to three representatives, each an artifact digest plus the matched step
325
+ ids. Only *confirmed* matches are grouped, by the same rule as `check`. A match that relies on a
326
+ substituted value (a defaulted kind, an unrecorded status) is counted as unconfirmed instead. A
327
+ field that is not known is written as `null` and listed under the step's `unknown`, never by the
328
+ value that stands in for it. The coverage section is always printed. It shows how many artifacts
329
+ were discovered, valid and skipped; how many traces may hide a match (`check`'s per-preset rule);
330
+ how many disclose any capture gap; how many carry no capture record; and the unconfirmed matches.
331
+ None of it is a rate, and an empty result is not an all-clear. A group does not show a shared root
332
+ cause. Version 1 accepts only strictly adjacent presets: between gap endpoints the matched path
333
+ is not recorded, so `repeated-agent-failure`, `tool-repeat-failure-heuristic` and
334
+ `tool-retry-failure-near` exit 2. `--out` writes the `tracegraph.motif-summary` schema-v1 JSON.
335
+ It must not name an input, is excluded from directory discovery, and is left untouched when the
336
+ run is refused. Exit 0 with at least one group, 1 with none (the report is still written), 2 on
337
+ an input error.
338
+
307
339
  `export-review-candidates` accepts the same artifact-file/directory batches as `query` and
308
340
  writes a deterministic schema-v1 envelope. Only presets ending in a failing `TOOL` predicate
309
341
  are eligible. The matched endpoint name must already be a qualified `server::tool` key —
@@ -343,7 +375,7 @@ and any change to them is called out in the [changelog](CHANGELOG.md).
343
375
  | Area | What exists |
344
376
  |---|---|
345
377
  | Sources | LangGraph checkpoint savers (including subgraph namespaces and native task failures), OTLP/OpenInference span exports, Phoenix CLI exports |
346
- | Commands | `ingest`, `ingest-otlp`, `ingest-phoenix`, `inspect`, `validate`, `explain`, `diff`, `analyze`, `presets`, `query`, `export-review-candidates`, `export-incident-memo`, `phoenix doctor`, `phoenix diagnose` |
378
+ | Commands | `ingest`, `ingest-otlp`, `ingest-phoenix`, `inspect`, `validate`, `explain`, `diff`, `analyze`, `presets`, `query`, `summarize`, `export-review-candidates`, `export-incident-memo`, `phoenix doctor`, `phoenix diagnose` |
347
379
  | Patterns | Versioned presets matched over the raw causal graph; the governance-eligible `tool-retry-failure@v2` requires an explicit producer retry marker, and repetition-only heuristics are query-only |
348
380
  | Backends | Pure-Python in-memory store (default); optional LadybugDB/Cypher accelerator (`[cypher]`), whose results must equal the pure-Python matcher's |
349
381
  | Ecosystem | SyncMill run correlation (`syncmill.run_id`), Toolgraph preflight evidence as non-causal metadata, a weekly Phoenix + SyncMill end-to-end workflow (Phoenix CLI and server pinned; SyncMill tracks its default branch) |
@@ -432,6 +464,19 @@ change (`ok` to `unset`, or the reverse).
432
464
 
433
465
  LangGraph ingestion reads both the configured error state channel and native task failures
434
466
  from checkpoint pending writes. A checkpoint is an error where the channel first holds an error.
467
+ An error the parent graph already held is not recorded again where a subgraph receives it, nor
468
+ where the parent graph continues after a subgraph that did not carry it, and a fork copy
469
+ (`update_state(..., as_node="__copy__")`), which runs no node, never records one.
470
+ One time-travel shape still records an error twice: copying a subgraph's *input* checkpoint as a
471
+ fork and resuming. The subgraph's next start records the error again, whether the parent graph
472
+ held it (#90) or a `Send` payload carried it (#96). The copy holds the same channels as the input
473
+ it copies but no link back to it. Each marker tried (an unread `__start__` value, a pending
474
+ `__start__` task) also appears on a fork of a plain `Pregel` graph's loop checkpoint, where
475
+ treating it as an input hid a genuine failure. Matching the copy to an input checkpoint with
476
+ identical channels and versions fails too. A `Pregel` input's first step can run a user node, so
477
+ the original run is no baseline for the copy's run. And a no-op
478
+ `update_state(..., as_node="__input__")` can write an input identical to a loop checkpoint. So
479
+ the duplicate is left in place.
435
480
  A node that writes the channel again while it still holds one may have failed again, or only
436
481
  have returned the whole state (and a reducer can change the value either way); the checkpoint
437
482
  cannot tell which, so such a write is not recorded as a failure but disclosed as the capture
@@ -497,9 +542,15 @@ unavailable, disclosing each on stderr; the producer's value is never kept. File
497
542
  and Phoenix `px` output share one size limit (`TRACEGRAPH_MAX_ARTIFACT_BYTES`). `query` reads
498
543
  artifacts one after another and keeps only the matches it prints (plus the traces `--explain`
499
544
  renders). With `--limit`, its peak memory therefore grows with the number of artifacts only
500
- by a small per-file record (path, trace id); without it, by the matches it prints. It still
501
- enumerates every match of the trace it is reading, to report the exact total: one trace with
502
- very many matches is not bounded by `--limit`.
545
+ by a small per-file record (path, trace id); without it, by the matches it prints. Inside one
546
+ trace, too, it keeps only the matches that still fit under `--limit`, but it still enumerates
547
+ and counts every match to report the exact total, so time grows with the match count: one
548
+ 2500-step trace with 3.1M matches takes about 3 s at `--limit 1`, in 70 MiB. The search's own
549
+ working state (steps reached at each distance of a gap) does not depend on `--limit`. With
550
+ `--backend ladybug` on a pattern that compiles to Cypher, LadybugDB itself buffers the
551
+ query's full result set: a 3001-step trace with 2.25M `tool-retry-failure` matches peaked
552
+ at 757 MiB, where the store alone takes about 300 MiB. A plain read, `count(*)` and
553
+ `ORDER BY … LIMIT` all buffered it in our measurements.
503
554
 
504
555
  Both stores isolate returned mutable objects. InMemory node upsert replaces by ID;
505
556
  Ladybug's existing insertion semantics are unchanged. Use `is_isomorphic` or `diff` for
@@ -79,6 +79,7 @@ tracegraph presets # list cross-trace query patterns
79
79
  tracegraph query tool-failure A.json B.json # find a causal pattern across many traces
80
80
  tracegraph query tool-retry-failure *.json # explicit retry marker, then same tool failing
81
81
  tracegraph query tool-failure --backend ladybug A.json B.json # use the optional Cypher accelerator
82
+ tracegraph summarize tool-retry-failure traces/ -o motifs.json # group matches by observed shape
82
83
 
83
84
  # export versioned, body-free governance evidence (an empty match set is a valid report)
84
85
  tracegraph export-review-candidates tool-retry-failure *.json -o candidates.json
@@ -99,7 +100,7 @@ Frequently-used options, beyond `--out` / `--json-out`:
99
100
  | `--explain` | `query` | Print each match's raw causal chain back to its root cause. |
100
101
  | `--structure` | `diff` | Compare topology only, ignoring labels. |
101
102
  | `--backend` | `query` | Use the optional LadybugDB Cypher accelerator instead of the pure-Python matcher. |
102
- | `--strict` | `query`, `export-review-candidates` | Fail with exit 2 when a directory holds a `*.json` file that is not a valid artifact, instead of skipping it with a warning. Use it in CI gates, where a skipped corrupt run would otherwise read as "no match". |
103
+ | `--strict` | `query`, `summarize`, `export-review-candidates` | Fail with exit 2 when a directory holds a `*.json` file that is not a valid artifact, instead of skipping it with a warning. Use it in CI gates, where a skipped corrupt run would otherwise read as "no match". |
103
104
  | `--rule` | `check` | Rule to evaluate, repeatable; there is no default rule set. |
104
105
 
105
106
  ### Regression check
@@ -273,6 +274,37 @@ Every shipped preset has a stable `pattern_id` and positive integer `pattern_ver
273
274
  as `pattern-id@vN` by `presets` and `query`. Increment the version when match semantics or
274
275
  review-export eligibility changes; wording-only description edits keep the current version.
275
276
 
277
+ `summarize` groups one preset's matches across the same file/directory batches. It is meant for
278
+ deciding which recurring failure shape to look at first, without tallying `query` output by hand.
279
+ A group is one observed shape. Two matches share a group when, for every matched step, the kind,
280
+ status, source and safe-v1 name agree, and for every edge between them the `CAUSED_BY` origin
281
+ agrees, under the same source kind, causal fidelity and link setting, and the same answer to
282
+ whether the trace discloses causal edges chosen heuristically at ingest (`motif-signature/v1`).
283
+ That last answer is per trace: an OTLP export records a parent picked by timing with the same
284
+ origin as an unambiguous one, and says only that some edge was picked that way, not which. The
285
+ answer is `disclosed` whenever a recognized heuristic-edge gap is present, else `not_disclosed`.
286
+ It is `unknown` for a trace with no capture record, or with a gap code this build does not know
287
+ and no recognized heuristic-edge gap. It is `not_applicable` for a single-step preset, which has
288
+ no edge.
289
+ Trace and step ids, error text and artifact file paths are never part of it. Names pass the same
290
+ safe-v1 rule as `analyze` reports. An identifier-shaped name is kept even when it looks like a path or an
291
+ address, and anything else becomes `redacted:<sha256>`. An operation whose name changes from run
292
+ to run gets one group per name. Each group counts its matches and, separately, the traces they
293
+ come from, and lists up to three representatives, each an artifact digest plus the matched step
294
+ ids. Only *confirmed* matches are grouped, by the same rule as `check`. A match that relies on a
295
+ substituted value (a defaulted kind, an unrecorded status) is counted as unconfirmed instead. A
296
+ field that is not known is written as `null` and listed under the step's `unknown`, never by the
297
+ value that stands in for it. The coverage section is always printed. It shows how many artifacts
298
+ were discovered, valid and skipped; how many traces may hide a match (`check`'s per-preset rule);
299
+ how many disclose any capture gap; how many carry no capture record; and the unconfirmed matches.
300
+ None of it is a rate, and an empty result is not an all-clear. A group does not show a shared root
301
+ cause. Version 1 accepts only strictly adjacent presets: between gap endpoints the matched path
302
+ is not recorded, so `repeated-agent-failure`, `tool-repeat-failure-heuristic` and
303
+ `tool-retry-failure-near` exit 2. `--out` writes the `tracegraph.motif-summary` schema-v1 JSON.
304
+ It must not name an input, is excluded from directory discovery, and is left untouched when the
305
+ run is refused. Exit 0 with at least one group, 1 with none (the report is still written), 2 on
306
+ an input error.
307
+
276
308
  `export-review-candidates` accepts the same artifact-file/directory batches as `query` and
277
309
  writes a deterministic schema-v1 envelope. Only presets ending in a failing `TOOL` predicate
278
310
  are eligible. The matched endpoint name must already be a qualified `server::tool` key —
@@ -312,7 +344,7 @@ and any change to them is called out in the [changelog](CHANGELOG.md).
312
344
  | Area | What exists |
313
345
  |---|---|
314
346
  | Sources | LangGraph checkpoint savers (including subgraph namespaces and native task failures), OTLP/OpenInference span exports, Phoenix CLI exports |
315
- | Commands | `ingest`, `ingest-otlp`, `ingest-phoenix`, `inspect`, `validate`, `explain`, `diff`, `analyze`, `presets`, `query`, `export-review-candidates`, `export-incident-memo`, `phoenix doctor`, `phoenix diagnose` |
347
+ | Commands | `ingest`, `ingest-otlp`, `ingest-phoenix`, `inspect`, `validate`, `explain`, `diff`, `analyze`, `presets`, `query`, `summarize`, `export-review-candidates`, `export-incident-memo`, `phoenix doctor`, `phoenix diagnose` |
316
348
  | Patterns | Versioned presets matched over the raw causal graph; the governance-eligible `tool-retry-failure@v2` requires an explicit producer retry marker, and repetition-only heuristics are query-only |
317
349
  | Backends | Pure-Python in-memory store (default); optional LadybugDB/Cypher accelerator (`[cypher]`), whose results must equal the pure-Python matcher's |
318
350
  | Ecosystem | SyncMill run correlation (`syncmill.run_id`), Toolgraph preflight evidence as non-causal metadata, a weekly Phoenix + SyncMill end-to-end workflow (Phoenix CLI and server pinned; SyncMill tracks its default branch) |
@@ -401,6 +433,19 @@ change (`ok` to `unset`, or the reverse).
401
433
 
402
434
  LangGraph ingestion reads both the configured error state channel and native task failures
403
435
  from checkpoint pending writes. A checkpoint is an error where the channel first holds an error.
436
+ An error the parent graph already held is not recorded again where a subgraph receives it, nor
437
+ where the parent graph continues after a subgraph that did not carry it, and a fork copy
438
+ (`update_state(..., as_node="__copy__")`), which runs no node, never records one.
439
+ One time-travel shape still records an error twice: copying a subgraph's *input* checkpoint as a
440
+ fork and resuming. The subgraph's next start records the error again, whether the parent graph
441
+ held it (#90) or a `Send` payload carried it (#96). The copy holds the same channels as the input
442
+ it copies but no link back to it. Each marker tried (an unread `__start__` value, a pending
443
+ `__start__` task) also appears on a fork of a plain `Pregel` graph's loop checkpoint, where
444
+ treating it as an input hid a genuine failure. Matching the copy to an input checkpoint with
445
+ identical channels and versions fails too. A `Pregel` input's first step can run a user node, so
446
+ the original run is no baseline for the copy's run. And a no-op
447
+ `update_state(..., as_node="__input__")` can write an input identical to a loop checkpoint. So
448
+ the duplicate is left in place.
404
449
  A node that writes the channel again while it still holds one may have failed again, or only
405
450
  have returned the whole state (and a reducer can change the value either way); the checkpoint
406
451
  cannot tell which, so such a write is not recorded as a failure but disclosed as the capture
@@ -466,9 +511,15 @@ unavailable, disclosing each on stderr; the producer's value is never kept. File
466
511
  and Phoenix `px` output share one size limit (`TRACEGRAPH_MAX_ARTIFACT_BYTES`). `query` reads
467
512
  artifacts one after another and keeps only the matches it prints (plus the traces `--explain`
468
513
  renders). With `--limit`, its peak memory therefore grows with the number of artifacts only
469
- by a small per-file record (path, trace id); without it, by the matches it prints. It still
470
- enumerates every match of the trace it is reading, to report the exact total: one trace with
471
- very many matches is not bounded by `--limit`.
514
+ by a small per-file record (path, trace id); without it, by the matches it prints. Inside one
515
+ trace, too, it keeps only the matches that still fit under `--limit`, but it still enumerates
516
+ and counts every match to report the exact total, so time grows with the match count: one
517
+ 2500-step trace with 3.1M matches takes about 3 s at `--limit 1`, in 70 MiB. The search's own
518
+ working state (steps reached at each distance of a gap) does not depend on `--limit`. With
519
+ `--backend ladybug` on a pattern that compiles to Cypher, LadybugDB itself buffers the
520
+ query's full result set: a 3001-step trace with 2.25M `tool-retry-failure` matches peaked
521
+ at 757 MiB, where the store alone takes about 300 MiB. A plain read, `count(*)` and
522
+ `ORDER BY … LIMIT` all buffered it in our measurements.
472
523
 
473
524
  Both stores isolate returned mutable objects. InMemory node upsert replaces by ID;
474
525
  Ladybug's existing insertion semantics are unchanged. Use `is_isomorphic` or `diff` for
@@ -4,7 +4,7 @@
4
4
  # the schema `kind` constants and the TRACEGRAPH_* environment variables — those are package
5
5
  # identity, not distribution identity, and renaming them would break published contracts.
6
6
  name = "agent-tracegraph"
7
- version = "0.5.0"
7
+ version = "0.6.1"
8
8
  description = "Time-travel debugging and causal RCA for LangGraph agent traces, on a causal graph."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -30,7 +30,10 @@ classifiers = [
30
30
 
31
31
  dependencies = [
32
32
  "pydantic>=2,<3",
33
- "langgraph-checkpoint-sqlite>=2",
33
+ # 3.1.1 fixes GHSA-47pj-3jcm-6whg (store namespace prefixes crossing segment boundaries).
34
+ # Tracegraph reads only SqliteSaver, never the store; the floor keeps installs off the
35
+ # flagged versions.
36
+ "langgraph-checkpoint-sqlite>=3.1.1",
34
37
  # Declared directly, not left transitive, because `ingest` now calls its serializer API:
35
38
  # `JsonPlusSerializer(allowed_msgpack_modules=...)` is what stops a checkpoint payload
36
39
  # invoking arbitrary callables, and it does not exist before 4.1 (4.0.0 raises TypeError,
@@ -0,0 +1,35 @@
1
+ """Describe an exception raised by code tracegraph does not control, without running more of it.
2
+
3
+ A boundary that promises ``ValueError`` often names the exception it caught. Doing that runs the
4
+ caller's code again: ``str(exc)`` calls its ``__str__``, and a metaclass can make the type's
5
+ ``__name__`` a property. Either can raise, or return a ``str`` subclass whose own methods raise
6
+ while the message is built, and that error would escape in place of the ``ValueError``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ #: Stands in for a type name that cannot be read safely.
12
+ _UNNAMED = "exception"
13
+
14
+
15
+ def type_name(exc: BaseException) -> str:
16
+ """The exception's type name, or a fixed text when reading it fails or is not a plain str."""
17
+ try:
18
+ name = type(exc).__name__
19
+ except KeyboardInterrupt:
20
+ raise
21
+ except BaseException:
22
+ return _UNNAMED
23
+ return name if type(name) is str else _UNNAMED
24
+
25
+
26
+ def describe(exc: BaseException) -> str:
27
+ """``"Type: message"``, or the type alone when the message is empty or cannot be read safely."""
28
+ name = type_name(exc)
29
+ try:
30
+ text = str(exc)
31
+ return f"{name}: {text}" if type(text) is str and text else name
32
+ except KeyboardInterrupt:
33
+ raise
34
+ except BaseException: # the composition too: building the text can fail (MemoryError)
35
+ return name
@@ -96,6 +96,7 @@ from typing import Any, Iterable, Iterator, get_type_hints
96
96
  from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata
97
97
  from pydantic import TypeAdapter, ValidationError
98
98
 
99
+ from tracegraph._errors import describe, type_name
99
100
  from tracegraph.adapters.base import Disclosures
100
101
  from tracegraph.input_validation import object_field
101
102
  from tracegraph.model import (
@@ -260,6 +261,7 @@ class LangGraphCheckpointAdapter:
260
261
  original_seq: dict[str, int] = {}
261
262
  steps: dict[str, Step] = {}
262
263
  writes_by_step: dict[str, dict[str, list[tuple[str, Any]]]] = {}
264
+ launch_keys: dict[str, list[_CheckpointKey]] = {}
263
265
  malformed_writes = 0
264
266
  for t in tuples:
265
267
  cfg, cp, md, parent_cfg = _checked(t)
@@ -278,6 +280,7 @@ class LangGraphCheckpointAdapter:
278
280
  checkpoints[step_id] = cp
279
281
  original_seq[step_id] = md.get("step", 0)
280
282
  parent_keys[step_id] = self._declared_parent_keys(parent_cfg, md, ns)
283
+ launch_keys[step_id] = self._launch_parent_keys(md, ns)
281
284
  writes_by_step[step_id], malformed = self._writes_by_task(
282
285
  getattr(t, "pending_writes", None)
283
286
  )
@@ -356,9 +359,23 @@ class LangGraphCheckpointAdapter:
356
359
  # both stay display labels and are recorded as unknown, named or not. Task,
357
360
  # interrupt and scheduled steps are named by LangGraph's own task ids instead.
358
361
  step.unknown_fields = ["kind", "name"]
359
- causal_parent_ids = parents_by_step.get(step_id, [])
360
- error_parent_cps = [checkpoints[parent_id] for parent_id in causal_parent_ids]
361
- err = self._error_introduced(checkpoints[step_id], error_parent_cps)
362
+ # An error is new here only if none of the states this checkpoint was built from held
363
+ # one, and the causal parents alone can miss them: exit-edge expansion replaces a
364
+ # same-namespace predecessor with a subgraph's exit, and the input checkpoint a
365
+ # ``__start__`` applies holds its values as pending writes, so that input's own
366
+ # parents (the namespace's previous invocation, and the checkpoint that launched the
367
+ # subgraph) stand in. A fork (``update_state(..., as_node="__copy__")``) copies an
368
+ # existing checkpoint and runs no node, so whatever error it holds is not new; it is
369
+ # linked to the copied checkpoint's parent, which need not hold that error.
370
+ upstream_keys = list(parent_keys.get(step_id, []))
371
+ if no_producer and step.source is StepSource.LOOP and display_parent_id is not None:
372
+ upstream_keys += [*parent_keys.get(display_parent_id, []),
373
+ *launch_keys.get(display_parent_id, [])]
374
+ upstream_ids = [*parents_by_step.get(step_id, []),
375
+ *(pid for key in upstream_keys if (pid := step_id_by_key.get(key)))]
376
+ error_parent_cps = [checkpoints[pid] for pid in dict.fromkeys(upstream_ids)]
377
+ err = (None if step.source is StepSource.FORK
378
+ else self._error_introduced(checkpoints[step_id], error_parent_cps))
362
379
  # Only a checkpoint a node wrote can disclose a rewrite: an input, an update and a
363
380
  # fork (``update_state(..., as_node="__copy__")`` copies state, linked to the copied
364
381
  # checkpoint's parent) carry what the caller supplied. The channel's own history is
@@ -669,9 +686,9 @@ class LangGraphCheckpointAdapter:
669
686
  except KeyboardInterrupt:
670
687
  raise
671
688
  except BaseException as exc:
672
- text = str(exc)
673
- reason = f"{type(exc).__name__}: {text}" if text else type(exc).__name__
674
- raise ValueError(f"the supplied graph could not read checkpoint {cid}: {reason}") from exc
689
+ raise ValueError(
690
+ f"the supplied graph could not read checkpoint {cid}: {describe(exc)}"
691
+ ) from exc
675
692
  read = (snapshot.config or {}).get("configurable", {}).get("checkpoint_id")
676
693
  if read != cid:
677
694
  raise ValueError(
@@ -794,7 +811,12 @@ class LangGraphCheckpointAdapter:
794
811
  seed = unhexlify(cid.replace("-", ""))
795
812
  except (BinasciiError, AttributeError, ValueError):
796
813
  return None
797
- joined = b"".join(part.encode() for part in parts)
814
+ try:
815
+ joined = b"".join(part.encode() for part in parts)
816
+ except UnicodeEncodeError:
817
+ # A lone surrogate has no UTF-8 encoding, so LangGraph could not have hashed this
818
+ # candidate into any recorded task id. It names nothing; it must not end the ingest.
819
+ return None
798
820
  if legacy:
799
821
  digest = sha1(seed, usedforsecurity=False)
800
822
  digest.update(joined)
@@ -943,6 +965,13 @@ class LangGraphCheckpointAdapter:
943
965
  parent_ns = parent_cfg.get("checkpoint_ns", ns) or _ROOT_NS
944
966
  return [(parent_ns, parent_id)]
945
967
 
968
+ return LangGraphCheckpointAdapter._launch_parent_keys(md, ns)
969
+
970
+ @staticmethod
971
+ def _launch_parent_keys(md: dict, ns: str) -> list[_CheckpointKey]:
972
+ """The closest enclosing-graph checkpoint named in ``metadata.parents``: for a subgraph,
973
+ the checkpoint it was launched from, recorded even when ``parent_config`` points at
974
+ the namespace's previous invocation instead."""
946
975
  # `_checked` has already established `parents` as a str -> str mapping.
947
976
  parents = md.get("parents") or {}
948
977
  closest = LangGraphCheckpointAdapter._closest_parent_ns(ns, parents)
@@ -1222,7 +1251,7 @@ def _decoded(listing: Iterable[Any]) -> Iterator[Any]:
1222
1251
  return
1223
1252
  except Exception as exc:
1224
1253
  raise ValueError(
1225
- f"checkpoint history cannot be decoded ({type(exc).__name__})"
1254
+ f"checkpoint history cannot be decoded ({type_name(exc)})"
1226
1255
  ) from exc
1227
1256
  yield item
1228
1257
 
@@ -73,6 +73,7 @@ from tracegraph.model import (
73
73
  StepSource,
74
74
  StepStatus,
75
75
  Trace,
76
+ cost_is_bounded,
76
77
  UnknownField,
77
78
  )
78
79
 
@@ -255,7 +256,9 @@ def _as_decimal(value: Any) -> str | None:
255
256
  parsed = Decimal(str(value))
256
257
  except InvalidOperation:
257
258
  return None
258
- return format(parsed, "f") if parsed.is_finite() and parsed >= 0 else None
259
+ if not (parsed.is_finite() and parsed >= 0 and cost_is_bounded(parsed)):
260
+ return None
261
+ return format(parsed, "f")
259
262
 
260
263
 
261
264
  def _as_currency(value: Any) -> str | None:
@@ -37,6 +37,7 @@ from tracegraph.model import (
37
37
  NormalizedTrace,
38
38
  Step,
39
39
  StepStatus,
40
+ _validated_copy,
40
41
  )
41
42
  from tracegraph.normalize import ProjectionRule, validate_normalized
42
43
 
@@ -540,6 +541,7 @@ def _topology_rule(
540
541
 
541
542
  def evaluate(current: NormalizedTrace, baseline: NormalizedTrace, rules: list[RuleSpec]) -> CheckReport:
542
543
  """Evaluate ``rules`` over the pair. Raises ``ValueError`` for a malformed trace."""
544
+ current, baseline = _validated_copy(current), _validated_copy(baseline)
543
545
  contexts = (_context(current), _context(baseline))
544
546
  common = _common_reasons(current, baseline)
545
547
  langgraph = "langgraph" in (current.trace.source_kind, baseline.trace.source_kind)