agentloop-profiler 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
agentloop/quality.py ADDED
@@ -0,0 +1,446 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import importlib
5
+ import json
6
+ import math
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from agentloop.markdown import markdown_table_cell
11
+ from agentloop.structured_quality import SCORER_TYPES as STRUCTURED_SCORER_TYPES
12
+
13
+ _MAX_GLOB_PATTERN_LENGTH = 256
14
+ _MAX_GLOB_TEXT_LENGTH = 1_000_000
15
+ _MISSING = object()
16
+ _LEGACY_SCORER_TYPES = (
17
+ "contains",
18
+ "custom",
19
+ "exact_match",
20
+ "glob",
21
+ "json_subset",
22
+ "required_fields",
23
+ )
24
+ SUPPORTED_SCORER_TYPES = (*_LEGACY_SCORER_TYPES, *STRUCTURED_SCORER_TYPES)
25
+ _SCORER_TYPES = set(_LEGACY_SCORER_TYPES)
26
+ _REMOVED_SCORER_MIGRATIONS = {
27
+ "json_schema": (
28
+ "use 'required_fields' for dependency-free object-field checks or 'json_subset' "
29
+ "for expected JSON values; full JSON Schema is not implemented"
30
+ ),
31
+ "regex": "use bounded 'glob', 'contains', or 'exact_match' instead",
32
+ }
33
+
34
+
35
+ class QualityValidationError(ValueError):
36
+ """Raised when a quality suite cannot safely act as a correctness gate."""
37
+
38
+
39
+ def parse_quality_fixtures(payload: Any) -> list[dict[str, Any]]:
40
+ if isinstance(payload, list):
41
+ fixtures = payload
42
+ elif isinstance(payload, dict):
43
+ if "fixtures" not in payload:
44
+ raise QualityValidationError("top-level object must contain a 'fixtures' list")
45
+ fixtures = payload["fixtures"]
46
+ version = payload.get("schema_version", "1.0")
47
+ if version not in ("1.0", "2.0"):
48
+ raise QualityValidationError("unsupported quality fixture schema_version")
49
+ if version == "2.0" and isinstance(fixtures, list):
50
+ if any(
51
+ not isinstance(item, dict) or item.get("schema_version", version) != version
52
+ for item in fixtures
53
+ ):
54
+ raise QualityValidationError("conflicting structured fixture versions")
55
+ fixtures = [{**item, "schema_version": version} for item in fixtures]
56
+ else:
57
+ raise QualityValidationError("quality fixtures must be a JSON list or object")
58
+ return validate_quality_fixtures(fixtures)
59
+
60
+
61
+ def load_quality_fixtures(path: str | Path) -> list[dict[str, Any]]:
62
+ try:
63
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
64
+ except json.JSONDecodeError as exc:
65
+ raise QualityValidationError(
66
+ f"fixture file is not valid JSON: {exc.msg} at line {exc.lineno}, column {exc.colno}"
67
+ ) from exc
68
+ return parse_quality_fixtures(payload)
69
+
70
+
71
+ def validate_quality_fixtures(fixtures: Any) -> list[dict[str, Any]]:
72
+ if not isinstance(fixtures, list):
73
+ raise QualityValidationError("'fixtures' must be a list")
74
+ if not fixtures:
75
+ raise QualityValidationError("quality fixture suite must contain at least one case")
76
+ if any(isinstance(item, dict) and item.get("schema_version") == "2.0" for item in fixtures):
77
+ from agentloop.structured_quality import validate_fixtures
78
+
79
+ return validate_fixtures(fixtures)
80
+
81
+ for index, fixture in enumerate(fixtures):
82
+ path = f"fixtures[{index}]"
83
+ if not isinstance(fixture, dict):
84
+ raise QualityValidationError(f"{path} must be an object")
85
+ if fixture.get("schema_version", "1.0") != "1.0":
86
+ raise QualityValidationError(f"{path} has an unsupported quality fixture version")
87
+ if "id" in fixture and (not isinstance(fixture["id"], str) or not fixture["id"].strip()):
88
+ raise QualityValidationError(f"{path}.id must be a non-empty string")
89
+
90
+ scorer = fixture.get("scorer", {"type": "exact_match"})
91
+ if not isinstance(scorer, dict):
92
+ raise QualityValidationError(f"{path}.scorer must be an object")
93
+ scorer_type = scorer.get("type", "exact_match")
94
+ if not isinstance(scorer_type, str) or not scorer_type.strip():
95
+ raise QualityValidationError(f"{path}.scorer.type must be a non-empty string")
96
+ if scorer_type != scorer_type.strip():
97
+ raise QualityValidationError(
98
+ f"{path}.scorer.type must not contain leading or trailing whitespace"
99
+ )
100
+ if scorer_type not in _SCORER_TYPES:
101
+ if scorer_type in STRUCTURED_SCORER_TYPES:
102
+ raise QualityValidationError(f"{scorer_type} requires fixture schema_version 2.0")
103
+ migration = _REMOVED_SCORER_MIGRATIONS.get(scorer_type)
104
+ if migration:
105
+ raise QualityValidationError(
106
+ f"{path}.scorer.type '{scorer_type}' is no longer supported; {migration}"
107
+ )
108
+ raise QualityValidationError(
109
+ f"{path}.scorer.type is unsupported: {scorer_type}; "
110
+ f"expected one of {', '.join(sorted(_SCORER_TYPES))}"
111
+ )
112
+
113
+ if scorer_type == "exact_match":
114
+ if not _has_configured_value(fixture, scorer, "expected", "expected"):
115
+ raise QualityValidationError(
116
+ f"{path} exact_match scorer requires an explicit 'expected' value"
117
+ )
118
+ elif scorer_type == "contains":
119
+ value = _configured_value(fixture, scorer, "expected", "text")
120
+ if not isinstance(value, str) or not value.strip():
121
+ raise QualityValidationError(
122
+ f"{path} contains scorer requires non-empty 'expected' or 'scorer.text'"
123
+ )
124
+ elif scorer_type == "glob":
125
+ value = _configured_value(fixture, scorer, "expected", "pattern")
126
+ if not isinstance(value, str) or not value:
127
+ raise QualityValidationError(
128
+ f"{path} {scorer_type} scorer requires a non-empty pattern"
129
+ )
130
+ elif scorer_type == "required_fields":
131
+ required = scorer.get("required", scorer.get("required_fields"))
132
+ if not isinstance(required, list) or not required:
133
+ raise QualityValidationError(
134
+ f"{path} {scorer_type} scorer requires a non-empty field list"
135
+ )
136
+ if any(not isinstance(field, str) or not field.strip() for field in required):
137
+ raise QualityValidationError(
138
+ f"{path} {scorer_type} scorer fields must be non-empty strings"
139
+ )
140
+ elif scorer_type == "json_subset":
141
+ expected = scorer.get("expected", fixture.get("expected"))
142
+ expected_data = _json_value(expected)
143
+ if not isinstance(expected_data, dict) or not expected_data:
144
+ raise QualityValidationError(
145
+ f"{path} json_subset scorer requires a non-empty expected object"
146
+ )
147
+ elif scorer_type == "custom":
148
+ target = scorer.get("callable")
149
+ if (
150
+ not isinstance(target, str)
151
+ or target.count(":") != 1
152
+ or any(not part.strip() for part in target.split(":"))
153
+ ):
154
+ raise QualityValidationError(
155
+ f"{path} custom scorer callable must use module:function"
156
+ )
157
+
158
+ return fixtures
159
+
160
+
161
+ def build_quality_report(
162
+ fixtures: list[dict[str, Any]],
163
+ *,
164
+ baseline_trace: Any | None = None,
165
+ candidate_trace: Any | None = None,
166
+ min_score: float | None = None,
167
+ ) -> dict[str, Any]:
168
+ validate_quality_fixtures(fixtures)
169
+ if fixtures[0].get("schema_version") == "2.0":
170
+ from agentloop.structured_quality import build_report
171
+
172
+ return build_report(
173
+ fixtures,
174
+ baseline_trace=baseline_trace,
175
+ candidate_trace=candidate_trace,
176
+ min_score=min_score,
177
+ )
178
+ if min_score is not None:
179
+ _bounded_score(min_score, "min_score")
180
+
181
+ cases = []
182
+ for index, fixture in enumerate(fixtures, start=1):
183
+ case_id = str(fixture.get("id") or f"case_{index:03d}")
184
+ baseline_output = _output_for_side(fixture, "baseline", baseline_trace)
185
+ candidate_output = _output_for_side(fixture, "candidate", candidate_trace)
186
+ scorer = fixture.get("scorer", {"type": "exact_match"})
187
+ baseline_result = score_output(baseline_output, fixture, scorer)
188
+ candidate_result = score_output(candidate_output, fixture, scorer)
189
+ cases.append(
190
+ {
191
+ "case_id": case_id,
192
+ "input": fixture.get("input"),
193
+ "scorer": scorer,
194
+ "baseline": baseline_result,
195
+ "candidate": candidate_result,
196
+ "passed": candidate_result["passed"],
197
+ }
198
+ )
199
+
200
+ baseline_score = _average(case["baseline"]["score"] for case in cases)
201
+ candidate_score = _average(case["candidate"]["score"] for case in cases)
202
+ failed_cases = [case for case in cases if not case["passed"]]
203
+ passed = not failed_cases
204
+ if min_score is not None:
205
+ passed = passed and candidate_score >= min_score
206
+
207
+ return {
208
+ "case_count": len(cases),
209
+ "passed": passed,
210
+ "baseline_score": round(baseline_score, 4),
211
+ "candidate_score": round(candidate_score, 4),
212
+ "quality_delta": round(candidate_score - baseline_score, 4),
213
+ "min_score": min_score,
214
+ "failed_case_count": len(failed_cases),
215
+ "failed_cases": failed_cases,
216
+ "cases": cases,
217
+ }
218
+
219
+
220
+ def score_output(output: Any, fixture: dict[str, Any], scorer: dict[str, Any]) -> dict[str, Any]:
221
+ if fixture.get("schema_version") == "2.0":
222
+ from agentloop.structured_quality import score_result
223
+
224
+ return score_result(output, fixture, scorer)
225
+ scorer_type = str(scorer.get("type", "exact_match"))
226
+ if output is _MISSING:
227
+ return _result(False, "output is missing")
228
+ if scorer_type == "exact_match":
229
+ expected = _configured_value(fixture, scorer, "expected", "expected")
230
+ passed = _exactly_equal(output, expected)
231
+ return _result(passed, "exact match" if passed else "output did not exactly match expected")
232
+ if scorer_type == "contains":
233
+ expected = str(_configured_value(fixture, scorer, "expected", "text"))
234
+ passed = expected in _text_output(output)
235
+ return _result(
236
+ passed, "required text present" if passed else f"missing required text: {expected}"
237
+ )
238
+ if scorer_type == "glob":
239
+ pattern = str(_configured_value(fixture, scorer, "expected", "pattern"))
240
+ text = _text_output(output)
241
+ if len(pattern) > _MAX_GLOB_PATTERN_LENGTH:
242
+ return _result(False, "glob pattern exceeds the 256-character safety limit")
243
+ if len(text) > _MAX_GLOB_TEXT_LENGTH:
244
+ return _result(False, "output exceeds the 1,000,000-character glob safety limit")
245
+ passed = fnmatch.fnmatchcase(text, pattern)
246
+ return _result(passed, "glob matched" if passed else "glob did not match")
247
+ if scorer_type == "required_fields":
248
+ return _score_required_fields(output, scorer)
249
+ if scorer_type == "json_subset":
250
+ expected = scorer["expected"] if "expected" in scorer else fixture.get("expected", {})
251
+ return _score_json_subset(output, expected)
252
+ if scorer_type == "custom":
253
+ return _score_custom(output, fixture, scorer)
254
+ return _result(False, f"unknown scorer type: {scorer_type}")
255
+
256
+
257
+ def _score_required_fields(output: Any, scorer: dict[str, Any]) -> dict[str, Any]:
258
+ required = list(scorer.get("required") or scorer.get("required_fields") or [])
259
+ data = _json_value(output)
260
+ if not isinstance(data, dict):
261
+ return _result(False, "output is not a JSON object")
262
+ missing = [field for field in required if field not in data or _is_empty(data[field])]
263
+ return _result(
264
+ not missing,
265
+ "required fields present" if not missing else f"missing fields: {', '.join(missing)}",
266
+ )
267
+
268
+
269
+ def _score_json_subset(output: Any, expected: Any) -> dict[str, Any]:
270
+ data = _json_value(output)
271
+ expected_data = _json_value(expected)
272
+ if not isinstance(data, dict) or not isinstance(expected_data, dict):
273
+ return _result(False, "output and expected must be JSON objects")
274
+ missing = [key for key in expected_data if key not in data]
275
+ mismatches = [
276
+ key
277
+ for key, value in expected_data.items()
278
+ if key in data and not _exactly_equal(data[key], value)
279
+ ]
280
+ problems = []
281
+ if missing:
282
+ problems.append(f"missing keys: {', '.join(str(key) for key in missing)}")
283
+ if mismatches:
284
+ problems.append(f"mismatched keys: {', '.join(str(key) for key in mismatches)}")
285
+ return _result(
286
+ not problems,
287
+ "JSON subset matched" if not problems else "; ".join(problems),
288
+ )
289
+
290
+
291
+ def _score_custom(output: Any, fixture: dict[str, Any], scorer: dict[str, Any]) -> dict[str, Any]:
292
+ target = str(scorer.get("callable") or "")
293
+ if ":" not in target:
294
+ return _result(False, "custom scorer callable must use module:function")
295
+ module_name, function_name = target.split(":", 1)
296
+ try:
297
+ fn = getattr(importlib.import_module(module_name), function_name)
298
+ raw = fn(output, fixture, scorer)
299
+ except Exception as exc:
300
+ raise QualityValidationError(f"custom scorer {target} failed: {exc}") from exc
301
+ if isinstance(raw, dict):
302
+ if not isinstance(raw.get("passed"), bool):
303
+ raise QualityValidationError(
304
+ f"custom scorer {target} must return a boolean 'passed' field"
305
+ )
306
+ score = _bounded_score(
307
+ raw.get("score", 1.0 if raw["passed"] else 0.0),
308
+ f"custom scorer {target} score",
309
+ )
310
+ return {
311
+ "score": score,
312
+ "passed": raw["passed"],
313
+ "detail": str(raw.get("detail", "")),
314
+ }
315
+ if isinstance(raw, bool):
316
+ return _result(raw, "custom scorer passed" if raw else "custom scorer failed")
317
+ raise QualityValidationError(f"custom scorer {target} must return a boolean or result object")
318
+
319
+
320
+ def quality_report_to_markdown(report: dict[str, Any]) -> str:
321
+ if report.get("schema_version") == "2.0":
322
+ from agentloop.structured_quality import report_to_markdown
323
+
324
+ return report_to_markdown(report)
325
+ status = "passed" if report["passed"] else "failed"
326
+ lines = [
327
+ "# AgentLoop Quality Report",
328
+ "",
329
+ f"- Status: {status}",
330
+ f"- Cases: {report['case_count']}",
331
+ f"- Baseline score: {report['baseline_score']:.4f}",
332
+ f"- Candidate score: {report['candidate_score']:.4f}",
333
+ f"- Quality delta: {report['quality_delta']:.4f}",
334
+ f"- Failed cases: {report['failed_case_count']}",
335
+ "",
336
+ "## Cases",
337
+ "",
338
+ "| Case | Status | Baseline | Candidate | Detail |",
339
+ "|---|---|---:|---:|---|",
340
+ ]
341
+ for case in report["cases"]:
342
+ status = "pass" if case["passed"] else "fail"
343
+ lines.append(
344
+ "| "
345
+ f"{markdown_table_cell(case['case_id'])} | "
346
+ f"{status} | "
347
+ f"{case['baseline']['score']:.4f} | "
348
+ f"{case['candidate']['score']:.4f} | "
349
+ f"{markdown_table_cell(case['candidate']['detail'])} |"
350
+ )
351
+ return "\n".join(lines).rstrip() + "\n"
352
+
353
+
354
+ def _output_for_side(fixture: dict[str, Any], side: str, trace: Any | None) -> Any:
355
+ key = f"{side}_output"
356
+ if key in fixture:
357
+ return fixture[key]
358
+ if side == "candidate" and "output" in fixture:
359
+ return fixture["output"]
360
+ return _trace_output(trace)
361
+
362
+
363
+ def _trace_output(trace: Any | None) -> Any:
364
+ if trace is None:
365
+ return _MISSING
366
+ metadata = getattr(trace, "metadata", {}) or {}
367
+ if "output" in metadata:
368
+ return metadata["output"]
369
+ for event in reversed(getattr(trace, "events", [])):
370
+ if getattr(event, "event_type", "") == "model_call":
371
+ output_text = getattr(event, "output_text", None)
372
+ return _MISSING if output_text is None else output_text
373
+ return _MISSING
374
+
375
+
376
+ def _json_value(value: Any) -> Any:
377
+ if isinstance(value, str):
378
+ try:
379
+ return json.loads(value)
380
+ except json.JSONDecodeError:
381
+ return value
382
+ return value
383
+
384
+
385
+ def _exactly_equal(actual: Any, expected: Any) -> bool:
386
+ """Compare values without Python's cross-type equality coercions."""
387
+
388
+ if type(actual) is not type(expected):
389
+ return False
390
+ if isinstance(actual, dict):
391
+ return actual.keys() == expected.keys() and all(
392
+ _exactly_equal(actual[key], expected[key]) for key in actual
393
+ )
394
+ if isinstance(actual, list | tuple):
395
+ return len(actual) == len(expected) and all(
396
+ _exactly_equal(actual_item, expected_item)
397
+ for actual_item, expected_item in zip(actual, expected, strict=True)
398
+ )
399
+ return bool(actual == expected)
400
+
401
+
402
+ def _text_output(value: Any) -> str:
403
+ return "" if value is None else str(value)
404
+
405
+
406
+ def _average(values: Any) -> float:
407
+ items = list(values)
408
+ if not items:
409
+ return 0.0
410
+ return sum(float(item) for item in items) / len(items)
411
+
412
+
413
+ def _result(passed: bool, detail: str) -> dict[str, Any]:
414
+ return {"score": 1.0 if passed else 0.0, "passed": passed, "detail": detail}
415
+
416
+
417
+ def _is_empty(value: Any) -> bool:
418
+ if isinstance(value, str):
419
+ return not value.strip()
420
+ return value is None or (isinstance(value, list | dict) and not value)
421
+
422
+
423
+ def _configured_value(
424
+ fixture: dict[str, Any], scorer: dict[str, Any], fixture_key: str, scorer_key: str
425
+ ) -> Any:
426
+ return fixture[fixture_key] if fixture_key in fixture else scorer.get(scorer_key)
427
+
428
+
429
+ def _has_configured_value(
430
+ fixture: dict[str, Any], scorer: dict[str, Any], fixture_key: str, scorer_key: str
431
+ ) -> bool:
432
+ return fixture_key in fixture or scorer_key in scorer
433
+
434
+
435
+ def _require_non_empty(value: Any, path: str) -> None:
436
+ if _is_empty(value):
437
+ raise QualityValidationError(f"{path} must be configured and non-empty")
438
+
439
+
440
+ def _bounded_score(value: Any, name: str) -> float:
441
+ if isinstance(value, bool) or not isinstance(value, int | float):
442
+ raise QualityValidationError(f"{name} must be a number between 0 and 1")
443
+ score = float(value)
444
+ if not math.isfinite(score) or not 0.0 <= score <= 1.0:
445
+ raise QualityValidationError(f"{name} must be between 0 and 1")
446
+ return score