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/otel_ids.py ADDED
@@ -0,0 +1,81 @@
1
+ """Map arbitrary AgentLoop identifiers to valid OpenTelemetry trace/span ids.
2
+
3
+ OTLP requires trace ids to be 32 lowercase hex characters and span ids 16, with
4
+ a non-zero value. AgentLoop lets callers (and the API/local constructors) pick
5
+ custom run/event ids that may contain a prefix, non-hex characters, separators,
6
+ or all-zero values — the smoke script even uses a hyphenated run id. Exporting
7
+ those verbatim produced invalid ids that conforming OTLP consumers reject.
8
+
9
+ These helpers produce a valid, deterministic id for any native identifier while
10
+ leaving an already-valid id untouched, so:
11
+
12
+ - a valid OTLP id (correct width, non-zero lowercase hex) is preserved exactly,
13
+ including one imported into AgentLoop, so it round-trips back out unchanged;
14
+ - every other id — non-hex, wrong width (including the tracer's 16-hex run id
15
+ that is too short for a 32-char trace id), empty, or all-zero — is hashed with
16
+ SHA-256, which keeps distinct native ids distinct. In particular short hex ids
17
+ are NOT left-zero-padded: padding would map ``"a"`` and ``"0a"`` to the same
18
+ value and break within-trace uniqueness / parent linkage.
19
+
20
+ The exporters keep the original native id in span attributes so a remapped id
21
+ stays diagnosable.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+
28
+ _HEX_DIGITS = frozenset("0123456789abcdef")
29
+ _TRACE_WIDTH = 32
30
+ _SPAN_WIDTH = 16
31
+ _TRACE_PREFIXES = ("run_", "trace_")
32
+ _SPAN_PREFIXES = ("span_", "evt_")
33
+
34
+
35
+ def is_valid_trace_id(value: str) -> bool:
36
+ """True if ``value`` is a valid OTLP trace id (32 lowercase hex, non-zero)."""
37
+ return _is_valid(value, _TRACE_WIDTH)
38
+
39
+
40
+ def is_valid_span_id(value: str) -> bool:
41
+ """True if ``value`` is a valid OTLP span id (16 lowercase hex, non-zero)."""
42
+ return _is_valid(value, _SPAN_WIDTH)
43
+
44
+
45
+ def to_trace_id(native: str) -> str:
46
+ """Return a valid 32-char lowercase-hex OTLP trace id for ``native``."""
47
+ return _to_otel_id(native, _TRACE_WIDTH, _TRACE_PREFIXES)
48
+
49
+
50
+ def to_span_id(native: str) -> str:
51
+ """Return a valid 16-char lowercase-hex OTLP span id for ``native``."""
52
+ return _to_otel_id(native, _SPAN_WIDTH, _SPAN_PREFIXES)
53
+
54
+
55
+ def _is_valid(value: str, width: int) -> bool:
56
+ return len(value) == width and _HEX_DIGITS.issuperset(value) and set(value) != {"0"}
57
+
58
+
59
+ def _to_otel_id(native: str, width: int, prefixes: tuple[str, ...]) -> str:
60
+ stripped = _strip_prefix(str(native), prefixes).lower()
61
+ # An already-valid id (e.g. a real OTLP id, or one round-tripped through
62
+ # import) is preserved exactly. Everything else — non-hex, wrong width,
63
+ # empty, or all-zero — is hashed so distinct native ids stay distinct.
64
+ # Note: short hex ids are deliberately NOT zero-padded, because padding maps
65
+ # "a" and "0a" (and any leading-zero variants) to the same value.
66
+ if _is_valid(stripped, width):
67
+ return stripped
68
+ return _hash_id(native, width)
69
+
70
+
71
+ def _strip_prefix(value: str, prefixes: tuple[str, ...]) -> str:
72
+ for prefix in prefixes:
73
+ if value.startswith(prefix):
74
+ return value[len(prefix) :]
75
+ return value
76
+
77
+
78
+ def _hash_id(native: str, width: int) -> str:
79
+ digest = hashlib.sha256(str(native).encode("utf-8")).hexdigest()[:width]
80
+ # SHA-256 can't realistically be all-zero, but OTLP forbids it, so guard.
81
+ return ("1" + digest[1:]) if set(digest) == {"0"} else digest
@@ -0,0 +1,134 @@
1
+ """Normalized fields for pinned GenAI, OpenInference, and MCP conventions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ from agentloop.schema import TraceValidationError
9
+
10
+ INPUT_USAGE = ("gen_ai.usage.input_tokens", "llm.token_count.prompt", "llm.usage.prompt_tokens")
11
+ OUTPUT_USAGE = (
12
+ "gen_ai.usage.output_tokens",
13
+ "llm.token_count.completion",
14
+ "llm.usage.completion_tokens",
15
+ )
16
+ _OI_KINDS = {
17
+ "LLM": "model",
18
+ "EMBEDDING": "model",
19
+ "CHAIN": "workflow",
20
+ "AGENT": "agent",
21
+ "TOOL": "tool",
22
+ "RETRIEVER": "retriever",
23
+ "RERANKER": "reranker",
24
+ "GUARDRAIL": "guardrail",
25
+ "EVALUATOR": "evaluator",
26
+ }
27
+ _MEMORY_OPERATIONS = {
28
+ "create_memory",
29
+ "create_memory_store",
30
+ "delete_memory",
31
+ "delete_memory_store",
32
+ "search_memory",
33
+ "update_memory",
34
+ "upsert_memory",
35
+ }
36
+
37
+
38
+ def extended_kind(attrs: dict[str, Any]) -> str | None:
39
+ operation = attrs.get("gen_ai.operation.name")
40
+ if isinstance(operation, str) and operation in _MEMORY_OPERATIONS:
41
+ return "memory"
42
+ if isinstance(operation, str) and operation in {"plan", "fetch_response"}:
43
+ return "unknown"
44
+ if operation:
45
+ return None
46
+ if "openinference.span.kind" in attrs:
47
+ return _OI_KINDS.get(str(attrs["openinference.span.kind"]).upper(), "unknown")
48
+ if "mcp.method.name" in attrs:
49
+ return {"tools/call": "tool", "resources/read": "retriever"}.get(
50
+ str(attrs["mcp.method.name"]), "unknown"
51
+ )
52
+ if any(key in attrs for key in ("http.request.method", "http.method", "rpc.system")):
53
+ return "unknown"
54
+ return None
55
+
56
+
57
+ def usage_count(attrs: dict[str, Any], keys: tuple[str, ...]) -> int:
58
+ for key in keys:
59
+ if key not in attrs:
60
+ continue
61
+ value = attrs[key]
62
+ if isinstance(value, bool) or not isinstance(value, int | str):
63
+ raise TraceValidationError(key, "usage must be a nonnegative integer")
64
+ try:
65
+ result = int(value)
66
+ except ValueError as exc:
67
+ raise TraceValidationError(key, "usage must be a nonnegative integer") from exc
68
+ if result < 0:
69
+ raise TraceValidationError(key, "usage must be a nonnegative integer")
70
+ return result
71
+ return 0
72
+
73
+
74
+ def normalize_metadata(attrs: dict[str, Any], metadata: dict[str, Any]) -> None:
75
+ aliases = {
76
+ "provider": ("gen_ai.provider.name", "gen_ai.system", "llm.provider"),
77
+ "conversation_id": ("gen_ai.conversation.id",),
78
+ "session_id": ("session.id", "mcp.session.id"),
79
+ "tool_call_id": ("gen_ai.tool.call.id", "tool.id", "tool_call.id"),
80
+ "error_type": ("error.type", "exception.type"),
81
+ }
82
+ for target, keys in aliases.items():
83
+ for key in keys:
84
+ if isinstance(attrs.get(key), str):
85
+ metadata.setdefault(target, attrs[key])
86
+ break
87
+ for target, keys in {
88
+ "cached_input_tokens": (
89
+ "gen_ai.usage.cache_read.input_tokens",
90
+ "llm.token_count.prompt_details.cache_read",
91
+ ),
92
+ "cache_write_input_tokens": (
93
+ "gen_ai.usage.cache_write.input_tokens",
94
+ "llm.token_count.prompt_details.cache_write",
95
+ ),
96
+ "reasoning_output_tokens": (
97
+ "gen_ai.usage.reasoning.output_tokens",
98
+ "llm.token_count.completion_details.reasoning",
99
+ ),
100
+ }.items():
101
+ if any(key in attrs for key in keys):
102
+ metadata.setdefault(target, usage_count(attrs, keys))
103
+ reported_cost = attrs.get("llm.cost.total")
104
+ if (
105
+ not isinstance(reported_cost, bool)
106
+ and isinstance(reported_cost, int | float)
107
+ and math.isfinite(reported_cost)
108
+ and reported_cost >= 0
109
+ ):
110
+ metadata.setdefault("provider_reported_cost_usd", reported_cost)
111
+
112
+
113
+ def evaluation_result(attrs: dict[str, Any], *, source: str) -> dict[str, Any] | None:
114
+ families = (
115
+ (
116
+ "gen_ai.evaluation.name",
117
+ "gen_ai.evaluation.score.value",
118
+ "gen_ai.evaluation.score.label",
119
+ "gen_ai.evaluation.explanation",
120
+ ),
121
+ ("evaluation.name", "evaluation.score", "evaluation.label", "evaluation.explanation"),
122
+ ("annotation.name", "annotation.score", "annotation.label", "annotation.explanation"),
123
+ )
124
+ for name, score, label, explanation in families:
125
+ if name in attrs:
126
+ return {
127
+ "source": source,
128
+ "name": attrs[name],
129
+ "score": attrs.get(score),
130
+ "label": attrs.get(label),
131
+ "explanation": attrs.get(explanation),
132
+ "interpretation": "external evaluation; scale and acceptance criteria are source-defined",
133
+ }
134
+ return None
@@ -0,0 +1,141 @@
1
+ """Conservative concurrency candidates shared by reports and execution graphs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from typing import Any
7
+
8
+ from agentloop.operations import operation_kind
9
+ from agentloop.timing import duration_ms, event_interval_ms
10
+ from agentloop.workflow_types import stage_summary
11
+
12
+ PARALLELISM_NOTICE = (
13
+ "Repeated tool calls may be candidates for concurrent execution. "
14
+ "Verify output independence and shared-state safety before changing them."
15
+ )
16
+ PARALLELISM_REWRITE = (
17
+ "After verifying output independence and shared-state safety, use asyncio.gather "
18
+ "or ThreadPoolExecutor and validate the change with replay."
19
+ )
20
+ PARALLELISM_FORMULA = (
21
+ "sum(tool durations) - max(tool duration), assuming the calls can run concurrently"
22
+ )
23
+ _ASSUMPTIONS = (
24
+ "No call consumes another call's output.",
25
+ "Calls do not conflict through shared mutable state.",
26
+ "Calls are on the runtime-limiting path and scheduling adds no overhead.",
27
+ )
28
+
29
+
30
+ def parallelization_candidates(
31
+ events: Iterable[Any], *, dependency_edges: Iterable[tuple[str, str]] = ()
32
+ ) -> list[dict[str, Any]]:
33
+ """Return hypotheses, never proof of independence or verified savings.
34
+
35
+ Optional metadata: ``parallel_safe`` is a boolean declaration, ``depends_on``
36
+ is a list of span IDs. Explicit relationships take precedence over declarations.
37
+ Inferred sequence edges must not be passed as causal dependencies.
38
+ """
39
+ by_id = {}
40
+ groups: dict[tuple[str, str | None], list[str]] = {}
41
+ dependencies: dict[str, set[str]] = {}
42
+ invalid_dependencies: set[str] = set()
43
+ unsafe: set[str] = set()
44
+ declarations: set[str] = set()
45
+ for index, event in enumerate(events):
46
+ span_id = (
47
+ getattr(event, "event_id", None) or getattr(event, "node_id", None) or f"node_{index}"
48
+ )
49
+ if span_id in by_id:
50
+ return [] # Ambiguous span identity cannot support dependency analysis.
51
+ by_id[span_id] = event
52
+ metadata = getattr(event, "metadata", None)
53
+ metadata = metadata if isinstance(metadata, Mapping) else {}
54
+ required = metadata.get("depends_on", [])
55
+ if not isinstance(required, list) or any(
56
+ not isinstance(value, str) or not value for value in required
57
+ ):
58
+ invalid_dependencies.add(span_id)
59
+ required = []
60
+ dependencies[span_id] = set(required)
61
+ stage = stage_summary(event)
62
+ if stage is not None and (
63
+ stage.get("schema_status") != "supported" or stage.get("dependency_status") == "invalid"
64
+ ):
65
+ invalid_dependencies.add(span_id)
66
+ if "parallel_safe" in metadata:
67
+ if metadata["parallel_safe"] is True:
68
+ declarations.add(span_id)
69
+ else:
70
+ unsafe.add(span_id)
71
+ if operation_kind(event) == "tool":
72
+ groups.setdefault((event.name, getattr(event, "parent_id", None)), []).append(span_id)
73
+ for span_id, event in by_id.items():
74
+ parent_id = getattr(event, "parent_id", None)
75
+ if parent_id in by_id:
76
+ dependencies[span_id].add(parent_id)
77
+ for source, target in dependency_edges:
78
+ if target in dependencies:
79
+ dependencies[target].add(source)
80
+
81
+ candidates = []
82
+ for (name, _), span_ids in groups.items():
83
+ members = set(span_ids)
84
+ if len(members) < 3 or members & unsafe or members & invalid_dependencies:
85
+ continue
86
+ pending = [ancestor for member in members for ancestor in dependencies[member]]
87
+ visited = set()
88
+ blocked = False
89
+ while pending:
90
+ ancestor = pending.pop()
91
+ if ancestor in members or ancestor not in by_id or ancestor in invalid_dependencies:
92
+ blocked = True
93
+ break
94
+ if ancestor not in visited:
95
+ visited.add(ancestor)
96
+ pending.extend(dependencies[ancestor])
97
+ if blocked:
98
+ continue
99
+
100
+ intervals = [event_interval_ms(by_id[span_id]) for span_id in span_ids]
101
+ known = sorted(interval for interval in intervals if interval is not None)
102
+ if any(previous[1] > current[0] for previous, current in zip(known, known[1:])):
103
+ continue # Observed overlap already invalidates the serial-savings model.
104
+ complete_timing = len(known) == len(span_ids)
105
+ durations = [duration_ms(by_id[span_id]) for span_id in span_ids]
106
+ sequential, parallel = sum(durations), max(durations)
107
+ if sequential <= parallel:
108
+ continue
109
+ declared = members <= declarations
110
+ assumptions = list(_ASSUMPTIONS)
111
+ if not complete_timing:
112
+ assumptions.append("Calls ran serially; timing is incomplete or inconsistent.")
113
+ candidates.append(
114
+ {
115
+ "name": name,
116
+ "event_type": "tool_call",
117
+ "count": len(span_ids),
118
+ "node_ids": sorted(span_ids),
119
+ "sequential_time_ms": round(sequential, 3),
120
+ "estimated_parallel_time_ms": round(parallel, 3),
121
+ "estimated_savings_ms": round(sequential - parallel, 3),
122
+ "evidence_level": "declared" if declared else "inferred",
123
+ "confidence": "medium" if declared and complete_timing else "low",
124
+ "assumptions": assumptions,
125
+ "observations": {
126
+ "evidence_level": "observed",
127
+ "tool_call_count": len(span_ids),
128
+ "cumulative_span_time_ms": round(sequential, 3),
129
+ "timing": "non_overlapping" if complete_timing else "unavailable",
130
+ },
131
+ "description": (
132
+ "The integration declares these calls safe to run concurrently; validate with replay."
133
+ if declared
134
+ else PARALLELISM_NOTICE
135
+ ),
136
+ "estimate_formula": PARALLELISM_FORMULA,
137
+ }
138
+ )
139
+ return sorted(
140
+ candidates, key=lambda item: (-item["estimated_savings_ms"], item["name"], item["node_ids"])
141
+ )