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.
- agentloop/__init__.py +136 -0
- agentloop/ablation_protocol.py +229 -0
- agentloop/ablations.py +564 -0
- agentloop/aggregate_cli.py +88 -0
- agentloop/aggregate_stats.py +175 -0
- agentloop/aggregates.py +532 -0
- agentloop/audit.py +39 -0
- agentloop/autoinstrument.py +145 -0
- agentloop/batch_experiments.py +295 -0
- agentloop/batching.py +95 -0
- agentloop/budget_types.py +335 -0
- agentloop/budgets.py +539 -0
- agentloop/calibration.py +872 -0
- agentloop/checkpoint_state.py +306 -0
- agentloop/checkpoint_store.py +86 -0
- agentloop/ci.py +202 -0
- agentloop/cli.py +1314 -0
- agentloop/client.py +228 -0
- agentloop/completion.py +345 -0
- agentloop/completion_types.py +199 -0
- agentloop/config.py +50 -0
- agentloop/context_controls.py +443 -0
- agentloop/context_experiments.py +337 -0
- agentloop/context_types.py +216 -0
- agentloop/costs.py +667 -0
- agentloop/cursor_validation.py +48 -0
- agentloop/decorators.py +445 -0
- agentloop/demo.py +300 -0
- agentloop/doctor.py +341 -0
- agentloop/drift.py +361 -0
- agentloop/drift_cli.py +147 -0
- agentloop/drift_types.py +110 -0
- agentloop/drift_windows.py +340 -0
- agentloop/entrypoint.py +201 -0
- agentloop/estimates.py +133 -0
- agentloop/events.py +75 -0
- agentloop/experiment_artifacts.py +80 -0
- agentloop/experiment_observations.py +169 -0
- agentloop/experiment_reports.py +545 -0
- agentloop/experiment_types.py +355 -0
- agentloop/experiments.py +392 -0
- agentloop/exporters.py +117 -0
- agentloop/finding_benchmark_types.py +131 -0
- agentloop/finding_benchmarks.py +424 -0
- agentloop/findings.py +419 -0
- agentloop/graph.py +464 -0
- agentloop/harness.py +825 -0
- agentloop/harness_bench/__init__.py +1 -0
- agentloop/harness_bench/cli.py +77 -0
- agentloop/harness_bench/drivers.py +96 -0
- agentloop/harness_bench/offline.py +507 -0
- agentloop/harness_evidence.py +585 -0
- agentloop/harness_usage.py +91 -0
- agentloop/html_report.py +860 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/harbor/__init__.py +1 -0
- agentloop/integrations/harbor/atif.py +982 -0
- agentloop/integrations/harbor/atif_validation.py +329 -0
- agentloop/integrations/harbor/cli.py +207 -0
- agentloop/integrations/harbor/manifest.py +86 -0
- agentloop/integrations/harbor/trial_evidence.py +196 -0
- agentloop/integrations/harbor/trials.py +1109 -0
- agentloop/integrations/harbor/verifier.py +118 -0
- agentloop/integrations/langgraph.py +150 -0
- agentloop/integrations/langgraph_harness.py +466 -0
- agentloop/integrations/langgraph_recovery.py +366 -0
- agentloop/integrations/omnigent/__init__.py +1 -0
- agentloop/integrations/omnigent/cli.py +78 -0
- agentloop/integrations/omnigent/evidence.py +93 -0
- agentloop/integrations/omnigent/telemetry.py +422 -0
- agentloop/integrations/openai.py +415 -0
- agentloop/integrations/openai_agents.py +291 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/interoperability/__init__.py +5 -0
- agentloop/interoperability/artifacts.py +112 -0
- agentloop/interoperability/cli.py +59 -0
- agentloop/interoperability/cohort_evidence.py +175 -0
- agentloop/interoperability/cohorts.py +681 -0
- agentloop/interoperability/contracts.py +427 -0
- agentloop/interoperability/coordination.py +554 -0
- agentloop/interoperability/evidence.py +215 -0
- agentloop/interoperability/otlp_jsonl.py +775 -0
- agentloop/interoperability/otlp_validation.py +496 -0
- agentloop/interoperability/registry.py +561 -0
- agentloop/interoperability/validation.py +333 -0
- agentloop/intervention_service.py +110 -0
- agentloop/intervention_store.py +118 -0
- agentloop/interventions.py +190 -0
- agentloop/issues.py +90 -0
- agentloop/judgment_adapters.py +125 -0
- agentloop/judgment_benchmark_types.py +98 -0
- agentloop/judgment_benchmarks.py +606 -0
- agentloop/judgment_types.py +241 -0
- agentloop/judgment_views.py +38 -0
- agentloop/judgments.py +342 -0
- agentloop/loop_guards.py +274 -0
- agentloop/loop_types.py +66 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +342 -0
- agentloop/migrations.py +303 -0
- agentloop/model_routing.py +470 -0
- agentloop/onboarding.py +315 -0
- agentloop/operations.py +44 -0
- agentloop/optimizer.py +199 -0
- agentloop/otel.py +715 -0
- agentloop/otel_ids.py +81 -0
- agentloop/otel_semantics.py +134 -0
- agentloop/parallelism.py +141 -0
- agentloop/patches.py +603 -0
- agentloop/plan_export.py +126 -0
- agentloop/policy_promotion.py +325 -0
- agentloop/promotion_evidence.py +304 -0
- agentloop/promotion_types.py +209 -0
- agentloop/quality.py +446 -0
- agentloop/ranking.py +529 -0
- agentloop/recovery_evidence.py +96 -0
- agentloop/replay.py +753 -0
- agentloop/retention.py +375 -0
- agentloop/retention_types.py +113 -0
- agentloop/reuse_experiments.py +247 -0
- agentloop/routing_checks.py +116 -0
- agentloop/routing_types.py +241 -0
- agentloop/rules.py +396 -0
- agentloop/runtime.py +286 -0
- agentloop/savings.py +158 -0
- agentloop/scheduling_plan.py +99 -0
- agentloop/scheduling_types.py +169 -0
- agentloop/schema.py +257 -0
- agentloop/semantic_rules.py +200 -0
- agentloop/semantic_waste.py +345 -0
- agentloop/semantic_waste_types.py +88 -0
- agentloop/server.py +494 -0
- agentloop/store.py +1429 -0
- agentloop/structural_experiments.py +431 -0
- agentloop/structural_types.py +113 -0
- agentloop/structured_quality.py +1020 -0
- agentloop/studies.py +629 -0
- agentloop/study_statistics.py +58 -0
- agentloop/substitution_evidence.py +199 -0
- agentloop/substitution_export.py +190 -0
- agentloop/substitution_reports.py +407 -0
- agentloop/substitution_types.py +284 -0
- agentloop/substitutions.py +267 -0
- agentloop/timing.py +142 -0
- agentloop/tokens.py +188 -0
- agentloop/tool_scheduling.py +410 -0
- agentloop/trace_sources.py +31 -0
- agentloop/tracer.py +494 -0
- agentloop/value.py +306 -0
- agentloop/version.py +3 -0
- agentloop/workflow_types.py +264 -0
- agentloop/workflows.py +284 -0
- agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
- agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
- agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
- agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +5 -0
- dashboard/app.py +922 -0
- dashboard/streamlit_compat.py +63 -0
- dashboard/trace_upload.py +57 -0
- 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}
|