agent-tracegraph 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,676 @@
1
+ """Deterministic, body-free diagnosis for one normalized trace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ from collections import deque
10
+ from datetime import datetime, timezone
11
+ from decimal import Decimal, InvalidOperation
12
+ from pathlib import Path
13
+ import tempfile
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ from tracegraph import artifact
18
+ from tracegraph.analysis.ahu import diff as tree_diff
19
+ from tracegraph.analysis.patterns import PRESETS, build_index, find_matches
20
+ from tracegraph.model import CausalFidelity, EdgeOrigin, EdgeType, NormalizedTrace, Step, StepStatus
21
+ from tracegraph.normalize import validate_structure
22
+
23
+ REPORT_SCHEMA_VERSION = 2
24
+ _AUTO_PRESETS = (
25
+ "tool-retry-failure",
26
+ "repeated-agent-failure",
27
+ "gate-failure-after-success",
28
+ "timeout",
29
+ "plan-then-tool-failure",
30
+ "tool-failure",
31
+ "error",
32
+ )
33
+
34
+
35
+ class DiagnosticStep(BaseModel):
36
+ step_id: str
37
+ seq: int
38
+ name: str | None
39
+ kind: str
40
+ status: str
41
+
42
+
43
+ class DiagnosticEdge(BaseModel):
44
+ effect: str
45
+ cause: str
46
+ origin: str | None
47
+
48
+
49
+ class FailureFinding(BaseModel):
50
+ step: DiagnosticStep
51
+ causal_steps: list[DiagnosticStep] = Field(default_factory=list)
52
+ causal_edges: list[DiagnosticEdge] = Field(default_factory=list)
53
+
54
+
55
+ class PatternFinding(BaseModel):
56
+ pattern_id: str
57
+ pattern_version: int
58
+ step_ids: list[str]
59
+ labels: list[str | None]
60
+
61
+
62
+ class EvaluationFinding(BaseModel):
63
+ step_id: str
64
+ name: str
65
+ label: str | None = None
66
+ score: float | None = None
67
+
68
+
69
+ class MetricSummary(BaseModel):
70
+ wall_duration_ms: float | None = None
71
+ prompt_tokens: int | None = None
72
+ completion_tokens: int | None = None
73
+ total_tokens: int | None = None
74
+ total_cost: str | None = None
75
+ cost_currency: str | None = None
76
+ evaluations: list[EvaluationFinding] = Field(default_factory=list)
77
+
78
+
79
+ class DecisionEvidenceFinding(BaseModel):
80
+ source: str
81
+ artifact_digest: str
82
+ graph_generation: int
83
+ verdict: str
84
+
85
+
86
+ class BehaviorChange(BaseModel):
87
+ logical_step_key: str
88
+ name: str | None
89
+ before: str | None
90
+ after: str | None
91
+
92
+
93
+ class ComparisonSummary(BaseModel):
94
+ baseline_digest: str
95
+ topology_identical: bool
96
+ topology_changes: list[str]
97
+ behavior_changes: list[BehaviorChange]
98
+ pattern_count_deltas: dict[str, int]
99
+ metric_deltas: dict[str, float | int | str | None]
100
+
101
+
102
+ class AnalysisReport(BaseModel):
103
+ schema_version: int = REPORT_SCHEMA_VERSION
104
+ kind: str = "tracegraph.analysis"
105
+ artifact_digest: str
106
+ trace_id: str
107
+ source_kind: str
108
+ status: str
109
+ causal_fidelity: str
110
+ links_preserved: bool | None
111
+ step_count: int
112
+ error_count: int
113
+ primary_failures: list[FailureFinding]
114
+ propagated_failures: list[DiagnosticStep]
115
+ patterns: list[PatternFinding]
116
+ metrics: MetricSummary
117
+ decision_evidence: list[DecisionEvidenceFinding] = Field(default_factory=list)
118
+ comparison: ComparisonSummary | None = None
119
+ warnings: list[str] = Field(default_factory=list)
120
+ privacy_profile: str = "safe-v1"
121
+
122
+
123
+ #: The exact shape ``_safe`` emits, used to detect aliases that reached the final report.
124
+ _ALIAS = re.compile(r"redacted:[0-9a-f]{64}")
125
+
126
+
127
+ def _safe(value: str | None) -> str | None:
128
+ if value is None or re.fullmatch(r"[A-Za-z0-9_.:/#@-]{1,200}", value):
129
+ return value
130
+ return "redacted:" + hashlib.sha256(value.encode()).hexdigest()
131
+
132
+
133
+ def _display_name(step: Step) -> str:
134
+ return _safe(step.name or step.kind.value)
135
+
136
+
137
+ def _shown(step: Step) -> DiagnosticStep:
138
+ return DiagnosticStep(
139
+ step_id=step.step_id,
140
+ seq=step.seq,
141
+ name=step.name,
142
+ kind=step.kind.value,
143
+ status=step.status.value,
144
+ )
145
+
146
+
147
+ def _cause_adjacency(nt: NormalizedTrace) -> dict[str, list[str]]:
148
+ out: dict[str, list[str]] = {}
149
+ for edge in nt.edges_of(EdgeType.CAUSED_BY):
150
+ out.setdefault(edge.src, []).append(edge.dst)
151
+ steps = nt.steps_by_id()
152
+ for causes in out.values():
153
+ causes.sort(key=lambda sid: (steps[sid].seq, sid))
154
+ return out
155
+
156
+
157
+ def _ancestors(start: str, adjacency: dict[str, list[str]]) -> set[str]:
158
+ seen: set[str] = set()
159
+ queue = deque(adjacency.get(start, []))
160
+ while queue:
161
+ current = queue.popleft()
162
+ if current in seen:
163
+ continue
164
+ seen.add(current)
165
+ queue.extend(adjacency.get(current, []))
166
+ return seen
167
+
168
+
169
+ def _failures(nt: NormalizedTrace) -> tuple[list[FailureFinding], list[DiagnosticStep]]:
170
+ steps = nt.steps_by_id()
171
+ errors = {step.step_id for step in nt.steps if step.status is StepStatus.ERROR}
172
+ adjacency = _cause_adjacency(nt)
173
+ explicit = {}
174
+ depth = {}
175
+ for edge in nt.edges_of(EdgeType.CAUSED_BY):
176
+ if edge.origin in {EdgeOrigin.CHECKPOINT_PARENT, EdgeOrigin.GRAPH_PARENT, EdgeOrigin.SPAN_LINK}:
177
+ explicit.setdefault(edge.src, []).append(edge.dst)
178
+ for step in sorted(nt.steps, key=lambda s: (s.seq, s.step_id)):
179
+ depth[step.step_id] = 1 + max((depth[c] for c in adjacency.get(step.step_id, [])), default=-1)
180
+ # Candidate vs context in one pass: only explicit-origin paths establish
181
+ # error ancestry. Containment or legacy/unknown paths cannot suppress candidates. cause.seq < effect.seq (validate_raw guarantees it), so
182
+ # walking steps in ascending seq computes each node's flag after all its causes' —
183
+ # O(V+E) total instead of a full ancestor BFS per error.
184
+ has_error_ancestor: dict[str, bool] = {}
185
+ for step in sorted(nt.steps, key=lambda s: (s.seq, s.step_id)):
186
+ has_error_ancestor[step.step_id] = any(
187
+ cause in errors or has_error_ancestor[cause]
188
+ for cause in explicit.get(step.step_id, [])
189
+ )
190
+ primary_ids = sorted(
191
+ (sid for sid in errors if not has_error_ancestor[sid]),
192
+ key=lambda sid: (-depth[sid], steps[sid].seq, sid),
193
+ )
194
+ propagated_ids = sorted(errors - set(primary_ids), key=lambda sid: (steps[sid].seq, sid))
195
+
196
+ # Edges indexed by effect: each finding then touches only its own nodes' edges
197
+ # (output-proportional) instead of re-scanning the whole edge list per finding.
198
+ edges_by_effect: dict[str, list] = {}
199
+ for edge in nt.edges_of(EdgeType.CAUSED_BY):
200
+ edges_by_effect.setdefault(edge.src, []).append(edge)
201
+
202
+ findings: list[FailureFinding] = []
203
+ for step_id in primary_ids:
204
+ # Full ancestor sets are materialized only for primary failures (the findings
205
+ # need them); propagated errors never pay a BFS.
206
+ ancestor_ids = _ancestors(step_id, adjacency)
207
+ ordered = sorted(ancestor_ids, key=lambda sid: (steps[sid].seq, sid))
208
+ relevant = ancestor_ids | {step_id}
209
+ selected = [
210
+ edge
211
+ for sid in relevant
212
+ for edge in edges_by_effect.get(sid, [])
213
+ if edge.dst in relevant
214
+ ]
215
+ selected.sort(
216
+ key=lambda edge: (steps[edge.src].seq, steps[edge.dst].seq, (edge.src, edge.dst))
217
+ )
218
+ edges = [
219
+ DiagnosticEdge(
220
+ effect=edge.src,
221
+ cause=edge.dst,
222
+ origin=edge.origin.value if edge.origin else None,
223
+ )
224
+ for edge in selected
225
+ ]
226
+ findings.append(
227
+ FailureFinding(
228
+ step=_shown(steps[step_id]),
229
+ causal_steps=[_shown(steps[sid]) for sid in ordered],
230
+ causal_edges=edges,
231
+ )
232
+ )
233
+ return findings, [_shown(steps[sid]) for sid in propagated_ids]
234
+
235
+
236
+ def _patterns(nt: NormalizedTrace) -> list[PatternFinding]:
237
+ """Every auto preset's matches, de-duplicated *within* a preset only.
238
+
239
+ The key is ``(pattern_id, match)``: two different presets legitimately describe the same
240
+ steps from different angles (a failing tool step is both ``tool-failure`` and ``error``),
241
+ and suppressing the later one would silently drop a finding *and* skew
242
+ ``pattern_count_deltas`` — the delta for a preset would depend on whether an unrelated
243
+ preset happened to match the same tuple first. A single preset yielding the same match
244
+ twice is still collapsed.
245
+ """
246
+ index = build_index(nt)
247
+ steps = index[0]
248
+ seen: set[tuple[str, tuple[str, ...]]] = set()
249
+ findings: list[PatternFinding] = []
250
+ for name in _AUTO_PRESETS:
251
+ pattern = PRESETS[name]
252
+ for match in find_matches(nt, pattern, index=index):
253
+ key = (name, tuple(match))
254
+ if key in seen:
255
+ continue
256
+ seen.add(key)
257
+ findings.append(
258
+ PatternFinding(
259
+ pattern_id=name,
260
+ pattern_version=pattern.pattern_version or 1,
261
+ step_ids=list(match),
262
+ labels=[steps[sid].name for sid in match],
263
+ )
264
+ )
265
+ return findings
266
+
267
+
268
+ def _iso(value: str | None) -> datetime | None:
269
+ if value is None:
270
+ return None
271
+ try:
272
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
273
+ return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
274
+ except ValueError:
275
+ return None
276
+
277
+
278
+ def _metrics(nt: NormalizedTrace) -> MetricSummary:
279
+ """Metric summary only; see :func:`_metrics_with_notes` for the disclosure notes."""
280
+ return _metrics_with_notes(nt)[0]
281
+
282
+
283
+ def _metrics_with_notes(nt: NormalizedTrace) -> tuple[MetricSummary, list[str], bool]:
284
+ """Summarize observed metrics and report what had to be withheld.
285
+
286
+ Every degradation is disclosed rather than absorbed: a wall duration that cannot be
287
+ trusted becomes ``None`` with a note, and a single unparseable cost withholds the whole
288
+ total (the same posture the mixed-currency path already takes) instead of silently
289
+ summing the remainder into an understated figure. "Unavailable", never a wrong number.
290
+
291
+ The third element says whether ``wall_duration_ms`` covers every step. A duration built
292
+ from a subset is a lower bound, which is honest to publish on its own but cannot be
293
+ subtracted: the unobserved work has unknown length, so a delta computed from it has
294
+ unknown magnitude *and* unknown sign.
295
+ """
296
+ notes: list[str] = []
297
+ duration_complete = False
298
+ starts: list[datetime] = []
299
+ ends: list[datetime] = []
300
+ reversed_steps = 0
301
+ for step in nt.steps:
302
+ start = _iso(step.ts)
303
+ end = _iso(step.evidence.end_ts) if step.evidence else None
304
+ if start is not None:
305
+ starts.append(start)
306
+ if end is not None:
307
+ ends.append(end)
308
+ # A step that ends before it began is internally inconsistent, and the aggregate
309
+ # envelope hides it: with steps [0s,2s] and [10s,1s] the envelope max(ends)-min(starts)
310
+ # is a healthy-looking +2s, and the 10s start never appears in the answer at all. Check
311
+ # each pair on its own, not just the outer bounds.
312
+ if start is not None and end is not None and end < start:
313
+ reversed_steps += 1
314
+ wall: float | None = None
315
+ if starts and ends:
316
+ wall = round((max(ends) - min(starts)).total_seconds() * 1000, 6)
317
+ if reversed_steps:
318
+ wall = None
319
+ notes.append(
320
+ f"wall_duration_ms unavailable: {reversed_steps} step(s) report an end before "
321
+ "their own start; span timestamps are inconsistent."
322
+ )
323
+ elif wall < 0:
324
+ # An end that precedes every start means the timestamps disagree (clock skew,
325
+ # a mis-scaled unit, a mislabeled span). A negative duration is not a fact.
326
+ wall = None
327
+ notes.append(
328
+ "wall_duration_ms unavailable: the latest observed end precedes the earliest "
329
+ "observed start; span timestamps are inconsistent."
330
+ )
331
+ else:
332
+ # Both ends of the span matter. A missing *start* is as distorting as a missing
333
+ # end: the earliest step may be the one without a usable timestamp, in which case
334
+ # min(starts) is later than the real beginning and the duration comes out short.
335
+ total = len(nt.steps)
336
+ gaps = []
337
+ if len(starts) < total:
338
+ gaps.append(f"{len(starts)} of {total} reported a usable start")
339
+ if len(ends) < total:
340
+ gaps.append(f"{len(ends)} of {total} reported an end")
341
+ if gaps:
342
+ notes.append(
343
+ "wall_duration_ms is a lower bound: " + "; ".join(gaps) + "."
344
+ )
345
+ else:
346
+ duration_complete = True
347
+
348
+ evidence_items = [step.evidence for step in nt.steps if step.evidence]
349
+
350
+ def total_int(field: str) -> int | None:
351
+ values = [
352
+ getattr(item, field)
353
+ for item in evidence_items
354
+ if getattr(item, field) is not None
355
+ ]
356
+ return sum(values) if values else None
357
+
358
+ costs: list[Decimal] = []
359
+ cost_currencies: set[str] = set()
360
+ has_currencyless_cost = False
361
+ unusable_costs = 0
362
+ for evidence in evidence_items:
363
+ if evidence.total_cost is not None:
364
+ try:
365
+ parsed = Decimal(evidence.total_cost)
366
+ except InvalidOperation:
367
+ unusable_costs += 1
368
+ continue
369
+ if not (parsed.is_finite() and parsed >= 0):
370
+ unusable_costs += 1
371
+ continue
372
+ costs.append(parsed)
373
+ if evidence.cost_currency:
374
+ cost_currencies.add(evidence.cost_currency)
375
+ else:
376
+ has_currencyless_cost = True
377
+ ambiguous_cost = len(cost_currencies) > 1 or (
378
+ bool(cost_currencies) and has_currencyless_cost
379
+ )
380
+ if unusable_costs:
381
+ # Dropping the bad value and summing the rest would report a confident number that
382
+ # is knowably too low. Withhold the total and say why.
383
+ ambiguous_cost = True
384
+ notes.append(
385
+ f"total_cost unavailable: {unusable_costs} step(s) reported a cost that is not a "
386
+ "finite non-negative decimal, so the remaining costs would understate the total."
387
+ )
388
+ evaluations = [
389
+ EvaluationFinding(
390
+ step_id=step.step_id,
391
+ name=item.name,
392
+ label=item.label,
393
+ score=item.score,
394
+ )
395
+ for step in nt.steps
396
+ if step.evidence
397
+ for item in step.evidence.evaluations
398
+ ]
399
+ evaluations.sort(key=lambda item: (item.step_id, item.name, item.label or ""))
400
+ return MetricSummary(
401
+ wall_duration_ms=wall,
402
+ prompt_tokens=total_int("prompt_tokens"),
403
+ completion_tokens=total_int("completion_tokens"),
404
+ total_tokens=total_int("total_tokens"),
405
+ total_cost=(format(sum(costs), "f") if costs and not ambiguous_cost else None),
406
+ cost_currency=(
407
+ next(iter(cost_currencies))
408
+ if len(cost_currencies) == 1 and not ambiguous_cost
409
+ else None
410
+ ),
411
+ evaluations=evaluations,
412
+ ), notes, duration_complete
413
+
414
+
415
+ def _logical_keys(nt: NormalizedTrace, table: dict | None = None) -> dict[int, Step]:
416
+ steps = nt.steps_by_id()
417
+ parent = {edge.src: edge.dst for edge in nt.edges_of(EdgeType.TREE_PARENT)}
418
+ children: dict[str | None, list[str]] = {}
419
+ for step in nt.steps:
420
+ children.setdefault(parent.get(step.step_id), []).append(step.step_id)
421
+ for ids in children.values():
422
+ ids.sort(key=lambda sid: (steps[sid].seq, sid))
423
+
424
+ # Occurrence index among same-(kind, name) siblings, in the sorted sibling order —
425
+ # precomputed with running counters instead of a per-step sibling rescan.
426
+ occurrence_of: dict[str, int] = {}
427
+ for ids in children.values():
428
+ counts: dict[tuple[object, object], int] = {}
429
+ for sid in ids:
430
+ key = (steps[sid].kind, steps[sid].name)
431
+ occurrence_of[sid] = counts.get(key, 0)
432
+ counts[key] = occurrence_of[sid] + 1
433
+
434
+ if table is None:
435
+ table = {}
436
+ result: dict[int, Step] = {}
437
+ queue = deque()
438
+ for root in children.get(None, []):
439
+ queue.append((root, None))
440
+ while queue:
441
+ step_id, prefix = queue.popleft()
442
+ step = steps[step_id]
443
+ signature = (prefix, step.kind.value, step.name, occurrence_of[step_id])
444
+ key = table.setdefault(signature, len(table))
445
+ result[key] = step
446
+ for child in children.get(step_id, []):
447
+ queue.append((child, key))
448
+ return result
449
+
450
+
451
+ def _delta(after: float | int | None, before: float | int | None) -> float | int | None:
452
+ return after - before if after is not None and before is not None else None
453
+
454
+
455
+ def _comparison(
456
+ current: NormalizedTrace,
457
+ baseline: NormalizedTrace,
458
+ *,
459
+ current_patterns: list[PatternFinding],
460
+ current_metrics: MetricSummary,
461
+ current_duration_complete: bool,
462
+ notes: list[str] | None = None,
463
+ ) -> ComparisonSummary:
464
+ """Compare against a baseline. Appends the baseline's own metric disclosures to ``notes``.
465
+
466
+ A delta is only as trustworthy as both sides of it, so a baseline whose duration is a
467
+ lower bound (or whose cost had to be withheld) must say so too — otherwise the report
468
+ presents a confident metric_delta computed from a number it would not have published
469
+ on its own.
470
+ """
471
+ structural = tree_diff(baseline, current, display_label=_display_name)
472
+ table = {}
473
+ before, after = _logical_keys(baseline, table), _logical_keys(current, table)
474
+ signatures = {number: signature for signature, number in table.items()}
475
+
476
+ def display_key(key):
477
+ segments = []
478
+ while key is not None:
479
+ key, kind, name, occurrence = signatures[key]
480
+ safe = _safe(name)
481
+ tagged = ["redacted" if safe != name else "literal", safe]
482
+ segments.append([kind, tagged, occurrence])
483
+ return json.dumps(list(reversed(segments)), ensure_ascii=True, separators=(",", ":"))
484
+ changes: list[BehaviorChange] = []
485
+ for key in sorted(set(before) | set(after)):
486
+ old, new = before.get(key), after.get(key)
487
+ old_status = old.status.value if old else None
488
+ new_status = new.status.value if new else None
489
+ if old_status != new_status:
490
+ changes.append(
491
+ BehaviorChange(
492
+ logical_step_key=display_key(key),
493
+ name=(new or old).name if (new or old) else None,
494
+ before=old_status,
495
+ after=new_status,
496
+ )
497
+ )
498
+
499
+ before_patterns = _patterns(baseline)
500
+ after_patterns = current_patterns
501
+ pattern_ids = set(_AUTO_PRESETS)
502
+ deltas = {
503
+ name: sum(item.pattern_id == name for item in after_patterns)
504
+ - sum(item.pattern_id == name for item in before_patterns)
505
+ for name in sorted(pattern_ids)
506
+ }
507
+ bm, baseline_notes, baseline_duration_complete = _metrics_with_notes(baseline)
508
+ cm = current_metrics
509
+ cost_delta: str | None = None
510
+ if (
511
+ bm.total_cost is not None
512
+ and cm.total_cost is not None
513
+ and bm.cost_currency is not None
514
+ and bm.cost_currency == cm.cost_currency
515
+ ):
516
+ cost_delta = format(Decimal(cm.total_cost) - Decimal(bm.total_cost), "f")
517
+ if notes is not None:
518
+ notes.extend(f"baseline: {note}" for note in baseline_notes)
519
+ # A delta between two lower bounds is not a lower bound on the delta. If either side
520
+ # missed a step, the unobserved work could be longer than the difference, so the number
521
+ # would be wrong in magnitude and possibly in sign. Withhold it and say why; disclosing
522
+ # the inputs' incompleteness does not license publishing a confident subtraction of them.
523
+ durations_comparable = current_duration_complete and baseline_duration_complete
524
+ if not durations_comparable and None not in (cm.wall_duration_ms, bm.wall_duration_ms):
525
+ if notes is not None:
526
+ notes.append(
527
+ "wall_duration_ms delta unavailable: at least one side's duration covers "
528
+ "only part of its trace, so the difference has unknown size and sign."
529
+ )
530
+ return ComparisonSummary(
531
+ baseline_digest=_digest(baseline),
532
+ topology_identical=structural.identical,
533
+ topology_changes=structural.changes,
534
+ behavior_changes=changes,
535
+ pattern_count_deltas=deltas,
536
+ metric_deltas={
537
+ "wall_duration_ms": (
538
+ _delta(cm.wall_duration_ms, bm.wall_duration_ms)
539
+ if durations_comparable
540
+ else None
541
+ ),
542
+ "prompt_tokens": _delta(cm.prompt_tokens, bm.prompt_tokens),
543
+ "completion_tokens": _delta(cm.completion_tokens, bm.completion_tokens),
544
+ "total_tokens": _delta(cm.total_tokens, bm.total_tokens),
545
+ "total_cost": cost_delta,
546
+ },
547
+ )
548
+
549
+
550
+ def _digest(nt: NormalizedTrace) -> str:
551
+ return "sha256:" + hashlib.sha256(artifact.dumps(nt).encode("utf-8")).hexdigest()
552
+
553
+
554
+ def analyze(nt: NormalizedTrace, *, baseline: NormalizedTrace | None = None) -> AnalysisReport:
555
+ # Analyses index by step id and walk causes in seq order; on a malformed trace that
556
+ # surfaces as a raw KeyError deep inside a helper. Reject it here as a ValueError so the
557
+ # library entry point has the same failure contract as artifact.loads().
558
+ validate_structure(nt)
559
+ if baseline is not None:
560
+ validate_structure(baseline)
561
+ primary, propagated = _failures(nt)
562
+ patterns = _patterns(nt)
563
+ metrics, metric_notes, duration_complete = _metrics_with_notes(nt)
564
+ warnings: list[str] = []
565
+ if nt.trace.causal_fidelity is CausalFidelity.PARENT_ONLY:
566
+ warnings.append(
567
+ "Phoenix export preserves parent relationships only; additional fan-in causes may be missing."
568
+ )
569
+ for label, trace in (("current", nt), ("baseline", baseline)):
570
+ if trace is None:
571
+ continue
572
+ if label == "baseline" and trace.trace.causal_fidelity is CausalFidelity.PARENT_ONLY:
573
+ warnings.append("baseline: Phoenix export preserves parent relationships only; additional fan-in causes may be missing.")
574
+ lossy = sum(step.projection_lossy for step in trace.steps)
575
+ if lossy:
576
+ warnings.append(f"{label}: derived tree drops causes at {lossy} step(s); topology comparison covers TREE_PARENT only.")
577
+ if trace.trace.source_kind == "langgraph":
578
+ # Unconditional on purpose. Capture now covers both the configured error channel
579
+ # and native task failures, but an artifact ingested by an older build, or one
580
+ # whose checkpoints use a layout this adapter does not recognize, carries no
581
+ # derived failure step and looks identical to a clean run. Absence of a failure
582
+ # is therefore still not evidence of success, and nothing in the artifact says
583
+ # which case applies — so the disclosure cannot be made conditional.
584
+ warnings.append(f"{label}: LangGraph failure capture covers the configured error channel and task pending writes for recognized checkpoint layouts; absence of an observed failure does not prove execution success.")
585
+ if trace.trace.links_preserved:
586
+ warnings.append(f"{label}: link preservation covers valid in-trace links only; foreign or unresolved links are omitted.")
587
+ if primary or propagated:
588
+ warnings.append("Failure candidates are investigation leads. Containment/unknown edges do not prove propagation; explicit causal ancestry is context, not proof of exception propagation.")
589
+ if any(getattr(metrics, field) is not None for field in ("prompt_tokens", "completion_tokens", "total_tokens", "total_cost")):
590
+ warnings.append("Metrics sum observed span values; coverage may be partial and producer aggregates may double count.")
591
+ # Withheld or partially-covered metrics are disclosed, never silently absorbed. The
592
+ # comparison is built first because it contributes the baseline's own disclosures: a
593
+ # delta is only as trustworthy as both sides of it.
594
+ comparison = (
595
+ _comparison(
596
+ nt,
597
+ baseline,
598
+ current_patterns=patterns,
599
+ current_metrics=metrics,
600
+ current_duration_complete=duration_complete,
601
+ notes=metric_notes,
602
+ )
603
+ if baseline
604
+ else None
605
+ )
606
+ warnings.extend(metric_notes)
607
+ report = AnalysisReport(
608
+ artifact_digest=_digest(nt),
609
+ trace_id=nt.trace.trace_id,
610
+ source_kind=nt.trace.source_kind,
611
+ status=nt.trace.status.value,
612
+ causal_fidelity=nt.trace.causal_fidelity.value,
613
+ links_preserved=nt.trace.links_preserved,
614
+ step_count=len(nt.steps),
615
+ error_count=sum(step.status is StepStatus.ERROR for step in nt.steps),
616
+ primary_failures=primary,
617
+ propagated_failures=propagated,
618
+ patterns=patterns,
619
+ metrics=metrics,
620
+ decision_evidence=[
621
+ DecisionEvidenceFinding(**item.model_dump()) for item in nt.trace.decision_evidence
622
+ ],
623
+ comparison=comparison,
624
+ warnings=warnings,
625
+ )
626
+ def clean(value):
627
+ return _safe(value)
628
+
629
+ report.source_kind = clean(report.source_kind)
630
+ report.metrics.cost_currency = clean(report.metrics.cost_currency)
631
+ for finding in report.primary_failures:
632
+ for step in [finding.step, *finding.causal_steps]:
633
+ step.name = clean(step.name)
634
+ for step in report.propagated_failures:
635
+ step.name = clean(step.name)
636
+ for finding in report.patterns:
637
+ finding.labels = [clean(name) for name in finding.labels]
638
+ for item in report.metrics.evaluations:
639
+ item.name, item.label = clean(item.name), clean(item.label)
640
+ if report.comparison:
641
+ for change in report.comparison.behavior_changes:
642
+ change.name = clean(change.name)
643
+ # Disclose redaction if and only if an alias actually survives into the report. Deriving
644
+ # the flag from the finished document (rather than from every clean() call) covers text
645
+ # that only reaches the report indirectly — baseline topology descriptions and logical
646
+ # step keys — without raising the warning on names that were never published at all. A
647
+ # disclosure attached to a report containing zero aliases just teaches readers to skip it.
648
+ if _ALIAS.search(dumps(report)):
649
+ report.warnings.append("Display text was replaced with deterministic redacted aliases; safe-v1 retains structural identifiers and is not anonymization.")
650
+ return report
651
+
652
+
653
+ def dumps(report: AnalysisReport) -> str:
654
+ return json.dumps(report.model_dump(mode="json"), indent=2, sort_keys=True) + "\n"
655
+
656
+
657
+ def save_atomic(report: AnalysisReport, path: str | Path) -> None:
658
+ target = Path(path)
659
+ text = dumps(report)
660
+ # Round-trip the public model before replacing an existing report (validated on the
661
+ # parsed shape directly — no need to re-parse the JSON we just produced).
662
+ AnalysisReport.model_validate(report.model_dump(mode="json"))
663
+ target.parent.mkdir(parents=True, exist_ok=True)
664
+ temp_name: str | None = None
665
+ try:
666
+ with tempfile.NamedTemporaryFile(
667
+ mode="w", encoding="utf-8", dir=target.parent, prefix=f".{target.name}.", delete=False
668
+ ) as handle:
669
+ handle.write(text)
670
+ handle.flush()
671
+ os.fsync(handle.fileno())
672
+ temp_name = handle.name
673
+ os.replace(temp_name, target)
674
+ finally:
675
+ if temp_name is not None:
676
+ Path(temp_name).unlink(missing_ok=True)