agent-tracegraph 0.7.0__tar.gz → 0.8.0__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.7.0 → agent_tracegraph-0.8.0}/CHANGELOG.md +58 -0
  2. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/PKG-INFO +19 -2
  3. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/README.md +18 -1
  4. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/pyproject.toml +1 -1
  5. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/otlp_spans.py +38 -5
  6. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/phoenix_export.py +34 -0
  7. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/check.py +2 -1
  8. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/diagnose.py +13 -0
  9. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/memo.py +14 -2
  10. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/cli.py +31 -5
  11. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/model.py +4 -0
  12. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/.gitignore +0 -0
  13. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/LICENSE +0 -0
  14. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/SECURITY.md +0 -0
  15. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/contracts/analysis-report.schema.json +0 -0
  16. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/contracts/check-report.schema.json +0 -0
  17. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/contracts/phoenix-runtime.json +0 -0
  18. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/contracts/review-candidates.schema.json +0 -0
  19. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/__init__.py +0 -0
  20. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/_atomic_write.py +0 -0
  21. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/_errors.py +0 -0
  22. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/__init__.py +0 -0
  23. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/base.py +0 -0
  24. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/adapters/langgraph_checkpoint.py +0 -0
  25. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/__init__.py +0 -0
  26. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/ahu.py +0 -0
  27. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/explain.py +0 -0
  28. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/motifs.py +0 -0
  29. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/analysis/patterns.py +0 -0
  30. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/artifact.py +0 -0
  31. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/graph_ref.py +0 -0
  32. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/input_validation.py +0 -0
  33. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/normalize.py +0 -0
  34. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/py.typed +0 -0
  35. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/review_candidates.py +0 -0
  36. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/sqlite_snapshot.py +0 -0
  37. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/store/__init__.py +0 -0
  38. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/store/base.py +0 -0
  39. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/store/in_memory.py +0 -0
  40. {agent_tracegraph-0.7.0 → agent_tracegraph-0.8.0}/src/tracegraph/store/ladybug.py +0 -0
@@ -6,6 +6,64 @@ 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.8.0 - 2026-10-20
10
+
11
+ ### Changed
12
+
13
+ - **`analyze` and the incident memo say when a causal parent was chosen heuristically** (#138).
14
+ Ingest records `otlp.graph_parent_by_timing` (a loop parent taken by start time or, since #136,
15
+ by start number) or `otlp.graph_parent_fallback` (a declared graph parent not honored, so the
16
+ span parent is used) in the artifact, and `check` and `summarize` read those codes, but
17
+ `analyze` and `export-incident-memo` did not, so a report or memo built from the artifact did
18
+ not say that a parent may have been chosen heuristically, and the memo wrote a graph parent
19
+ taken by start order as "caused by". `analyze` now adds a warning for each side that
20
+ carries either code, and the memo adds a fidelity disclosure. The record does not say which
21
+ edge was chosen that way, so in a trace with `otlp.graph_parent_by_timing` the memo writes every
22
+ `graph_parent` edge in its causal ancestry graph as "preceded by (graph parent, possibly chosen heuristically)" instead of
23
+ "caused by"; checkpoint parents and span links keep "caused by". Reports and memos of traces
24
+ without these codes are unchanged.
25
+
26
+ - **`explain`, `query --explain` and `inspect` say when a causal parent was chosen heuristically**
27
+ (#140). They read the same two codes: `inspect` prints the caveat `analyze` uses under its
28
+ tree, and `explain` and `query --explain` under every chain with at least one cause (a root
29
+ step has no parent to qualify). In such a trace their fan-in note no longer says "multiple real
30
+ causes" or "trust this raw chain, not the tree"; it says the single-parent tree drops some
31
+ recorded causes and the raw chain lists every recorded ancestor, and the `query --explain` note
32
+ says "in this chain" rather than "in this match", since the step with several causes can be an
33
+ ancestor outside the match. Output for traces without these codes is unchanged. `analyze` still
34
+ classifies failures through a `graph_parent` edge as it did.
35
+
36
+ - **A Phoenix loop parent is chosen by start numbers whose unit is unknown again** (#136). Since
37
+ 0.7.0 a Phoenix time given as a number below 10^17 is reported as unavailable, and with it the
38
+ only evidence for choosing among several spans that carry the `graph.node.id` a span names as
39
+ its `graph.node.parent_id`. The span then fell back to its `parent_id`, or the export was
40
+ refused without one. Now, when that span and every candidate start at such a number, the
41
+ nearest preceding candidate by those numbers is taken. 0.6.1 also chose by them, read as
42
+ nanoseconds, but it dropped a float's fraction first, so with fractional seconds (`a` at
43
+ 1783987200.25, `b` at 1783987200.75, the span at 1783987200.5) it took `b` where this release
44
+ takes `a`. The times themselves stay unavailable. The choice is counted under the existing
45
+ `otlp.graph_parent_by_timing` gap with its own warning line, which says it assumes the export
46
+ gives every time in one unit; `check` treats it as a heuristic edge, as it does a choice by
47
+ readable time. Exports with ISO or nanosecond times are read as before, and so is a set of
48
+ candidates where any one has a readable time, no start, or no number below the floor. The
49
+ 0.7.0 example (spans `a` and `b` at 1783987200 and 1783987201, `c` at 1783987202 with no
50
+ `parent_id`) is accepted again with `b` as `c`'s parent. Where 0.7.0 fell back to the span's
51
+ `parent_id`, the parent taken now can differ, and so can what follows from it. Each case below
52
+ is a separate export built from that example, adding only what it names:
53
+ - The export can be refused if the new choice closes a causal cycle. Add a root span `r` at
54
+ 1783987199 and give `c` the `parent_id` `r` and `b` the `parent_id` `c`: 0.7.0 kept `r` as
55
+ `c`'s parent and exited 0; this release takes `b`, so `ingest-phoenix` and `analyze` exit 2
56
+ and `phoenix diagnose` exits 1 (0.6.1 refused that export too).
57
+ - `analyze` can classify failures differently, because the edge is now a `graph_parent`, which
58
+ it reads as explicit ancestry (#142). Give `c` the `parent_id` `b` and let `b` and `c` fail:
59
+ 0.7.0 reported both as primary failures; this release reports `b` as primary and `c` as
60
+ propagated.
61
+ - Step numbers can change. Add spans `x`, `y` (`parent_id` `x`) and `z` with no start time,
62
+ and give `b` the `parent_id` `y` and `c` the `parent_id` `z`: 0.7.0 numbered `c` 3 and `b` 5;
63
+ this release numbers `b` 4 and `c` 5.
64
+
65
+ Ties are still broken with these spans at time 0, so step order can also differ from 0.6.1.
66
+
9
67
  ## 0.7.0 - 2026-10-20
10
68
 
11
69
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-tracegraph
3
- Version: 0.7.0
3
+ Version: 0.8.0
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
@@ -38,7 +38,7 @@ tracegraph ingests agent execution traces that already exist (LangGraph checkpoi
38
38
  Phoenix CLI exports, and OpenInference/OTLP spans) into a **normalized causal graph**,
39
39
  then runs analyses the mainstream observability tools don't:
40
40
 
41
- - **`explain`** — walk backward from a failure over the *raw* causal graph to every real cause.
41
+ - **`explain`** — walk backward from a failure over the *raw* causal graph to every recorded cause.
42
42
  - **`diff`** — exact structural regression diff between two runs (rooted-tree / AHU isomorphism).
43
43
  - **cross-trace pattern matching** — e.g. "tool X → retry → tool X → failure" across all runs.
44
44
 
@@ -221,6 +221,15 @@ Phoenix exports preserve span parents but currently do not expose the original O
221
221
  links. These artifacts are explicitly marked `causal_fidelity=parent_only`; tracegraph warns
222
222
  that additional fan-in causes may be missing instead of claiming a complete DAG.
223
223
 
224
+ Phoenix's own export gives times as ISO strings. A time given as a number is read as epoch
225
+ nanoseconds only from 10^17 up; a smaller one (seconds, milliseconds, a relative offset) is in
226
+ a unit tracegraph cannot tell, so it is reported as unavailable and disclosed as
227
+ `otlp.unreadable_timestamp`. Such numbers still order spans for one purpose: when several
228
+ spans carry the `graph.node.id` that a span names as its `graph.node.parent_id`, and that span
229
+ and every candidate start at such a number, the nearest preceding candidate by those numbers is
230
+ the parent. That assumes the export gives every time in one unit, and it is disclosed under
231
+ `otlp.graph_parent_by_timing` with its own warning line, so `check` treats the edge as heuristic.
232
+
224
233
  The exact Phoenix CLI and server versions used by CI are centrally pinned. See the
225
234
  [Phoenix runtime pin maintenance runbook](docs/phoenix-cli-contract.md) for the controlled
226
235
  upgrade and contract-verification procedure, and the
@@ -456,6 +465,14 @@ origins, fidelity disclosures, and the digest of the exact artifact bytes. Logic
456
465
  comparison keys are JSON-encoded typed paths; consumers should treat them as opaque strings.
457
466
  Containment and unknown edges preserve all error investigation candidates, deeper first.
458
467
  Explicit causal ancestry supplies context, not proof that an exception propagated.
468
+ When the artifact records that a causal parent was chosen heuristically at ingest
469
+ (`otlp.graph_parent_by_timing` or `otlp.graph_parent_fallback`), `analyze` adds a warning for
470
+ that side and the memo a fidelity disclosure. The record does not say which edge, so a memo of
471
+ a trace with `otlp.graph_parent_by_timing` writes every `graph_parent` edge as "preceded by
472
+ (graph parent, possibly chosen heuristically)" instead of "caused by". `inspect` prints the
473
+ same caveat under its tree, and `explain` and `query --explain` under every chain that has at
474
+ least one cause; there their fan-in note no longer calls the causes real or says to trust the
475
+ chain.
459
476
  Explicit OTLP OK takes precedence over handled exception events on new ingestion; old
460
477
  artifacts are not reinterpreted.
461
478
  A span whose status is UNSET and that carries no exception event (one with a message or
@@ -7,7 +7,7 @@ tracegraph ingests agent execution traces that already exist (LangGraph checkpoi
7
7
  Phoenix CLI exports, and OpenInference/OTLP spans) into a **normalized causal graph**,
8
8
  then runs analyses the mainstream observability tools don't:
9
9
 
10
- - **`explain`** — walk backward from a failure over the *raw* causal graph to every real cause.
10
+ - **`explain`** — walk backward from a failure over the *raw* causal graph to every recorded cause.
11
11
  - **`diff`** — exact structural regression diff between two runs (rooted-tree / AHU isomorphism).
12
12
  - **cross-trace pattern matching** — e.g. "tool X → retry → tool X → failure" across all runs.
13
13
 
@@ -190,6 +190,15 @@ Phoenix exports preserve span parents but currently do not expose the original O
190
190
  links. These artifacts are explicitly marked `causal_fidelity=parent_only`; tracegraph warns
191
191
  that additional fan-in causes may be missing instead of claiming a complete DAG.
192
192
 
193
+ Phoenix's own export gives times as ISO strings. A time given as a number is read as epoch
194
+ nanoseconds only from 10^17 up; a smaller one (seconds, milliseconds, a relative offset) is in
195
+ a unit tracegraph cannot tell, so it is reported as unavailable and disclosed as
196
+ `otlp.unreadable_timestamp`. Such numbers still order spans for one purpose: when several
197
+ spans carry the `graph.node.id` that a span names as its `graph.node.parent_id`, and that span
198
+ and every candidate start at such a number, the nearest preceding candidate by those numbers is
199
+ the parent. That assumes the export gives every time in one unit, and it is disclosed under
200
+ `otlp.graph_parent_by_timing` with its own warning line, so `check` treats the edge as heuristic.
201
+
193
202
  The exact Phoenix CLI and server versions used by CI are centrally pinned. See the
194
203
  [Phoenix runtime pin maintenance runbook](docs/phoenix-cli-contract.md) for the controlled
195
204
  upgrade and contract-verification procedure, and the
@@ -425,6 +434,14 @@ origins, fidelity disclosures, and the digest of the exact artifact bytes. Logic
425
434
  comparison keys are JSON-encoded typed paths; consumers should treat them as opaque strings.
426
435
  Containment and unknown edges preserve all error investigation candidates, deeper first.
427
436
  Explicit causal ancestry supplies context, not proof that an exception propagated.
437
+ When the artifact records that a causal parent was chosen heuristically at ingest
438
+ (`otlp.graph_parent_by_timing` or `otlp.graph_parent_fallback`), `analyze` adds a warning for
439
+ that side and the memo a fidelity disclosure. The record does not say which edge, so a memo of
440
+ a trace with `otlp.graph_parent_by_timing` writes every `graph_parent` edge as "preceded by
441
+ (graph parent, possibly chosen heuristically)" instead of "caused by". `inspect` prints the
442
+ same caveat under its tree, and `explain` and `query --explain` under every chain that has at
443
+ least one cause; there their fan-in note no longer calls the causes real or says to trust the
444
+ chain.
428
445
  Explicit OTLP OK takes precedence over handled exception events on new ingestion; old
429
446
  artifacts are not reinterpreted.
430
447
  A span whose status is UNSET and that carries no exception event (one with a message or
@@ -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.7.0"
7
+ version = "0.8.0"
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"
@@ -376,11 +376,16 @@ class OTLPSpanAdapter:
376
376
  causal_fidelity: CausalFidelity | None = None,
377
377
  links_preserved: bool | None = None,
378
378
  evaluation_channel: str | None = None,
379
+ start_order_channel: str | None = None,
379
380
  ) -> None:
380
381
  self._doc = document
381
382
  #: Span key carrying pre-sanitized evaluation summaries. Only an adapter that builds
382
383
  #: the span dicts itself (Phoenix) sets it; raw OTLP input never supplies evaluations.
383
384
  self._evaluation_channel = evaluation_channel
385
+ #: Span key carrying a start time whose unit is unknown, as a plain number that orders
386
+ #: spans within one export. Only Phoenix sets it, for a number below the epoch-nanosecond
387
+ #: floor; the time itself stays unavailable.
388
+ self._start_order_channel = start_order_channel
384
389
  self._source_kind = source_kind
385
390
  #: Prefer ``graph.node.parent_id`` (logical agent graph) over ``parentSpanId``.
386
391
  self._prefer_graph_parent = prefer_graph_parent
@@ -462,8 +467,18 @@ class OTLPSpanAdapter:
462
467
  for sid, span in by_id.items()
463
468
  if _parse_nanos(_first(span, "startTimeUnixNano", "start_time_unix_nano")) is None
464
469
  }
470
+ # A unit-unknown start number orders spans that all carry one, with prerank's
471
+ # tiebreaks; it says nothing about how they relate to a span with a readable time.
472
+ channel = self._start_order_channel
473
+ order_key = {
474
+ sid: (value, depth[sid], sid)
475
+ for sid, span in by_id.items()
476
+ if channel is not None and (value := span.get(channel)) is not None
477
+ }
465
478
  disclosures = Disclosures()
466
- causes, origins = self._causes(by_id, attrs, prerank, parent_span, untimed, disclosures)
479
+ causes, origins = self._causes(
480
+ by_id, attrs, prerank, parent_span, untimed, order_key, disclosures
481
+ )
467
482
  seq = self._topo_seq(by_id, causes, prerank)
468
483
 
469
484
  steps: list[Step] = []
@@ -725,6 +740,7 @@ class OTLPSpanAdapter:
725
740
  prerank: dict[str, int],
726
741
  parent_span: dict[str, str | None],
727
742
  untimed: set[str],
743
+ order_key: dict[str, tuple[Any, int, str]],
728
744
  disclosures: Disclosures,
729
745
  ) -> tuple[dict[str, list[str]], dict[tuple[str, str], EdgeOrigin]]:
730
746
  """Reconstruct ``effect -> [causes]`` from declared signals: one parent + links.
@@ -752,12 +768,13 @@ class OTLPSpanAdapter:
752
768
  causes[effect].append(cause)
753
769
  origins[(effect, cause)] = origin
754
770
 
755
- by_timing = fallback = 0
771
+ by_timing = by_order = fallback = 0
756
772
  for sid in by_id:
757
773
  parent, parent_origin, resolution = self._resolve_parent(
758
- sid, attrs, node_index, prerank, parent_span, untimed
774
+ sid, attrs, node_index, prerank, parent_span, untimed, order_key
759
775
  )
760
776
  by_timing += resolution == "by_timing"
777
+ by_order += resolution == "by_order"
761
778
  fallback += resolution == "fallback"
762
779
  if parent is not None:
763
780
  add(sid, parent, parent_origin)
@@ -789,6 +806,16 @@ class OTLPSpanAdapter:
789
806
  "the nearest preceding one by start time was taken as the parent.",
790
807
  by_timing,
791
808
  )
809
+ if by_order:
810
+ # Same code as by_timing: a heuristic choice among candidates, which readers since
811
+ # 0.6 already treat as one.
812
+ disclosures.add(
813
+ "otlp.graph_parent_by_timing",
814
+ f"{by_order} span(s) declare a graph.node.parent_id that several spans carry; "
815
+ "the nearest preceding one was taken by start-time numbers whose unit is "
816
+ "unknown, assuming the export gives every time in one unit.",
817
+ by_order,
818
+ )
792
819
  if fallback:
793
820
  disclosures.add(
794
821
  "otlp.graph_parent_fallback",
@@ -826,6 +853,7 @@ class OTLPSpanAdapter:
826
853
  prerank: dict[str, int],
827
854
  parent_span: dict[str, str | None],
828
855
  untimed: set[str],
856
+ order_key: dict[str, tuple[Any, int, str]],
829
857
  ) -> tuple[str | None, EdgeOrigin, str | None]:
830
858
  """The single chosen parent cause: the logical graph parent if resolvable, else the
831
859
  declared span parent.
@@ -835,7 +863,8 @@ class OTLPSpanAdapter:
835
863
 
836
864
  * a single span carries that ``graph.node.id`` → trust it outright (unambiguous);
837
865
  * several do (a loop) → pick the nearest *preceding* execution, but only when this
838
- span and every candidate have a readable start time; otherwise there is no
866
+ span and every candidate have a readable start time, or else all carry a
867
+ unit-unknown start number (``order_key``) to compare; otherwise there is no
839
868
  evidence of order and the parent is ambiguous.
840
869
 
841
870
  If ``graph.node.parent_id`` is declared but resolves to no in-trace span, or is
@@ -846,7 +875,7 @@ class OTLPSpanAdapter:
846
875
  ``None`` only when the span truly has no declared parent of either kind.
847
876
 
848
877
  The third value says how a declared logical parent was resolved when that was not
849
- unambiguous: ``"by_timing"`` or ``"fallback"``; otherwise ``None``.
878
+ unambiguous: ``"by_timing"``, ``"by_order"`` or ``"fallback"``; otherwise ``None``.
850
879
  """
851
880
  pid = parent_span.get(sid)
852
881
  if self._prefer_graph_parent:
@@ -859,6 +888,10 @@ class OTLPSpanAdapter:
859
888
  preceding = [c for c in candidates if timed and prerank[c] < prerank[sid]]
860
889
  if preceding:
861
890
  return max(preceding, key=lambda c: prerank[c]), EdgeOrigin.GRAPH_PARENT, "by_timing"
891
+ ordered = not timed and all(s in order_key for s in (sid, *candidates))
892
+ preceding = [c for c in candidates if ordered and order_key[c] < order_key[sid]]
893
+ if preceding:
894
+ return max(preceding, key=lambda c: order_key[c]), EdgeOrigin.GRAPH_PARENT, "by_order"
862
895
  if pid is None:
863
896
  detail = "no in-trace span" if not candidates else "multiple ambiguous spans"
864
897
  raise ValueError(
@@ -79,6 +79,38 @@ def _nanos(value: Any) -> Any:
79
79
  return str(int(parsed.timestamp() * 1_000_000_000))
80
80
 
81
81
 
82
+ def _start_order(value: Any) -> int | float | None:
83
+ """A start time below the epoch-nanosecond floor, as the number it is, or ``None``.
84
+
85
+ Its unit is unknown, so it is no time, but within one export it still orders spans: the
86
+ span builder uses it only to choose among several candidate loop parents, and discloses
87
+ that it did. It follows ``_nanos``'s branches, so an ISO string (a compact one such as
88
+ ``20260714_123456`` included) never reaches the number path. A bool or bytes value is not
89
+ taken as a number here, although ``_nanos`` refuses those as times too.
90
+ """
91
+ if isinstance(value, bool):
92
+ return None
93
+ if isinstance(value, float):
94
+ return value if math.isfinite(value) and int(value) < _MIN_EPOCH_NANOS else None
95
+ if isinstance(value, int):
96
+ return value if value < _MIN_EPOCH_NANOS else None
97
+ if not isinstance(value, str):
98
+ return None
99
+ number: int | None
100
+ if value.isdigit():
101
+ try:
102
+ number = int(value)
103
+ except ValueError: # a non-ASCII digit, or past the interpreter's int-string limit
104
+ return None
105
+ else:
106
+ try:
107
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
108
+ return None
109
+ except ValueError:
110
+ number = _parse_nanos(value)
111
+ return number if number is not None and number < _MIN_EPOCH_NANOS else None
112
+
113
+
82
114
  _OPERATION_NAME = re.compile(r"[A-Za-z0-9_.:/#@-]{1,200}")
83
115
 
84
116
 
@@ -202,6 +234,7 @@ class PhoenixExportAdapter:
202
234
  include_error_messages=False,
203
235
  preserve_unset_status=True,
204
236
  evaluation_channel="_tracegraph_evaluations",
237
+ start_order_channel="_tracegraph_start_order",
205
238
  ).ingest(trace_id)
206
239
  if unreadable_scores:
207
240
  disclosures = Disclosures()
@@ -257,4 +290,5 @@ class PhoenixExportAdapter:
257
290
  "attributes": _attrs(span),
258
291
  "events": _span_array(span, "events"),
259
292
  "_tracegraph_evaluations": _evaluations(span),
293
+ "_tracegraph_start_order": _start_order(_first(span, "start_time", "startTime")),
260
294
  }
@@ -34,6 +34,7 @@ from tracegraph.analysis.diagnose import _ALIAS, _digest, _display_name, _safe
34
34
  from tracegraph.analysis.patterns import PRESETS, PathPattern, StepPredicate, build_index, find_matches
35
35
  from tracegraph.model import (
36
36
  CAPTURE_GAP_CODES,
37
+ HEURISTIC_EDGE_GAP_CODES,
37
38
  NormalizedTrace,
38
39
  Step,
39
40
  StepStatus,
@@ -66,7 +67,7 @@ _MAY_OMIT_STEPS = frozenset({
66
67
  #: is unaffected, so only the count rules read this set.
67
68
  _REWRITTEN_ERRORS = frozenset({"langgraph.rewritten_error_channel"})
68
69
  _MAY_HIDE_FAILURES = _MAY_OMIT_STEPS | _REWRITTEN_ERRORS
69
- _HEURISTIC_EDGES = frozenset({"otlp.graph_parent_by_timing", "otlp.graph_parent_fallback"})
70
+ _HEURISTIC_EDGES = HEURISTIC_EDGE_GAP_CODES
70
71
 
71
72
  LANGGRAPH_LIMITATION = (
72
73
  "LangGraph checkpoint layouts this adapter does not recognize record no failure, so a "
@@ -29,6 +29,7 @@ from tracegraph.model import (
29
29
  EXPLICIT_ORIGINS,
30
30
  MAX_COST_CHARS,
31
31
  CausalFidelity,
32
+ HEURISTIC_EDGE_GAP_CODES,
32
33
  EdgeType,
33
34
  NormalizedTrace,
34
35
  Step,
@@ -54,6 +55,16 @@ LANGGRAPH_FIDELITY_WARNING = (
54
55
  LINKS_PRESERVED_FIDELITY_WARNING = (
55
56
  "Link preservation covers valid in-trace links only; foreign or unresolved links are omitted."
56
57
  )
58
+ HEURISTIC_PARENT_FIDELITY_WARNING = (
59
+ "Some causal parents were chosen heuristically at ingest, by start order or by falling back "
60
+ "to the span parent; a chain through them is inferred, not declared."
61
+ )
62
+
63
+
64
+ def has_heuristic_edges(nt: NormalizedTrace) -> bool:
65
+ """Whether the trace's capture record discloses a causal parent chosen heuristically."""
66
+ capture = nt.trace.capture
67
+ return capture is not None and any(gap.code in HEURISTIC_EDGE_GAP_CODES for gap in capture.gaps)
57
68
 
58
69
 
59
70
  def _clause(sentence: str) -> str:
@@ -677,6 +688,8 @@ def analyze(nt: NormalizedTrace, *, baseline: NormalizedTrace | None = None) ->
677
688
  if trace.trace.links_preserved:
678
689
  # Mid-sentence after the label, so the shared sentence starts in lower case here.
679
690
  warnings.append(f"{label}: {_clause(LINKS_PRESERVED_FIDELITY_WARNING)}")
691
+ if has_heuristic_edges(trace):
692
+ warnings.append(f"{label}: {_clause(HEURISTIC_PARENT_FIDELITY_WARNING)}")
680
693
  if primary or propagated:
681
694
  warnings.append(INCIDENT_FIDELITY_WARNING)
682
695
  if any(getattr(metrics, field) is not None for field in ("prompt_tokens", "completion_tokens", "total_tokens", "total_cost")):
@@ -7,10 +7,12 @@ from pathlib import Path
7
7
 
8
8
  from tracegraph._atomic_write import write_text_atomic
9
9
  from tracegraph.analysis.diagnose import (
10
+ HEURISTIC_PARENT_FIDELITY_WARNING,
10
11
  INCIDENT_FIDELITY_WARNING,
11
12
  LANGGRAPH_FIDELITY_WARNING,
12
13
  LINKS_PRESERVED_FIDELITY_WARNING,
13
14
  PARENT_ONLY_FIDELITY_WARNING,
15
+ has_heuristic_edges,
14
16
  pattern_findings,
15
17
  )
16
18
  from tracegraph.model import (
@@ -43,7 +45,11 @@ def _format_origin(origin: EdgeOrigin | None) -> str:
43
45
  return f"origin: {origin.value}"
44
46
 
45
47
 
46
- def _relation_phrase(origin: EdgeOrigin | None) -> str:
48
+ def _relation_phrase(origin: EdgeOrigin | None, *, timed_graph_parents: bool) -> str:
49
+ # The capture record says a graph parent was chosen by start order somewhere in the trace,
50
+ # not which one, so no graph-parent edge there is presented as a declared cause.
51
+ if origin is EdgeOrigin.GRAPH_PARENT and timed_graph_parents:
52
+ return "preceded by (graph parent, possibly chosen heuristically)"
47
53
  if origin in EXPLICIT_ORIGINS:
48
54
  return "caused by"
49
55
  if origin is EdgeOrigin.SPAN_PARENT_FALLBACK:
@@ -161,12 +167,16 @@ def build_incident_memo(
161
167
  if edge.src in reachable_step_ids
162
168
  ]
163
169
 
170
+ capture = nt.trace.capture
171
+ timed_graph_parents = capture is not None and any(
172
+ gap.code == "otlp.graph_parent_by_timing" for gap in capture.gaps
173
+ )
164
174
  if ancestry_edges:
165
175
  lines.append("### Causal Ancestry Graph")
166
176
  for edge in ancestry_edges:
167
177
  eff_alias = alias_by_id[edge.src]
168
178
  cause_alias = alias_by_id[edge.dst]
169
- rel = _relation_phrase(edge.origin)
179
+ rel = _relation_phrase(edge.origin, timed_graph_parents=timed_graph_parents)
170
180
  origin_str = f" [{_format_origin(edge.origin)}]"
171
181
  lines.append(f"- `{eff_alias}` {rel} `{cause_alias}`{origin_str}")
172
182
  lines.append("")
@@ -182,6 +192,8 @@ def build_incident_memo(
182
192
  disclosures.append(LANGGRAPH_FIDELITY_WARNING)
183
193
  if nt.trace.links_preserved:
184
194
  disclosures.append(LINKS_PRESERVED_FIDELITY_WARNING)
195
+ if has_heuristic_edges(nt):
196
+ disclosures.append(HEURISTIC_PARENT_FIDELITY_WARNING)
185
197
 
186
198
  lines.append("## Fidelity Disclosures")
187
199
  for d in disclosures:
@@ -33,7 +33,8 @@ from tracegraph import artifact
33
33
  from tracegraph.input_validation import loads as load_json
34
34
  from tracegraph.analysis import PRESETS
35
35
  from tracegraph.analysis import check as regression_check
36
- from tracegraph.analysis.diagnose import AnalysisReport
36
+ from tracegraph.analysis.diagnose import HEURISTIC_PARENT_FIDELITY_WARNING, AnalysisReport
37
+ from tracegraph.analysis.diagnose import has_heuristic_edges
37
38
  from tracegraph.analysis.diagnose import analyze as build_analysis
38
39
  from tracegraph.analysis.diagnose import save_atomic as save_analysis_report
39
40
  from tracegraph.analysis import motifs
@@ -1278,6 +1279,10 @@ def _render_tree(nt: NormalizedTrace) -> None:
1278
1279
  f"\n[dim]{len(nt.steps)} steps · {n_tool} tool · "
1279
1280
  f"{n_err} error · {n_lossy} lossy-projection{paused}{scheduled}[/]"
1280
1281
  )
1282
+ # The tree projects CAUSED_BY, so a heuristic parent can place a step whether or not
1283
+ # any step is lossy.
1284
+ if has_heuristic_edges(nt):
1285
+ console.print(f"[yellow]⚠ {HEURISTIC_PARENT_FIDELITY_WARNING}[/]")
1281
1286
 
1282
1287
 
1283
1288
  _REDACT_ERRORS_HELP = (
@@ -1626,7 +1631,9 @@ def explain(
1626
1631
  """Trace a step's raw causal chain back toward its root cause."""
1627
1632
  store = _store_from_artifact(artifact_path, backend)
1628
1633
  try:
1629
- steps = store.trace().steps_by_id()
1634
+ trace = store.trace()
1635
+ steps = trace.steps_by_id()
1636
+ heuristic = has_heuristic_edges(trace)
1630
1637
  # An exact full-id match always wins — even when that id is also a *suffix* of a longer
1631
1638
  # namespaced id (e.g. "abc" vs "ns:abc"). Only fall back to suffix matching when the
1632
1639
  # input isn't itself a full step id, so a valid id is never rejected as "ambiguous".
@@ -1651,11 +1658,20 @@ def explain(
1651
1658
  for s in result.chain:
1652
1659
  flag = " [yellow]⚠ projection-lossy[/]" if s.projection_lossy else ""
1653
1660
  console.print(f" ← {escape(s.name or s.kind.value)} (step {s.seq}){flag}")
1654
- if result.is_lossy:
1661
+ # Where the capture record says a parent may have been chosen heuristically, the chain is
1662
+ # not called real or trustworthy; elsewhere the wording is unchanged.
1663
+ if result.is_lossy and heuristic:
1664
+ console.print(
1665
+ "\n[yellow]Some steps had more than one recorded cause; the single-parent tree view "
1666
+ "drops some of them, and this raw chain lists every recorded ancestor.[/]"
1667
+ )
1668
+ elif result.is_lossy:
1655
1669
  console.print(
1656
1670
  "\n[yellow]Some steps had multiple real causes; the single-parent tree view "
1657
1671
  "is lossy for them — trust this raw chain, not the tree.[/]"
1658
1672
  )
1673
+ if heuristic and result.chain:
1674
+ console.print(f"\n[yellow]⚠ {HEURISTIC_PARENT_FIDELITY_WARNING}[/]")
1659
1675
 
1660
1676
 
1661
1677
  @app.command()
@@ -1786,16 +1802,26 @@ def query(
1786
1802
  # Same fallback label as `tracegraph explain` and the tree renderers.
1787
1803
  console.print(f" ← {escape(s.name or s.kind.value)} (step {s.seq}){flag}")
1788
1804
  # Causal-honesty signal: when the matched effect (or anything in the chain)
1789
- # has more than one real cause, the single-parent tree projection dropped
1805
+ # has more than one recorded cause, the single-parent tree projection dropped
1790
1806
  # at least one. `tracegraph explain` surfaces this same warning — `query
1791
1807
  # --explain` must too, or a fan-in effect's lossiness becomes invisible
1792
1808
  # (target lossiness doesn't show up in the per-step ⚠ flags above, since
1793
1809
  # we only flag chain ancestors there).
1794
- if result.is_lossy:
1810
+ # A lossy step can be an ancestor outside the matched path, hence "chain".
1811
+ heuristic = has_heuristic_edges(traces_by_id[m.trace_id])
1812
+ if result.is_lossy and heuristic:
1813
+ console.print(
1814
+ " [yellow]⚠ some steps in this chain had more than one recorded cause — "
1815
+ "the single-parent tree drops some of them; this raw chain lists every "
1816
+ "recorded ancestor.[/]"
1817
+ )
1818
+ elif result.is_lossy:
1795
1819
  console.print(
1796
1820
  " [yellow]⚠ some steps in this match had multiple real causes — "
1797
1821
  "trust this raw chain, not the tree.[/]"
1798
1822
  )
1823
+ if heuristic and result.chain:
1824
+ console.print(f" [yellow]⚠ {HEURISTIC_PARENT_FIDELITY_WARNING}[/]")
1799
1825
 
1800
1826
  n_traces = len({m.trace_id for m in matches})
1801
1827
  suffix = f" — truncated from {total}" if truncated else ""
@@ -284,6 +284,10 @@ CAPTURE_GAP_CODES = frozenset(
284
284
  "phoenix.unreadable_evaluation_score",
285
285
  }
286
286
  )
287
+ #: The gaps that say a causal parent was chosen heuristically at ingest: among several spans by
288
+ #: start order, or by falling back to the span parent. ``check``, ``summarize``, ``analyze`` and the
289
+ #: incident memo read this set.
290
+ HEURISTIC_EDGE_GAP_CODES = frozenset({"otlp.graph_parent_by_timing", "otlp.graph_parent_fallback"})
287
291
 
288
292
 
289
293
  class CaptureGap(BaseModel):