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
@@ -0,0 +1,545 @@
1
+ """Read-only experiment evidence and native store/study export adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ from collections import Counter
7
+ from fractions import Fraction
8
+ from pathlib import Path
9
+
10
+ from agentloop.ablation_protocol import timestamp
11
+ from agentloop.budget_types import Reservation, ResourceUsage, money_text
12
+ from agentloop.experiment_artifacts import (
13
+ journal_lock,
14
+ local_path,
15
+ read_json,
16
+ write_bytes_once,
17
+ write_once,
18
+ )
19
+ from agentloop.experiment_observations import summarize_observations
20
+ from agentloop.experiment_types import canonical, fingerprint
21
+ from agentloop.experiments import slot_id
22
+ from agentloop.harness_evidence import METADATA_KEY, validate_evidence
23
+ from agentloop.interventions import InterventionRecord
24
+ from agentloop.markdown import markdown_heading, markdown_table_cell
25
+ from agentloop.structured_quality import EVIDENCE_KEY, attach_quality_report, validate_report
26
+ from agentloop.studies import summarize_study
27
+ from agentloop.tracer import AgentTrace
28
+
29
+
30
+ def read_experiment(root):
31
+ root = Path(root).resolve()
32
+ envelope = read_json(local_path(root, "experiment.json"))
33
+ spec = envelope["specification"]
34
+ identity = "experiment_" + fingerprint(spec)
35
+ if envelope["experiment_id"] != identity or spec["schema_version"] != "1.0":
36
+ raise ValueError("experiment declaration hash or schema changed")
37
+ rows, planned = [], set()
38
+ baselines = {}
39
+ for case in spec["cases"]:
40
+ key = fingerprint(case["case_id"])
41
+ baseline = read_json(local_path(root, f"baselines/{key}.json"))
42
+ diagnosis = read_json(local_path(root, f"predictions/{key}.json"))
43
+ if (
44
+ fingerprint(baseline) != case["baseline_hash"]
45
+ or fingerprint(diagnosis) != case["diagnosis_hash"]
46
+ ):
47
+ raise ValueError("frozen baseline or prediction changed")
48
+ if timestamp(baseline["ended_at"]) > timestamp(spec["frozen_at"]):
49
+ raise ValueError("baseline outcome follows the frozen candidate plan")
50
+ selected = sorted(
51
+ (
52
+ item
53
+ for item in diagnosis["findings"]
54
+ if item["finding_id"] in case["source_finding_ids"]
55
+ ),
56
+ key=lambda item: item["finding_id"],
57
+ )
58
+ if selected != case["expected_effects"] or diagnosis["run_id"] != baseline["run_id"]:
59
+ raise ValueError("expected effects are not bound to their source baseline")
60
+ baselines[case["case_id"]] = baseline
61
+ for runner in spec["runners"]:
62
+ slot = slot_id(identity, case["case_id"], runner["candidate_id"])
63
+ planned.add(slot)
64
+ directory = f"attempts/{slot}"
65
+ row = {
66
+ "slot_id": slot,
67
+ "case_id": case["case_id"],
68
+ "candidate_id": runner["candidate_id"],
69
+ "status": "not_started",
70
+ "admission": None,
71
+ "receipt": None,
72
+ "trace": None,
73
+ "quality": None,
74
+ "intervention": None,
75
+ }
76
+ start = local_path(root, directory + "/started.json")
77
+ finish = local_path(root, directory + "/receipt.json")
78
+ if start.exists():
79
+ admission = read_json(start)
80
+ if (
81
+ admission.get("experiment_id") != identity
82
+ or admission.get("slot_id") != slot
83
+ or admission.get("case_id") != case["case_id"]
84
+ or admission.get("candidate_id") != runner["candidate_id"]
85
+ or admission.get("runner") != runner
86
+ ):
87
+ raise ValueError("admission record differs from frozen experiment")
88
+ row["status"] = "unresolved"
89
+ if timestamp(admission["started_at"]) < timestamp(spec["frozen_at"]):
90
+ raise ValueError("candidate admission precedes frozen expected effects")
91
+ row["admission"] = admission
92
+ if finish.exists():
93
+ receipt = read_json(finish)
94
+ if (
95
+ receipt.get("schema_version") != "1.0"
96
+ or receipt.get("experiment_id") != identity
97
+ or receipt.get("slot_id") != slot
98
+ or receipt.get("started_hash") != fingerprint(admission)
99
+ or receipt.get("status")
100
+ not in {"completed", "failed", "timed_out", "cancelled", "stopped"}
101
+ or set(receipt["artifact_hashes"])
102
+ != {"trace.json", "quality.json", "intervention.json"}
103
+ ):
104
+ raise ValueError("invalid experiment receipt")
105
+ for key, filename in (
106
+ ("trace", "trace.json"),
107
+ ("quality", "quality.json"),
108
+ ("intervention", "intervention.json"),
109
+ ):
110
+ value = read_json(local_path(root, directory + "/" + filename))
111
+ if fingerprint(value) != receipt["artifact_hashes"][filename]:
112
+ raise ValueError("experiment result artifact changed")
113
+ row[key] = value
114
+ trace = AgentTrace.from_dict(row["trace"])
115
+ if (
116
+ timestamp(trace.started_at) < timestamp(admission["started_at"])
117
+ or trace.ended_at is None
118
+ or timestamp(trace.ended_at) < timestamp(trace.started_at)
119
+ ):
120
+ raise ValueError("candidate timing differs from its admission")
121
+ validate_report(
122
+ row["quality"],
123
+ baseline_trace=AgentTrace.from_dict(baseline),
124
+ candidate_trace=trace,
125
+ )
126
+ record = InterventionRecord.from_dict(row["intervention"]).to_dict()
127
+ native = validate_evidence(trace.metadata[METADATA_KEY], trace_id=trace.run_id)
128
+ controls = [
129
+ item
130
+ for item in native["decisions"].values()
131
+ if item["call_id"] == receipt["budget_call_id"]
132
+ and item["harness_config_hash"] == receipt["budget_config_hash"]
133
+ and item["policy_id"] == "budget"
134
+ ]
135
+ if (
136
+ len(controls) != 2
137
+ or {item["phase"] for item in controls} != {"before", "after"}
138
+ or receipt["halted"]
139
+ != any(
140
+ item["mode"] == "enforce"
141
+ and item["resolved_action"] in {"stop", "escalate"}
142
+ for item in controls
143
+ )
144
+ or receipt["budget_dispatched"]
145
+ != next(item["dispatched"] for item in controls if item["phase"] == "after")
146
+ ):
147
+ raise ValueError("experiment admission budget evidence changed")
148
+ quality_case = row["quality"]["cases"][0]
149
+ execution_status = {
150
+ "completed": "completed",
151
+ "cancelled": "cancelled",
152
+ "timed_out": "timed_out",
153
+ }.get(receipt["status"], "failed")
154
+ if quality_case["candidate"]["execution_status"] != execution_status or receipt[
155
+ "invoked"
156
+ ] != any(event.event_id == "experiment_root_" + slot for event in trace.events):
157
+ raise ValueError("candidate execution state differs from observed evidence")
158
+ if (
159
+ len(row["quality"]["cases"]) != 1
160
+ or quality_case["case_id"] != case["case_id"]
161
+ or quality_case["expected_hash"] != case["expected_hash"]
162
+ or quality_case["baseline"]["output_hash"] != case["baseline_output_hash"]
163
+ or quality_case["scorer"]["configuration_hash"] != spec["scorer_hash"]
164
+ or (
165
+ receipt["status"] == "completed"
166
+ and quality_case["candidate"]["output_hash"] != receipt["output_hash"]
167
+ )
168
+ ):
169
+ raise ValueError("quality assessment differs from frozen inputs or scorer")
170
+ if (
171
+ trace.run_id != "experiment_run_" + slot
172
+ or trace.metadata["agentloop.experiment"]["status"] != receipt["status"]
173
+ or type(receipt["halted"]) is not bool
174
+ or type(receipt["invoked"]) is not bool
175
+ or type(receipt["budget_dispatched"]) is not bool
176
+ or record["measured"]["quality"] != row["quality"]
177
+ or record["measured"]["gates"]["config"] != spec["gates"]
178
+ ):
179
+ raise ValueError("experiment outcome or gate binding changed")
180
+ if (
181
+ record["trace_fingerprints"]
182
+ != {
183
+ "baseline": fingerprint(baseline),
184
+ "candidate": fingerprint(trace.to_dict()),
185
+ }
186
+ or record["predicted"]["findings"] != case["expected_effects"]
187
+ or record["configuration"]["experiment_id"] != identity
188
+ or record["configuration"]["slot_id"] != slot
189
+ or record["gates_passed"] != receipt["gates_passed"]
190
+ or row["quality"]["passed"] != receipt["quality_passed"]
191
+ ):
192
+ raise ValueError("native intervention is not bound to this experiment")
193
+ row.update(status=receipt["status"], receipt=receipt)
194
+ elif finish.exists():
195
+ raise ValueError("receipt exists without an admission record")
196
+ rows.append(row)
197
+ attempts = local_path(root, "attempts")
198
+ if attempts.exists() and any(
199
+ path.name not in planned for path in attempts.iterdir() if path.is_dir()
200
+ ):
201
+ raise ValueError("unplanned experiment attempt directory")
202
+ return {"experiment_id": identity, "specification": spec, "baselines": baselines, "rows": rows}
203
+
204
+
205
+ def summarize_experiment(root):
206
+ evidence = read_experiment(root)
207
+ rows = evidence["rows"]
208
+ reservations = [
209
+ Reservation(**row["admission"]["runner"]["reservation"])
210
+ for row in rows
211
+ if row["admission"] is not None
212
+ ]
213
+ usages = [
214
+ None
215
+ if row["receipt"] is None or row["receipt"]["usage"] is None
216
+ else ResourceUsage(**row["receipt"]["usage"])
217
+ for row in rows
218
+ if row["admission"] is not None
219
+ ]
220
+ budget_summary = {
221
+ "scope": evidence["specification"]["budget_scope"],
222
+ "limits": evidence["specification"]["budget"],
223
+ "admitted_attempts": len(reservations),
224
+ "reserved_known_tokens": sum(item.tokens for item in reservations if item.tokens_known),
225
+ "reserved_known_cost_usd": money_text(
226
+ sum(
227
+ (Fraction(str(item.cost_usd)) for item in reservations if item.cost_known),
228
+ Fraction(0),
229
+ )
230
+ ),
231
+ "unknown_token_reservations": sum(not item.tokens_known for item in reservations),
232
+ "unknown_cost_reservations": sum(not item.cost_known for item in reservations),
233
+ "unavailable_actual_token_count": sum(
234
+ item is None or not item.tokens_known for item in usages
235
+ ),
236
+ "unavailable_actual_cost_count": sum(
237
+ item is None or not item.cost_known for item in usages
238
+ ),
239
+ "reservation_refunds": 0,
240
+ "hard_spend_cap": False,
241
+ }
242
+ budget_summary["reported_total_tokens"] = (
243
+ None
244
+ if budget_summary["unavailable_actual_token_count"]
245
+ else sum(item.tokens for item in usages)
246
+ )
247
+ budget_summary["reported_total_cost_usd"] = (
248
+ None
249
+ if budget_summary["unavailable_actual_cost_count"]
250
+ else money_text(sum((Fraction(str(item.cost_usd)) for item in usages), Fraction(0)))
251
+ )
252
+ comparisons = []
253
+ for runner in evidence["specification"]["runners"]:
254
+ selected = [row for row in rows if row["candidate_id"] == runner["candidate_id"]]
255
+ complete = [row for row in selected if row["receipt"] is not None]
256
+ comparisons.append(
257
+ {
258
+ "candidate_id": runner["candidate_id"],
259
+ "planned_pairs": len(selected),
260
+ "observed_pairs": len(complete),
261
+ "missing_pairs": len(selected) - len(complete),
262
+ "status_counts": dict(sorted(Counter(row["status"] for row in selected).items())),
263
+ "quality_pass_count": sum(row["receipt"]["quality_passed"] for row in complete),
264
+ "gates_pass_count": sum(row["receipt"]["gates_passed"] for row in complete),
265
+ "all_planned_gates_passed": len(complete) == len(selected)
266
+ and all(row["receipt"]["gates_passed"] for row in complete),
267
+ "observations": summarize_observations(selected),
268
+ "measured_pairs": [
269
+ {
270
+ "case_id": row["case_id"],
271
+ "quality_passed": row["receipt"]["quality_passed"],
272
+ "gates_passed": row["receipt"]["gates_passed"],
273
+ "deltas": row["intervention"]["measured"]["deltas"],
274
+ }
275
+ for row in complete
276
+ ],
277
+ "predicted_effects": [
278
+ {"case_id": case["case_id"], "findings": case["expected_effects"]}
279
+ for case in evidence["specification"]["cases"]
280
+ ],
281
+ }
282
+ )
283
+ result = {
284
+ "schema_version": "1.0",
285
+ "experiment_id": evidence["experiment_id"],
286
+ "name": evidence["specification"]["name"],
287
+ "synthetic": evidence["specification"]["synthetic"],
288
+ "planned_attempts": len(rows),
289
+ "observed_attempts": sum(row["receipt"] is not None for row in rows),
290
+ "status_counts": dict(sorted(Counter(row["status"] for row in rows).items())),
291
+ "comparisons": comparisons,
292
+ "attempts": [
293
+ {key: row[key] for key in ("slot_id", "case_id", "candidate_id", "status", "receipt")}
294
+ for row in rows
295
+ ],
296
+ "interpretation": "Linked historical baselines and caller-run candidates; every planned slot remains in the denominator. Counterfactual benefit requires the explicit independent quality and performance gates. Unknown cost is not zero. Historical baselines are not randomized contemporaneous controls.",
297
+ }
298
+ result["budget"] = budget_summary
299
+ result["measurement_scope"] = {
300
+ "native_replay_cost": "recorded_model_profile_estimate",
301
+ "runner_usage": "separate_exclusive_caller_report",
302
+ "latency": "protected_candidate_runner_including_lifecycle_overhead",
303
+ "hidden_work": "not_intercepted",
304
+ }
305
+ result["snapshot_hash"] = fingerprint(result)
306
+ return result
307
+
308
+
309
+ def experiment_to_markdown(report):
310
+ lines = [
311
+ "# Optimization experiment: " + markdown_heading(report["name"]),
312
+ "",
313
+ report["interpretation"],
314
+ "",
315
+ ]
316
+ if report["synthetic"]:
317
+ lines += ["Synthetic evidence; no real-workload improvement claim.", ""]
318
+ lines += [
319
+ "| Candidate | Planned | Observed | Missing | Quality pass | All gates passed |",
320
+ "| --- | ---: | ---: | ---: | ---: | --- |",
321
+ ]
322
+ for item in report["comparisons"]:
323
+ values = [
324
+ item["candidate_id"],
325
+ item["planned_pairs"],
326
+ item["observed_pairs"],
327
+ item["missing_pairs"],
328
+ item["quality_pass_count"],
329
+ item["all_planned_gates_passed"],
330
+ ]
331
+ lines.append("| " + " | ".join(markdown_table_cell(str(value)) for value in values) + " |")
332
+ for item in report["comparisons"]:
333
+ lines += [
334
+ "",
335
+ "## Observations: " + markdown_heading(item["candidate_id"]),
336
+ "",
337
+ "Realized pair deltas remain separate from frozen predicted effects. Quality-preserving observations are conditional and retain the full planned denominator.",
338
+ "",
339
+ "```json",
340
+ canonical(
341
+ {
342
+ "observations": item.get("observations", {}),
343
+ "measured_pairs": item.get("measured_pairs", []),
344
+ "predicted_effects": item.get("predicted_effects", []),
345
+ }
346
+ ),
347
+ "```",
348
+ ]
349
+ lines += [
350
+ "",
351
+ "## Retained attempt states",
352
+ "",
353
+ "| Case | Candidate | State |",
354
+ "| --- | --- | --- |",
355
+ ]
356
+ for item in report["attempts"]:
357
+ lines.append(
358
+ "| "
359
+ + " | ".join(
360
+ markdown_table_cell(item[key]) for key in ("case_id", "candidate_id", "status")
361
+ )
362
+ + " |"
363
+ )
364
+ return "\n".join(lines) + "\n"
365
+
366
+
367
+ def experiment_to_html(report):
368
+ def table(headers, rows):
369
+ return (
370
+ "<table><thead><tr>"
371
+ + "".join('<th scope="col">' + html.escape(value) + "</th>" for value in headers)
372
+ + "</tr></thead><tbody>"
373
+ + "".join(
374
+ "<tr>"
375
+ + "".join("<td>" + html.escape(str(value)) + "</td>" for value in row)
376
+ + "</tr>"
377
+ for row in rows
378
+ )
379
+ + "</tbody></table>"
380
+ )
381
+
382
+ summary = table(
383
+ ("Candidate", "Planned", "Observed", "Missing", "Quality pass", "All gates passed"),
384
+ (
385
+ [
386
+ item[key]
387
+ for key in (
388
+ "candidate_id",
389
+ "planned_pairs",
390
+ "observed_pairs",
391
+ "missing_pairs",
392
+ "quality_pass_count",
393
+ "all_planned_gates_passed",
394
+ )
395
+ ]
396
+ for item in report["comparisons"]
397
+ ),
398
+ )
399
+ attempts = table(
400
+ ("Case", "Candidate", "State"),
401
+ (
402
+ [item[key] for key in ("case_id", "candidate_id", "status")]
403
+ for item in report["attempts"]
404
+ ),
405
+ )
406
+ notice = (
407
+ "<p>Synthetic evidence; no real-workload improvement claim.</p>"
408
+ if report["synthetic"]
409
+ else ""
410
+ )
411
+ detail = "".join(
412
+ "<details><summary>Observations, measured deltas and separate predictions: "
413
+ + html.escape(item["candidate_id"])
414
+ + "</summary><pre>"
415
+ + html.escape(
416
+ canonical(
417
+ {
418
+ "observations": item.get("observations", {}),
419
+ "measured_pairs": item.get("measured_pairs", []),
420
+ "predicted_effects": item.get("predicted_effects", []),
421
+ }
422
+ )
423
+ )
424
+ + "</pre></details>"
425
+ for item in report["comparisons"]
426
+ )
427
+ return (
428
+ '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; base-uri \'none\'"><title>Experiment evidence</title><style>body{font:16px/1.5 system-ui;margin:2rem auto;max-width:72rem;padding:0 1rem}table{border-collapse:collapse;width:100%}th,td{text-align:left;border-bottom:1px solid #ccc;padding:.6rem}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style></head><body><main><h1>'
429
+ + html.escape(report["name"])
430
+ + "</h1>"
431
+ + notice
432
+ + "<p>"
433
+ + html.escape(report["interpretation"])
434
+ + "</p><h2>Candidate comparisons</h2>"
435
+ + summary
436
+ + detail
437
+ + "<h2>Retained attempts</h2>"
438
+ + attempts
439
+ + "<details><summary>Budget and measurement scope</summary><pre>"
440
+ + html.escape(
441
+ canonical(
442
+ {"budget": report["budget"], "measurement_scope": report["measurement_scope"]}
443
+ )
444
+ )
445
+ + "</pre></details></main></body></html>"
446
+ )
447
+
448
+
449
+ def export_experiment(root):
450
+ """Export the same snapshot idempotently; never invoke a runner or scorer."""
451
+ root = Path(root).resolve()
452
+ with journal_lock(root):
453
+ evidence, report = read_experiment(root), summarize_experiment(root)
454
+ directory = "reports/" + report["snapshot_hash"]
455
+ studies = {}
456
+ for runner in evidence["specification"]["runners"]:
457
+ name = runner["candidate_id"]
458
+ selected = [row for row in evidence["rows"] if row["candidate_id"] == name]
459
+ if not any(row["trace"] is not None for row in selected):
460
+ studies[name] = {
461
+ "status": "unavailable",
462
+ "reason": "no_candidate_trace",
463
+ "planned_pairs": len(selected),
464
+ }
465
+ continue
466
+ folder = directory + "/" + fingerprint(name)
467
+ conditions = {"baseline": [], "candidate": []}
468
+ for row in selected:
469
+ baseline = AgentTrace.from_dict(evidence["baselines"][row["case_id"]])
470
+ baseline.metadata["agentloop.experiment_view"] = {
471
+ "source_trace_hash": fingerprint(evidence["baselines"][row["case_id"]]),
472
+ "derived_fields": ["pairing", "quality"],
473
+ }
474
+ baseline.metadata.update(
475
+ experiment_id=evidence["experiment_id"], case_id=row["case_id"]
476
+ )
477
+ if row["quality"] is not None:
478
+ attach_quality_report(baseline, row["quality"], side="baseline")
479
+ else:
480
+ # A missing candidate cannot certify independent baseline
481
+ # task quality. Keep the baseline run for unmatched pairing.
482
+ baseline.metadata.pop("quality_score", None)
483
+ baseline.metadata.pop(EVIDENCE_KEY, None)
484
+ filename = "baseline-" + fingerprint(row["case_id"]) + ".json"
485
+ write_once(root, folder + "/" + filename, baseline.to_dict())
486
+ conditions["baseline"].append(filename)
487
+ if row["trace"] is not None:
488
+ candidate = AgentTrace.from_dict(row["trace"])
489
+ candidate.metadata["agentloop.experiment_view"] = {
490
+ "source_trace_hash": fingerprint(row["trace"]),
491
+ "derived_fields": ["quality"],
492
+ }
493
+ attach_quality_report(candidate, row["quality"], side="candidate")
494
+ filename = "candidate-" + row["slot_id"] + ".json"
495
+ write_once(root, folder + "/" + filename, candidate.to_dict())
496
+ conditions["candidate"].append(filename)
497
+ manifest = {
498
+ "schema_version": "1.0",
499
+ "name": report["name"] + " / " + name,
500
+ "baseline": "baseline",
501
+ "conditions": conditions,
502
+ "pairing_keys": ["experiment_id", "case_id"],
503
+ }
504
+ path = write_once(root, folder + "/study.json", manifest)
505
+ studies[name] = summarize_study(path)
506
+ payload = {"experiment": report, "studies": studies}
507
+ write_once(root, directory + "/evidence.json", payload)
508
+ markdown = experiment_to_markdown(report)
509
+ page = experiment_to_html(report)
510
+ for name, text in (("evidence.md", markdown), ("evidence.html", page)):
511
+ write_bytes_once(root, directory + "/" + name, text.encode("utf-8"))
512
+ return {"directory": str(local_path(root, directory)), **payload}
513
+
514
+
515
+ def persist_experiment(root, store, *, project_id="default"):
516
+ """Use the existing trace/finding/intervention store; no new database schema."""
517
+ evidence = read_experiment(root)
518
+ ids = []
519
+ traces = [AgentTrace.from_dict(value) for value in evidence["baselines"].values()] + [
520
+ AgentTrace.from_dict(row["trace"]) for row in evidence["rows"] if row["trace"] is not None
521
+ ]
522
+ existing = {}
523
+ for trace in traces:
524
+ saved = store.get_trace(trace.run_id, project_id=project_id)
525
+ if saved is not None and fingerprint(saved.to_dict()) != fingerprint(trace.to_dict()):
526
+ raise ValueError("native store already has conflicting evidence for a run identity")
527
+ existing[trace.run_id] = saved is not None
528
+ for case in evidence["specification"]["cases"]:
529
+ trace = AgentTrace.from_dict(evidence["baselines"][case["case_id"]])
530
+ if not existing[trace.run_id]:
531
+ store.save_trace(trace, project_id=project_id)
532
+ store.save_diagnosis(
533
+ read_json(local_path(root, f"predictions/{fingerprint(case['case_id'])}.json")),
534
+ project_id=project_id,
535
+ )
536
+ for row in evidence["rows"]:
537
+ if row["trace"] is None:
538
+ continue
539
+ trace = AgentTrace.from_dict(row["trace"])
540
+ if not existing[trace.run_id]:
541
+ store.save_trace(trace, project_id=project_id)
542
+ record = InterventionRecord.from_dict(row["intervention"])
543
+ store.save_intervention(record, project_id=project_id)
544
+ ids.append(record.intervention_id)
545
+ return ids