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.
- agentloop/__init__.py +136 -0
- agentloop/ablation_protocol.py +229 -0
- agentloop/ablations.py +564 -0
- agentloop/aggregate_cli.py +88 -0
- agentloop/aggregate_stats.py +175 -0
- agentloop/aggregates.py +532 -0
- agentloop/audit.py +39 -0
- agentloop/autoinstrument.py +145 -0
- agentloop/batch_experiments.py +295 -0
- agentloop/batching.py +95 -0
- agentloop/budget_types.py +335 -0
- agentloop/budgets.py +539 -0
- agentloop/calibration.py +872 -0
- agentloop/checkpoint_state.py +306 -0
- agentloop/checkpoint_store.py +86 -0
- agentloop/ci.py +202 -0
- agentloop/cli.py +1314 -0
- agentloop/client.py +228 -0
- agentloop/completion.py +345 -0
- agentloop/completion_types.py +199 -0
- agentloop/config.py +50 -0
- agentloop/context_controls.py +443 -0
- agentloop/context_experiments.py +337 -0
- agentloop/context_types.py +216 -0
- agentloop/costs.py +667 -0
- agentloop/cursor_validation.py +48 -0
- agentloop/decorators.py +445 -0
- agentloop/demo.py +300 -0
- agentloop/doctor.py +341 -0
- agentloop/drift.py +361 -0
- agentloop/drift_cli.py +147 -0
- agentloop/drift_types.py +110 -0
- agentloop/drift_windows.py +340 -0
- agentloop/entrypoint.py +201 -0
- agentloop/estimates.py +133 -0
- agentloop/events.py +75 -0
- agentloop/experiment_artifacts.py +80 -0
- agentloop/experiment_observations.py +169 -0
- agentloop/experiment_reports.py +545 -0
- agentloop/experiment_types.py +355 -0
- agentloop/experiments.py +392 -0
- agentloop/exporters.py +117 -0
- agentloop/finding_benchmark_types.py +131 -0
- agentloop/finding_benchmarks.py +424 -0
- agentloop/findings.py +419 -0
- agentloop/graph.py +464 -0
- agentloop/harness.py +825 -0
- agentloop/harness_bench/__init__.py +1 -0
- agentloop/harness_bench/cli.py +77 -0
- agentloop/harness_bench/drivers.py +96 -0
- agentloop/harness_bench/offline.py +507 -0
- agentloop/harness_evidence.py +585 -0
- agentloop/harness_usage.py +91 -0
- agentloop/html_report.py +860 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/harbor/__init__.py +1 -0
- agentloop/integrations/harbor/atif.py +982 -0
- agentloop/integrations/harbor/atif_validation.py +329 -0
- agentloop/integrations/harbor/cli.py +207 -0
- agentloop/integrations/harbor/manifest.py +86 -0
- agentloop/integrations/harbor/trial_evidence.py +196 -0
- agentloop/integrations/harbor/trials.py +1109 -0
- agentloop/integrations/harbor/verifier.py +118 -0
- agentloop/integrations/langgraph.py +150 -0
- agentloop/integrations/langgraph_harness.py +466 -0
- agentloop/integrations/langgraph_recovery.py +366 -0
- agentloop/integrations/omnigent/__init__.py +1 -0
- agentloop/integrations/omnigent/cli.py +78 -0
- agentloop/integrations/omnigent/evidence.py +93 -0
- agentloop/integrations/omnigent/telemetry.py +422 -0
- agentloop/integrations/openai.py +415 -0
- agentloop/integrations/openai_agents.py +291 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/interoperability/__init__.py +5 -0
- agentloop/interoperability/artifacts.py +112 -0
- agentloop/interoperability/cli.py +59 -0
- agentloop/interoperability/cohort_evidence.py +175 -0
- agentloop/interoperability/cohorts.py +681 -0
- agentloop/interoperability/contracts.py +427 -0
- agentloop/interoperability/coordination.py +554 -0
- agentloop/interoperability/evidence.py +215 -0
- agentloop/interoperability/otlp_jsonl.py +775 -0
- agentloop/interoperability/otlp_validation.py +496 -0
- agentloop/interoperability/registry.py +561 -0
- agentloop/interoperability/validation.py +333 -0
- agentloop/intervention_service.py +110 -0
- agentloop/intervention_store.py +118 -0
- agentloop/interventions.py +190 -0
- agentloop/issues.py +90 -0
- agentloop/judgment_adapters.py +125 -0
- agentloop/judgment_benchmark_types.py +98 -0
- agentloop/judgment_benchmarks.py +606 -0
- agentloop/judgment_types.py +241 -0
- agentloop/judgment_views.py +38 -0
- agentloop/judgments.py +342 -0
- agentloop/loop_guards.py +274 -0
- agentloop/loop_types.py +66 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +342 -0
- agentloop/migrations.py +303 -0
- agentloop/model_routing.py +470 -0
- agentloop/onboarding.py +315 -0
- agentloop/operations.py +44 -0
- agentloop/optimizer.py +199 -0
- agentloop/otel.py +715 -0
- agentloop/otel_ids.py +81 -0
- agentloop/otel_semantics.py +134 -0
- agentloop/parallelism.py +141 -0
- agentloop/patches.py +603 -0
- agentloop/plan_export.py +126 -0
- agentloop/policy_promotion.py +325 -0
- agentloop/promotion_evidence.py +304 -0
- agentloop/promotion_types.py +209 -0
- agentloop/quality.py +446 -0
- agentloop/ranking.py +529 -0
- agentloop/recovery_evidence.py +96 -0
- agentloop/replay.py +753 -0
- agentloop/retention.py +375 -0
- agentloop/retention_types.py +113 -0
- agentloop/reuse_experiments.py +247 -0
- agentloop/routing_checks.py +116 -0
- agentloop/routing_types.py +241 -0
- agentloop/rules.py +396 -0
- agentloop/runtime.py +286 -0
- agentloop/savings.py +158 -0
- agentloop/scheduling_plan.py +99 -0
- agentloop/scheduling_types.py +169 -0
- agentloop/schema.py +257 -0
- agentloop/semantic_rules.py +200 -0
- agentloop/semantic_waste.py +345 -0
- agentloop/semantic_waste_types.py +88 -0
- agentloop/server.py +494 -0
- agentloop/store.py +1429 -0
- agentloop/structural_experiments.py +431 -0
- agentloop/structural_types.py +113 -0
- agentloop/structured_quality.py +1020 -0
- agentloop/studies.py +629 -0
- agentloop/study_statistics.py +58 -0
- agentloop/substitution_evidence.py +199 -0
- agentloop/substitution_export.py +190 -0
- agentloop/substitution_reports.py +407 -0
- agentloop/substitution_types.py +284 -0
- agentloop/substitutions.py +267 -0
- agentloop/timing.py +142 -0
- agentloop/tokens.py +188 -0
- agentloop/tool_scheduling.py +410 -0
- agentloop/trace_sources.py +31 -0
- agentloop/tracer.py +494 -0
- agentloop/value.py +306 -0
- agentloop/version.py +3 -0
- agentloop/workflow_types.py +264 -0
- agentloop/workflows.py +284 -0
- agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
- agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
- agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
- agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +5 -0
- dashboard/app.py +922 -0
- dashboard/streamlit_compat.py +63 -0
- dashboard/trace_upload.py +57 -0
- 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
|
+
)
|