agent-tracegraph 0.3.0__tar.gz → 0.3.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 (35) hide show
  1. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/CHANGELOG.md +55 -0
  2. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/PKG-INFO +5 -2
  3. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/README.md +4 -1
  4. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/pyproject.toml +1 -1
  5. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/adapters/langgraph_checkpoint.py +135 -36
  6. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/adapters/otlp_spans.py +91 -35
  7. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/adapters/phoenix_export.py +28 -11
  8. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/.gitignore +0 -0
  9. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/LICENSE +0 -0
  10. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/SECURITY.md +0 -0
  11. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/contracts/analysis-report.schema.json +0 -0
  12. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/contracts/phoenix-runtime.json +0 -0
  13. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/contracts/review-candidates.schema.json +0 -0
  14. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/__init__.py +0 -0
  15. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/_atomic_write.py +0 -0
  16. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/adapters/__init__.py +0 -0
  17. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/adapters/base.py +0 -0
  18. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/__init__.py +0 -0
  19. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/ahu.py +0 -0
  20. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/diagnose.py +0 -0
  21. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/explain.py +0 -0
  22. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/memo.py +0 -0
  23. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/analysis/patterns.py +0 -0
  24. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/artifact.py +0 -0
  25. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/cli.py +0 -0
  26. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/input_validation.py +0 -0
  27. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/model.py +0 -0
  28. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/normalize.py +0 -0
  29. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/py.typed +0 -0
  30. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/review_candidates.py +0 -0
  31. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/sqlite_snapshot.py +0 -0
  32. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/store/__init__.py +0 -0
  33. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/store/base.py +0 -0
  34. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/store/in_memory.py +0 -0
  35. {agent_tracegraph-0.3.0 → agent_tracegraph-0.3.1}/src/tracegraph/store/ladybug.py +0 -0
@@ -6,6 +6,61 @@ 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.3.1 - 2026-10-13
10
+
11
+ ### Fixed
12
+
13
+ - **An unreadable cost no longer understates the run's total.** The importers dropped a
14
+ `llm.cost.total` that was not a finite non-negative decimal (`"abc"`, `-1`, a boolean), so
15
+ `analyze` summed the remaining costs and printed a total that was knowably too low, with no
16
+ warning. The documented rule that a single unparseable cost withholds the whole total only
17
+ applied to artifacts built by hand. An unreadable total cost is now stored as the fixed
18
+ marker `"unreadable"`, never the producer's value, and the report withholds `total_cost`
19
+ and its delta with the existing note.
20
+ - Token counts, prompt and completion costs, cost currencies and Phoenix evaluation scores
21
+ that cannot be read were dropped without a trace. They are still reported as unavailable,
22
+ and `ingest-otlp`, `ingest-phoenix`, `analyze` and `phoenix diagnose` now say so on stderr,
23
+ naming the kind of value and how many spans (for scores, evaluations) carried it, never
24
+ the value itself. A boolean score still reads as `1.0` or `0.0`, as before.
25
+ - An OTLP `intValue` sent as a fractional JSON number was truncated before validation:
26
+ `1.5` became `1` and `-0.5` became `0`, so an invalid token count looked valid. It is now
27
+ validated as sent.
28
+ - **A checkpoint history LangGraph could not have written is a usage error, not a
29
+ traceback or a different trace.** About half of 64 one-field mutations of a real history
30
+ (a non-object `metadata` or `channel_values`, a numeric `checkpoint_ns`, a boolean `v`, an
31
+ unhashable parent checkpoint id, ...) crashed `ingest` with a traceback, and several were
32
+ read silently as something else: a malformed parent config or `metadata.parents` dropped
33
+ the declared parent and made the checkpoint a root, and `metadata.step` of `true` or `1.5`
34
+ was read as step 1. Each checkpoint is now checked against the types LangGraph declares
35
+ for the fields tracegraph reads, strictly, and rejected with a message naming the field.
36
+ A config `checkpoint_id` that disagrees with the checkpoint's own id is rejected too.
37
+ - A checkpoint row the saver cannot decode (an unknown serialization type, metadata nested
38
+ past the recursion limit, a non-string metadata column) crashed `ingest` and `discover`
39
+ with a traceback, except that `discover` reported an unknown serialization type as "does
40
+ not support listing all threads". Both now report the history as undecodable.
41
+ - Pending writes that are not a `(task id, channel, value)` triple were skipped without a
42
+ word, and any 3-character string was unpacked as one (`"abc"` became task `a`). They are
43
+ still ignored, but `ingest` now says how many on stderr, since one could have carried a
44
+ task failure.
45
+
46
+ ### Compatibility notes for imports
47
+
48
+ - An artifact imported from a span with an unreadable total cost now carries
49
+ `"total_cost": "unreadable"` instead of no cost, and its reports withhold the run's
50
+ total cost. No schema change: 0.3.0 reads such an artifact and withholds the same way.
51
+ - Because `intValue` is no longer truncated, `llm.cost.total = {"intValue": 1.5}` is stored as
52
+ `"1.5"` instead of `"1"`, and a `toolgraph.graph_generation` of `{"intValue": 1.5}` is
53
+ rejected like any other invalid generation instead of being read as `1`.
54
+ - The committed Phoenix fixture carries an unreadable `llm.cost.prompt`; reading it now prints
55
+ one stderr disclosure. Every committed fixture's artifact, report, memo, candidate file and
56
+ CLI stdout is unchanged.
57
+ - LangGraph histories that `ingest` used to accept with a malformed field listed above are
58
+ now refused. Histories written by LangGraph's own savers are unaffected: the checks cover
59
+ only fields tracegraph reads (`counters_since_delta_snapshot`, which savers return as
60
+ lists although it is declared as tuples, is not checked), a missing `updated_channels`
61
+ and a legacy `pending_sends` are accepted, and an unknown `metadata.source` string still
62
+ reads as a loop. The committed LangGraph fixture is byte-identical.
63
+
9
64
  ## 0.3.0 - 2026-10-13
10
65
 
11
66
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-tracegraph
3
- Version: 0.3.0
3
+ Version: 0.3.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
@@ -387,7 +387,10 @@ means valid in-trace links only. Structural diff compares the derived tree and d
387
387
  lossy step counts. Metrics sum available span observations: partial coverage and producer
388
388
  aggregation can undercount or double count. Coverage gaps are disclosed rather than absorbed
389
389
  — a wall duration derived from a subset of steps says so, one whose end precedes its start is
390
- withheld, and a single unparseable cost withholds the whole total instead of understating it. File input has a size limit, but stdin/Phoenix
390
+ withheld, and a single unparseable cost withholds the whole total instead of understating it.
391
+ An OTLP or Phoenix import records an unreadable total cost as `unreadable` for that reason, and
392
+ reports token counts, other costs, currencies and evaluation scores it cannot read as
393
+ unavailable, disclosing each on stderr; the producer's value is never kept. File input has a size limit, but stdin/Phoenix
391
394
  subprocess output and query materialization are not streaming memory guarantees.
392
395
 
393
396
  Both stores isolate returned mutable objects. InMemory node upsert replaces by ID;
@@ -356,7 +356,10 @@ means valid in-trace links only. Structural diff compares the derived tree and d
356
356
  lossy step counts. Metrics sum available span observations: partial coverage and producer
357
357
  aggregation can undercount or double count. Coverage gaps are disclosed rather than absorbed
358
358
  — a wall duration derived from a subset of steps says so, one whose end precedes its start is
359
- withheld, and a single unparseable cost withholds the whole total instead of understating it. File input has a size limit, but stdin/Phoenix
359
+ withheld, and a single unparseable cost withholds the whole total instead of understating it.
360
+ An OTLP or Phoenix import records an unreadable total cost as `unreadable` for that reason, and
361
+ reports token counts, other costs, currencies and evaluation scores it cannot read as
362
+ unavailable, disclosing each on stderr; the producer's value is never kept. File input has a size limit, but stdin/Phoenix
360
363
  subprocess output and query materialization are not streaming memory guarantees.
361
364
 
362
365
  Both stores isolate returned mutable objects. InMemory node upsert replaces by ID;
@@ -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.3.0"
7
+ version = "0.3.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"
@@ -62,8 +62,12 @@ from binascii import Error as BinasciiError, unhexlify
62
62
  from hashlib import sha1
63
63
  import heapq
64
64
  import re
65
- from typing import Any
65
+ from typing import Any, Iterable, Iterator, get_type_hints
66
66
 
67
+ from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata
68
+ from pydantic import TypeAdapter, ValidationError
69
+
70
+ from tracegraph.input_validation import object_field
67
71
  from tracegraph.model import (
68
72
  CausalFidelity,
69
73
  Edge,
@@ -82,6 +86,22 @@ _BRANCH_PREFIX = "branch:to:"
82
86
  _JOIN_PREFIX = "join:"
83
87
  _TOOL_TOKENS = {"tool", "tools"}
84
88
 
89
+ # The shape of every field this adapter reads, taken from the types LangGraph itself declares
90
+ # rather than restated here, so a LangGraph type change surfaces (in the upstream drift
91
+ # workflow) instead of hiding behind a stale copy. Only fields the adapter reads are checked:
92
+ # LangGraph declares ``counters_since_delta_snapshot`` as tuples, but both savers hand it back
93
+ # as lists, so checking everything would reject histories LangGraph itself wrote. ``source``
94
+ # is any string, not LangGraph's Literal, because an unknown source still reads as a loop.
95
+ _CHECKPOINT_FIELDS = {
96
+ name: TypeAdapter(get_type_hints(Checkpoint)[name])
97
+ for name in ("id", "ts", "v", "channel_values", "channel_versions", "versions_seen")
98
+ }
99
+ _METADATA_FIELDS = {
100
+ **{name: TypeAdapter(get_type_hints(CheckpointMetadata)[name]) for name in ("step", "parents")},
101
+ "source": TypeAdapter(str),
102
+ }
103
+ _CONFIG_FIELDS = {name: TypeAdapter(str) for name in ("thread_id", "checkpoint_ns", "checkpoint_id")}
104
+
85
105
  # LangGraph's reserved pending-write channels and task-path markers, spelled out rather than
86
106
  # imported: they live under ``langgraph._internal``, which is private and not a dependency of
87
107
  # this package. They are stable on-disk names — a checkpoint written by any 4.x saver uses
@@ -165,18 +185,18 @@ class LangGraphCheckpointAdapter:
165
185
  "this checkpointer does not support listing all threads"
166
186
  )
167
187
  try:
168
- tuples = list(list_fn(None))
188
+ listing = list_fn(None)
169
189
  except NotImplementedError as exc:
170
- # Only the documented capability signal becomes "unsupported"; any other
171
- # failure (sqlite corruption, I/O error, a saver bug) must propagate, not
172
- # masquerade as a missing capability.
190
+ # Only the documented capability signal becomes "unsupported", and only when the
191
+ # call itself raises it. The same exception raised while iterating is the
192
+ # serializer failing to decode a row, which `_decoded` reports as corrupt input.
173
193
  raise NotImplementedError(
174
194
  "this checkpointer does not support listing all threads"
175
195
  ) from exc
176
196
  seen: set[str] = set()
177
197
  ordered: list[str] = []
178
- for t in tuples:
179
- tid = (t.config.get("configurable") or {}).get("thread_id")
198
+ for t in _decoded(listing):
199
+ tid = _checked(t)[0]["thread_id"]
180
200
  if tid is not None and tid not in seen:
181
201
  seen.add(tid)
182
202
  ordered.append(tid)
@@ -186,8 +206,8 @@ class LangGraphCheckpointAdapter:
186
206
  config = {"configurable": {"thread_id": trace_id}}
187
207
  tuples = [
188
208
  t
189
- for t in self._saver.list(config)
190
- if (t.config.get("configurable") or {}).get("thread_id") == trace_id
209
+ for t in _decoded(self._saver.list(config))
210
+ if _checked(t)[0]["thread_id"] == trace_id
191
211
  ]
192
212
  if not tuples:
193
213
  raise KeyError(f"no checkpoints for thread_id {trace_id!r}")
@@ -199,12 +219,11 @@ class LangGraphCheckpointAdapter:
199
219
  original_seq: dict[str, int] = {}
200
220
  steps: dict[str, Step] = {}
201
221
  writes_by_step: dict[str, dict[str, list[tuple[str, Any]]]] = {}
222
+ malformed_writes = 0
202
223
  for t in tuples:
203
- cfg = t.config.get("configurable") or {}
204
- cp = t.checkpoint
205
- md = t.metadata or {}
224
+ cfg, cp, md, parent_cfg = _checked(t)
206
225
  ns = cfg.get("checkpoint_ns", _ROOT_NS) or _ROOT_NS
207
- cid = cfg.get("checkpoint_id") or cp["id"]
226
+ cid = cp["id"]
208
227
  key = (ns, cid)
209
228
  if key in step_id_by_key:
210
229
  raise ValueError(f"duplicate checkpoint {cid!r} in namespace {ns!r}")
@@ -216,15 +235,12 @@ class LangGraphCheckpointAdapter:
216
235
  step_id_by_key[key] = step_id
217
236
  key_by_step_id[step_id] = key
218
237
  checkpoints[step_id] = cp
219
- try:
220
- original_seq[step_id] = int(md.get("step", 0))
221
- except (TypeError, ValueError) as exc:
222
- raise ValueError(
223
- f"checkpoint {cid!r} in thread {trace_id!r} has a non-integer "
224
- f"metadata step: {md.get('step')!r}"
225
- ) from exc
226
- parent_keys[step_id] = self._declared_parent_keys(t, ns)
227
- writes_by_step[step_id] = self._writes_by_task(getattr(t, "pending_writes", None))
238
+ original_seq[step_id] = md.get("step", 0)
239
+ parent_keys[step_id] = self._declared_parent_keys(parent_cfg, md, ns)
240
+ writes_by_step[step_id], malformed = self._writes_by_task(
241
+ getattr(t, "pending_writes", None)
242
+ )
243
+ malformed_writes += malformed
228
244
  steps[step_id] = Step(
229
245
  step_id=step_id,
230
246
  trace_id=trace_id,
@@ -304,6 +320,11 @@ class LangGraphCheckpointAdapter:
304
320
  ingest_warnings = self._derive_task_steps(
305
321
  trace_id, steps, checkpoints, key_by_step_id, original_seq, writes_by_step, edges
306
322
  )
323
+ if malformed_writes:
324
+ ingest_warnings.append(
325
+ f"{malformed_writes} pending write(s) are not a (task id, channel, value) "
326
+ "triple and were ignored; a task failure recorded in one would not be reported."
327
+ )
307
328
 
308
329
  any_error = any(s.status is StepStatus.ERROR for s in steps.values())
309
330
  trace = Trace(
@@ -419,23 +440,28 @@ class LangGraphCheckpointAdapter:
419
440
  return warnings
420
441
 
421
442
  @staticmethod
422
- def _writes_by_task(pending_writes: Any) -> dict[str, list[tuple[str, Any]]]:
443
+ def _writes_by_task(pending_writes: Any) -> tuple[dict[str, list[tuple[str, Any]]], int]:
423
444
  """Group a checkpoint's pending writes by task id, in first-seen order.
424
445
 
425
446
  Savers are free to order writes as they like — the SQLite saver sorts by task id and
426
447
  index, the in-memory one keeps insertion order — so nothing here may depend on
427
448
  position. ``pending_writes`` is also optional on ``CheckpointTuple``; a third-party
428
- saver that omits it reads as "no writes", never as an error.
449
+ saver that omits it reads as "no writes", never as an error. Returns the grouping and
450
+ how many entries were not a ``(str, str, value)`` triple: those are not ours to
451
+ interpret, but the caller discloses them because one could have carried a failure.
429
452
  """
430
453
  grouped: dict[str, list[tuple[str, Any]]] = {}
454
+ malformed = 0
431
455
  for write in pending_writes or ():
432
- try:
433
- task_id, channel, value = write
434
- except (TypeError, ValueError):
435
- continue # not a (task_id, channel, value) triple; not ours to interpret
456
+ if not isinstance(write, (tuple, list)) or len(write) != 3:
457
+ malformed += 1
458
+ continue
459
+ task_id, channel, value = write
436
460
  if isinstance(task_id, str) and isinstance(channel, str):
437
461
  grouped.setdefault(task_id, []).append((channel, value))
438
- return grouped
462
+ else:
463
+ malformed += 1
464
+ return grouped, malformed
439
465
 
440
466
  @staticmethod
441
467
  def _hash_task_id(cid: str, legacy: bool, *parts: str) -> str | None:
@@ -593,22 +619,18 @@ class LangGraphCheckpointAdapter:
593
619
  return str(value)
594
620
 
595
621
  @staticmethod
596
- def _declared_parent_keys(t: Any, ns: str) -> list[_CheckpointKey]:
597
- parent_cfg = (t.parent_config or {}).get("configurable") or {}
622
+ def _declared_parent_keys(parent_cfg: dict, md: dict, ns: str) -> list[_CheckpointKey]:
598
623
  parent_id = parent_cfg.get("checkpoint_id")
599
624
  if parent_id is not None:
600
625
  parent_ns = parent_cfg.get("checkpoint_ns", ns) or _ROOT_NS
601
626
  return [(parent_ns, parent_id)]
602
627
 
603
- md = t.metadata or {}
628
+ # `_checked` has already established `parents` as a str -> str mapping.
604
629
  parents = md.get("parents") or {}
605
- if not isinstance(parents, dict):
606
- return []
607
630
  closest = LangGraphCheckpointAdapter._closest_parent_ns(ns, parents)
608
631
  if closest is None:
609
632
  return []
610
- checkpoint_id = parents[closest]
611
- return [] if checkpoint_id is None else [(closest or _ROOT_NS, checkpoint_id)]
633
+ return [(closest or _ROOT_NS, parents[closest])]
612
634
 
613
635
  @staticmethod
614
636
  def _closest_parent_ns(ns: str, parents: dict[str, str]) -> str | None:
@@ -809,3 +831,80 @@ class LangGraphCheckpointAdapter:
809
831
  if previous: # error was already present upstream -> not introduced here
810
832
  return None
811
833
  return str(current)
834
+
835
+
836
+ def _decoded(listing: Iterable[Any]) -> Iterator[Any]:
837
+ """Iterate a saver's listing, turning a failure to decode a row into a usage error.
838
+
839
+ Only saver code runs while the next tuple is produced: the serializer reviving a blob,
840
+ ``json.loads`` on the metadata column, the database driver. Whatever it raises there
841
+ (an unknown serialization type, JSON nested past the recursion limit, a non-string column)
842
+ means the history cannot be read, which is a property of the input, not a crash.
843
+ """
844
+ iterator = iter(listing)
845
+ while True:
846
+ try:
847
+ item = next(iterator)
848
+ except StopIteration:
849
+ return
850
+ except Exception as exc:
851
+ raise ValueError(
852
+ f"checkpoint history cannot be decoded ({type(exc).__name__})"
853
+ ) from exc
854
+ yield item
855
+
856
+
857
+ def _checked(t: Any) -> tuple[dict, dict, dict, dict]:
858
+ """The tuple's ``configurable``, checkpoint and metadata, each checked against the shape
859
+ LangGraph declares for the fields this adapter reads (strictly: ``True`` is not ``1``).
860
+
861
+ A field is checked only when present; ``checkpoint.id`` is required, and when the config
862
+ also names the checkpoint the two must agree, because step identity comes from one and
863
+ chronology and task hashing from the other.
864
+ """
865
+ # A config without a thread id is not "empty": `ingest` filters on the thread id, so it
866
+ # would silently drop the checkpoint, and with it any failure it recorded.
867
+ config = getattr(t, "config", None)
868
+ if not isinstance(config, dict) or not isinstance(config.get("configurable"), dict):
869
+ raise ValueError("checkpoint config must be an object with a configurable object")
870
+ cfg = config["configurable"]
871
+ if "thread_id" not in cfg:
872
+ raise ValueError("checkpoint config has no thread_id")
873
+ cp = getattr(t, "checkpoint", None)
874
+ if not isinstance(cp, dict):
875
+ raise ValueError("checkpoint must be an object")
876
+ cid = cp.get("id")
877
+ label = f"checkpoint {cid!r}" if isinstance(cid, str) else "a checkpoint"
878
+ if "id" not in cp:
879
+ raise ValueError(f"{label} has no id")
880
+ md = object_field(getattr(t, "metadata", None), f"{label} metadata")
881
+ # No parent config is legitimate (a thread's first checkpoint). One that is present but
882
+ # names no checkpoint is not: read as "no parent", it would make this checkpoint a root.
883
+ parent = getattr(t, "parent_config", None)
884
+ parent_cfg: dict = {}
885
+ if parent is not None:
886
+ if not isinstance(parent, dict) or not isinstance(parent.get("configurable"), dict):
887
+ raise ValueError(f"{label} parent config must be an object with a configurable object")
888
+ parent_cfg = parent["configurable"]
889
+ if "checkpoint_id" not in parent_cfg:
890
+ raise ValueError(f"{label} parent config has no checkpoint_id")
891
+ for where, fields, values in (
892
+ ("checkpoint", _CHECKPOINT_FIELDS, cp),
893
+ ("metadata", _METADATA_FIELDS, md),
894
+ ("config", _CONFIG_FIELDS, cfg),
895
+ ("parent config", _CONFIG_FIELDS, parent_cfg),
896
+ ):
897
+ for name, adapter in fields.items():
898
+ if name not in values:
899
+ continue
900
+ try:
901
+ adapter.validate_python(values[name], strict=True)
902
+ except ValidationError as exc:
903
+ reason = exc.errors()[0]["msg"]
904
+ raise ValueError(f"{label} has a malformed {where} {name}: {reason}") from None
905
+ if "checkpoint_id" in cfg and cfg["checkpoint_id"] != cid:
906
+ raise ValueError(f"{label} disagrees with its config checkpoint_id")
907
+ writes = getattr(t, "pending_writes", None)
908
+ if writes is not None and not isinstance(writes, (list, tuple)):
909
+ raise ValueError(f"{label} has pending writes that are not a list")
910
+ return cfg, cp, md, parent_cfg
@@ -118,6 +118,8 @@ def _attr_value(v: Any) -> Any:
118
118
  raw = v[key]
119
119
  if isinstance(raw, bool):
120
120
  return raw # `int(True) == 1` would hide that the value was a boolean
121
+ if isinstance(raw, float) and not raw.is_integer():
122
+ return raw # `int(1.5) == 1` would hand validation a plausible count
121
123
  try:
122
124
  return int(raw)
123
125
  except (TypeError, ValueError, OverflowError):
@@ -229,43 +231,83 @@ def _iso_ts(start_nano: Any) -> str | None:
229
231
  return datetime.fromtimestamp(nanos / 1e9, tz=timezone.utc).isoformat()
230
232
 
231
233
 
232
- def _int_attr(attrs: dict[str, Any], *names: str) -> int | None:
233
- for name in names:
234
- value = attrs.get(name)
235
- if value is None or isinstance(value, bool):
236
- continue
237
- try:
238
- parsed = int(value)
239
- except (TypeError, ValueError, OverflowError):
240
- continue
241
- if parsed < 0 or (isinstance(value, float) and not value.is_integer()):
242
- continue
243
- return parsed
244
- return None
234
+ def _as_count(value: Any) -> int | None:
235
+ if value is None or isinstance(value, bool):
236
+ return None
237
+ try:
238
+ parsed = int(value)
239
+ except (TypeError, ValueError, OverflowError):
240
+ return None
241
+ if parsed < 0 or (isinstance(value, float) and not value.is_integer()):
242
+ return None
243
+ return parsed
245
244
 
246
245
 
247
- def _decimal_attr(attrs: dict[str, Any], *names: str) -> str | None:
248
- for name in names:
249
- value = attrs.get(name)
250
- if value is None or not isinstance(value, (int, float, str)):
251
- continue
252
- try:
253
- parsed = Decimal(str(value))
254
- except InvalidOperation:
255
- continue
256
- if parsed.is_finite() and parsed >= 0:
257
- return format(parsed, "f")
258
- return None
246
+ def _as_decimal(value: Any) -> str | None:
247
+ if value is None or isinstance(value, bool) or not isinstance(value, (int, float, str)):
248
+ return None
249
+ try:
250
+ parsed = Decimal(str(value))
251
+ except InvalidOperation:
252
+ return None
253
+ return format(parsed, "f") if parsed.is_finite() and parsed >= 0 else None
259
254
 
260
255
 
261
- def _currency_attr(attrs: dict[str, Any]) -> str | None:
262
- value = attrs.get("llm.cost.currency")
256
+ def _as_currency(value: Any) -> str | None:
263
257
  if not isinstance(value, str):
264
258
  return None
265
259
  normalized = value.upper()
266
260
  return normalized if re.fullmatch(r"[A-Z]{3,8}", normalized) else None
267
261
 
268
262
 
263
+ def _first_readable(attrs: dict[str, Any], read: Any, *names: str) -> Any:
264
+ for name in names:
265
+ parsed = read(attrs.get(name))
266
+ if parsed is not None:
267
+ return parsed
268
+ return None
269
+
270
+
271
+ def _unreadable(attrs: dict[str, Any], read: Any, *names: str) -> bool:
272
+ """The producer sent a value under one of ``names`` and none of them can be read."""
273
+ return _first_readable(attrs, read, *names) is None and any(
274
+ attrs.get(name) is not None for name in names
275
+ )
276
+
277
+
278
+ _TOKEN_ATTRS = (
279
+ ("llm.token_count.prompt", "llm.token_count.input"),
280
+ ("llm.token_count.completion", "llm.token_count.output"),
281
+ ("llm.token_count.total",),
282
+ )
283
+ _COST_ATTRS = (
284
+ ("llm.cost.prompt", "llm.cost.input"),
285
+ ("llm.cost.completion", "llm.cost.output"),
286
+ ("llm.cost.total",),
287
+ )
288
+ _CURRENCY_ATTR = "llm.cost.currency"
289
+ #: Stored in ``total_cost`` when the producer sent a total cost that cannot be read. It is not a
290
+ #: decimal, so analysis withholds the run's total instead of summing the rest into a figure that
291
+ #: is knowably too low. Fixed text: the producer's own value never reaches the artifact.
292
+ UNREADABLE_COST = "unreadable"
293
+
294
+
295
+ def _unreadable_telemetry(attrs: dict[str, Any]) -> dict[str, set[str]]:
296
+ """Which kinds of telemetry this span sent but tracegraph cannot read, and under which of
297
+ our fixed attribute names (never the producer's value)."""
298
+ groups = (
299
+ [("token count", _as_count, names) for names in _TOKEN_ATTRS]
300
+ + [("prompt or completion cost", _as_decimal, names) for names in _COST_ATTRS[:2]]
301
+ + [("total cost", _as_decimal, _COST_ATTRS[2])]
302
+ + [("cost currency", _as_currency, (_CURRENCY_ATTR,))]
303
+ )
304
+ found: dict[str, set[str]] = {}
305
+ for kind, read, names in groups:
306
+ if _unreadable(attrs, read, *names):
307
+ found.setdefault(kind, set()).update(n for n in names if attrs.get(n) is not None)
308
+ return found
309
+
310
+
269
311
  def _digest_attr(attrs: dict[str, Any], name: str) -> str | None:
270
312
  value = attrs.get(name)
271
313
  if value is None:
@@ -294,15 +336,16 @@ def _evidence(
294
336
  evidence = StepEvidence(
295
337
  end_ts=_iso_ts(end),
296
338
  duration_ms=duration_ms,
297
- prompt_tokens=_int_attr(attrs, "llm.token_count.prompt", "llm.token_count.input"),
298
- completion_tokens=_int_attr(
299
- attrs, "llm.token_count.completion", "llm.token_count.output"
339
+ prompt_tokens=_first_readable(attrs, _as_count, *_TOKEN_ATTRS[0]),
340
+ completion_tokens=_first_readable(attrs, _as_count, *_TOKEN_ATTRS[1]),
341
+ total_tokens=_first_readable(attrs, _as_count, *_TOKEN_ATTRS[2]),
342
+ prompt_cost=_first_readable(attrs, _as_decimal, *_COST_ATTRS[0]),
343
+ completion_cost=_first_readable(attrs, _as_decimal, *_COST_ATTRS[1]),
344
+ total_cost=(
345
+ _first_readable(attrs, _as_decimal, *_COST_ATTRS[2])
346
+ or (UNREADABLE_COST if _unreadable(attrs, _as_decimal, *_COST_ATTRS[2]) else None)
300
347
  ),
301
- total_tokens=_int_attr(attrs, "llm.token_count.total"),
302
- prompt_cost=_decimal_attr(attrs, "llm.cost.prompt", "llm.cost.input"),
303
- completion_cost=_decimal_attr(attrs, "llm.cost.completion", "llm.cost.output"),
304
- total_cost=_decimal_attr(attrs, "llm.cost.total"),
305
- cost_currency=_currency_attr(attrs),
348
+ cost_currency=_as_currency(attrs.get(_CURRENCY_ATTR)),
306
349
  artifact_digest=_digest_attr(attrs, "syncmill.artifact_digest"),
307
350
  evaluations=evaluations,
308
351
  )
@@ -471,6 +514,19 @@ class OTLPSpanAdapter:
471
514
  if unreadable
472
515
  else []
473
516
  )
517
+ dropped = [_unreadable_telemetry(attrs[sid]) for sid in by_id]
518
+ for kind, consequence in (
519
+ ("token count", "that count is reported as unavailable"),
520
+ ("prompt or completion cost", "that cost is reported as unavailable"),
521
+ ("total cost", "the run's total cost is withheld rather than understated"),
522
+ ("cost currency", "that cost is reported without a currency"),
523
+ ):
524
+ count = sum(1 for found in dropped if kind in found)
525
+ if count:
526
+ names = ", ".join(sorted(set().union(*(found.get(kind, set()) for found in dropped))))
527
+ warnings.append(
528
+ f"{count} span(s) carry a {kind} that cannot be read ({names}); {consequence}."
529
+ )
474
530
  return RawTrace(trace=trace, steps=steps, causal_edges=edges, ingest_warnings=warnings)
475
531
 
476
532
  # --- helpers ---
@@ -73,7 +73,19 @@ def _attrs(span: dict[str, Any]) -> dict[str, Any]:
73
73
  return safe
74
74
 
75
75
 
76
+ def _as_score(value: Any) -> float | None:
77
+ if value is None:
78
+ return None
79
+ try:
80
+ score = float(value)
81
+ except (TypeError, ValueError, OverflowError):
82
+ return None
83
+ return score if math.isfinite(score) else None
84
+
85
+
76
86
  def _evaluations(span: dict[str, Any]) -> list[dict[str, Any]]:
87
+ """The span's evaluations, each with ``unreadable_score`` set when a score was sent but
88
+ cannot be represented; the caller strips that key and discloses the count."""
77
89
  out: list[dict[str, Any]] = []
78
90
  annotations = span.get("annotations")
79
91
  if annotations is None:
@@ -93,16 +105,12 @@ def _evaluations(span: dict[str, Any]) -> list[dict[str, Any]]:
93
105
  if name is None:
94
106
  continue
95
107
  label = _first(result, "label") or annotation.get("label")
96
- score = _first(result, "score")
97
- if score is None:
98
- score = annotation.get("score")
99
- try:
100
- score = float(score) if score is not None else None
101
- except (TypeError, ValueError, OverflowError):
102
- score = None
103
- if score is not None and not math.isfinite(score):
104
- score = None
105
- out.append({"name": name, "label": _safe_operation_name(label), "score": score})
108
+ sent = _first(result, "score")
109
+ if sent is None:
110
+ sent = annotation.get("score")
111
+ score = _as_score(sent)
112
+ out.append({"name": name, "label": _safe_operation_name(label), "score": score,
113
+ "unreadable_score": sent is not None and score is None})
106
114
  return out
107
115
 
108
116
 
@@ -153,8 +161,11 @@ class PhoenixExportAdapter:
153
161
  except KeyError as exc:
154
162
  raise KeyError(f"no Phoenix trace {trace_id!r}") from exc
155
163
  spans = [self._span(trace_id, span) for span in trace["spans"]]
164
+ unreadable_scores = sum(
165
+ item.pop("unreadable_score") for span in spans for item in span["_tracegraph_evaluations"]
166
+ )
156
167
  document = {"resourceSpans": [{"scopeSpans": [{"spans": spans}]}]}
157
- return OTLPSpanAdapter(
168
+ raw = OTLPSpanAdapter(
158
169
  document,
159
170
  source_kind="phoenix_cli",
160
171
  links_as_causes=False,
@@ -162,6 +173,12 @@ class PhoenixExportAdapter:
162
173
  preserve_unset_status=True,
163
174
  evaluation_channel="_tracegraph_evaluations",
164
175
  ).ingest(trace_id)
176
+ if unreadable_scores:
177
+ raw.ingest_warnings.append(
178
+ f"{unreadable_scores} evaluation score(s) cannot be read as a finite number; "
179
+ "each is reported as null."
180
+ )
181
+ return raw
165
182
 
166
183
  @staticmethod
167
184
  def _trace_id(trace: dict[str, Any]) -> str: