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/studies.py ADDED
@@ -0,0 +1,629 @@
1
+ """Deterministic paired study summaries using only the Python standard library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import json
7
+ import math
8
+ import random
9
+ import statistics
10
+ from collections import Counter
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from agentloop.costs import is_cost_evaluable
15
+ from agentloop.markdown import markdown_code_span, markdown_heading, markdown_table_cell
16
+ from agentloop.tokens import is_token_basis_evaluable
17
+ from agentloop.tracer import AgentTrace
18
+
19
+ STUDY_SCHEMA_VERSION = "1.0"
20
+ _METRICS = (
21
+ "success",
22
+ "quality_score",
23
+ "runtime_ms",
24
+ "input_tokens",
25
+ "output_tokens",
26
+ "model_call_count",
27
+ "tool_call_count",
28
+ "retry_count",
29
+ "cost_usd",
30
+ )
31
+
32
+
33
+ class StudyValidationError(ValueError):
34
+ """The manifest or one of its source traces is not a valid study input."""
35
+
36
+
37
+ def _quantile_sorted(ordered: list[float], fraction: float) -> float | None:
38
+ """Linearly interpolate at (n - 1) * fraction, including singleton samples."""
39
+ if not ordered:
40
+ return None
41
+ position = (len(ordered) - 1) * fraction
42
+ lower, upper = math.floor(position), math.ceil(position)
43
+ return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
44
+
45
+
46
+ def summarize_values(values: list[float | None]) -> dict[str, Any]:
47
+ known = sorted(value for value in values if value is not None)
48
+ return {
49
+ "count": len(known),
50
+ "missing_count": len(values) - len(known),
51
+ "mean": statistics.fmean(known) if known else None,
52
+ "median": _quantile_sorted(known, 0.5),
53
+ "min": known[0] if known else None,
54
+ "max": known[-1] if known else None,
55
+ "p05": _quantile_sorted(known, 0.05),
56
+ "p25": _quantile_sorted(known, 0.25),
57
+ "p75": _quantile_sorted(known, 0.75),
58
+ "p95": _quantile_sorted(known, 0.95),
59
+ }
60
+
61
+
62
+ def _bootstrap(values: list[float | None], config: dict[str, Any]) -> dict[str, Any]:
63
+ known = [value for value in values if value is not None]
64
+ samples, seed, confidence = config["samples"], config["seed"], config["confidence"]
65
+ result = {
66
+ "method": "paired_percentile_bootstrap_mean_delta",
67
+ **config,
68
+ "pair_count": len(known),
69
+ "lower": None,
70
+ "upper": None,
71
+ "status": "insufficient_pairs",
72
+ }
73
+ if len(known) < 2:
74
+ return result
75
+ # Reproducible statistical resampling; this generator creates no credentials.
76
+ rng = random.Random(seed) # nosec B311
77
+ means = sorted(statistics.fmean(rng.choices(known, k=len(known))) for _ in range(samples))
78
+ tail = (1 - confidence) / 2
79
+ result.update(
80
+ lower=_quantile_sorted(means, tail),
81
+ upper=_quantile_sorted(means, 1 - tail),
82
+ status="computed",
83
+ )
84
+ return result
85
+
86
+
87
+ def _bootstrap_config(value: Any) -> dict[str, Any] | None:
88
+ if value is None:
89
+ return None
90
+ if not isinstance(value, dict) or set(value) - {"samples", "seed", "confidence"}:
91
+ raise StudyValidationError("bootstrap must specify samples, seed, and/or confidence")
92
+ config = {"samples": 1000, "seed": 0, "confidence": 0.95, **value}
93
+ if type(config["samples"]) is not int or not 1 <= config["samples"] <= 10000:
94
+ raise StudyValidationError("bootstrap.samples must be an integer between 1 and 10000")
95
+ if type(config["seed"]) is not int:
96
+ raise StudyValidationError("bootstrap.seed must be an integer")
97
+ confidence = config["confidence"]
98
+ if (
99
+ isinstance(confidence, bool)
100
+ or not isinstance(confidence, int | float)
101
+ or not 0 < confidence < 1
102
+ ):
103
+ raise StudyValidationError("bootstrap.confidence must be between 0 and 1")
104
+ return config
105
+
106
+
107
+ def _validate_manifest(manifest: Any) -> None:
108
+ if not isinstance(manifest, dict) or manifest.get("schema_version") != STUDY_SCHEMA_VERSION:
109
+ raise StudyValidationError("study schema_version must be 1.0")
110
+ if set(manifest) - {
111
+ "schema_version",
112
+ "name",
113
+ "baseline",
114
+ "conditions",
115
+ "pairing_keys",
116
+ "bootstrap",
117
+ }:
118
+ raise StudyValidationError("unknown study manifest field")
119
+ if not isinstance(manifest.get("name"), str) or not manifest["name"].strip():
120
+ raise StudyValidationError("study name must be a nonempty string")
121
+ conditions = manifest.get("conditions")
122
+ if not isinstance(conditions, dict) or len(conditions) < 2:
123
+ raise StudyValidationError("at least two conditions are required")
124
+ if not isinstance(manifest.get("baseline"), str) or manifest["baseline"] not in conditions:
125
+ raise StudyValidationError("baseline must name one of the conditions")
126
+ keys = manifest.get("pairing_keys")
127
+ if (
128
+ not isinstance(keys, list)
129
+ or not keys
130
+ or any(not isinstance(key, str) or not key for key in keys)
131
+ or len(set(keys)) != len(keys)
132
+ ):
133
+ raise StudyValidationError("pairing_keys must be a nonempty list of distinct metadata keys")
134
+ for name, patterns in conditions.items():
135
+ if (
136
+ not isinstance(name, str)
137
+ or not name.strip()
138
+ or not isinstance(patterns, list)
139
+ or not patterns
140
+ or any(not isinstance(pattern, str) or not pattern for pattern in patterns)
141
+ ):
142
+ raise StudyValidationError(
143
+ "each named condition must contain a nonempty list of trace paths/globs"
144
+ )
145
+ _bootstrap_config(manifest.get("bootstrap"))
146
+
147
+
148
+ def _pair_key(metadata: dict[str, Any], keys: list[str]) -> str | None:
149
+ values = []
150
+ for key in keys:
151
+ value = metadata.get(key)
152
+ if not isinstance(value, str | int | float | bool) or (
153
+ isinstance(value, float) and not math.isfinite(value)
154
+ ):
155
+ return None
156
+ # The type tag prevents booleans, numbers, and strings from aliasing.
157
+ values.append([type(value).__name__, value])
158
+ return json.dumps(values, separators=(",", ":"))
159
+
160
+
161
+ def _run(path: Path, keys: list[str]) -> dict[str, Any]:
162
+ from agentloop.retention import require_complete_evidence
163
+
164
+ try:
165
+ trace = AgentTrace.from_dict(json.loads(path.read_text(encoding="utf-8")))
166
+ require_complete_evidence(trace, "study")
167
+ report = trace.report()
168
+ except (OSError, ValueError, TypeError, KeyError) as exc:
169
+ raise StudyValidationError(f"invalid trace {path}: {exc}") from exc
170
+ quality = trace.metadata.get("quality_score")
171
+ if quality is not None and (
172
+ isinstance(quality, bool) or not isinstance(quality, int | float) or not 0 <= quality <= 1
173
+ ):
174
+ raise StudyValidationError(
175
+ f"{path}: metadata.quality_score must be a number between 0 and 1"
176
+ )
177
+ success = trace.metadata.get("success")
178
+ if success is not None and type(success) is not bool:
179
+ raise StudyValidationError(f"{path}: metadata.success must be a boolean")
180
+ failures = [event for event in trace.events if event.status == "error"]
181
+ categories = Counter(
182
+ str(event.metadata.get("error_type") or "event_error") for event in failures
183
+ )
184
+ if success is False and not failures:
185
+ categories["task_failure"] += 1
186
+ # No error spans means execution succeeded, not that task quality was verified.
187
+ success_value = not failures if success is None else success and not failures
188
+ success_basis = "task_metadata_and_execution" if success is not None else "execution_only"
189
+ execution = report.get("execution")
190
+ if execution is not None:
191
+ status = execution.get("status") if execution.get("schema_status") == "supported" else None
192
+ if status in {"failed", "cancelled", "interrupted"}:
193
+ success_value = False
194
+ categories[f"workflow_{status}"] += 1
195
+ elif status != "completed":
196
+ success_value = False if failures or success is False else None
197
+ success_basis = "task_metadata_and_workflow" if success is not None else "workflow_status"
198
+ quality_evidence = report.get("quality_evidence")
199
+ if quality_evidence is not None:
200
+ quality = quality_evidence["score"]
201
+ if (
202
+ failures
203
+ or success is False
204
+ or quality_evidence.get("summary", {}).get("execution_failed_count", 0) > 0
205
+ or execution is not None
206
+ and execution.get("status") in {"failed", "cancelled", "interrupted"}
207
+ ):
208
+ success_value = False
209
+ elif quality_evidence.get("status") == "complete":
210
+ success_value = quality_evidence["passed"]
211
+ else:
212
+ success_value = None
213
+ success_basis = "versioned_quality_and_execution"
214
+ if not quality_evidence.get("passed"):
215
+ categories[
216
+ "quality_rejected"
217
+ if quality_evidence.get("status") == "complete"
218
+ else "quality_" + quality_evidence.get("status", "invalid")
219
+ ] += 1
220
+ cost_known = is_cost_evaluable(report["cost_status"]) and is_token_basis_evaluable(
221
+ report["token_status"]
222
+ )
223
+ from agentloop.substitution_evidence import read_trial_evidence
224
+
225
+ trial = read_trial_evidence(trace)
226
+ trial_metrics = {}
227
+ cost_state, token_state = report["cost_status"], report["token_status"]
228
+ if trial is not None:
229
+ if trial["status"] == "valid":
230
+ usage = trial["usage"]
231
+ trial_metrics = {
232
+ "runtime_ms": trial["latency_ms"],
233
+ "input_tokens": usage["input_tokens"],
234
+ "output_tokens": usage["output_tokens"],
235
+ "cost_usd": usage["cost_usd"],
236
+ }
237
+ cost_state = "complete" if usage["cost_usd"] is not None else "unknown"
238
+ token_state = (
239
+ "estimated"
240
+ if usage["token_basis"] == "estimated"
241
+ else (
242
+ "exact"
243
+ if usage["input_tokens"] is not None and usage["output_tokens"] is not None
244
+ else (
245
+ "partial"
246
+ if usage["input_tokens"] is not None or usage["output_tokens"] is not None
247
+ else "unavailable"
248
+ )
249
+ )
250
+ )
251
+ if trial["outcome"] != "completed":
252
+ quality = None
253
+ success_value = (
254
+ False
255
+ if trial["outcome"] in {"failed", "timed_out", "invalid_result"}
256
+ or success_value is False
257
+ else None
258
+ )
259
+ else:
260
+ trial_metrics = dict.fromkeys(
261
+ ("runtime_ms", "input_tokens", "output_tokens", "cost_usd")
262
+ )
263
+ quality = None
264
+ success_value = False if failures or success is False else None
265
+ cost_state, token_state = "unknown", "unavailable"
266
+ categories["substitution_evidence_" + trial["status"]] += 1
267
+ if quality_evidence is None:
268
+ quality = None
269
+ success_value = False if success_value is False else None
270
+ categories["quality_unavailable"] += 1
271
+ success_basis = (
272
+ "substitution_trial_and_versioned_quality"
273
+ if quality_evidence is not None
274
+ else "substitution_trial_without_quality"
275
+ )
276
+ from agentloop.integrations.harbor.trial_evidence import (
277
+ PAIRING_KEYS,
278
+ read_trial,
279
+ trial_study_values,
280
+ )
281
+
282
+ external_trial = read_trial(trace)
283
+ if external_trial is not None:
284
+ if not set(PAIRING_KEYS) <= set(keys):
285
+ raise StudyValidationError(
286
+ "external trial studies require task/scorer/environment/protocol/repetition pairing keys"
287
+ )
288
+ quality, success_value = trial_study_values(external_trial)
289
+ success_basis = "external_verifier_contract_and_execution"
290
+ measurement = external_trial["measurement"]
291
+ trial_metrics = {
292
+ "runtime_ms": measurement["runtime_ms"],
293
+ "input_tokens": measurement["input_tokens"],
294
+ "output_tokens": measurement["output_tokens"],
295
+ "cost_usd": measurement["cost_usd"],
296
+ }
297
+ if external_trial["outcome"]["quality_pass"] is False:
298
+ categories["external_quality_rejected"] += 1
299
+ elif external_trial["outcome"]["quality_pass"] is None:
300
+ categories["external_quality_indeterminate"] += 1
301
+ from agentloop.interoperability.cohort_evidence import COHORT_PAIRING_KEYS, read_cohort
302
+
303
+ cohort = read_cohort(trace)
304
+ if cohort is not None:
305
+ if not set(COHORT_PAIRING_KEYS) <= set(keys):
306
+ raise StudyValidationError(
307
+ "cohort studies require exact task/digest/scorer/verifier/environment/protocol/repetition keys"
308
+ )
309
+ success_value = cohort["accepted"]
310
+ success_basis = "external_correctness_execution_and_source_coverage"
311
+ if success_value is not True:
312
+ categories["cohort_rejected" if success_value is False else "cohort_indeterminate"] += 1
313
+ return {
314
+ "path": str(path),
315
+ **({"external_cohort": cohort} if cohort is not None else {}),
316
+ **(
317
+ {"external_trial": external_trial, "quality_basis": "configured_binary_acceptance"}
318
+ if external_trial is not None
319
+ else {}
320
+ ),
321
+ **(
322
+ {"decision_trial": trial, "measurement_scope": "declared_decision_step"}
323
+ if trial is not None
324
+ else {}
325
+ ),
326
+ **(
327
+ {"quality_evidence": quality_evidence, "decision_count": report["decision_count"]}
328
+ if quality_evidence is not None
329
+ else {}
330
+ ),
331
+ **({"execution": report["execution"]} if "execution" in report else {}),
332
+ **({"stages": report["stages"]} if "stages" in report else {}),
333
+ "run_id": trace.run_id,
334
+ "pair_key": _pair_key(trace.metadata, keys),
335
+ "pairing_metadata": {key: trace.metadata.get(key) for key in keys},
336
+ "metrics": {
337
+ **(
338
+ {"decision_count": report["decision_count"]} if quality_evidence is not None else {}
339
+ ),
340
+ "success": float(success_value) if success_value is not None else None,
341
+ "quality_score": quality,
342
+ "runtime_ms": report["total_runtime_ms"],
343
+ "input_tokens": report["input_tokens"],
344
+ "output_tokens": report["output_tokens"],
345
+ "model_call_count": report["model_call_count"],
346
+ "tool_call_count": report["tool_call_count"],
347
+ "retry_count": report["retry_count"],
348
+ "cost_usd": report["estimated_cost_usd"] if cost_known else None,
349
+ **trial_metrics,
350
+ },
351
+ "success_basis": success_basis,
352
+ "cost_status": cost_state,
353
+ "token_status": token_state,
354
+ "operation_counts": report.get("operation_counts", {}),
355
+ "failure_categories": dict(sorted(categories.items())),
356
+ "synthetic": trace.metadata.get("synthetic") is True,
357
+ }
358
+
359
+
360
+ def _condition(runs: list[dict[str, Any]]) -> dict[str, Any]:
361
+ categories: Counter[str] = Counter()
362
+ operations: Counter[str] = Counter()
363
+ for run in runs:
364
+ categories.update(run["failure_categories"])
365
+ operations.update(run["operation_counts"])
366
+ metrics = (
367
+ *_METRICS,
368
+ *(("decision_count",) if any("decision_count" in run["metrics"] for run in runs) else ()),
369
+ )
370
+ return {
371
+ "run_count": len(runs),
372
+ "metrics": {
373
+ key: summarize_values([run["metrics"].get(key) for run in runs]) for key in metrics
374
+ },
375
+ "cost_status_counts": dict(sorted(Counter(run["cost_status"] for run in runs).items())),
376
+ "token_status_counts": dict(sorted(Counter(run["token_status"] for run in runs).items())),
377
+ "success_basis_counts": dict(sorted(Counter(run["success_basis"] for run in runs).items())),
378
+ "operation_counts": dict(sorted(operations.items())),
379
+ "failure_categories": dict(sorted(categories.items())),
380
+ "runs": runs,
381
+ }
382
+
383
+
384
+ def _compare(
385
+ baseline: list[dict[str, Any]],
386
+ candidate: list[dict[str, Any]],
387
+ bootstrap: dict[str, Any] | None,
388
+ ) -> dict[str, Any]:
389
+ metrics = (
390
+ *_METRICS,
391
+ *(
392
+ ("decision_count",)
393
+ if any(
394
+ "decision_count" in run["metrics"] for runs in (baseline, candidate) for run in runs
395
+ )
396
+ else ()
397
+ ),
398
+ )
399
+ groups: dict[str, dict[str, list[dict[str, Any]]]] = {"baseline": {}, "candidate": {}}
400
+ unmatched = []
401
+ for side, runs in (("baseline", baseline), ("candidate", candidate)):
402
+ for run in runs:
403
+ if run["pair_key"] is None:
404
+ unmatched.append(
405
+ {
406
+ "side": side,
407
+ "run_id": run["run_id"],
408
+ "path": run["path"],
409
+ "reason": "missing_or_invalid_pairing_metadata",
410
+ }
411
+ )
412
+ else:
413
+ groups[side].setdefault(run["pair_key"], []).append(run)
414
+ pairs = []
415
+ for key in sorted(set(groups["baseline"]) | set(groups["candidate"])):
416
+ left, right = groups["baseline"].get(key, []), groups["candidate"].get(key, [])
417
+ if len(left) != 1 or len(right) != 1:
418
+ reason = (
419
+ "duplicate_pairing_key"
420
+ if len(left) > 1 or len(right) > 1
421
+ else "missing_counterpart"
422
+ )
423
+ for side, runs in (("baseline", left), ("candidate", right)):
424
+ unmatched.extend(
425
+ {
426
+ "side": side,
427
+ "run_id": run["run_id"],
428
+ "path": run["path"],
429
+ "pairing_metadata": run["pairing_metadata"],
430
+ "reason": reason,
431
+ }
432
+ for run in runs
433
+ )
434
+ continue
435
+ before, after = left[0], right[0]
436
+ deltas = {
437
+ metric: (
438
+ after["metrics"][metric] - before["metrics"][metric]
439
+ if after["metrics"].get(metric) is not None
440
+ and before["metrics"].get(metric) is not None
441
+ else None
442
+ )
443
+ for metric in metrics
444
+ }
445
+ pairs.append(
446
+ {
447
+ "baseline_run_id": before["run_id"],
448
+ "candidate_run_id": after["run_id"],
449
+ "pairing_metadata": before["pairing_metadata"],
450
+ "deltas": deltas,
451
+ }
452
+ )
453
+ summaries = {}
454
+ for metric in metrics:
455
+ values = [pair["deltas"][metric] for pair in pairs]
456
+ summaries[metric] = summarize_values(values)
457
+ if bootstrap:
458
+ summaries[metric]["bootstrap_interval"] = _bootstrap(values, bootstrap)
459
+ return {
460
+ "pair_count": len(pairs),
461
+ "delta_direction": "candidate_minus_baseline",
462
+ "metrics": summaries,
463
+ "pairs": pairs,
464
+ "unmatched": sorted(unmatched, key=lambda run: (run["side"], run["run_id"], run["path"])),
465
+ }
466
+
467
+
468
+ def summarize_study(manifest_path: str | Path) -> dict[str, Any]:
469
+ path = Path(manifest_path).resolve()
470
+ try:
471
+ manifest = json.loads(path.read_text(encoding="utf-8"))
472
+ except (OSError, ValueError) as exc:
473
+ raise StudyValidationError(f"cannot read study manifest: {exc}") from exc
474
+ _validate_manifest(manifest)
475
+ bootstrap = _bootstrap_config(manifest.get("bootstrap"))
476
+ conditions = {}
477
+ seen_ids: set[str] = set()
478
+ for name, patterns in sorted(manifest["conditions"].items()):
479
+ paths: set[Path] = set()
480
+ for pattern in patterns:
481
+ resolved_pattern = str(path.parent / pattern)
482
+ matches = [
483
+ Path(match).resolve()
484
+ for match in glob.glob(resolved_pattern, recursive=True)
485
+ if Path(match).is_file()
486
+ ]
487
+ if not matches:
488
+ raise StudyValidationError(
489
+ f"condition {name!r}: trace pattern matched no files: {pattern}"
490
+ )
491
+ paths.update(matches)
492
+ runs = [_run(trace_path, manifest["pairing_keys"]) for trace_path in sorted(paths)]
493
+ for run in runs:
494
+ if run["run_id"] in seen_ids:
495
+ raise StudyValidationError(f"duplicate run_id in study: {run['run_id']}")
496
+ seen_ids.add(run["run_id"])
497
+ runs.sort(key=lambda run: (run["pair_key"] or "", run["run_id"]))
498
+ conditions[name] = _condition(runs)
499
+ trial_runs = [
500
+ run
501
+ for condition in conditions.values()
502
+ for run in condition["runs"]
503
+ if "decision_trial" in run
504
+ ]
505
+ if trial_runs:
506
+ if len(trial_runs) != sum(condition["run_count"] for condition in conditions.values()):
507
+ raise StudyValidationError(
508
+ "cannot mix declared decision-step measurements with ordinary model-only trace metrics"
509
+ )
510
+ plans = {
511
+ run["decision_trial"]["plan_hash"]
512
+ for run in trial_runs
513
+ if run["decision_trial"]["status"] == "valid"
514
+ }
515
+ if len(plans) > 1:
516
+ raise StudyValidationError("decision trials must share one frozen experiment plan")
517
+ baseline = manifest["baseline"]
518
+ return {
519
+ **({"measurement_scope": "declared_decision_step"} if trial_runs else {}),
520
+ "schema_version": STUDY_SCHEMA_VERSION,
521
+ "name": manifest["name"],
522
+ "manifest": manifest,
523
+ "baseline_condition": baseline,
524
+ "conditions": conditions,
525
+ "comparisons": {
526
+ name: _compare(conditions[baseline]["runs"], condition["runs"], bootstrap)
527
+ for name, condition in conditions.items()
528
+ if name != baseline
529
+ },
530
+ "includes_synthetic_data": any(
531
+ run["synthetic"] for condition in conditions.values() for run in condition["runs"]
532
+ ),
533
+ "interpretation": "Descriptive paired comparisons. Bootstrap intervals assume exchangeable independent pairs; repeated tasks may require cluster-aware analysis. No significance decision is made.",
534
+ }
535
+
536
+
537
+ def study_to_markdown(report: dict[str, Any]) -> str:
538
+ lines = [
539
+ f"# Study: {markdown_heading(report['name'])}",
540
+ "",
541
+ report["interpretation"],
542
+ "",
543
+ "Deltas are candidate minus baseline. Negative latency/cost deltas indicate reductions.",
544
+ "",
545
+ "Cost statistics use evaluable runs or pairs only; counts disclose missing observations.",
546
+ "",
547
+ ]
548
+ if report["includes_synthetic_data"]:
549
+ lines.extend(
550
+ [
551
+ "This study includes synthetic data; fixture results are not application performance evidence.",
552
+ "",
553
+ ]
554
+ )
555
+ if report.get("measurement_scope") == "declared_decision_step":
556
+ lines.extend(
557
+ [
558
+ "Cost and tokens use frozen caller-declared decision-step measurements, with reported/calculated bases retained per run. These are separate from model-only profile totals. Latency measures the callback; unavailable measurements are not zero. Shared baseline runs across studies are not additional independent observations.",
559
+ "",
560
+ ]
561
+ )
562
+ for name, condition in report["conditions"].items():
563
+ lines.extend(
564
+ [f"## Condition: {markdown_heading(name)}", "", f"Runs: {condition['run_count']}", ""]
565
+ )
566
+ lines.extend(_metric_table(condition["metrics"]))
567
+ lines.extend(
568
+ [
569
+ "",
570
+ "Failure categories: "
571
+ + markdown_code_span(json.dumps(condition["failure_categories"], sort_keys=True)),
572
+ "Success basis: "
573
+ + markdown_code_span(json.dumps(condition["success_basis_counts"], sort_keys=True)),
574
+ "",
575
+ ]
576
+ )
577
+ for name, comparison in report["comparisons"].items():
578
+ lines.extend(
579
+ [
580
+ f"## Paired comparison: {markdown_heading(name)}",
581
+ "",
582
+ f"Matched pairs: {comparison['pair_count']}",
583
+ "",
584
+ ]
585
+ )
586
+ lines.extend(_metric_table(comparison["metrics"]))
587
+ lines.extend(
588
+ ["", "### Unmatched runs", "", "| Side | Run | Reason |", "| --- | --- | --- |"]
589
+ )
590
+ for run in comparison["unmatched"]:
591
+ lines.append(
592
+ "| "
593
+ + " | ".join(markdown_table_cell(run[key]) for key in ("side", "run_id", "reason"))
594
+ + " |"
595
+ )
596
+ if not comparison["unmatched"]:
597
+ lines.append("| — | — | None |")
598
+ lines.append("")
599
+ return "\n".join(lines).rstrip() + "\n"
600
+
601
+
602
+ def _metric_table(metrics: dict[str, Any]) -> list[str]:
603
+ def display(value: Any) -> str:
604
+ return "unavailable" if value is None else f"{value:.6g}"
605
+
606
+ lines = [
607
+ "| Metric | Count | Missing | Mean | Median | P05 | P95 | Bootstrap interval |",
608
+ "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
609
+ ]
610
+ for metric, summary in metrics.items():
611
+ interval = summary.get("bootstrap_interval")
612
+ bounds = (
613
+ "not requested"
614
+ if interval is None
615
+ else (
616
+ f"{interval['confidence']:.1%}: [{display(interval['lower'])}, {display(interval['upper'])}]"
617
+ if interval["status"] == "computed"
618
+ else "insufficient pairs"
619
+ )
620
+ )
621
+ cells = [
622
+ metric,
623
+ str(summary["count"]),
624
+ str(summary["missing_count"]),
625
+ *(display(summary[key]) for key in ("mean", "median", "p05", "p95")),
626
+ bounds,
627
+ ]
628
+ lines.append("| " + " | ".join(markdown_table_cell(cell) for cell in cells) + " |")
629
+ return lines
@@ -0,0 +1,58 @@
1
+ """Shared task-weighted descriptive statistics for offline evidence artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import statistics
7
+ from collections import defaultdict
8
+
9
+ from agentloop.studies import summarize_values
10
+
11
+
12
+ def _percentile(values, fraction):
13
+ position = (len(values) - 1) * fraction
14
+ low = int(position)
15
+ high = min(low + 1, len(values) - 1)
16
+ return values[low] + (values[high] - values[low]) * (position - low)
17
+
18
+
19
+ def task_weighted_summary(observations, settings):
20
+ """Summarize validated (task ID, finite value or None) observations.
21
+
22
+ Missing observations remain in the planned denominator. Available values are
23
+ averaged within each task; bootstrap draws then give each observed task equal
24
+ weight. This does not remedy missingness or selection bias.
25
+ """
26
+ values, grouped, planned_tasks = [], defaultdict(list), set()
27
+ for task, value in observations:
28
+ planned_tasks.add(task)
29
+ values.append(value)
30
+ if value is not None:
31
+ grouped[task].append(value)
32
+ means = [statistics.fmean(grouped[key]) for key in sorted(grouped)]
33
+ interval = {
34
+ "method": "percentile_bootstrap_equal_weight_task_means",
35
+ **settings,
36
+ "task_count": len(means),
37
+ "lower": None,
38
+ "upper": None,
39
+ "status": "insufficient_tasks",
40
+ }
41
+ if len(means) >= 2:
42
+ rng = random.Random(settings["seed"]) # nosec B311 - statistical resampling
43
+ samples = sorted(
44
+ statistics.fmean(rng.choices(means, k=len(means))) for _ in range(settings["samples"])
45
+ )
46
+ tail = (1 - settings["confidence"]) / 2
47
+ interval.update(
48
+ lower=_percentile(samples, tail),
49
+ upper=_percentile(samples, 1 - tail),
50
+ status="computed",
51
+ )
52
+ return {
53
+ "observation_summary": summarize_values(values),
54
+ "task_summary": summarize_values(means),
55
+ "interval": interval,
56
+ "planned_observation_count": len(values),
57
+ "planned_task_count": len(planned_tasks),
58
+ }