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,1020 @@
1
+ """Versioned, offline scoring for structured workflow outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import inspect
7
+ import json
8
+ import math
9
+ from collections import Counter
10
+ from fractions import Fraction
11
+ from hashlib import sha256
12
+ from types import SimpleNamespace
13
+
14
+ CONTRACT_VERSION = "2.0"
15
+ EVIDENCE_KEY = "agentloop.quality"
16
+ SCORER_TYPES = ("decision", "multilabel", "numeric", "fields", "matches")
17
+ LEGACY_TYPES = ("contains", "custom", "exact_match", "glob", "json_subset", "required_fields")
18
+ _COMMON = {"type", "version", "pass_score"}
19
+ _ALLOWED = {
20
+ "decision": {"labels", "field"},
21
+ "multilabel": {"labels", "field"},
22
+ "numeric": {"minimum", "maximum", "tolerance", "field", "unit"},
23
+ "fields": {"allow_extra", "field"},
24
+ "matches": {"symmetric", "field"},
25
+ "exact_match": {"expected"},
26
+ "contains": {"text"},
27
+ "glob": {"pattern"},
28
+ "json_subset": {"expected"},
29
+ "required_fields": {"required", "required_fields"},
30
+ "custom": {"callable", "configuration", "capture_detail"},
31
+ }
32
+ _EXECUTION = {
33
+ "completed",
34
+ "failed",
35
+ "cancelled",
36
+ "interrupted",
37
+ "timed_out",
38
+ "stopped",
39
+ "incomplete",
40
+ "running",
41
+ "unknown",
42
+ "unreported",
43
+ }
44
+
45
+
46
+ def _fail(message):
47
+ from agentloop.quality import QualityValidationError
48
+
49
+ raise QualityValidationError(message)
50
+
51
+
52
+ def _canonical(value):
53
+ try:
54
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
55
+ except (ValueError, TypeError, RecursionError):
56
+ _fail("structured quality values must be finite JSON")
57
+
58
+
59
+ def _hash(value):
60
+ return sha256(_canonical(value).encode()).hexdigest()
61
+
62
+
63
+ def _finite(value):
64
+ try:
65
+ return type(value) in {int, float} and math.isfinite(value)
66
+ except OverflowError:
67
+ return False
68
+
69
+
70
+ def _label(value):
71
+ if value is not None and type(value) not in {str, int, float, bool}:
72
+ _fail("decision labels and entity IDs must be JSON scalars")
73
+ return _canonical([type(value).__name__, value])
74
+
75
+
76
+ def _is_hash(value):
77
+ return (
78
+ isinstance(value, str)
79
+ and len(value) == 64
80
+ and all(char in "0123456789abcdef" for char in value)
81
+ )
82
+
83
+
84
+ def uses_contract(fixtures):
85
+ return isinstance(fixtures, list) and any(
86
+ isinstance(item, dict) and item.get("schema_version") == CONTRACT_VERSION
87
+ for item in fixtures
88
+ )
89
+
90
+
91
+ def validate_fixtures(fixtures):
92
+ from agentloop.quality import validate_quality_fixtures
93
+
94
+ if not isinstance(fixtures, list) or not fixtures:
95
+ _fail("structured quality requires a nonempty fixture list")
96
+ ids = set()
97
+ for fixture in fixtures:
98
+ if not isinstance(fixture, dict) or fixture.get("schema_version") != CONTRACT_VERSION:
99
+ _fail("every structured fixture must declare schema_version 2.0")
100
+ allowed = {
101
+ "schema_version",
102
+ "id",
103
+ "expected",
104
+ "scorer",
105
+ "baseline_output",
106
+ "candidate_output",
107
+ "output",
108
+ "input_ref",
109
+ "expected_ref",
110
+ "baseline_output_ref",
111
+ "candidate_output_ref",
112
+ "baseline_status",
113
+ "candidate_status",
114
+ }
115
+ if set(fixture) - allowed:
116
+ _fail("unknown structured fixture field")
117
+ identity = fixture.get("id")
118
+ if not isinstance(identity, str) or not identity.strip() or identity in ids:
119
+ _fail("structured fixture IDs must be nonempty and unique")
120
+ ids.add(identity)
121
+ scorer = fixture.get("scorer")
122
+ if (
123
+ not isinstance(scorer, dict)
124
+ or not isinstance(scorer.get("type"), str)
125
+ or scorer["type"] not in _ALLOWED
126
+ ):
127
+ _fail("unsupported structured scorer type")
128
+ kind = scorer["type"]
129
+ if set(scorer) - (_COMMON | _ALLOWED[kind]):
130
+ _fail(f"unknown {kind} scorer configuration field")
131
+ version = scorer.get("version", "1.0" if kind != "custom" else None)
132
+ if (
133
+ not isinstance(version, str)
134
+ or not version.strip()
135
+ or (kind != "custom" and version != "1.0")
136
+ ):
137
+ _fail("built-in scorer version must be 1.0; custom scorers require an explicit version")
138
+ if "pass_score" in scorer and (
139
+ not _finite(scorer["pass_score"]) or not 0 <= scorer["pass_score"] <= 1
140
+ ):
141
+ _fail("pass_score must be in [0,1]")
142
+ if "capture_detail" in scorer and type(scorer["capture_detail"]) is not bool:
143
+ _fail("capture_detail must be a boolean")
144
+ if "field" in scorer and (not isinstance(scorer["field"], str) or not scorer["field"]):
145
+ _fail("field must be a nonempty top-level object key")
146
+ for key in ("input_ref", "expected_ref", "baseline_output_ref", "candidate_output_ref"):
147
+ if key in fixture and (not isinstance(fixture[key], str) or not fixture[key]):
148
+ _fail(f"{key} must be a nonempty reference")
149
+ for side in ("baseline", "candidate"):
150
+ if f"{side}_status" in fixture and (
151
+ not isinstance(fixture[f"{side}_status"], str)
152
+ or fixture[f"{side}_status"] not in _EXECUTION
153
+ ):
154
+ _fail("unsupported execution status")
155
+ if kind in {"decision", "multilabel"}:
156
+ labels = scorer.get("labels")
157
+ if (
158
+ not isinstance(labels, list)
159
+ or not labels
160
+ or len({_label(item) for item in labels}) != len(labels)
161
+ ):
162
+ _fail("labels must be a nonempty list of distinct typed JSON scalars")
163
+ elif kind == "numeric":
164
+ low, high, tolerance = (
165
+ scorer.get("minimum"),
166
+ scorer.get("maximum"),
167
+ scorer.get("tolerance", 0),
168
+ )
169
+ if (
170
+ not all(_finite(value) for value in (low, high, tolerance))
171
+ or low >= high
172
+ or tolerance < 0
173
+ ):
174
+ _fail("numeric scorers require finite minimum < maximum and nonnegative tolerance")
175
+ if "unit" in scorer and (not isinstance(scorer["unit"], str) or not scorer["unit"]):
176
+ _fail("unit must be a nonempty identifier")
177
+ elif kind in {"fields", "matches"}:
178
+ key = "allow_extra" if kind == "fields" else "symmetric"
179
+ if key in scorer and type(scorer[key]) is not bool:
180
+ _fail(f"{key} must be a boolean")
181
+ if kind in LEGACY_TYPES:
182
+ legacy = {key: value for key, value in fixture.items() if key != "schema_version"}
183
+ legacy["scorer"] = {
184
+ key: value for key, value in scorer.items() if key not in _COMMON - {"type"}
185
+ }
186
+ try:
187
+ validate_quality_fixtures([legacy])
188
+ except (TypeError, RecursionError, OverflowError):
189
+ _fail(
190
+ "legacy scorer configuration cannot be interpreted in the structured contract"
191
+ )
192
+ _canonical(fixture)
193
+ return fixtures
194
+
195
+
196
+ def _result(score, status, detail, *, counts=None, error_type=None):
197
+ return {
198
+ "score": score,
199
+ "status": status,
200
+ "detail": detail,
201
+ "counts": counts or {},
202
+ "error_type": error_type,
203
+ }
204
+
205
+
206
+ def _set_scores(expected, actual, *, label_domain=None):
207
+ if not isinstance(expected, list):
208
+ return _result(None, "invalid_expected", "expected a set-like JSON list")
209
+ if not isinstance(actual, list):
210
+ return _result(0.0, "invalid_output", "output must be a JSON list")
211
+ try:
212
+ left = [_label(value) for value in expected]
213
+ except ValueError:
214
+ return _result(None, "invalid_expected", "reference set members must be scalar labels")
215
+ try:
216
+ right = [_label(value) for value in actual]
217
+ except ValueError:
218
+ return _result(0.0, "invalid_output", "set members must be scalar labels")
219
+ if len(set(left)) != len(left):
220
+ return _result(None, "invalid_expected", "duplicate expected labels")
221
+ if len(set(right)) != len(right):
222
+ return _result(0.0, "invalid_output", "duplicate output labels")
223
+ if label_domain is not None and not set(left) <= label_domain:
224
+ return _result(None, "unknown_expected", "expected label is outside the declared domain")
225
+ unknown = len(set(right) - label_domain) if label_domain is not None else 0
226
+ tp, fp, fn = (
227
+ len(set(left) & set(right)),
228
+ len(set(right) - set(left)),
229
+ len(set(left) - set(right)),
230
+ )
231
+ denominator = 2 * tp + fp + fn
232
+ score = 2 * tp / denominator if denominator else 1.0
233
+ return _result(
234
+ score,
235
+ "unknown_label" if unknown else "evaluated",
236
+ "set F1",
237
+ counts={
238
+ "true_positive": tp,
239
+ "false_positive": fp,
240
+ "false_negative": fn,
241
+ "unknown_labels": unknown,
242
+ },
243
+ )
244
+
245
+
246
+ def _pairs(value, symmetric):
247
+ if not isinstance(value, list):
248
+ return None
249
+ pairs = []
250
+ for item in value:
251
+ if not isinstance(item, list) or len(item) != 2:
252
+ return None
253
+ pair = [_label(part) for part in item]
254
+ pairs.append(_canonical(sorted(pair) if symmetric else pair))
255
+ return pairs
256
+
257
+
258
+ def _custom(output, fixture, scorer):
259
+ module, name = scorer["callable"].split(":", 1)
260
+ try:
261
+ function = getattr(importlib.import_module(module), name)
262
+ if inspect.iscoroutinefunction(function) or inspect.isasyncgenfunction(function):
263
+ return _result(None, "scorer_error", "custom scorer must be synchronous")
264
+ raw = function(
265
+ json.loads(_canonical(output)),
266
+ json.loads(_canonical(fixture)),
267
+ json.loads(_canonical(scorer)),
268
+ )
269
+ if inspect.iscoroutine(raw):
270
+ raw.close()
271
+ return _result(None, "scorer_error", "custom scorer returned a coroutine")
272
+ if isinstance(raw, bool):
273
+ return {
274
+ **_result(float(raw), "evaluated", "custom scorer result"),
275
+ "custom_passed": raw,
276
+ }
277
+ if not isinstance(raw, dict) or type(raw.get("passed")) is not bool:
278
+ return _result(None, "scorer_error", "custom scorer returned an invalid result")
279
+ score = raw.get("score", float(raw["passed"]))
280
+ if not _finite(score) or not 0 <= score <= 1:
281
+ return _result(None, "scorer_error", "custom scorer returned an invalid score")
282
+ result = _result(
283
+ float(score),
284
+ "evaluated",
285
+ str(raw.get("detail", ""))[:1024]
286
+ if scorer.get("capture_detail")
287
+ else "custom scorer result",
288
+ )
289
+ result["custom_passed"] = raw["passed"]
290
+ return result
291
+ except TimeoutError as exc:
292
+ return _result(
293
+ None, "scorer_timeout", "custom scorer timed out", error_type=type(exc).__name__
294
+ )
295
+ except Exception as exc:
296
+ return _result(None, "scorer_error", "custom scorer failed", error_type=type(exc).__name__)
297
+
298
+
299
+ def score_output(output, fixture, scorer):
300
+ from agentloop.quality import (
301
+ _MISSING,
302
+ _exactly_equal,
303
+ _json_value,
304
+ )
305
+ from agentloop.quality import (
306
+ score_output as legacy_score,
307
+ )
308
+
309
+ kind = scorer["type"]
310
+ expected = fixture.get("expected", scorer.get("expected", _MISSING))
311
+ if output is _MISSING:
312
+ return _result(None, "missing_output", "output is unavailable")
313
+ if "field" in scorer:
314
+ parsed = _json_value(output)
315
+ if not isinstance(parsed, dict) or scorer["field"] not in parsed:
316
+ return _result(0.0, "invalid_output", "selected output field is absent")
317
+ output = parsed[scorer["field"]]
318
+ if kind in {"decision", "multilabel", "numeric", "fields", "matches"} and expected is _MISSING:
319
+ return _result(None, "missing_expected", "reference outcome is unavailable")
320
+ if kind == "decision":
321
+ domain = {_label(value) for value in scorer["labels"]}
322
+ try:
323
+ truth = _label(expected)
324
+ except ValueError:
325
+ return _result(None, "invalid_expected", "reference decision must be a scalar label")
326
+ try:
327
+ prediction = _label(output)
328
+ except ValueError:
329
+ return _result(0.0, "invalid_output", "decision must be a scalar label")
330
+ if truth not in domain:
331
+ return _result(
332
+ None, "unknown_expected", "reference label is outside the declared domain"
333
+ )
334
+ return _result(
335
+ float(truth == prediction),
336
+ "evaluated" if prediction in domain else "unknown_label",
337
+ "exact decision match",
338
+ counts={
339
+ "correct": int(truth == prediction),
340
+ "unknown_labels": int(prediction not in domain),
341
+ },
342
+ )
343
+ if kind == "multilabel":
344
+ return _set_scores(
345
+ expected,
346
+ _json_value(output),
347
+ label_domain={_label(value) for value in scorer["labels"]},
348
+ )
349
+ if kind == "numeric":
350
+ low, high = scorer["minimum"], scorer["maximum"]
351
+ if not _finite(expected) or not low <= expected <= high:
352
+ return _result(
353
+ None, "invalid_expected", "reference number is outside configured bounds"
354
+ )
355
+ if not _finite(output) or not low <= output <= high:
356
+ return _result(0.0, "invalid_output", "output number is outside configured bounds")
357
+ error = abs(Fraction(str(output)) - Fraction(str(expected)))
358
+ try:
359
+ numeric_error = float(error)
360
+ except OverflowError:
361
+ numeric_error = None
362
+ return _result(
363
+ float(error <= Fraction(str(scorer.get("tolerance", 0)))),
364
+ "evaluated",
365
+ "bounded numeric tolerance",
366
+ counts={
367
+ "absolute_error": numeric_error,
368
+ "absolute_error_status": "available"
369
+ if numeric_error is not None
370
+ else "numeric_range",
371
+ },
372
+ )
373
+ if kind == "fields":
374
+ actual, truth = _json_value(output), _json_value(expected)
375
+ if not isinstance(truth, dict) or not truth:
376
+ return _result(
377
+ None, "invalid_expected", "reference extraction must be a nonempty object"
378
+ )
379
+ if not isinstance(actual, dict):
380
+ return _result(0.0, "invalid_output", "extraction must be an object")
381
+ matched = sum(
382
+ key in actual and _exactly_equal(actual[key], value) for key, value in truth.items()
383
+ )
384
+ extra, missing = len(set(actual) - set(truth)), len(set(truth) - set(actual))
385
+ denominator = len(truth) + (0 if scorer.get("allow_extra", True) else extra)
386
+ return _result(
387
+ matched / denominator,
388
+ "evaluated",
389
+ "extraction field agreement",
390
+ counts={
391
+ "expected_fields": len(truth),
392
+ "matched_fields": matched,
393
+ "missing_fields": missing,
394
+ "extra_fields": extra,
395
+ },
396
+ )
397
+ if kind == "matches":
398
+ try:
399
+ truth = _pairs(_json_value(expected), scorer.get("symmetric", False))
400
+ except ValueError:
401
+ return _result(None, "invalid_expected", "reference pair identifiers must be scalars")
402
+ try:
403
+ actual = _pairs(_json_value(output), scorer.get("symmetric", False))
404
+ except ValueError:
405
+ return _result(0.0, "invalid_output", "pair identifiers must be scalars")
406
+ if truth is None:
407
+ return _result(None, "invalid_expected", "reference matches must be pairs")
408
+ if actual is None:
409
+ return _result(0.0, "invalid_output", "output matches must be pairs")
410
+ return _set_scores(truth, actual)
411
+ if kind == "custom":
412
+ return _custom(output, fixture, scorer)
413
+ legacy_fixture = {key: value for key, value in fixture.items() if key != "schema_version"}
414
+ value = legacy_score(output, legacy_fixture, scorer)
415
+ return _result(
416
+ value["score"], "evaluated", f"{kind} {'passed' if value['passed'] else 'failed'}"
417
+ )
418
+
419
+
420
+ def _output_for_side(fixture, side, trace):
421
+ from agentloop.quality import _MISSING
422
+
423
+ key = f"{side}_output"
424
+ if key in fixture:
425
+ return fixture[key]
426
+ if side == "candidate" and "output" in fixture:
427
+ return fixture["output"]
428
+ metadata = getattr(trace, "metadata", {}) if trace is not None else {}
429
+ return metadata["output"] if "output" in metadata else _MISSING
430
+
431
+
432
+ def _execution_status(fixture, side, trace):
433
+ from agentloop.workflow_types import workflow_summary
434
+
435
+ if trace is not None and getattr(trace, "metadata", {}).get("success") is False:
436
+ return "failed"
437
+ if f"{side}_status" in fixture:
438
+ return fixture[f"{side}_status"]
439
+ if trace is None:
440
+ return "unreported"
441
+ workflow = workflow_summary(getattr(trace, "metadata", {}))
442
+ if workflow is not None:
443
+ return workflow.get("status") or "unknown"
444
+ return "failed" if any(event.status == "error" for event in trace.events) else "unreported"
445
+
446
+
447
+ def _side_result(output, fixture, scorer, side, trace):
448
+ from agentloop.quality import _MISSING
449
+
450
+ present = output is not _MISSING
451
+ output_hash = None
452
+ if present:
453
+ try:
454
+ encoded = _canonical(output)
455
+ output_hash = sha256(encoded.encode()).hexdigest()
456
+ output = json.loads(encoded)
457
+ except ValueError:
458
+ result = _result(0.0, "invalid_output", "output must be portable finite JSON")
459
+ else:
460
+ try:
461
+ result = score_output(output, fixture, scorer)
462
+ except (ValueError, TypeError, RecursionError, OverflowError) as exc:
463
+ result = _result(
464
+ None,
465
+ "scorer_error",
466
+ "scorer could not assess the supplied output",
467
+ error_type=type(exc).__name__,
468
+ )
469
+ else:
470
+ result = score_output(output, fixture, scorer)
471
+ status = _execution_status(fixture, side, trace)
472
+ scorer_passed = (
473
+ result["score"] is not None
474
+ and result["status"] == "evaluated"
475
+ and result["score"] >= scorer.get("pass_score", 1.0)
476
+ and result.get("custom_passed", True)
477
+ )
478
+ result.update(
479
+ scorer_passed=bool(scorer_passed),
480
+ passed=bool(scorer_passed and status in {"completed", "unreported"}),
481
+ execution_status=status,
482
+ output_present=present,
483
+ output_hash=output_hash,
484
+ output_ref=fixture.get(f"{side}_output_ref"),
485
+ )
486
+ return result
487
+
488
+
489
+ def _summary(results):
490
+ known = [value["score"] for value in results if value["score"] is not None]
491
+ failed_execution = sum(
492
+ value["execution_status"] in {"failed", "cancelled", "interrupted", "timed_out", "stopped"}
493
+ for value in results
494
+ )
495
+ unknown_execution = sum(
496
+ value["execution_status"] in {"running", "unknown", "incomplete"} for value in results
497
+ )
498
+ complete = len(known) == len(results) and not failed_execution and not unknown_execution
499
+ observed_mean = (
500
+ float(sum(Fraction(str(value)) for value in known) / len(known)) if known else None
501
+ )
502
+ return {
503
+ "case_count": len(results),
504
+ "assessed_count": len(known),
505
+ "unavailable_count": len(results) - len(known),
506
+ "execution_failed_count": failed_execution,
507
+ "execution_unknown_count": unknown_execution,
508
+ "score": observed_mean if complete else None,
509
+ "observed_score_mean": observed_mean,
510
+ "complete": complete,
511
+ "passed": all(value["passed"] for value in results),
512
+ "status_counts": dict(sorted(Counter(value["status"] for value in results).items())),
513
+ }
514
+
515
+
516
+ def _minimum(summary, minimum, results):
517
+ summary["minimum_score"] = minimum
518
+ summary["score_gate_passed"] = (
519
+ True
520
+ if minimum is None
521
+ else None
522
+ if summary["score"] is None
523
+ else sum(Fraction(str(result["score"])) for result in results) / len(results)
524
+ >= Fraction(str(minimum))
525
+ )
526
+ summary["passed"] = summary["passed"] and summary["score_gate_passed"] is True
527
+ return summary
528
+
529
+
530
+ def _confusion(fixtures, cases, side, trace):
531
+ from agentloop.quality import _MISSING, _json_value
532
+
533
+ groups = {}
534
+ for fixture, case in zip(fixtures, cases):
535
+ scorer = fixture["scorer"]
536
+ if scorer["type"] != "decision":
537
+ continue
538
+ identity = case["scorer"]["configuration_hash"]
539
+ labels = scorer["labels"]
540
+ if identity not in groups:
541
+ groups[identity] = {
542
+ "labels": labels,
543
+ "_indices": {_label(value): index for index, value in enumerate(labels)},
544
+ "_matrix": Counter(),
545
+ "matrix_format": "sparse_row_column_count",
546
+ "invalid_prediction_column": len(labels),
547
+ "case_count": 0,
548
+ "paired_output_count": 0,
549
+ "missing_output_count": 0,
550
+ "unlabelled_count": 0,
551
+ "invalid_prediction_count": 0,
552
+ "unassessed_output_count": 0,
553
+ }
554
+ group = groups[identity]
555
+ indices = group["_indices"]
556
+ group["case_count"] += 1
557
+ output = _output_for_side(fixture, side, trace)
558
+ if output is _MISSING:
559
+ group["missing_output_count"] += 1
560
+ expected = fixture.get("expected", _MISSING)
561
+ try:
562
+ row = indices.get(_label(expected)) if expected is not _MISSING else None
563
+ except ValueError:
564
+ row = None
565
+ if row is None:
566
+ group["unlabelled_count"] += 1
567
+ continue
568
+ if output is _MISSING:
569
+ continue
570
+ if case[side]["status"] in {"scorer_error", "scorer_timeout"}:
571
+ group["unassessed_output_count"] += 1
572
+ continue
573
+ if "field" in scorer:
574
+ value = _json_value(output)
575
+ output = value.get(scorer["field"], _MISSING) if isinstance(value, dict) else _MISSING
576
+ try:
577
+ column = (
578
+ indices.get(_label(output), len(labels)) if output is not _MISSING else len(labels)
579
+ )
580
+ except ValueError:
581
+ column = len(labels)
582
+ group["_matrix"][(row, column)] += 1
583
+ group["paired_output_count"] += 1
584
+ group["invalid_prediction_count"] += column == len(labels)
585
+ for group in groups.values():
586
+ matrix = group.pop("_matrix")
587
+ group.pop("_indices")
588
+ group["matrix"] = [[row, column, count] for (row, column), count in sorted(matrix.items())]
589
+ row_totals, column_totals = Counter(), Counter()
590
+ for (row, column), count in matrix.items():
591
+ row_totals[row] += count
592
+ column_totals[column] += count
593
+ correct = sum(count for (row, column), count in matrix.items() if row == column)
594
+ group["accuracy_on_available_outputs"] = (
595
+ correct / group["paired_output_count"] if group["paired_output_count"] else None
596
+ )
597
+ group["coverage"] = (
598
+ group["paired_output_count"] / group["case_count"] if group["case_count"] else None
599
+ )
600
+ per_label = []
601
+ for index, label in enumerate(group["labels"]):
602
+ tp = matrix[(index, index)]
603
+ fp = column_totals[index] - tp
604
+ fn = row_totals[index] - tp
605
+ per_label.append(
606
+ {
607
+ "label": label,
608
+ "true_positive": tp,
609
+ "false_positive": fp,
610
+ "false_negative": fn,
611
+ "precision": tp / (tp + fp) if tp + fp else None,
612
+ "recall": tp / (tp + fn) if tp + fn else None,
613
+ "f1": 2 * tp / (2 * tp + fp + fn) if 2 * tp + fp + fn else None,
614
+ }
615
+ )
616
+ group["per_label"] = per_label
617
+ return groups
618
+
619
+
620
+ def build_report(fixtures, *, baseline_trace=None, candidate_trace=None, min_score=None):
621
+ from agentloop.quality import _MISSING, _bounded_score
622
+
623
+ validate_fixtures(fixtures)
624
+ if min_score is not None:
625
+ _bounded_score(min_score, "min_score")
626
+ fixtures = json.loads(_canonical(fixtures))
627
+ cases = []
628
+ for fixture in fixtures:
629
+ scorer = fixture["scorer"]
630
+ version = scorer.get("version", "1.0")
631
+ configuration = {**scorer, "version": version}
632
+ expected = fixture.get("expected", scorer.get("expected", _MISSING))
633
+ cases.append(
634
+ {
635
+ "case_id": fixture["id"],
636
+ "input_ref": fixture.get("input_ref"),
637
+ "expected_ref": fixture.get("expected_ref"),
638
+ "expected_present": expected is not _MISSING,
639
+ "expected_hash": _hash(expected) if expected is not _MISSING else None,
640
+ "scorer": {
641
+ "type": scorer["type"],
642
+ "version": version,
643
+ "configuration_hash": _hash(configuration),
644
+ "pass_score": scorer.get("pass_score", 1.0),
645
+ "implementation": scorer.get("callable")
646
+ if scorer["type"] == "custom"
647
+ else f"agentloop.builtin.{scorer['type']}",
648
+ },
649
+ **{
650
+ side: _side_result(
651
+ _output_for_side(fixture, side, trace), fixture, scorer, side, trace
652
+ )
653
+ for side, trace in (
654
+ ("baseline", baseline_trace),
655
+ ("candidate", candidate_trace),
656
+ )
657
+ },
658
+ }
659
+ )
660
+ for case in cases:
661
+ case["passed"] = case["candidate"]["passed"]
662
+ summaries = {
663
+ side: _minimum(
664
+ _summary([case[side] for case in cases]), min_score, [case[side] for case in cases]
665
+ )
666
+ for side in ("baseline", "candidate")
667
+ }
668
+ left, right = summaries["baseline"]["score"], summaries["candidate"]["score"]
669
+ failed = [case for case in cases if not case["passed"]]
670
+ report = {
671
+ "schema_version": CONTRACT_VERSION,
672
+ "case_count": len(cases),
673
+ "cases": cases,
674
+ "passed": summaries["candidate"]["passed"],
675
+ "baseline_score": left,
676
+ "candidate_score": right,
677
+ "quality_delta": None
678
+ if left is None or right is None
679
+ else float(Fraction(str(right)) - Fraction(str(left))),
680
+ "min_score": min_score,
681
+ "failed_case_count": len(failed),
682
+ "failed_cases": failed,
683
+ "summaries": summaries,
684
+ "indeterminate_case_count": sum(
685
+ case["candidate"]["score"] is None
686
+ or case["candidate"]["execution_status"] in {"running", "unknown", "incomplete"}
687
+ for case in cases
688
+ ),
689
+ "trace_ids": {
690
+ "baseline": getattr(baseline_trace, "run_id", None),
691
+ "candidate": getattr(candidate_trace, "run_id", None),
692
+ },
693
+ "classification": {
694
+ side: _confusion(fixtures, cases, side, trace)
695
+ for side, trace in (("baseline", baseline_trace), ("candidate", candidate_trace))
696
+ },
697
+ "interpretation": "Versioned caller-defined criteria. Unavailable evaluations and unfinished executions cannot pass by disappearing from the denominator. Observed score means and confusion metrics do not prove task success or general correctness; classification metrics condition on available labelled outputs and disclose coverage. No raw inputs/outputs are retained by default.",
698
+ }
699
+ report["evidence_hash"] = _hash(report)
700
+ return report
701
+
702
+
703
+ def score_result(output, fixture, scorer):
704
+ owned = {**fixture, "scorer": scorer}
705
+ validate_fixtures([owned])
706
+ return _side_result(output, owned, scorer, "candidate", None)
707
+
708
+
709
+ def decision_span_count(trace):
710
+ from agentloop.operations import operation_kind
711
+ from agentloop.workflow_types import stage_summary
712
+
713
+ return sum(
714
+ operation_kind(event) in {"classifier", "rule"}
715
+ or (stage_summary(event) or {}).get("kind") in {"classifier", "rule"}
716
+ for event in trace.events
717
+ )
718
+
719
+
720
+ def report_to_markdown(report):
721
+ from agentloop.markdown import markdown_table_cell
722
+
723
+ def display(value):
724
+ return "unavailable" if value is None else str(value)
725
+
726
+ lines = [
727
+ "# Structured quality report",
728
+ "",
729
+ report["interpretation"],
730
+ "",
731
+ f"Contract: {report['schema_version']}; cases: {report['case_count']}; passed: {report['passed']}; indeterminate cases: {report['indeterminate_case_count']}.",
732
+ "",
733
+ f"Baseline score: {display(report['baseline_score'])}; candidate score: {display(report['candidate_score'])}; delta: {display(report['quality_delta'])}.",
734
+ "",
735
+ "| Case | Scorer / version | Baseline score / state | Candidate score / state | Candidate execution | Passed |",
736
+ "| --- | --- | --- | --- | --- | --- |",
737
+ ]
738
+ for case in report["cases"]:
739
+ cells = [
740
+ case["case_id"],
741
+ f"{case['scorer']['type']} / {case['scorer']['version']}",
742
+ f"{display(case['baseline']['score'])} / {case['baseline']['status']}",
743
+ f"{display(case['candidate']['score'])} / {case['candidate']['status']}",
744
+ case["candidate"]["execution_status"],
745
+ str(case["passed"]),
746
+ ]
747
+ lines.append("| " + " | ".join(markdown_table_cell(value) for value in cells) + " |")
748
+ for side in ("baseline", "candidate"):
749
+ summary = report["summaries"][side]
750
+ lines.extend(
751
+ [
752
+ "",
753
+ f"## {side.title()} denominators",
754
+ "",
755
+ f"Assessed: {summary['assessed_count']}/{summary['case_count']}; unavailable: {summary['unavailable_count']}; failed executions: {summary['execution_failed_count']}; unknown execution: {summary['execution_unknown_count']}.",
756
+ ]
757
+ )
758
+ for identity, group in report["classification"][side].items():
759
+ lines.extend(
760
+ [
761
+ "",
762
+ f"Decision scorer {identity}: available labelled outputs {group['paired_output_count']}/{group['case_count']}; missing outputs {group['missing_output_count']}; unlabelled {group['unlabelled_count']}; invalid predictions {group['invalid_prediction_count']}.",
763
+ f"Accuracy on available outputs: {display(group['accuracy_on_available_outputs'])}; coverage: {display(group['coverage'])}.",
764
+ "",
765
+ "```json",
766
+ json.dumps(
767
+ {
768
+ "labels": group["labels"],
769
+ "matrix_format": group["matrix_format"],
770
+ "invalid_prediction_column": group["invalid_prediction_column"],
771
+ "matrix": group["matrix"],
772
+ "per_label": group["per_label"],
773
+ },
774
+ sort_keys=True,
775
+ indent=2,
776
+ ),
777
+ "```",
778
+ ]
779
+ )
780
+ lines.extend(
781
+ [
782
+ "",
783
+ "Scorer versions, configuration hashes and expected/output hashes are preserved in JSON. Stored labels are caller-declared class names, not raw message bodies.",
784
+ "",
785
+ ]
786
+ )
787
+ return "\n".join(lines)
788
+
789
+
790
+ def attach_quality_report(trace, report, *, side="candidate"):
791
+ if side not in {"baseline", "candidate"} or report.get("schema_version") != CONTRACT_VERSION:
792
+ _fail("attach requires a versioned quality report and baseline/candidate side")
793
+ copied = validate_report(report)
794
+ digest = copied["evidence_hash"]
795
+ identity = copied["trace_ids"][side]
796
+ if identity is not None and identity != trace.run_id:
797
+ _fail("quality report side belongs to a different trace")
798
+ envelope = _side_envelope(trace.run_id, side, copied, digest)
799
+ trace.metadata[EVIDENCE_KEY] = envelope
800
+
801
+
802
+ def _side_envelope(identity, side, copied, digest):
803
+ envelope = {
804
+ "schema_version": CONTRACT_VERSION,
805
+ "report_hash": digest,
806
+ "trace_id": identity,
807
+ "side": side,
808
+ "summary": copied["summaries"][side],
809
+ "cases": [
810
+ {
811
+ "case_id": case["case_id"],
812
+ "scorer": case["scorer"],
813
+ "expected_hash": case["expected_hash"],
814
+ "result": case[side],
815
+ }
816
+ for case in copied["cases"]
817
+ ],
818
+ }
819
+ envelope["evidence_hash"] = _hash(envelope)
820
+ return envelope
821
+
822
+
823
+ def validate_report(report, *, baseline_trace=None, candidate_trace=None):
824
+ try:
825
+ copied = json.loads(_canonical(report))
826
+ digest = copied.pop("evidence_hash")
827
+ if (
828
+ copied.get("schema_version") != CONTRACT_VERSION
829
+ or not _is_hash(digest)
830
+ or digest != _hash(copied)
831
+ ):
832
+ raise ValueError
833
+ cases = copied["cases"]
834
+ if (
835
+ not isinstance(cases, list)
836
+ or not cases
837
+ or type(copied["case_count"]) is not int
838
+ or copied["case_count"] != len(cases)
839
+ ):
840
+ raise ValueError
841
+ for side, trace in (("baseline", baseline_trace), ("candidate", candidate_trace)):
842
+ identity = copied["trace_ids"][side]
843
+ if identity is not None and (
844
+ not isinstance(identity, str)
845
+ or not identity
846
+ or trace is not None
847
+ and identity != trace.run_id
848
+ ):
849
+ raise ValueError
850
+ identity = identity or "unbound-quality-side"
851
+ envelope = _side_envelope(identity, side, copied, digest)
852
+ result = read_quality_evidence(
853
+ SimpleNamespace(run_id=identity, metadata={EVIDENCE_KEY: envelope})
854
+ )
855
+ if (
856
+ result["status"] in {"invalid", "unsupported"}
857
+ or copied[f"{side}_score"] != result["score"]
858
+ or copied["summaries"][side]["minimum_score"] != copied["min_score"]
859
+ ):
860
+ raise ValueError
861
+ score = copied[f"{side}_score"]
862
+ if score is not None and not _finite(score):
863
+ raise ValueError
864
+ if any(
865
+ type(case["passed"]) is not bool or case["passed"] != case["candidate"]["passed"]
866
+ for case in cases
867
+ ):
868
+ raise ValueError
869
+ failed = [case for case in cases if not case["passed"]]
870
+ indeterminate = sum(
871
+ case["candidate"]["score"] is None
872
+ or case["candidate"]["execution_status"] in {"running", "unknown", "incomplete"}
873
+ for case in cases
874
+ )
875
+ if (
876
+ type(copied["indeterminate_case_count"]) is not int
877
+ or copied["indeterminate_case_count"] != indeterminate
878
+ ):
879
+ raise ValueError
880
+ left, right = copied["baseline_score"], copied["candidate_score"]
881
+ delta = (
882
+ None
883
+ if left is None or right is None
884
+ else float(Fraction(str(right)) - Fraction(str(left)))
885
+ )
886
+ if (
887
+ copied["failed_cases"] != failed
888
+ or type(copied["failed_case_count"]) is not int
889
+ or copied["failed_case_count"] != len(failed)
890
+ or copied["quality_delta"] != delta
891
+ or type(copied["passed"]) is not bool
892
+ or copied["passed"] != copied["summaries"]["candidate"]["passed"]
893
+ ):
894
+ raise ValueError
895
+ copied["evidence_hash"] = digest
896
+ return copied
897
+ except (ValueError, TypeError, KeyError, RecursionError):
898
+ _fail("structured quality report is malformed, changed, or bound to different traces")
899
+
900
+
901
+ def read_quality_evidence(trace):
902
+ raw = getattr(trace, "metadata", {}).get(EVIDENCE_KEY)
903
+ if raw is None:
904
+ return None
905
+ if not isinstance(raw, dict) or raw.get("schema_version") != CONTRACT_VERSION:
906
+ return {"status": "unsupported", "score": None, "passed": False}
907
+ try:
908
+ copied = json.loads(_canonical(raw))
909
+ expected = copied.pop("evidence_hash")
910
+ if expected != _hash(copied) or copied["trace_id"] != trace.run_id:
911
+ raise ValueError
912
+ summary = copied["summary"]
913
+ cases = copied["cases"]
914
+ if not isinstance(cases, list) or not cases:
915
+ raise ValueError
916
+ identities = [case["case_id"] for case in cases]
917
+ if any(not isinstance(identity, str) or not identity for identity in identities) or len(
918
+ set(identities)
919
+ ) != len(identities):
920
+ raise ValueError
921
+ unknown_statuses = {
922
+ "missing_output",
923
+ "missing_expected",
924
+ "invalid_expected",
925
+ "unknown_expected",
926
+ "scorer_error",
927
+ "scorer_timeout",
928
+ }
929
+ for case in cases:
930
+ result, scorer = case["result"], case["scorer"]
931
+ if (
932
+ scorer["type"] not in _ALLOWED
933
+ or not isinstance(scorer["version"], str)
934
+ or not scorer["version"]
935
+ or not _is_hash(scorer["configuration_hash"])
936
+ or not isinstance(scorer["implementation"], str)
937
+ or not scorer["implementation"]
938
+ ):
939
+ raise ValueError
940
+ if scorer["type"] != "custom" and (
941
+ scorer["version"] != "1.0"
942
+ or scorer["implementation"] != f"agentloop.builtin.{scorer['type']}"
943
+ ):
944
+ raise ValueError
945
+ if "custom_passed" in result and type(result["custom_passed"]) is not bool:
946
+ raise ValueError
947
+ if type(result["output_present"]) is not bool:
948
+ raise ValueError
949
+ score = result["score"]
950
+ if score is None:
951
+ if result["status"] not in unknown_statuses:
952
+ raise ValueError
953
+ elif (
954
+ not _finite(score)
955
+ or not 0 <= score <= 1
956
+ or result["status"] not in {"evaluated", "unknown_label", "invalid_output"}
957
+ ):
958
+ raise ValueError
959
+ threshold = scorer["pass_score"]
960
+ if (
961
+ not _finite(threshold)
962
+ or not 0 <= threshold <= 1
963
+ or result["execution_status"] not in _EXECUTION
964
+ ):
965
+ raise ValueError
966
+ passed = (
967
+ score is not None
968
+ and result["status"] == "evaluated"
969
+ and score >= threshold
970
+ and result.get("custom_passed", True)
971
+ )
972
+ if (
973
+ type(result["scorer_passed"]) is not bool
974
+ or result["scorer_passed"] != passed
975
+ or type(result["passed"]) is not bool
976
+ or result["passed"]
977
+ != (passed and result["execution_status"] in {"completed", "unreported"})
978
+ ):
979
+ raise ValueError
980
+ minimum = summary["minimum_score"]
981
+ for key in (
982
+ "case_count",
983
+ "assessed_count",
984
+ "unavailable_count",
985
+ "execution_failed_count",
986
+ "execution_unknown_count",
987
+ ):
988
+ if type(summary[key]) is not int or summary[key] < 0:
989
+ raise ValueError
990
+ if (
991
+ summary["score_gate_passed"] is not None
992
+ and type(summary["score_gate_passed"]) is not bool
993
+ ):
994
+ raise ValueError
995
+ if minimum is not None and (not _finite(minimum) or not 0 <= minimum <= 1):
996
+ raise ValueError
997
+ if (
998
+ _minimum(
999
+ _summary([case["result"] for case in cases]),
1000
+ minimum,
1001
+ [case["result"] for case in cases],
1002
+ )
1003
+ != summary
1004
+ ):
1005
+ raise ValueError
1006
+ score = summary["score"]
1007
+ if score is not None and (not _finite(score) or not 0 <= score <= 1):
1008
+ raise ValueError
1009
+ if type(summary["passed"]) is not bool or type(summary["complete"]) is not bool:
1010
+ raise ValueError
1011
+ return {
1012
+ "status": "complete" if summary["complete"] else "incomplete",
1013
+ "score": score,
1014
+ "passed": summary["passed"],
1015
+ "report_hash": copied["report_hash"],
1016
+ "summary": summary,
1017
+ "cases": copied["cases"],
1018
+ }
1019
+ except (ValueError, KeyError, TypeError, RecursionError):
1020
+ return {"status": "invalid", "score": None, "passed": False}