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/ablations.py ADDED
@@ -0,0 +1,564 @@
1
+ """Offline, loss-aware summaries of host-run harness ablations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections import Counter, defaultdict
8
+ from fractions import Fraction
9
+ from pathlib import Path
10
+
11
+ from agentloop.ablation_protocol import (
12
+ AblationProtocol,
13
+ AblationValidationError,
14
+ canonical,
15
+ fields,
16
+ number,
17
+ pair_key,
18
+ text_value,
19
+ timestamp,
20
+ )
21
+ from agentloop.interventions import (
22
+ InterventionRecord,
23
+ InterventionValidationError,
24
+ )
25
+ from agentloop.markdown import markdown_code_span, markdown_heading, markdown_table_cell
26
+ from agentloop.studies import StudyValidationError, summarize_study
27
+ from agentloop.study_statistics import task_weighted_summary
28
+ from agentloop.trace_sources import TraceSource
29
+
30
+ RESOURCE_METRICS = (
31
+ "latency_ms",
32
+ "tokens",
33
+ "cost_usd",
34
+ "model_calls",
35
+ "tool_calls",
36
+ "retries",
37
+ "policy_eval_ms",
38
+ )
39
+ METRICS = (
40
+ "success",
41
+ "quality_score",
42
+ "provider_cost_usd",
43
+ "calculated_cost_usd",
44
+ *(metric for metric in RESOURCE_METRICS if metric != "cost_usd"),
45
+ )
46
+ STATUSES = frozenset({"completed", "failed", "cancelled", "timed_out", "stopped", "incomplete"})
47
+ OBSERVATION_FIELDS = frozenset(
48
+ {
49
+ "observation_id",
50
+ "protocol_hash",
51
+ "task_id",
52
+ "repetition",
53
+ "cache_condition",
54
+ "condition",
55
+ "position",
56
+ "started_at",
57
+ "versions",
58
+ "reset_confirmed",
59
+ "provider_seed",
60
+ "status",
61
+ "stop_reason",
62
+ "success",
63
+ "quality_score",
64
+ "metrics",
65
+ "token_status",
66
+ "cost_status",
67
+ "cost_basis",
68
+ "trace_run_id",
69
+ }
70
+ )
71
+
72
+
73
+ def _observation(value, spec, protocol_hash, frozen_at, schedule):
74
+ fields(value, OBSERVATION_FIELDS, "observation")
75
+ for key in (
76
+ "observation_id",
77
+ "protocol_hash",
78
+ "task_id",
79
+ "repetition",
80
+ "cache_condition",
81
+ "condition",
82
+ ):
83
+ text_value(value[key], key)
84
+ if not isinstance(value["versions"], dict):
85
+ raise AblationValidationError("observed versions must be a mapping")
86
+ if type(value["reset_confirmed"]) is not bool or (
87
+ value["success"] is not None and type(value["success"]) is not bool
88
+ ):
89
+ raise AblationValidationError("reset_confirmed and available success must be booleans")
90
+ if value["provider_seed"] is not None and type(value["provider_seed"]) not in {int, str}:
91
+ raise AblationValidationError("provider_seed must be an integer, string or null")
92
+ if not isinstance(value["status"], str) or value["status"] not in STATUSES:
93
+ raise AblationValidationError("unsupported observation status")
94
+ for key in ("stop_reason", "trace_run_id"):
95
+ if value[key] is not None:
96
+ text_value(value[key], key)
97
+ if value["stop_reason"] is not None and not re.fullmatch(
98
+ r"[A-Za-z0-9_.:-]{1,96}", value["stop_reason"]
99
+ ):
100
+ raise AblationValidationError("stop_reason must be a bounded identifier")
101
+ number(value["position"], "position", integer=True)
102
+ number(value["quality_score"], "quality_score", maximum=1, nullable=True)
103
+ fields(value["metrics"], RESOURCE_METRICS, "metrics")
104
+ for metric, amount in value["metrics"].items():
105
+ number(
106
+ amount,
107
+ metric,
108
+ integer=metric in {"tokens", "model_calls", "tool_calls", "retries"},
109
+ nullable=True,
110
+ )
111
+ for key, options in (
112
+ ("token_status", {"exact", "empty", "estimated", "partial", "unavailable", "unspecified"}),
113
+ ("cost_status", {"complete", "empty", "partial", "unknown"}),
114
+ ("cost_basis", {"provider_reported", "calculated", "mixed", "unknown"}),
115
+ ):
116
+ if not isinstance(value[key], str) or value[key] not in options:
117
+ raise AblationValidationError(f"unsupported {key}")
118
+ for metric, status in (("tokens", "token_status"), ("cost_usd", "cost_status")):
119
+ if value[status] == "empty" and value["metrics"][metric] not in {None, 0}:
120
+ raise AblationValidationError(f"empty {status} cannot contain a positive measurement")
121
+ issues = []
122
+ slot = schedule.get(pair_key(value))
123
+ if slot is None or value["condition"] not in spec["conditions"]:
124
+ issues.append("unplanned_observation")
125
+ elif value["position"] != slot["order"].index(value["condition"]):
126
+ issues.append("order_mismatch")
127
+ if value["protocol_hash"] != protocol_hash:
128
+ issues.append("protocol_mismatch")
129
+ if timestamp(value["started_at"]) < frozen_at:
130
+ issues.append("observed_before_freeze")
131
+ if value["versions"] != spec["versions"]:
132
+ issues.append("version_mismatch")
133
+ if not value["reset_confirmed"]:
134
+ issues.append("reset_unconfirmed")
135
+ if (
136
+ value["condition"] in spec["conditions"]
137
+ and spec["conditions"][value["condition"]]["mode"] == "uninstrumented"
138
+ and value["trace_run_id"] is not None
139
+ ):
140
+ issues.append("uninstrumented_has_trace")
141
+ metrics = dict(value["metrics"])
142
+ metrics["success"] = float(value["success"]) if value["success"] is not None else None
143
+ metrics["quality_score"] = value["quality_score"]
144
+ if value["token_status"] not in {"exact", "empty"}:
145
+ metrics["tokens"] = None
146
+ if value["cost_status"] not in {"complete", "empty"} or value["cost_basis"] not in {
147
+ "provider_reported",
148
+ "calculated",
149
+ }:
150
+ metrics["cost_usd"] = None
151
+ if value["cost_basis"] == "calculated" and value["token_status"] not in {"exact", "empty"}:
152
+ metrics["cost_usd"] = None
153
+ metrics["provider_cost_usd"] = (
154
+ metrics["cost_usd"] if value["cost_basis"] == "provider_reported" else None
155
+ )
156
+ metrics["calculated_cost_usd"] = (
157
+ metrics["cost_usd"] if value["cost_basis"] == "calculated" else None
158
+ )
159
+ return {"observation": value, "issues": issues, "evaluable_metrics": metrics}
160
+
161
+
162
+ def _gate(left, right, settings):
163
+ if left["issues"] or right["issues"]:
164
+ return "indeterminate", "protocol_deviation"
165
+ rows = (left["observation"], right["observation"])
166
+ if any(row["status"] != "completed" or row["success"] is False for row in rows):
167
+ return "rejected", "incomplete_or_unsuccessful_task"
168
+ if any(row["success"] is None or row["quality_score"] is None for row in rows):
169
+ return "indeterminate", "missing_task_quality"
170
+ before, after = (row["quality_score"] for row in rows)
171
+ regression = Fraction(str(before)) - Fraction(str(after))
172
+ if min(before, after) < settings["min_score"] or regression > Fraction(
173
+ str(settings["max_regression"])
174
+ ):
175
+ return "rejected", "quality_threshold"
176
+ return "accepted", "task_quality_preserved"
177
+
178
+
179
+ def _task_summary(cases, metric, settings, *, eligible=False):
180
+ result = task_weighted_summary(
181
+ (
182
+ (
183
+ case["pairing"]["task_id"],
184
+ None if eligible and case["quality_gate"] != "accepted" else case["deltas"][metric],
185
+ )
186
+ for case in cases
187
+ ),
188
+ settings,
189
+ )
190
+ return {
191
+ "pair_summary": result["observation_summary"],
192
+ "task_summary": result["task_summary"],
193
+ "interval": result["interval"],
194
+ "planned_pair_count": result["planned_observation_count"],
195
+ "planned_task_count": result["planned_task_count"],
196
+ }
197
+
198
+
199
+ def _comparisons(conditions):
200
+ modes = {
201
+ item["mode"]: name
202
+ for name, item in conditions.items()
203
+ if item["mode"] in {"uninstrumented", "tracing"}
204
+ }
205
+ tracing = modes["tracing"]
206
+ yield modes["uninstrumented"], tracing, "trace_overhead"
207
+ shadows = {
208
+ canonical(item["policies"]): name
209
+ for name, item in conditions.items()
210
+ if item["mode"] == "shadow"
211
+ }
212
+ for name, condition in sorted(conditions.items()):
213
+ if condition["mode"] == "shadow":
214
+ yield tracing, name, "policy_shadow_overhead"
215
+ elif condition["mode"] == "enforce":
216
+ yield shadows[canonical(condition["policies"])], name, "enforcement_effect"
217
+ yield tracing, name, "total_policy_effect"
218
+
219
+
220
+ def build_ablation_report(protocol, observations):
221
+ """Summarize supplied host observations; never run, reset or score the agent."""
222
+ protocol = AblationProtocol.from_dict(
223
+ protocol.to_dict() if isinstance(protocol, AblationProtocol) else protocol
224
+ )
225
+ spec = protocol.to_dict()["specification"]
226
+ if not isinstance(observations, list):
227
+ raise AblationValidationError("observations must be a list, including when empty")
228
+ observations = json.loads(canonical(observations))
229
+ schedule = {pair_key(slot): slot for slot in spec["schedule"]}
230
+ protocol_hash, frozen_at = protocol.protocol_hash, timestamp(spec["frozen_at"])
231
+ rows = [_observation(value, spec, protocol_hash, frozen_at, schedule) for value in observations]
232
+ rows.sort(key=lambda row: row["observation"]["observation_id"])
233
+ ids = [row["observation"]["observation_id"] for row in rows]
234
+ if len(ids) != len(set(ids)):
235
+ raise AblationValidationError("observation IDs must be unique")
236
+ grouped = defaultdict(list)
237
+ for row in rows:
238
+ value = row["observation"]
239
+ grouped[(pair_key(value), value["condition"])].append(row)
240
+ comparisons = []
241
+ for before, after, kind in _comparisons(spec["conditions"]):
242
+ strata = defaultdict(list)
243
+ for key, slot in sorted(schedule.items()):
244
+ left, right = grouped[(key, before)], grouped[(key, after)]
245
+ gate, reason = (
246
+ "unmatched",
247
+ "duplicate_pairing_key"
248
+ if len(left) > 1 or len(right) > 1
249
+ else "missing_counterpart",
250
+ )
251
+ deltas = dict.fromkeys(METRICS)
252
+ if len(left) == len(right) == 1:
253
+ gate, reason = _gate(left[0], right[0], spec["quality_gate"])
254
+ if not left[0]["issues"] and not right[0]["issues"]:
255
+ for metric in METRICS:
256
+ a, b = (
257
+ left[0]["evaluable_metrics"][metric],
258
+ right[0]["evaluable_metrics"][metric],
259
+ )
260
+ if a is not None and b is not None:
261
+ deltas[metric] = b - a
262
+ case = {
263
+ "pairing": {
264
+ name: slot[name] for name in ("task_id", "repetition", "cache_condition")
265
+ },
266
+ "baseline_observations": [row["observation"]["observation_id"] for row in left],
267
+ "candidate_observations": [row["observation"]["observation_id"] for row in right],
268
+ "quality_gate": gate,
269
+ "reason": reason,
270
+ "deltas": deltas,
271
+ }
272
+ strata[(spec["tasks"][slot["task_id"]]["split"], slot["cache_condition"])].append(case)
273
+ for (split, cache), cases in sorted(strata.items()):
274
+ counts = Counter(case["quality_gate"] for case in cases)
275
+ comparisons.append(
276
+ {
277
+ "baseline": before,
278
+ "candidate": after,
279
+ "kind": kind,
280
+ "split": split,
281
+ "cache_condition": cache,
282
+ "planned_pair_count": len(cases),
283
+ "quality_gate_counts": {
284
+ key: counts[key]
285
+ for key in ("accepted", "rejected", "indeterminate", "unmatched")
286
+ },
287
+ "quality_acceptance_rate": counts["accepted"] / len(cases),
288
+ "cases": cases,
289
+ "descriptive_deltas": {
290
+ metric: _task_summary(cases, metric, spec["bootstrap"])
291
+ for metric in METRICS
292
+ },
293
+ "quality_preserving_deltas": {
294
+ metric: _task_summary(cases, metric, spec["bootstrap"], eligible=True)
295
+ for metric in METRICS
296
+ },
297
+ }
298
+ )
299
+ balance = []
300
+ for split in ("pilot", "held_out"):
301
+ for cache in sorted({slot["cache_condition"] for slot in schedule.values()}):
302
+ slots = [
303
+ slot
304
+ for slot in schedule.values()
305
+ if spec["tasks"][slot["task_id"]]["split"] == split
306
+ and slot["cache_condition"] == cache
307
+ ]
308
+ if not slots:
309
+ continue
310
+ positions = {name: [0] * len(spec["conditions"]) for name in spec["conditions"]}
311
+ for slot in slots:
312
+ for position, name in enumerate(slot["order"]):
313
+ positions[name][position] += 1
314
+ balance.append(
315
+ {
316
+ "split": split,
317
+ "cache_condition": cache,
318
+ "planned_position_counts": positions,
319
+ "position_balanced": all(
320
+ max(counts) - min(counts) <= 1 for counts in positions.values()
321
+ ),
322
+ }
323
+ )
324
+ return {
325
+ "schema_version": "1.0",
326
+ "protocol": protocol.to_dict(),
327
+ "observations": rows,
328
+ "includes_synthetic_data": spec["synthetic"],
329
+ "planned_observation_count": len(schedule) * len(spec["conditions"]),
330
+ "observed_count": len(rows),
331
+ "unplanned_observation_count": sum(
332
+ "unplanned_observation" in row["issues"] for row in rows
333
+ ),
334
+ "status_counts": dict(
335
+ sorted(Counter(row["observation"]["status"] for row in rows).items())
336
+ ),
337
+ "cost_basis_counts": dict(
338
+ sorted(Counter(row["observation"]["cost_basis"] for row in rows).items())
339
+ ),
340
+ "token_status_counts": dict(
341
+ sorted(Counter(row["observation"]["token_status"] for row in rows).items())
342
+ ),
343
+ "cost_status_counts": dict(
344
+ sorted(Counter(row["observation"]["cost_status"] for row in rows).items())
345
+ ),
346
+ "stop_reason_counts": dict(
347
+ sorted(
348
+ Counter(
349
+ row["observation"]["stop_reason"]
350
+ for row in rows
351
+ if row["observation"]["stop_reason"] is not None
352
+ ).items()
353
+ )
354
+ ),
355
+ "order_balance": balance,
356
+ "comparisons": comparisons,
357
+ "interpretation": "Descriptive host observations, not causal identification. Intervals give equal weight to observed task means within each split/cache stratum; repetitions are not independent tasks. Missingness, selection, small task counts, order/carryover effects and scorer errors remain limitations. A frozen hash is not proof of preregistration or environment reset. Policy evaluation time is not end-to-end overhead. Synthetic fixtures are not empirical benefits.",
358
+ }
359
+
360
+
361
+ def summarize_ablation(bundle_path):
362
+ """Load an offline bundle and preserve existing study/ledger artifacts."""
363
+ path = Path(bundle_path).resolve()
364
+ source_paths = {path}
365
+ try:
366
+ bundle = json.loads(path.read_text(encoding="utf-8"))
367
+ fields(
368
+ bundle,
369
+ {"schema_version", "protocol", "observations", "study_manifest", "interventions"},
370
+ "bundle",
371
+ )
372
+ if bundle["schema_version"] != "1.0":
373
+ raise AblationValidationError("bundle schema_version must be 1.0")
374
+ report = build_ablation_report(bundle["protocol"], bundle["observations"])
375
+ manifest = bundle["study_manifest"]
376
+ if manifest is not None:
377
+ text_value(manifest, "study_manifest")
378
+ source_paths.add((path.parent / manifest).resolve())
379
+ report["trace_study"] = (
380
+ summarize_study(path.parent / manifest) if manifest is not None else None
381
+ )
382
+ if report["trace_study"] is not None:
383
+ source_paths.update(
384
+ Path(run["path"]).resolve()
385
+ for condition in report["trace_study"]["conditions"].values()
386
+ for run in condition["runs"]
387
+ )
388
+ if not isinstance(bundle["interventions"], list):
389
+ raise AblationValidationError("interventions must be a list of existing ledger paths")
390
+ report["interventions"] = []
391
+ intervention_ids = set()
392
+ for reference in bundle["interventions"]:
393
+ text_value(reference, "intervention path")
394
+ source_paths.add((path.parent / reference).resolve())
395
+ payload = json.loads((path.parent / reference).read_text(encoding="utf-8"))
396
+ record = InterventionRecord.from_dict(payload).to_dict()
397
+ if record["intervention_id"] in intervention_ids:
398
+ raise AblationValidationError("duplicate intervention artifact")
399
+ intervention_ids.add(record["intervention_id"])
400
+ report["interventions"].append(record)
401
+ _verify_links(report)
402
+ report["source_paths"] = sorted(str(source) for source in source_paths)
403
+ return report
404
+ except (
405
+ OSError,
406
+ ValueError,
407
+ TypeError,
408
+ KeyError,
409
+ RecursionError,
410
+ StudyValidationError,
411
+ InterventionValidationError,
412
+ ) as exc:
413
+ raise AblationValidationError(f"cannot read ablation bundle: {exc}") from exc
414
+
415
+
416
+ def _verify_links(report):
417
+ study = report["trace_study"]
418
+ spec = report["protocol"]["specification"]
419
+ referenced = [
420
+ row["observation"]
421
+ for row in report["observations"]
422
+ if row["observation"]["trace_run_id"] is not None
423
+ ]
424
+ if study is None:
425
+ if referenced or report["interventions"]:
426
+ raise AblationValidationError(
427
+ "trace/ledger references require an existing study manifest"
428
+ )
429
+ return
430
+ if study["manifest"]["pairing_keys"] != ["task_id", "repetition", "cache_condition"]:
431
+ raise AblationValidationError(
432
+ "linked study must use task_id, repetition, cache_condition pairing"
433
+ )
434
+ runs = {}
435
+ for condition, details in study["conditions"].items():
436
+ if (
437
+ condition not in spec["conditions"]
438
+ or spec["conditions"][condition]["mode"] == "uninstrumented"
439
+ ):
440
+ raise AblationValidationError(
441
+ "linked trace study has an undeclared or uninstrumented condition"
442
+ )
443
+ for run in details["runs"]:
444
+ runs[run["run_id"]] = (condition, run)
445
+ for observation in referenced:
446
+ linked = runs.get(observation["trace_run_id"])
447
+ if (
448
+ linked is None
449
+ or linked[0] != observation["condition"]
450
+ or linked[1]["pairing_metadata"]
451
+ != {key: observation[key] for key in ("task_id", "repetition", "cache_condition")}
452
+ ):
453
+ raise AblationValidationError(
454
+ "observation trace reference does not match its condition/pairing key"
455
+ )
456
+ fingerprints = {}
457
+ for record in report["interventions"]:
458
+ for side in ("baseline", "candidate"):
459
+ identity = record[f"{side}_run_id"]
460
+ if identity not in runs:
461
+ raise AblationValidationError(
462
+ "intervention references a trace outside the linked study"
463
+ )
464
+ if identity not in fingerprints:
465
+ source = TraceSource.from_dict(
466
+ json.loads(Path(runs[identity][1]["path"]).read_text(encoding="utf-8"))
467
+ )
468
+ fingerprints[identity] = source.fingerprints
469
+ if record["trace_fingerprints"][side] not in fingerprints[identity]:
470
+ raise AblationValidationError("intervention trace fingerprint no longer matches")
471
+ report["unlinked_trace_run_ids"] = sorted(
472
+ set(runs) - {row["trace_run_id"] for row in referenced}
473
+ )
474
+ report["includes_synthetic_data"] = (
475
+ report["includes_synthetic_data"]
476
+ or study["includes_synthetic_data"]
477
+ or any(record["metadata"].get("synthetic") is True for record in report["interventions"])
478
+ )
479
+
480
+
481
+ def ablation_to_markdown(report):
482
+ spec = report["protocol"]["specification"]
483
+ lines = [
484
+ f"# Ablation: {markdown_heading(spec['name'])}",
485
+ "",
486
+ report["interpretation"],
487
+ "",
488
+ f"Evidence: {'synthetic fixture' if report['includes_synthetic_data'] else 'host-supplied study; permissions and execution require external verification'}.",
489
+ "",
490
+ f"Planned observations: {report['planned_observation_count']}; observed: {report['observed_count']}; unplanned: {report['unplanned_observation_count']}.",
491
+ "",
492
+ "Execution statuses: "
493
+ + markdown_code_span(json.dumps(report["status_counts"], sort_keys=True)),
494
+ "",
495
+ "Token completeness: "
496
+ + markdown_code_span(json.dumps(report["token_status_counts"], sort_keys=True)),
497
+ "",
498
+ "Cost completeness / basis: "
499
+ + markdown_code_span(
500
+ json.dumps([report["cost_status_counts"], report["cost_basis_counts"]], sort_keys=True)
501
+ ),
502
+ "",
503
+ "Stop reasons: "
504
+ + markdown_code_span(json.dumps(report["stop_reason_counts"], sort_keys=True)),
505
+ "",
506
+ "Deltas are candidate minus baseline. Negative latency/cost deltas are reductions, not evidence of task success.",
507
+ "",
508
+ "| Comparison | Split / cache | Planned | Quality accepted | Rejected | Indeterminate | Unmatched |",
509
+ "| --- | --- | ---: | ---: | ---: | ---: | ---: |",
510
+ ]
511
+ for item in report["comparisons"]:
512
+ counts = item["quality_gate_counts"]
513
+ cells = [
514
+ f"{item['baseline']} -> {item['candidate']} ({item['kind']})",
515
+ f"{item['split']} / {item['cache_condition']}",
516
+ item["planned_pair_count"],
517
+ *(counts[key] for key in ("accepted", "rejected", "indeterminate", "unmatched")),
518
+ ]
519
+ lines.append("| " + " | ".join(markdown_table_cell(str(value)) for value in cells) + " |")
520
+ for item in report["comparisons"]:
521
+ lines.extend(
522
+ [
523
+ "",
524
+ f"## {markdown_heading(item['baseline'])} -> {markdown_heading(item['candidate'])}: {markdown_heading(item['split'])} / {markdown_heading(item['cache_condition'])}",
525
+ "",
526
+ "Means give equal weight to evaluable tasks. Quality-preserving columns exclude rejected/indeterminate pairs; the planned denominator above still includes them.",
527
+ "",
528
+ "| Metric | Pairs: known / quality-eligible / planned | Tasks: known / quality-eligible / planned | Descriptive mean delta | Quality-preserving mean delta | Task interval (quality-preserving) |",
529
+ "| --- | ---: | ---: | ---: | ---: | --- |",
530
+ ]
531
+ )
532
+ for metric in METRICS:
533
+ raw = item["descriptive_deltas"][metric]
534
+ eligible = item["quality_preserving_deltas"][metric]
535
+ interval = eligible["interval"]
536
+ bounds = (
537
+ f"[{interval['lower']:.6g}, {interval['upper']:.6g}]"
538
+ if interval["status"] == "computed"
539
+ else "insufficient tasks"
540
+ )
541
+ cells = [
542
+ metric,
543
+ f"{raw['pair_summary']['count']} / {eligible['pair_summary']['count']} / {raw['planned_pair_count']}",
544
+ f"{raw['task_summary']['count']} / {eligible['task_summary']['count']} / {raw['planned_task_count']}",
545
+ raw["task_summary"]["mean"],
546
+ eligible["task_summary"]["mean"],
547
+ bounds,
548
+ ]
549
+ lines.append(
550
+ "| "
551
+ + " | ".join(
552
+ markdown_table_cell("unavailable" if value is None else str(value))
553
+ for value in cells
554
+ )
555
+ + " |"
556
+ )
557
+ lines.extend(
558
+ [
559
+ "",
560
+ "Full JSON retains every observation, protocol deviation, stop reason, duplicate/missing pair, metric denominator and task-level interval.",
561
+ "",
562
+ ]
563
+ )
564
+ return "\n".join(lines)
@@ -0,0 +1,88 @@
1
+ """File-stream entry points; importing this module does not register global apps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from agentloop.aggregates import MAX_ARTIFACT_BYTES, TraceAggregate, aggregate_files, read_json
10
+
11
+ aggregate_app = typer.Typer(
12
+ help="Bounded-memory summaries of native traces and aggregate partitions."
13
+ )
14
+
15
+
16
+ def _paths(inputs, manifest, out):
17
+ if not inputs and manifest is None:
18
+ raise ValueError("supply trace paths or --manifest")
19
+ destination = out.resolve()
20
+ if manifest is not None and manifest.resolve() == destination:
21
+ raise ValueError("output must differ from manifest")
22
+ for path in inputs or []:
23
+ if path.resolve() == destination:
24
+ raise ValueError("output must differ from every input")
25
+ yield path
26
+ if manifest is not None:
27
+ # JSONL strings, relative to the manifest. No unbounded list of filenames.
28
+ import json
29
+
30
+ with manifest.open("rb") as handle:
31
+ while line := handle.readline(16_385):
32
+ if len(line) > 16_384:
33
+ raise ValueError("manifest line exceeds 16384 bytes")
34
+ value = json.loads(line)
35
+ if not isinstance(value, str) or not value:
36
+ raise ValueError("manifest lines must be JSON path strings")
37
+ path = manifest.parent / value
38
+ if path.resolve() == destination:
39
+ raise ValueError("output must differ from every input")
40
+ yield path
41
+
42
+
43
+ @aggregate_app.command("traces")
44
+ def aggregate_traces_command(
45
+ inputs: list[Path] | None = typer.Argument(None),
46
+ out: Path = typer.Option(Path("runs/aggregate.json")),
47
+ manifest: Path | None = typer.Option(None, help="JSONL paths, relative to this manifest."),
48
+ skip_invalid: bool = typer.Option(
49
+ False, help="Count invalid inputs; do not retain error payloads."
50
+ ),
51
+ max_bytes: int = typer.Option(MAX_ARTIFACT_BYTES, min=1, max=1073741824),
52
+ ):
53
+ """Summarize one native trace file at a time, without graph/finding analysis."""
54
+ from agentloop.cli import _write_json, console
55
+
56
+ try:
57
+ result = aggregate_files(
58
+ _paths(inputs, manifest, out),
59
+ on_error="skip" if skip_invalid else "raise",
60
+ max_bytes=max_bytes,
61
+ )
62
+ except (OSError, ValueError, TypeError, OverflowError) as exc:
63
+ raise typer.BadParameter(str(exc), param_hint="inputs") from None
64
+ _write_json(out, result.to_dict())
65
+ console.print(f"Wrote aggregate to {out}")
66
+
67
+
68
+ @aggregate_app.command("merge")
69
+ def merge_aggregates_command(
70
+ inputs: list[Path] | None = typer.Argument(None),
71
+ out: Path = typer.Option(Path("runs/merged-aggregate.json")),
72
+ manifest: Path | None = typer.Option(None, help="JSONL partition paths."),
73
+ ):
74
+ """Merge disjoint partitions with matching schemas, configurations and policies."""
75
+ from agentloop.cli import _write_json, console
76
+
77
+ result = None
78
+ try:
79
+ for path in _paths(inputs, manifest, out):
80
+ partition = TraceAggregate.from_dict(read_json(path))
81
+ if result is None:
82
+ result = partition
83
+ else:
84
+ result.merge(partition)
85
+ except (OSError, ValueError, TypeError, OverflowError) as exc:
86
+ raise typer.BadParameter(str(exc), param_hint="inputs") from None
87
+ _write_json(out, (result or TraceAggregate()).to_dict())
88
+ console.print(f"Wrote merged aggregate to {out}")