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,407 @@
1
+ """Read-only validation and summaries of frozen substitution trial bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter, defaultdict
7
+ from fractions import Fraction
8
+ from statistics import fmean
9
+
10
+ from agentloop.judgment_types import JudgeIdentity, canonical, fingerprint, finite, is_hash, text
11
+ from agentloop.quality import SUPPORTED_SCORER_TYPES
12
+ from agentloop.structured_quality import validate_report
13
+ from agentloop.substitution_evidence import read_trial_evidence
14
+ from agentloop.substitution_types import SUBSTITUTION_VERSION, DecisionStep
15
+ from agentloop.tracer import AgentTrace
16
+
17
+
18
+ def _copy(value):
19
+ return json.loads(canonical(value))
20
+
21
+
22
+ def load_bundle(bundle, max_invocations=10000):
23
+ owned = _copy(bundle)
24
+ try:
25
+ if owned["schema_version"] != SUBSTITUTION_VERSION or type(owned["enabled"]) is not bool:
26
+ raise ValueError("unsupported substitution bundle")
27
+ protocol, declarations = owned["protocol"], owned["implementations"]
28
+ if (
29
+ protocol["schema_version"] != SUBSTITUTION_VERSION
30
+ or not isinstance(declarations, dict)
31
+ or len(declarations) < 2
32
+ or owned["baseline"] not in declarations
33
+ ):
34
+ raise ValueError("invalid experiment plan")
35
+ if owned["plan_hash"] != fingerprint(
36
+ {"protocol": protocol, "baseline": owned["baseline"], "implementations": declarations}
37
+ ):
38
+ raise ValueError("experiment plan hash mismatch")
39
+ for key in ("name", "version"):
40
+ text(protocol[key], key)
41
+ step = DecisionStep(**protocol["step"])
42
+ if type(protocol["synthetic"]) is not bool or step.synthetic and not protocol["synthetic"]:
43
+ raise ValueError("synthetic source markers must be preserved")
44
+ for name, declaration in declarations.items():
45
+ text(name, "condition")
46
+ if declaration["name"] != name or declaration["kind"] not in {
47
+ "model",
48
+ "classifier",
49
+ "rule",
50
+ "tool",
51
+ "external_service",
52
+ }:
53
+ raise ValueError("invalid implementation declaration")
54
+ JudgeIdentity(**declaration["identity"])
55
+ examples = {}
56
+ for item in protocol["examples"]:
57
+ for key in ("example_id", "input_ref", "quality_ref"):
58
+ text(item[key], key)
59
+ if (
60
+ item["example_id"] in examples
61
+ or not is_hash(item["input_hash"])
62
+ or item["expected_hash"] is not None
63
+ and not is_hash(item["expected_hash"])
64
+ ):
65
+ raise ValueError("invalid or duplicate example declaration")
66
+ examples[item["example_id"]] = item
67
+ if (
68
+ not examples
69
+ or type(protocol["repetitions"]) is not int
70
+ or protocol["repetitions"] < 1
71
+ or type(protocol["seed"]) is not int
72
+ ):
73
+ raise ValueError("invalid examples, repetitions or seed")
74
+ for key in ("min_quality", "max_quality_drop"):
75
+ if not finite(protocol[key]) or not 0 <= protocol[key] <= 1:
76
+ raise ValueError("invalid quality threshold")
77
+ if protocol["timeout_s"] is not None and (
78
+ not finite(protocol["timeout_s"]) or protocol["timeout_s"] <= 0
79
+ ):
80
+ raise ValueError("invalid timeout")
81
+ scorer = protocol["scorer"]
82
+ text(scorer["version"], "scorer version")
83
+ if scorer["type"] not in SUPPORTED_SCORER_TYPES or not is_hash(scorer["config_hash"]):
84
+ raise ValueError("invalid scorer identity")
85
+ count = len(examples) * protocol["repetitions"] * len(declarations)
86
+ if type(max_invocations) is not int or max_invocations < 1 or count > max_invocations:
87
+ raise ValueError("planned experiment exceeds max_invocations")
88
+ if (
89
+ not isinstance(owned["records"], list)
90
+ or not isinstance(owned["pairs"], list)
91
+ or not isinstance(owned["environment"], dict)
92
+ ):
93
+ raise ValueError("invalid record collections or environment")
94
+ if set(owned["environment"]) != {"python", "system", "architecture"} or any(
95
+ not isinstance(value, str) for value in owned["environment"].values()
96
+ ):
97
+ raise ValueError("invalid environment declaration")
98
+ return owned, examples
99
+ except (KeyError, TypeError, AttributeError):
100
+ raise ValueError("invalid substitution bundle") from None
101
+
102
+
103
+ def index_trials(bundle, examples):
104
+ indexed, extras = defaultdict(list), []
105
+ for index, row in enumerate(bundle["records"]):
106
+ if (
107
+ not isinstance(row, dict)
108
+ or not isinstance(row.get("condition"), str)
109
+ or row["condition"] not in bundle["implementations"]
110
+ or not isinstance(row.get("example_id"), str)
111
+ or row["example_id"] not in examples
112
+ or type(row.get("repetition")) is not int
113
+ or not 0 <= row["repetition"] < bundle["protocol"]["repetitions"]
114
+ ):
115
+ extras.append(index)
116
+ else:
117
+ indexed[row["condition"], row["example_id"], row["repetition"]].append(row)
118
+ slots, run_ids = {}, Counter()
119
+ for rows in indexed.values():
120
+ for row in rows:
121
+ if isinstance(row.get("trace"), dict) and isinstance(row["trace"].get("run_id"), str):
122
+ run_ids[row["trace"]["run_id"]] += 1
123
+ for name, declaration in bundle["implementations"].items():
124
+ for example_id, example in examples.items():
125
+ for repeat in range(bundle["protocol"]["repetitions"]):
126
+ key = name, example_id, repeat
127
+ rows = indexed.get(key, [])
128
+ slot = {
129
+ "condition": name,
130
+ "example_id": example_id,
131
+ "repetition": repeat,
132
+ "status": "missing",
133
+ "trace": None,
134
+ "receipt": None,
135
+ }
136
+ if len(rows) > 1:
137
+ slot["status"] = "duplicate"
138
+ elif rows:
139
+ try:
140
+ trace = AgentTrace.from_dict(_copy(rows[0]["trace"]))
141
+ receipt = read_trial_evidence(trace)
142
+ if (
143
+ receipt is None
144
+ or receipt["status"] != "valid"
145
+ or receipt["plan_hash"] != bundle["plan_hash"]
146
+ or receipt["example_id"] != example_id
147
+ or receipt["repetition"] != repeat
148
+ or receipt["implementation"] != declaration
149
+ or receipt["input_hash"] != example["input_hash"]
150
+ or run_ids[trace.run_id] != 1
151
+ or (not bundle["enabled"] and receipt["outcome"] != "disabled")
152
+ or trace.metadata.get("plan_hash") != bundle["plan_hash"]
153
+ or trace.metadata.get("example_id") != example_id
154
+ or trace.metadata.get("repetition") != repeat
155
+ ):
156
+ raise ValueError("trial does not match frozen plan")
157
+ if trace.metadata.get("synthetic") != bundle["protocol"]["synthetic"]:
158
+ raise ValueError("trial changed the synthetic marker")
159
+ slot.update(status=receipt["outcome"], trace=trace, receipt=receipt)
160
+ except (ValueError, TypeError, KeyError, AttributeError):
161
+ slot["status"] = "invalid_record"
162
+ slots[key] = slot
163
+ return slots, extras
164
+
165
+
166
+ def _quality_matches(quality, before, after, example, protocol):
167
+ checked = validate_report(
168
+ quality, baseline_trace=before["trace"], candidate_trace=after["trace"]
169
+ )
170
+ if (
171
+ checked["trace_ids"]
172
+ != {"baseline": before["trace"].run_id, "candidate": after["trace"].run_id}
173
+ or checked["case_count"] != 1
174
+ or checked["min_score"] != protocol["min_quality"]
175
+ ):
176
+ raise ValueError("quality is not bound to this trial pair")
177
+ case = checked["cases"][0]
178
+ scorer = case["scorer"]
179
+ if (
180
+ case["case_id"] != example["example_id"]
181
+ or case["input_ref"] != example["input_ref"]
182
+ or case["expected_ref"] != example["quality_ref"]
183
+ or case["expected_hash"] != example["expected_hash"]
184
+ or scorer["type"] != protocol["scorer"]["type"]
185
+ or scorer["version"] != protocol["scorer"]["version"]
186
+ or scorer["configuration_hash"] != protocol["scorer"]["config_hash"]
187
+ ):
188
+ raise ValueError("quality changed the frozen example or scorer")
189
+ for side, trial in (("baseline", before), ("candidate", after)):
190
+ if case[side]["output_hash"] != trial["receipt"]["output_hash"] or case[side][
191
+ "output_present"
192
+ ] != (trial["status"] == "completed"):
193
+ raise ValueError("quality output does not match the recorded result")
194
+ if trial["status"] != "completed" and checked[f"{side}_score"] is not None:
195
+ raise ValueError("unavailable output cannot have known quality")
196
+ return checked
197
+
198
+
199
+ def paired_assessments(bundle, slots, examples):
200
+ indexed, extras = defaultdict(list), []
201
+ candidates = set(bundle["implementations"]) - {bundle["baseline"]}
202
+ for index, row in enumerate(bundle["pairs"]):
203
+ if (
204
+ not isinstance(row, dict)
205
+ or not isinstance(row.get("condition"), str)
206
+ or row["condition"] not in candidates
207
+ or not isinstance(row.get("example_id"), str)
208
+ or row["example_id"] not in examples
209
+ or type(row.get("repetition")) is not int
210
+ or not 0 <= row["repetition"] < bundle["protocol"]["repetitions"]
211
+ ):
212
+ extras.append(index)
213
+ else:
214
+ indexed[row["condition"], row["example_id"], row["repetition"]].append(row)
215
+ result = []
216
+ for name in sorted(candidates):
217
+ for example_id, example in examples.items():
218
+ for repeat in range(bundle["protocol"]["repetitions"]):
219
+ key = name, example_id, repeat
220
+ before, after = slots[bundle["baseline"], example_id, repeat], slots[key]
221
+ row = {
222
+ "condition": name,
223
+ "example_id": example_id,
224
+ "repetition": repeat,
225
+ "status": "unavailable",
226
+ "baseline_score": None,
227
+ "candidate_score": None,
228
+ "candidate_passed": None,
229
+ "baseline_agreement": None,
230
+ "quality": None,
231
+ }
232
+ saved = indexed.get(key, [])
233
+ if len(saved) == 1 and before["trace"] is not None and after["trace"] is not None:
234
+ pair = saved[0]
235
+ if (
236
+ pair.get("baseline_run_id") == before["trace"].run_id
237
+ and pair.get("candidate_run_id") == after["trace"].run_id
238
+ ):
239
+ if before["status"] == after["status"] == "completed":
240
+ row["baseline_agreement"] = (
241
+ before["receipt"]["output_hash"] == after["receipt"]["output_hash"]
242
+ )
243
+ if pair.get("quality") is not None:
244
+ try:
245
+ quality = _quality_matches(
246
+ pair["quality"], before, after, example, bundle["protocol"]
247
+ )
248
+ row.update(
249
+ status="assessed",
250
+ baseline_score=quality["baseline_score"],
251
+ candidate_score=quality["candidate_score"],
252
+ candidate_passed=quality["summaries"]["candidate"]["passed"],
253
+ quality=quality,
254
+ )
255
+ except (ValueError, TypeError, KeyError):
256
+ row["status"] = "invalid_quality"
257
+ elif pair.get("quality_error") == "assessment_failed":
258
+ row["status"] = "scoring_failed"
259
+ elif len(saved) > 1:
260
+ row["status"] = "duplicate_quality"
261
+ result.append(row)
262
+ return result, extras
263
+
264
+
265
+ def _mean(values):
266
+ if not values:
267
+ return None
268
+ try:
269
+ return fmean(values)
270
+ except OverflowError:
271
+ return float(sum((Fraction(str(value)) for value in values), Fraction()) / len(values))
272
+
273
+
274
+ def _exact_mean(values):
275
+ return (
276
+ sum((Fraction(str(value)) for value in values), Fraction()) / len(values)
277
+ if values
278
+ else None
279
+ )
280
+
281
+
282
+ def summarize_substitution_experiment(bundle, *, max_invocations=10000):
283
+ owned, examples = load_bundle(bundle, max_invocations)
284
+ slots, unmatched = index_trials(owned, examples)
285
+ pairs, unmatched_pairs = paired_assessments(owned, slots, examples)
286
+ by_condition, comparisons_by_condition = defaultdict(list), defaultdict(list)
287
+ for row in slots.values():
288
+ by_condition[row["condition"]].append(row)
289
+ for row in pairs:
290
+ comparisons_by_condition[row["condition"]].append(row)
291
+ results = []
292
+ for name, declaration in owned["implementations"].items():
293
+ rows = by_condition[name]
294
+ counts = Counter(item["status"] for item in rows)
295
+ receipts = [item["receipt"] for item in rows if item["receipt"] is not None]
296
+ metrics = {}
297
+ for key in ("latency_ms", "input_tokens", "output_tokens", "cost_usd"):
298
+ values = [
299
+ item["latency_ms"] if key == "latency_ms" else item["usage"][key]
300
+ for item in receipts
301
+ ]
302
+ known = [value for value in values if value is not None]
303
+ metrics[key] = {
304
+ "observed_mean": _mean(known),
305
+ "observed_count": len(known),
306
+ "missing_count": len(rows) - len(known),
307
+ "complete": len(known) == len(rows),
308
+ }
309
+ results.append(
310
+ {
311
+ **declaration,
312
+ "planned": len(rows),
313
+ "status_counts": dict(counts),
314
+ "failure_rate": sum(
315
+ counts[key]
316
+ for key in ("failed", "timed_out", "invalid_result", "invalid_record")
317
+ )
318
+ / len(rows),
319
+ "unavailable_output_rate": (len(rows) - counts["completed"]) / len(rows),
320
+ "measurements": metrics,
321
+ "cost_basis_counts": dict(
322
+ Counter(item["usage"]["cost_basis"] for item in receipts)
323
+ ),
324
+ "token_basis_counts": dict(
325
+ Counter(item["usage"]["token_basis"] for item in receipts)
326
+ ),
327
+ }
328
+ )
329
+ comparisons = []
330
+ for name, values in comparisons_by_condition.items():
331
+ by_example = defaultdict(list)
332
+ for item in values:
333
+ by_example[item["example_id"]].append(item)
334
+ complete_examples = [
335
+ rows
336
+ for rows in by_example.values()
337
+ if len(rows) == owned["protocol"]["repetitions"]
338
+ and all(
339
+ item["baseline_score"] is not None and item["candidate_score"] is not None
340
+ for item in rows
341
+ )
342
+ ]
343
+ complete = len(complete_examples) == len(examples)
344
+ before = _exact_mean(
345
+ [_exact_mean([item["baseline_score"] for item in rows]) for rows in complete_examples]
346
+ )
347
+ after = _exact_mean(
348
+ [_exact_mean([item["candidate_score"] for item in rows]) for rows in complete_examples]
349
+ )
350
+ agreement = [
351
+ float(item["baseline_agreement"])
352
+ for item in values
353
+ if item["baseline_agreement"] is not None
354
+ ]
355
+ preserved = None
356
+ if complete:
357
+ preserved = (
358
+ all(item["candidate_passed"] for item in values)
359
+ and after >= Fraction(str(owned["protocol"]["min_quality"]))
360
+ and before - after <= Fraction(str(owned["protocol"]["max_quality_drop"]))
361
+ )
362
+ comparisons.append(
363
+ {
364
+ "condition": name,
365
+ "planned_pairs": len(values),
366
+ "complete_quality_examples": len(complete_examples),
367
+ "quality_complete": complete,
368
+ "quality_status_counts": dict(Counter(item["status"] for item in values)),
369
+ "observed_baseline_quality": float(before) if before is not None else None,
370
+ "observed_candidate_quality": float(after) if after is not None else None,
371
+ "observed_quality_delta": float(after - before)
372
+ if before is not None and after is not None
373
+ else None,
374
+ "quality_preserved_on_examples": preserved,
375
+ "weighting": "equal examples; all planned repetitions must have known quality",
376
+ "baseline_agreement": {
377
+ "method": "typed_canonical_json_equality",
378
+ "observed_rate": _mean(agreement),
379
+ "observed_count": len(agreement),
380
+ "missing_count": len(values) - len(agreement),
381
+ "is_independent_quality": False,
382
+ },
383
+ }
384
+ )
385
+ return {
386
+ "schema_version": SUBSTITUTION_VERSION,
387
+ "name": owned["protocol"]["name"],
388
+ "plan_hash": owned["plan_hash"],
389
+ "source_bundle_hash": fingerprint(owned),
390
+ "baseline": owned["baseline"],
391
+ "synthetic": owned["protocol"]["synthetic"],
392
+ "environment": owned["environment"],
393
+ "example_count": len(examples),
394
+ "unique_input_count": len({item["input_hash"] for item in examples.values()}),
395
+ "sample_status": "exploratory",
396
+ "interpretation": "Exploratory offline comparison on frozen examples; baseline agreement is not independent quality and no candidate is activated. Small samples and shared baseline observations cannot establish general model capability.",
397
+ "measurement_scope": "declared_decision_step",
398
+ "implementations": results,
399
+ "comparisons": comparisons,
400
+ "pairs": pairs,
401
+ "unmatched_records": unmatched,
402
+ "unmatched_pairs": unmatched_pairs,
403
+ "trial_statuses": [
404
+ {key: row[key] for key in ("condition", "example_id", "repetition", "status")}
405
+ for row in slots.values()
406
+ ],
407
+ }
@@ -0,0 +1,284 @@
1
+ """Frozen declarations for explicit offline decision implementation experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ from dataclasses import asdict, dataclass, field
8
+ from functools import cached_property
9
+ from typing import Callable
10
+
11
+ from agentloop.judgment_types import (
12
+ JudgeIdentity,
13
+ JudgeUsage,
14
+ canonical,
15
+ fingerprint,
16
+ finite,
17
+ is_hash,
18
+ text,
19
+ )
20
+ from agentloop.quality import validate_quality_fixtures
21
+
22
+ SUBSTITUTION_VERSION = "1.0"
23
+ TRIAL_KEY = "agentloop.substitution_trial"
24
+ _MISSING = object()
25
+ DecisionIdentity = JudgeIdentity
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class DecisionStep:
30
+ step_id: str
31
+ version: str
32
+ source_run_id: str
33
+ source_span_id: str
34
+ source_trace_hash: str
35
+ synthetic: bool = False
36
+
37
+ def __post_init__(self):
38
+ for name in ("step_id", "version", "source_run_id", "source_span_id"):
39
+ text(getattr(self, name), name)
40
+ if not is_hash(self.source_trace_hash) or type(self.synthetic) is not bool:
41
+ raise ValueError("step requires a source fingerprint and boolean synthetic marker")
42
+
43
+ @classmethod
44
+ def from_trace(cls, trace, span_id, *, step_id, version):
45
+ from agentloop.tracer import AgentTrace
46
+
47
+ owned = AgentTrace.from_dict(trace.to_dict())
48
+ if span_id not in {item.event_id for item in owned.events}:
49
+ raise ValueError("source span must exist in the source trace")
50
+ return cls(
51
+ step_id,
52
+ version,
53
+ owned.run_id,
54
+ span_id,
55
+ fingerprint(owned.to_dict()),
56
+ owned.metadata.get("synthetic") is True,
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True, init=False)
61
+ class SubstitutionExample:
62
+ example_id: str
63
+ input_ref: str
64
+ quality_ref: str
65
+ _input_json: str = field(repr=False)
66
+ _expected_json: str | None = field(repr=False)
67
+
68
+ def __init__(self, example_id, inputs, *, input_ref, quality_ref, expected=_MISSING):
69
+ for name, value in (
70
+ ("example_id", example_id),
71
+ ("input_ref", input_ref),
72
+ ("quality_ref", quality_ref),
73
+ ):
74
+ text(value, name)
75
+ object.__setattr__(self, name, value)
76
+ object.__setattr__(self, "_input_json", canonical(inputs))
77
+ object.__setattr__(
78
+ self, "_expected_json", None if expected is _MISSING else canonical(expected)
79
+ )
80
+
81
+ @property
82
+ def inputs(self):
83
+ return json.loads(self._input_json)
84
+
85
+ def fixture(self, scorer):
86
+ return {
87
+ "schema_version": "2.0",
88
+ "id": self.example_id,
89
+ "input_ref": self.input_ref,
90
+ "scorer": scorer,
91
+ "expected_ref": self.quality_ref,
92
+ **(
93
+ {"expected": json.loads(self._expected_json)}
94
+ if self._expected_json is not None
95
+ else {}
96
+ ),
97
+ }
98
+
99
+ def declaration(self):
100
+ return {
101
+ "example_id": self.example_id,
102
+ "input_ref": self.input_ref,
103
+ "input_hash": fingerprint(self.inputs),
104
+ "quality_ref": self.quality_ref,
105
+ "expected_hash": fingerprint(json.loads(self._expected_json))
106
+ if self._expected_json is not None
107
+ else None,
108
+ }
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class DecisionRequest:
113
+ example_id: str
114
+ input_ref: str
115
+ step: DecisionStep
116
+ _input_json: str = field(repr=False)
117
+
118
+ @cached_property
119
+ def inputs(self):
120
+ """Own one decoded input per invocation, isolated from every other trial."""
121
+ return json.loads(self._input_json)
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class DecisionImplementation:
126
+ name: str
127
+ identity: JudgeIdentity
128
+ invoke: Callable = field(repr=False)
129
+ kind: str = "model"
130
+
131
+ def __post_init__(self):
132
+ text(self.name, "implementation name")
133
+ if type(self.identity) is not JudgeIdentity or self.kind not in {
134
+ "model",
135
+ "classifier",
136
+ "rule",
137
+ "tool",
138
+ "external_service",
139
+ }:
140
+ raise ValueError("implementation requires a versioned identity and supported kind")
141
+ if (
142
+ not callable(self.invoke)
143
+ or inspect.iscoroutinefunction(self.invoke)
144
+ or inspect.isasyncgenfunction(self.invoke)
145
+ ):
146
+ raise ValueError("implementation must be a trusted synchronous callable")
147
+
148
+ def declaration(self):
149
+ return {"name": self.name, "identity": asdict(self.identity), "kind": self.kind}
150
+
151
+
152
+ @dataclass(frozen=True, init=False)
153
+ class DecisionResult:
154
+ status: str
155
+ usage: JudgeUsage
156
+ cost_ref: str | None
157
+ _output_json: str | None = field(repr=False)
158
+
159
+ def __init__(self, output=_MISSING, *, status="completed", usage=None, cost_ref=None):
160
+ if status not in {"completed", "unknown"}:
161
+ raise ValueError("result status must be completed or unknown")
162
+ if (status == "completed") != (output is not _MISSING):
163
+ raise ValueError(
164
+ "completed results require an output; unknown results cannot carry one"
165
+ )
166
+ usage = JudgeUsage() if usage is None else usage
167
+ if type(usage) is not JudgeUsage:
168
+ raise ValueError("usage must be JudgeUsage")
169
+ text(cost_ref, "cost_ref", optional=True)
170
+ object.__setattr__(self, "status", status)
171
+ object.__setattr__(self, "usage", usage)
172
+ object.__setattr__(self, "cost_ref", cost_ref)
173
+ object.__setattr__(self, "_output_json", None if output is _MISSING else canonical(output))
174
+
175
+ @property
176
+ def output(self):
177
+ if self._output_json is None:
178
+ raise ValueError("result has no observed output")
179
+ return json.loads(self._output_json)
180
+
181
+
182
+ @dataclass(frozen=True, init=False)
183
+ class SubstitutionProtocol:
184
+ name: str
185
+ version: str
186
+ step: DecisionStep
187
+ examples: tuple[SubstitutionExample, ...]
188
+ repetitions: int
189
+ seed: int
190
+ timeout_s: float | None
191
+ min_quality: float
192
+ max_quality_drop: float
193
+ synthetic: bool
194
+ _scorer_json: str = field(repr=False)
195
+
196
+ def __init__(
197
+ self,
198
+ name,
199
+ version,
200
+ step,
201
+ examples,
202
+ scorer,
203
+ *,
204
+ repetitions=1,
205
+ seed=0,
206
+ timeout_s=None,
207
+ min_quality=1.0,
208
+ max_quality_drop=0.0,
209
+ synthetic=False,
210
+ ):
211
+ text(name, "name")
212
+ text(version, "version")
213
+ if (
214
+ type(step) is not DecisionStep
215
+ or not isinstance(examples, (tuple, list))
216
+ or not examples
217
+ or any(type(item) is not SubstitutionExample for item in examples)
218
+ ):
219
+ raise ValueError("protocol requires a source step and nonempty frozen examples")
220
+ if len({item.example_id for item in examples}) != len(examples):
221
+ raise ValueError("example IDs must be unique")
222
+ if (
223
+ type(repetitions) is not int
224
+ or repetitions < 1
225
+ or type(seed) is not int
226
+ or type(synthetic) is not bool
227
+ ):
228
+ raise ValueError("invalid repetition, seed or synthetic declaration")
229
+ if timeout_s is not None and (not finite(timeout_s) or timeout_s <= 0):
230
+ raise ValueError("timeout_s must be finite and positive")
231
+ for value in (min_quality, max_quality_drop):
232
+ if not finite(value) or not 0 <= value <= 1:
233
+ raise ValueError("quality thresholds must be in [0, 1]")
234
+ if not isinstance(scorer, dict):
235
+ raise ValueError("scorer must be a versioned structured quality configuration")
236
+ text(scorer.get("version"), "scorer version")
237
+ owned = json.loads(canonical(scorer))
238
+ validate_quality_fixtures([item.fixture(owned) for item in examples])
239
+ values = {
240
+ "name": name,
241
+ "version": version,
242
+ "step": step,
243
+ "examples": tuple(examples),
244
+ "repetitions": repetitions,
245
+ "seed": seed,
246
+ "timeout_s": timeout_s,
247
+ "min_quality": min_quality,
248
+ "max_quality_drop": max_quality_drop,
249
+ "synthetic": synthetic or step.synthetic,
250
+ "_scorer_json": canonical(owned),
251
+ }
252
+ for key, value in values.items():
253
+ object.__setattr__(self, key, value)
254
+
255
+ @property
256
+ def scorer(self):
257
+ return json.loads(self._scorer_json)
258
+
259
+ def declaration(self):
260
+ scorer = self.scorer
261
+ examples = []
262
+ for item in self.examples:
263
+ declared = item.declaration()
264
+ if item._expected_json is None and "expected" in scorer:
265
+ declared["expected_hash"] = fingerprint(scorer["expected"])
266
+ examples.append(declared)
267
+ return {
268
+ "schema_version": SUBSTITUTION_VERSION,
269
+ "name": self.name,
270
+ "version": self.version,
271
+ "step": asdict(self.step),
272
+ "examples": examples,
273
+ "scorer": {
274
+ "type": scorer["type"],
275
+ "version": scorer["version"],
276
+ "config_hash": fingerprint(scorer),
277
+ },
278
+ "repetitions": self.repetitions,
279
+ "seed": self.seed,
280
+ "timeout_s": self.timeout_s,
281
+ "min_quality": self.min_quality,
282
+ "max_quality_drop": self.max_quality_drop,
283
+ "synthetic": self.synthetic,
284
+ }