agentloop-profiler 0.1.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.
Files changed (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
agentloop/graph.py ADDED
@@ -0,0 +1,464 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from dataclasses import dataclass, field
5
+ from heapq import heapify, heappop, heappush
6
+ from typing import Any
7
+
8
+ from agentloop.operations import operation_kind
9
+ from agentloop.parallelism import parallelization_candidates
10
+ from agentloop.timing import (
11
+ cumulative_span_time_ms,
12
+ elapsed_runtime_ms,
13
+ event_interval_ms,
14
+ )
15
+ from agentloop.timing import (
16
+ duration_ms as recorded_duration_ms,
17
+ )
18
+ from agentloop.workflow_types import stage_summary, workflow_summary
19
+
20
+
21
+ @dataclass
22
+ class ExecutionNode:
23
+ node_id: str
24
+ name: str
25
+ event_type: str
26
+ duration_ms: float
27
+ model: str | None = None
28
+ input_tokens: int = 0
29
+ output_tokens: int = 0
30
+ status: str = "ok"
31
+ metadata: dict[str, Any] = field(default_factory=dict)
32
+ started_at: str | None = None
33
+ ended_at: str | None = None
34
+ parent_id: str | None = None
35
+ token_provenance: str | None = None
36
+
37
+ @property
38
+ def operation_kind(self) -> str:
39
+ return operation_kind(self)
40
+
41
+ @property
42
+ def total_tokens(self) -> int:
43
+ return self.input_tokens + self.output_tokens
44
+
45
+ def to_dict(self) -> dict[str, Any]:
46
+ result = {
47
+ "node_id": self.node_id,
48
+ "name": self.name,
49
+ "event_type": self.event_type,
50
+ "operation_kind": self.operation_kind,
51
+ "duration_ms": round(self.duration_ms, 3),
52
+ "started_at": self.started_at,
53
+ "ended_at": self.ended_at,
54
+ "parent_id": self.parent_id,
55
+ "model": self.model,
56
+ "input_tokens": self.input_tokens,
57
+ "output_tokens": self.output_tokens,
58
+ "total_tokens": self.total_tokens,
59
+ "token_provenance": self.token_provenance,
60
+ "status": self.status,
61
+ "metadata": self.metadata,
62
+ }
63
+ stage = stage_summary(self)
64
+ if stage is not None:
65
+ result["stage"] = stage
66
+ return result
67
+
68
+
69
+ @dataclass
70
+ class ExecutionEdge:
71
+ source: str
72
+ target: str
73
+ kind: str = "sequence"
74
+
75
+ def to_dict(self) -> dict[str, str]:
76
+ return {"source": self.source, "target": self.target, "kind": self.kind}
77
+
78
+
79
+ @dataclass
80
+ class CriticalPath:
81
+ node_ids: list[str]
82
+ duration_ms: float
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ return {"node_ids": self.node_ids, "duration_ms": round(self.duration_ms, 3)}
86
+
87
+
88
+ @dataclass(slots=True)
89
+ class _PathState:
90
+ node_id: str
91
+ duration_ms: float
92
+ started_ms: float | None = None
93
+ ended_ms: float | None = None
94
+ predecessor: _PathState | None = field(default=None, repr=False)
95
+ node_count: int = 1
96
+
97
+
98
+ @dataclass
99
+ class ExecutionGraph:
100
+ nodes: list[ExecutionNode]
101
+ edges: list[ExecutionEdge]
102
+ elapsed_ms: float | None = None
103
+ execution: dict[str, Any] | None = None
104
+ dependency_mode: str | None = None
105
+ external_evidence: dict[str, Any] | None = None
106
+
107
+ @classmethod
108
+ def from_trace(cls, trace: Any) -> "ExecutionGraph":
109
+ from agentloop.interoperability.evidence import read_external
110
+
111
+ external = read_external(trace)
112
+ indexed_events = list(enumerate(trace.events))
113
+ if external is None:
114
+ indexed_events.sort(key=_event_sort_key)
115
+
116
+ nodes: list[ExecutionNode] = []
117
+ event_by_node_id: dict[str, Any] = {}
118
+ for original_index, event in indexed_events:
119
+ node_id = event.event_id or f"node_{original_index}"
120
+ nodes.append(
121
+ ExecutionNode(
122
+ node_id=node_id,
123
+ name=event.name,
124
+ event_type=event.event_type,
125
+ duration_ms=event.duration_ms,
126
+ model=event.model,
127
+ input_tokens=event.input_tokens,
128
+ output_tokens=event.output_tokens,
129
+ token_provenance=getattr(event, "token_provenance", None),
130
+ status=event.status,
131
+ metadata=event.metadata or {},
132
+ started_at=event.started_at,
133
+ ended_at=event.ended_at,
134
+ parent_id=event.parent_id,
135
+ )
136
+ )
137
+ event_by_node_id[node_id] = event
138
+
139
+ node_ids = set(event_by_node_id)
140
+ edges: list[ExecutionEdge] = []
141
+ roots: list[ExecutionNode] = []
142
+ for node in nodes:
143
+ if node.parent_id and node.parent_id in node_ids and node.parent_id != node.node_id:
144
+ edges.append(
145
+ ExecutionEdge(source=node.parent_id, target=node.node_id, kind="parent")
146
+ )
147
+ else:
148
+ roots.append(node)
149
+
150
+ stages = {node.node_id: stage_summary(node) for node in nodes}
151
+ annotated = {key: value for key, value in stages.items() if value is not None}
152
+ declared = any(
153
+ value.get("dependency_status") in {"declared", "invalid"}
154
+ for value in annotated.values()
155
+ )
156
+ if declared:
157
+ for target, stage in annotated.items():
158
+ if stage.get("dependencies_declared"):
159
+ edges.extend(
160
+ ExecutionEdge(source, target, "dependency")
161
+ for source in stage["depends_on"]
162
+ if source in node_ids
163
+ )
164
+ elif external is None:
165
+ edges.extend(_inferred_sequence_edges(roots))
166
+ return cls(
167
+ nodes=nodes,
168
+ edges=_deduplicate_edges(edges),
169
+ elapsed_ms=elapsed_runtime_ms(trace),
170
+ execution=workflow_summary(getattr(trace, "metadata", {})),
171
+ dependency_mode=("declared" if declared else "inferred_sequence")
172
+ if annotated
173
+ else None,
174
+ external_evidence=external,
175
+ )
176
+
177
+ def total_runtime_ms(self) -> float | None:
178
+ if self.external_evidence is not None:
179
+ return self.external_evidence["runtime_ms"]
180
+ if self.elapsed_ms is not None:
181
+ return max(0.0, self.elapsed_ms)
182
+ intervals = [event_interval_ms(node) for node in self.nodes]
183
+ if intervals and all(interval is not None for interval in intervals):
184
+ valid = [interval for interval in intervals if interval is not None]
185
+ return max(ended for _, ended in valid) - min(started for started, _ in valid)
186
+ return cumulative_span_time_ms(self.nodes)
187
+
188
+ def critical_path(self) -> CriticalPath:
189
+ if (
190
+ self.external_evidence is not None
191
+ and not self.external_evidence["event_timing_complete"]
192
+ ):
193
+ raise ValueError("critical path requires measured external event intervals")
194
+ details = self.dependency_summary()
195
+ if details is not None and not details["valid"]:
196
+ raise ValueError(
197
+ "critical path is unavailable for invalid or unresolved declared dependencies"
198
+ )
199
+ return self._critical_path()
200
+
201
+ def _critical_path(self) -> CriticalPath:
202
+ if not self.nodes:
203
+ return CriticalPath([], 0.0)
204
+
205
+ node_by_id = {node.node_id: node for node in self.nodes}
206
+ predecessors: dict[str, list[str]] = {node_id: [] for node_id in node_by_id}
207
+ successors: dict[str, list[str]] = {node_id: [] for node_id in node_by_id}
208
+ for edge in self.edges:
209
+ if edge.source not in node_by_id or edge.target not in node_by_id:
210
+ continue
211
+ predecessors[edge.target].append(edge.source)
212
+ successors[edge.source].append(edge.target)
213
+
214
+ ordered_ids = _topological_order(self.nodes, predecessors, successors)
215
+ states: dict[str, _PathState] = {}
216
+ for node_id in ordered_ids:
217
+ node = node_by_id[node_id]
218
+ interval = event_interval_ms(node)
219
+ if interval is None:
220
+ own = _PathState(node_id, recorded_duration_ms(node))
221
+ else:
222
+ own = _PathState(node_id, interval[1] - interval[0], interval[0], interval[1])
223
+
224
+ candidates = [own]
225
+ for predecessor_id in predecessors[node_id]:
226
+ predecessor = states.get(predecessor_id)
227
+ if predecessor is not None:
228
+ candidates.append(_extend_path(predecessor, node, interval))
229
+ states[node_id] = max(
230
+ candidates, key=lambda state: (state.duration_ms, state.node_count)
231
+ )
232
+
233
+ best = max(states.values(), key=lambda state: (state.duration_ms, state.node_count))
234
+ # Share path prefixes while traversing; materialize only the winning path.
235
+ node_ids = []
236
+ state: _PathState | None = best
237
+ while state is not None:
238
+ node_ids.append(state.node_id)
239
+ state = state.predecessor
240
+ node_ids.reverse()
241
+ return CriticalPath(node_ids, best.duration_ms)
242
+
243
+ def dependency_summary(self):
244
+ if self.dependency_mode is None:
245
+ return None
246
+ identities = {node.node_id for node in self.nodes}
247
+ declared, invalid, unresolved = 0, [], []
248
+ for node in self.nodes:
249
+ stage = stage_summary(node)
250
+ if stage is None:
251
+ continue
252
+ if stage.get("dependency_status") == "invalid":
253
+ invalid.append(node.node_id)
254
+ if stage.get("dependencies_declared"):
255
+ declared += 1
256
+ unresolved.extend(
257
+ {"source": source, "target": node.node_id}
258
+ for source in stage["depends_on"]
259
+ if source not in identities
260
+ )
261
+ successors = {identity: set() for identity in identities}
262
+ indegrees = dict.fromkeys(identities, 0)
263
+ for edge in self.edges:
264
+ if edge.source not in identities or edge.target not in identities:
265
+ unresolved.append({"source": edge.source, "target": edge.target})
266
+ elif edge.target not in successors[edge.source]:
267
+ successors[edge.source].add(edge.target)
268
+ indegrees[edge.target] += 1
269
+ ready = deque(identity for identity, count in indegrees.items() if not count)
270
+ visited = 0
271
+ while ready:
272
+ identity = ready.popleft()
273
+ visited += 1
274
+ for target in successors[identity]:
275
+ indegrees[target] -= 1
276
+ if indegrees[target] == 0:
277
+ ready.append(target)
278
+ cyclic = visited != len(identities)
279
+ return {
280
+ "basis": self.dependency_mode,
281
+ "declared_span_count": declared,
282
+ "span_count": len(self.nodes),
283
+ "all_spans_declared": declared == len(self.nodes),
284
+ "invalid_spans": sorted(invalid),
285
+ "unresolved": sorted(unresolved, key=lambda item: (item["source"], item["target"])),
286
+ "cyclic": cyclic,
287
+ "valid": not invalid
288
+ and not unresolved
289
+ and not cyclic
290
+ and len(identities) == len(self.nodes),
291
+ }
292
+
293
+ def parallelizable_groups(self) -> list[dict[str, Any]]:
294
+ return parallelization_candidates(
295
+ self.nodes,
296
+ dependency_edges=(
297
+ (edge.source, edge.target)
298
+ for edge in self.edges
299
+ if edge.kind in {"dependency", "depends_on"}
300
+ ),
301
+ )
302
+
303
+ def bottlenecks(self, limit: int = 5) -> list[dict[str, Any]]:
304
+ if self.external_evidence is not None and (
305
+ not self.external_evidence["event_timing_complete"]
306
+ or self.external_evidence["runtime_ms"] is None
307
+ ):
308
+ return []
309
+ total = self.total_runtime_ms() or 1.0
310
+ ranked = sorted(self.nodes, key=lambda node: node.duration_ms, reverse=True)[:limit]
311
+ return [
312
+ {
313
+ **node.to_dict(),
314
+ "runtime_share": round(node.duration_ms / total, 4),
315
+ }
316
+ for node in ranked
317
+ ]
318
+
319
+ def to_dict(self) -> dict[str, Any]:
320
+ dependencies = self.dependency_summary()
321
+ result = {
322
+ "nodes": [node.to_dict() for node in self.nodes],
323
+ "edges": [edge.to_dict() for edge in self.edges],
324
+ "total_runtime_ms": None
325
+ if self.total_runtime_ms() is None
326
+ else round(self.total_runtime_ms(), 3),
327
+ "cumulative_span_time_ms": round(cumulative_span_time_ms(self.nodes), 3),
328
+ "critical_path": self._critical_path().to_dict()
329
+ if dependencies is None or dependencies["valid"]
330
+ else {"node_ids": [], "duration_ms": None, "status": "unavailable"},
331
+ "parallelizable_groups": self.parallelizable_groups(),
332
+ "bottlenecks": self.bottlenecks(),
333
+ }
334
+ if self.execution is not None:
335
+ result["execution"] = self.execution
336
+ if dependencies is not None:
337
+ result["dependency_evidence"] = dependencies
338
+ if self.external_evidence is not None:
339
+ result["external_evidence"] = self.external_evidence
340
+ result["total_runtime_ms"] = self.external_evidence["runtime_ms"]
341
+ for node in result["nodes"]:
342
+ if node["event_type"] == "model_call":
343
+ for key in ("input_tokens", "output_tokens"):
344
+ if node["metadata"].get(key + "_available") is False:
345
+ node[key] = None
346
+ if node["input_tokens"] is None or node["output_tokens"] is None:
347
+ node["total_tokens"] = None
348
+ if node["metadata"].get("timing_available") is False:
349
+ node["duration_ms"] = None
350
+ if node["metadata"].get("source_status_available") is False:
351
+ node["status"] = "unknown"
352
+ if not self.external_evidence["event_timing_complete"]:
353
+ result.update(
354
+ cumulative_span_time_ms=None,
355
+ critical_path={"node_ids": [], "duration_ms": None, "status": "unavailable"},
356
+ parallelizable_groups=[],
357
+ bottlenecks=[],
358
+ )
359
+ return result
360
+
361
+
362
+ def _event_sort_key(item: tuple[int, Any]) -> tuple[Any, ...]:
363
+ original_index, event = item
364
+ interval = event_interval_ms(event)
365
+ if interval is None:
366
+ return (1, original_index)
367
+ return (0, interval[0], interval[1], str(event.event_id), original_index)
368
+
369
+
370
+ def _node_sort_key(node: ExecutionNode) -> tuple[Any, ...]:
371
+ interval = event_interval_ms(node)
372
+ if interval is None:
373
+ return (1, node.node_id)
374
+ return (0, interval[0], interval[1], node.node_id)
375
+
376
+
377
+ def _inferred_sequence_edges(roots: list[ExecutionNode]) -> list[ExecutionEdge]:
378
+ if len(roots) < 2:
379
+ return []
380
+
381
+ intervals = {node.node_id: event_interval_ms(node) for node in roots}
382
+ if any(interval is None for interval in intervals.values()):
383
+ return [
384
+ ExecutionEdge(source=previous.node_id, target=current.node_id)
385
+ for previous, current in zip(roots, roots[1:])
386
+ ]
387
+
388
+ ordered = sorted(roots, key=_node_sort_key)
389
+ edges: list[ExecutionEdge] = []
390
+ pending: list[tuple[float, float, str]] = []
391
+ latest: tuple[float, float, str] | None = None
392
+ for current in ordered:
393
+ current_interval = intervals[current.node_id]
394
+ if current_interval is None:
395
+ continue
396
+ started, ended = current_interval
397
+ # Each earlier span becomes eligible once. Retain the same latest-end,
398
+ # latest-start, then node-ID tie break without scanning every earlier span.
399
+ while pending and pending[0][0] <= started:
400
+ candidate = heappop(pending)
401
+ if latest is None or candidate > latest:
402
+ latest = candidate
403
+ if latest is not None:
404
+ edges.append(ExecutionEdge(source=latest[2], target=current.node_id))
405
+ # Insert after selecting a predecessor, including for zero-duration spans.
406
+ heappush(pending, (ended, started, current.node_id))
407
+ return edges
408
+
409
+
410
+ def _deduplicate_edges(edges: list[ExecutionEdge]) -> list[ExecutionEdge]:
411
+ seen: set[tuple[str, str, str]] = set()
412
+ result: list[ExecutionEdge] = []
413
+ for edge in edges:
414
+ key = (edge.source, edge.target, edge.kind)
415
+ if key not in seen:
416
+ result.append(edge)
417
+ seen.add(key)
418
+ return result
419
+
420
+
421
+ def _topological_order(
422
+ nodes: list[ExecutionNode],
423
+ predecessors: dict[str, list[str]],
424
+ successors: dict[str, list[str]],
425
+ ) -> list[str]:
426
+ node_by_id = {node.node_id: node for node in nodes}
427
+ # Successor lists retain repeated edges, including different relationship
428
+ # kinds, so count every incoming edge to match the decrements below.
429
+ indegree = {node_id: len(items) for node_id, items in predecessors.items()}
430
+ sort_keys = {node_id: _node_sort_key(node) for node_id, node in node_by_id.items()}
431
+ ready = [(sort_keys[node_id], node_id) for node_id, count in indegree.items() if count == 0]
432
+ heapify(ready)
433
+ ordered: list[str] = []
434
+ while ready:
435
+ _, node_id = heappop(ready)
436
+ ordered.append(node_id)
437
+ for target in successors[node_id]:
438
+ indegree[target] -= 1
439
+ if indegree[target] == 0:
440
+ heappush(ready, (sort_keys[target], target))
441
+
442
+ if len(ordered) != len(nodes):
443
+ visited = set(ordered)
444
+ remaining = [node for node in nodes if node.node_id not in visited]
445
+ ordered.extend(node.node_id for node in sorted(remaining, key=_node_sort_key))
446
+ return ordered
447
+
448
+
449
+ def _extend_path(
450
+ predecessor: _PathState,
451
+ node: ExecutionNode,
452
+ interval: tuple[float, float] | None,
453
+ ) -> _PathState:
454
+ node_count = predecessor.node_count + 1
455
+ if predecessor.started_ms is not None and predecessor.ended_ms is not None and interval:
456
+ started = min(predecessor.started_ms, interval[0])
457
+ ended = max(predecessor.ended_ms, interval[1])
458
+ return _PathState(node.node_id, ended - started, started, ended, predecessor, node_count)
459
+ return _PathState(
460
+ node.node_id,
461
+ predecessor.duration_ms + recorded_duration_ms(node),
462
+ predecessor=predecessor,
463
+ node_count=node_count,
464
+ )