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
agentloop/studies.py
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
"""Deterministic paired study summaries using only the Python standard library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import random
|
|
9
|
+
import statistics
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentloop.costs import is_cost_evaluable
|
|
15
|
+
from agentloop.markdown import markdown_code_span, markdown_heading, markdown_table_cell
|
|
16
|
+
from agentloop.tokens import is_token_basis_evaluable
|
|
17
|
+
from agentloop.tracer import AgentTrace
|
|
18
|
+
|
|
19
|
+
STUDY_SCHEMA_VERSION = "1.0"
|
|
20
|
+
_METRICS = (
|
|
21
|
+
"success",
|
|
22
|
+
"quality_score",
|
|
23
|
+
"runtime_ms",
|
|
24
|
+
"input_tokens",
|
|
25
|
+
"output_tokens",
|
|
26
|
+
"model_call_count",
|
|
27
|
+
"tool_call_count",
|
|
28
|
+
"retry_count",
|
|
29
|
+
"cost_usd",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StudyValidationError(ValueError):
|
|
34
|
+
"""The manifest or one of its source traces is not a valid study input."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _quantile_sorted(ordered: list[float], fraction: float) -> float | None:
|
|
38
|
+
"""Linearly interpolate at (n - 1) * fraction, including singleton samples."""
|
|
39
|
+
if not ordered:
|
|
40
|
+
return None
|
|
41
|
+
position = (len(ordered) - 1) * fraction
|
|
42
|
+
lower, upper = math.floor(position), math.ceil(position)
|
|
43
|
+
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def summarize_values(values: list[float | None]) -> dict[str, Any]:
|
|
47
|
+
known = sorted(value for value in values if value is not None)
|
|
48
|
+
return {
|
|
49
|
+
"count": len(known),
|
|
50
|
+
"missing_count": len(values) - len(known),
|
|
51
|
+
"mean": statistics.fmean(known) if known else None,
|
|
52
|
+
"median": _quantile_sorted(known, 0.5),
|
|
53
|
+
"min": known[0] if known else None,
|
|
54
|
+
"max": known[-1] if known else None,
|
|
55
|
+
"p05": _quantile_sorted(known, 0.05),
|
|
56
|
+
"p25": _quantile_sorted(known, 0.25),
|
|
57
|
+
"p75": _quantile_sorted(known, 0.75),
|
|
58
|
+
"p95": _quantile_sorted(known, 0.95),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _bootstrap(values: list[float | None], config: dict[str, Any]) -> dict[str, Any]:
|
|
63
|
+
known = [value for value in values if value is not None]
|
|
64
|
+
samples, seed, confidence = config["samples"], config["seed"], config["confidence"]
|
|
65
|
+
result = {
|
|
66
|
+
"method": "paired_percentile_bootstrap_mean_delta",
|
|
67
|
+
**config,
|
|
68
|
+
"pair_count": len(known),
|
|
69
|
+
"lower": None,
|
|
70
|
+
"upper": None,
|
|
71
|
+
"status": "insufficient_pairs",
|
|
72
|
+
}
|
|
73
|
+
if len(known) < 2:
|
|
74
|
+
return result
|
|
75
|
+
# Reproducible statistical resampling; this generator creates no credentials.
|
|
76
|
+
rng = random.Random(seed) # nosec B311
|
|
77
|
+
means = sorted(statistics.fmean(rng.choices(known, k=len(known))) for _ in range(samples))
|
|
78
|
+
tail = (1 - confidence) / 2
|
|
79
|
+
result.update(
|
|
80
|
+
lower=_quantile_sorted(means, tail),
|
|
81
|
+
upper=_quantile_sorted(means, 1 - tail),
|
|
82
|
+
status="computed",
|
|
83
|
+
)
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _bootstrap_config(value: Any) -> dict[str, Any] | None:
|
|
88
|
+
if value is None:
|
|
89
|
+
return None
|
|
90
|
+
if not isinstance(value, dict) or set(value) - {"samples", "seed", "confidence"}:
|
|
91
|
+
raise StudyValidationError("bootstrap must specify samples, seed, and/or confidence")
|
|
92
|
+
config = {"samples": 1000, "seed": 0, "confidence": 0.95, **value}
|
|
93
|
+
if type(config["samples"]) is not int or not 1 <= config["samples"] <= 10000:
|
|
94
|
+
raise StudyValidationError("bootstrap.samples must be an integer between 1 and 10000")
|
|
95
|
+
if type(config["seed"]) is not int:
|
|
96
|
+
raise StudyValidationError("bootstrap.seed must be an integer")
|
|
97
|
+
confidence = config["confidence"]
|
|
98
|
+
if (
|
|
99
|
+
isinstance(confidence, bool)
|
|
100
|
+
or not isinstance(confidence, int | float)
|
|
101
|
+
or not 0 < confidence < 1
|
|
102
|
+
):
|
|
103
|
+
raise StudyValidationError("bootstrap.confidence must be between 0 and 1")
|
|
104
|
+
return config
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_manifest(manifest: Any) -> None:
|
|
108
|
+
if not isinstance(manifest, dict) or manifest.get("schema_version") != STUDY_SCHEMA_VERSION:
|
|
109
|
+
raise StudyValidationError("study schema_version must be 1.0")
|
|
110
|
+
if set(manifest) - {
|
|
111
|
+
"schema_version",
|
|
112
|
+
"name",
|
|
113
|
+
"baseline",
|
|
114
|
+
"conditions",
|
|
115
|
+
"pairing_keys",
|
|
116
|
+
"bootstrap",
|
|
117
|
+
}:
|
|
118
|
+
raise StudyValidationError("unknown study manifest field")
|
|
119
|
+
if not isinstance(manifest.get("name"), str) or not manifest["name"].strip():
|
|
120
|
+
raise StudyValidationError("study name must be a nonempty string")
|
|
121
|
+
conditions = manifest.get("conditions")
|
|
122
|
+
if not isinstance(conditions, dict) or len(conditions) < 2:
|
|
123
|
+
raise StudyValidationError("at least two conditions are required")
|
|
124
|
+
if not isinstance(manifest.get("baseline"), str) or manifest["baseline"] not in conditions:
|
|
125
|
+
raise StudyValidationError("baseline must name one of the conditions")
|
|
126
|
+
keys = manifest.get("pairing_keys")
|
|
127
|
+
if (
|
|
128
|
+
not isinstance(keys, list)
|
|
129
|
+
or not keys
|
|
130
|
+
or any(not isinstance(key, str) or not key for key in keys)
|
|
131
|
+
or len(set(keys)) != len(keys)
|
|
132
|
+
):
|
|
133
|
+
raise StudyValidationError("pairing_keys must be a nonempty list of distinct metadata keys")
|
|
134
|
+
for name, patterns in conditions.items():
|
|
135
|
+
if (
|
|
136
|
+
not isinstance(name, str)
|
|
137
|
+
or not name.strip()
|
|
138
|
+
or not isinstance(patterns, list)
|
|
139
|
+
or not patterns
|
|
140
|
+
or any(not isinstance(pattern, str) or not pattern for pattern in patterns)
|
|
141
|
+
):
|
|
142
|
+
raise StudyValidationError(
|
|
143
|
+
"each named condition must contain a nonempty list of trace paths/globs"
|
|
144
|
+
)
|
|
145
|
+
_bootstrap_config(manifest.get("bootstrap"))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _pair_key(metadata: dict[str, Any], keys: list[str]) -> str | None:
|
|
149
|
+
values = []
|
|
150
|
+
for key in keys:
|
|
151
|
+
value = metadata.get(key)
|
|
152
|
+
if not isinstance(value, str | int | float | bool) or (
|
|
153
|
+
isinstance(value, float) and not math.isfinite(value)
|
|
154
|
+
):
|
|
155
|
+
return None
|
|
156
|
+
# The type tag prevents booleans, numbers, and strings from aliasing.
|
|
157
|
+
values.append([type(value).__name__, value])
|
|
158
|
+
return json.dumps(values, separators=(",", ":"))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _run(path: Path, keys: list[str]) -> dict[str, Any]:
|
|
162
|
+
from agentloop.retention import require_complete_evidence
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
trace = AgentTrace.from_dict(json.loads(path.read_text(encoding="utf-8")))
|
|
166
|
+
require_complete_evidence(trace, "study")
|
|
167
|
+
report = trace.report()
|
|
168
|
+
except (OSError, ValueError, TypeError, KeyError) as exc:
|
|
169
|
+
raise StudyValidationError(f"invalid trace {path}: {exc}") from exc
|
|
170
|
+
quality = trace.metadata.get("quality_score")
|
|
171
|
+
if quality is not None and (
|
|
172
|
+
isinstance(quality, bool) or not isinstance(quality, int | float) or not 0 <= quality <= 1
|
|
173
|
+
):
|
|
174
|
+
raise StudyValidationError(
|
|
175
|
+
f"{path}: metadata.quality_score must be a number between 0 and 1"
|
|
176
|
+
)
|
|
177
|
+
success = trace.metadata.get("success")
|
|
178
|
+
if success is not None and type(success) is not bool:
|
|
179
|
+
raise StudyValidationError(f"{path}: metadata.success must be a boolean")
|
|
180
|
+
failures = [event for event in trace.events if event.status == "error"]
|
|
181
|
+
categories = Counter(
|
|
182
|
+
str(event.metadata.get("error_type") or "event_error") for event in failures
|
|
183
|
+
)
|
|
184
|
+
if success is False and not failures:
|
|
185
|
+
categories["task_failure"] += 1
|
|
186
|
+
# No error spans means execution succeeded, not that task quality was verified.
|
|
187
|
+
success_value = not failures if success is None else success and not failures
|
|
188
|
+
success_basis = "task_metadata_and_execution" if success is not None else "execution_only"
|
|
189
|
+
execution = report.get("execution")
|
|
190
|
+
if execution is not None:
|
|
191
|
+
status = execution.get("status") if execution.get("schema_status") == "supported" else None
|
|
192
|
+
if status in {"failed", "cancelled", "interrupted"}:
|
|
193
|
+
success_value = False
|
|
194
|
+
categories[f"workflow_{status}"] += 1
|
|
195
|
+
elif status != "completed":
|
|
196
|
+
success_value = False if failures or success is False else None
|
|
197
|
+
success_basis = "task_metadata_and_workflow" if success is not None else "workflow_status"
|
|
198
|
+
quality_evidence = report.get("quality_evidence")
|
|
199
|
+
if quality_evidence is not None:
|
|
200
|
+
quality = quality_evidence["score"]
|
|
201
|
+
if (
|
|
202
|
+
failures
|
|
203
|
+
or success is False
|
|
204
|
+
or quality_evidence.get("summary", {}).get("execution_failed_count", 0) > 0
|
|
205
|
+
or execution is not None
|
|
206
|
+
and execution.get("status") in {"failed", "cancelled", "interrupted"}
|
|
207
|
+
):
|
|
208
|
+
success_value = False
|
|
209
|
+
elif quality_evidence.get("status") == "complete":
|
|
210
|
+
success_value = quality_evidence["passed"]
|
|
211
|
+
else:
|
|
212
|
+
success_value = None
|
|
213
|
+
success_basis = "versioned_quality_and_execution"
|
|
214
|
+
if not quality_evidence.get("passed"):
|
|
215
|
+
categories[
|
|
216
|
+
"quality_rejected"
|
|
217
|
+
if quality_evidence.get("status") == "complete"
|
|
218
|
+
else "quality_" + quality_evidence.get("status", "invalid")
|
|
219
|
+
] += 1
|
|
220
|
+
cost_known = is_cost_evaluable(report["cost_status"]) and is_token_basis_evaluable(
|
|
221
|
+
report["token_status"]
|
|
222
|
+
)
|
|
223
|
+
from agentloop.substitution_evidence import read_trial_evidence
|
|
224
|
+
|
|
225
|
+
trial = read_trial_evidence(trace)
|
|
226
|
+
trial_metrics = {}
|
|
227
|
+
cost_state, token_state = report["cost_status"], report["token_status"]
|
|
228
|
+
if trial is not None:
|
|
229
|
+
if trial["status"] == "valid":
|
|
230
|
+
usage = trial["usage"]
|
|
231
|
+
trial_metrics = {
|
|
232
|
+
"runtime_ms": trial["latency_ms"],
|
|
233
|
+
"input_tokens": usage["input_tokens"],
|
|
234
|
+
"output_tokens": usage["output_tokens"],
|
|
235
|
+
"cost_usd": usage["cost_usd"],
|
|
236
|
+
}
|
|
237
|
+
cost_state = "complete" if usage["cost_usd"] is not None else "unknown"
|
|
238
|
+
token_state = (
|
|
239
|
+
"estimated"
|
|
240
|
+
if usage["token_basis"] == "estimated"
|
|
241
|
+
else (
|
|
242
|
+
"exact"
|
|
243
|
+
if usage["input_tokens"] is not None and usage["output_tokens"] is not None
|
|
244
|
+
else (
|
|
245
|
+
"partial"
|
|
246
|
+
if usage["input_tokens"] is not None or usage["output_tokens"] is not None
|
|
247
|
+
else "unavailable"
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
if trial["outcome"] != "completed":
|
|
252
|
+
quality = None
|
|
253
|
+
success_value = (
|
|
254
|
+
False
|
|
255
|
+
if trial["outcome"] in {"failed", "timed_out", "invalid_result"}
|
|
256
|
+
or success_value is False
|
|
257
|
+
else None
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
trial_metrics = dict.fromkeys(
|
|
261
|
+
("runtime_ms", "input_tokens", "output_tokens", "cost_usd")
|
|
262
|
+
)
|
|
263
|
+
quality = None
|
|
264
|
+
success_value = False if failures or success is False else None
|
|
265
|
+
cost_state, token_state = "unknown", "unavailable"
|
|
266
|
+
categories["substitution_evidence_" + trial["status"]] += 1
|
|
267
|
+
if quality_evidence is None:
|
|
268
|
+
quality = None
|
|
269
|
+
success_value = False if success_value is False else None
|
|
270
|
+
categories["quality_unavailable"] += 1
|
|
271
|
+
success_basis = (
|
|
272
|
+
"substitution_trial_and_versioned_quality"
|
|
273
|
+
if quality_evidence is not None
|
|
274
|
+
else "substitution_trial_without_quality"
|
|
275
|
+
)
|
|
276
|
+
from agentloop.integrations.harbor.trial_evidence import (
|
|
277
|
+
PAIRING_KEYS,
|
|
278
|
+
read_trial,
|
|
279
|
+
trial_study_values,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
external_trial = read_trial(trace)
|
|
283
|
+
if external_trial is not None:
|
|
284
|
+
if not set(PAIRING_KEYS) <= set(keys):
|
|
285
|
+
raise StudyValidationError(
|
|
286
|
+
"external trial studies require task/scorer/environment/protocol/repetition pairing keys"
|
|
287
|
+
)
|
|
288
|
+
quality, success_value = trial_study_values(external_trial)
|
|
289
|
+
success_basis = "external_verifier_contract_and_execution"
|
|
290
|
+
measurement = external_trial["measurement"]
|
|
291
|
+
trial_metrics = {
|
|
292
|
+
"runtime_ms": measurement["runtime_ms"],
|
|
293
|
+
"input_tokens": measurement["input_tokens"],
|
|
294
|
+
"output_tokens": measurement["output_tokens"],
|
|
295
|
+
"cost_usd": measurement["cost_usd"],
|
|
296
|
+
}
|
|
297
|
+
if external_trial["outcome"]["quality_pass"] is False:
|
|
298
|
+
categories["external_quality_rejected"] += 1
|
|
299
|
+
elif external_trial["outcome"]["quality_pass"] is None:
|
|
300
|
+
categories["external_quality_indeterminate"] += 1
|
|
301
|
+
from agentloop.interoperability.cohort_evidence import COHORT_PAIRING_KEYS, read_cohort
|
|
302
|
+
|
|
303
|
+
cohort = read_cohort(trace)
|
|
304
|
+
if cohort is not None:
|
|
305
|
+
if not set(COHORT_PAIRING_KEYS) <= set(keys):
|
|
306
|
+
raise StudyValidationError(
|
|
307
|
+
"cohort studies require exact task/digest/scorer/verifier/environment/protocol/repetition keys"
|
|
308
|
+
)
|
|
309
|
+
success_value = cohort["accepted"]
|
|
310
|
+
success_basis = "external_correctness_execution_and_source_coverage"
|
|
311
|
+
if success_value is not True:
|
|
312
|
+
categories["cohort_rejected" if success_value is False else "cohort_indeterminate"] += 1
|
|
313
|
+
return {
|
|
314
|
+
"path": str(path),
|
|
315
|
+
**({"external_cohort": cohort} if cohort is not None else {}),
|
|
316
|
+
**(
|
|
317
|
+
{"external_trial": external_trial, "quality_basis": "configured_binary_acceptance"}
|
|
318
|
+
if external_trial is not None
|
|
319
|
+
else {}
|
|
320
|
+
),
|
|
321
|
+
**(
|
|
322
|
+
{"decision_trial": trial, "measurement_scope": "declared_decision_step"}
|
|
323
|
+
if trial is not None
|
|
324
|
+
else {}
|
|
325
|
+
),
|
|
326
|
+
**(
|
|
327
|
+
{"quality_evidence": quality_evidence, "decision_count": report["decision_count"]}
|
|
328
|
+
if quality_evidence is not None
|
|
329
|
+
else {}
|
|
330
|
+
),
|
|
331
|
+
**({"execution": report["execution"]} if "execution" in report else {}),
|
|
332
|
+
**({"stages": report["stages"]} if "stages" in report else {}),
|
|
333
|
+
"run_id": trace.run_id,
|
|
334
|
+
"pair_key": _pair_key(trace.metadata, keys),
|
|
335
|
+
"pairing_metadata": {key: trace.metadata.get(key) for key in keys},
|
|
336
|
+
"metrics": {
|
|
337
|
+
**(
|
|
338
|
+
{"decision_count": report["decision_count"]} if quality_evidence is not None else {}
|
|
339
|
+
),
|
|
340
|
+
"success": float(success_value) if success_value is not None else None,
|
|
341
|
+
"quality_score": quality,
|
|
342
|
+
"runtime_ms": report["total_runtime_ms"],
|
|
343
|
+
"input_tokens": report["input_tokens"],
|
|
344
|
+
"output_tokens": report["output_tokens"],
|
|
345
|
+
"model_call_count": report["model_call_count"],
|
|
346
|
+
"tool_call_count": report["tool_call_count"],
|
|
347
|
+
"retry_count": report["retry_count"],
|
|
348
|
+
"cost_usd": report["estimated_cost_usd"] if cost_known else None,
|
|
349
|
+
**trial_metrics,
|
|
350
|
+
},
|
|
351
|
+
"success_basis": success_basis,
|
|
352
|
+
"cost_status": cost_state,
|
|
353
|
+
"token_status": token_state,
|
|
354
|
+
"operation_counts": report.get("operation_counts", {}),
|
|
355
|
+
"failure_categories": dict(sorted(categories.items())),
|
|
356
|
+
"synthetic": trace.metadata.get("synthetic") is True,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _condition(runs: list[dict[str, Any]]) -> dict[str, Any]:
|
|
361
|
+
categories: Counter[str] = Counter()
|
|
362
|
+
operations: Counter[str] = Counter()
|
|
363
|
+
for run in runs:
|
|
364
|
+
categories.update(run["failure_categories"])
|
|
365
|
+
operations.update(run["operation_counts"])
|
|
366
|
+
metrics = (
|
|
367
|
+
*_METRICS,
|
|
368
|
+
*(("decision_count",) if any("decision_count" in run["metrics"] for run in runs) else ()),
|
|
369
|
+
)
|
|
370
|
+
return {
|
|
371
|
+
"run_count": len(runs),
|
|
372
|
+
"metrics": {
|
|
373
|
+
key: summarize_values([run["metrics"].get(key) for run in runs]) for key in metrics
|
|
374
|
+
},
|
|
375
|
+
"cost_status_counts": dict(sorted(Counter(run["cost_status"] for run in runs).items())),
|
|
376
|
+
"token_status_counts": dict(sorted(Counter(run["token_status"] for run in runs).items())),
|
|
377
|
+
"success_basis_counts": dict(sorted(Counter(run["success_basis"] for run in runs).items())),
|
|
378
|
+
"operation_counts": dict(sorted(operations.items())),
|
|
379
|
+
"failure_categories": dict(sorted(categories.items())),
|
|
380
|
+
"runs": runs,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _compare(
|
|
385
|
+
baseline: list[dict[str, Any]],
|
|
386
|
+
candidate: list[dict[str, Any]],
|
|
387
|
+
bootstrap: dict[str, Any] | None,
|
|
388
|
+
) -> dict[str, Any]:
|
|
389
|
+
metrics = (
|
|
390
|
+
*_METRICS,
|
|
391
|
+
*(
|
|
392
|
+
("decision_count",)
|
|
393
|
+
if any(
|
|
394
|
+
"decision_count" in run["metrics"] for runs in (baseline, candidate) for run in runs
|
|
395
|
+
)
|
|
396
|
+
else ()
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
groups: dict[str, dict[str, list[dict[str, Any]]]] = {"baseline": {}, "candidate": {}}
|
|
400
|
+
unmatched = []
|
|
401
|
+
for side, runs in (("baseline", baseline), ("candidate", candidate)):
|
|
402
|
+
for run in runs:
|
|
403
|
+
if run["pair_key"] is None:
|
|
404
|
+
unmatched.append(
|
|
405
|
+
{
|
|
406
|
+
"side": side,
|
|
407
|
+
"run_id": run["run_id"],
|
|
408
|
+
"path": run["path"],
|
|
409
|
+
"reason": "missing_or_invalid_pairing_metadata",
|
|
410
|
+
}
|
|
411
|
+
)
|
|
412
|
+
else:
|
|
413
|
+
groups[side].setdefault(run["pair_key"], []).append(run)
|
|
414
|
+
pairs = []
|
|
415
|
+
for key in sorted(set(groups["baseline"]) | set(groups["candidate"])):
|
|
416
|
+
left, right = groups["baseline"].get(key, []), groups["candidate"].get(key, [])
|
|
417
|
+
if len(left) != 1 or len(right) != 1:
|
|
418
|
+
reason = (
|
|
419
|
+
"duplicate_pairing_key"
|
|
420
|
+
if len(left) > 1 or len(right) > 1
|
|
421
|
+
else "missing_counterpart"
|
|
422
|
+
)
|
|
423
|
+
for side, runs in (("baseline", left), ("candidate", right)):
|
|
424
|
+
unmatched.extend(
|
|
425
|
+
{
|
|
426
|
+
"side": side,
|
|
427
|
+
"run_id": run["run_id"],
|
|
428
|
+
"path": run["path"],
|
|
429
|
+
"pairing_metadata": run["pairing_metadata"],
|
|
430
|
+
"reason": reason,
|
|
431
|
+
}
|
|
432
|
+
for run in runs
|
|
433
|
+
)
|
|
434
|
+
continue
|
|
435
|
+
before, after = left[0], right[0]
|
|
436
|
+
deltas = {
|
|
437
|
+
metric: (
|
|
438
|
+
after["metrics"][metric] - before["metrics"][metric]
|
|
439
|
+
if after["metrics"].get(metric) is not None
|
|
440
|
+
and before["metrics"].get(metric) is not None
|
|
441
|
+
else None
|
|
442
|
+
)
|
|
443
|
+
for metric in metrics
|
|
444
|
+
}
|
|
445
|
+
pairs.append(
|
|
446
|
+
{
|
|
447
|
+
"baseline_run_id": before["run_id"],
|
|
448
|
+
"candidate_run_id": after["run_id"],
|
|
449
|
+
"pairing_metadata": before["pairing_metadata"],
|
|
450
|
+
"deltas": deltas,
|
|
451
|
+
}
|
|
452
|
+
)
|
|
453
|
+
summaries = {}
|
|
454
|
+
for metric in metrics:
|
|
455
|
+
values = [pair["deltas"][metric] for pair in pairs]
|
|
456
|
+
summaries[metric] = summarize_values(values)
|
|
457
|
+
if bootstrap:
|
|
458
|
+
summaries[metric]["bootstrap_interval"] = _bootstrap(values, bootstrap)
|
|
459
|
+
return {
|
|
460
|
+
"pair_count": len(pairs),
|
|
461
|
+
"delta_direction": "candidate_minus_baseline",
|
|
462
|
+
"metrics": summaries,
|
|
463
|
+
"pairs": pairs,
|
|
464
|
+
"unmatched": sorted(unmatched, key=lambda run: (run["side"], run["run_id"], run["path"])),
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def summarize_study(manifest_path: str | Path) -> dict[str, Any]:
|
|
469
|
+
path = Path(manifest_path).resolve()
|
|
470
|
+
try:
|
|
471
|
+
manifest = json.loads(path.read_text(encoding="utf-8"))
|
|
472
|
+
except (OSError, ValueError) as exc:
|
|
473
|
+
raise StudyValidationError(f"cannot read study manifest: {exc}") from exc
|
|
474
|
+
_validate_manifest(manifest)
|
|
475
|
+
bootstrap = _bootstrap_config(manifest.get("bootstrap"))
|
|
476
|
+
conditions = {}
|
|
477
|
+
seen_ids: set[str] = set()
|
|
478
|
+
for name, patterns in sorted(manifest["conditions"].items()):
|
|
479
|
+
paths: set[Path] = set()
|
|
480
|
+
for pattern in patterns:
|
|
481
|
+
resolved_pattern = str(path.parent / pattern)
|
|
482
|
+
matches = [
|
|
483
|
+
Path(match).resolve()
|
|
484
|
+
for match in glob.glob(resolved_pattern, recursive=True)
|
|
485
|
+
if Path(match).is_file()
|
|
486
|
+
]
|
|
487
|
+
if not matches:
|
|
488
|
+
raise StudyValidationError(
|
|
489
|
+
f"condition {name!r}: trace pattern matched no files: {pattern}"
|
|
490
|
+
)
|
|
491
|
+
paths.update(matches)
|
|
492
|
+
runs = [_run(trace_path, manifest["pairing_keys"]) for trace_path in sorted(paths)]
|
|
493
|
+
for run in runs:
|
|
494
|
+
if run["run_id"] in seen_ids:
|
|
495
|
+
raise StudyValidationError(f"duplicate run_id in study: {run['run_id']}")
|
|
496
|
+
seen_ids.add(run["run_id"])
|
|
497
|
+
runs.sort(key=lambda run: (run["pair_key"] or "", run["run_id"]))
|
|
498
|
+
conditions[name] = _condition(runs)
|
|
499
|
+
trial_runs = [
|
|
500
|
+
run
|
|
501
|
+
for condition in conditions.values()
|
|
502
|
+
for run in condition["runs"]
|
|
503
|
+
if "decision_trial" in run
|
|
504
|
+
]
|
|
505
|
+
if trial_runs:
|
|
506
|
+
if len(trial_runs) != sum(condition["run_count"] for condition in conditions.values()):
|
|
507
|
+
raise StudyValidationError(
|
|
508
|
+
"cannot mix declared decision-step measurements with ordinary model-only trace metrics"
|
|
509
|
+
)
|
|
510
|
+
plans = {
|
|
511
|
+
run["decision_trial"]["plan_hash"]
|
|
512
|
+
for run in trial_runs
|
|
513
|
+
if run["decision_trial"]["status"] == "valid"
|
|
514
|
+
}
|
|
515
|
+
if len(plans) > 1:
|
|
516
|
+
raise StudyValidationError("decision trials must share one frozen experiment plan")
|
|
517
|
+
baseline = manifest["baseline"]
|
|
518
|
+
return {
|
|
519
|
+
**({"measurement_scope": "declared_decision_step"} if trial_runs else {}),
|
|
520
|
+
"schema_version": STUDY_SCHEMA_VERSION,
|
|
521
|
+
"name": manifest["name"],
|
|
522
|
+
"manifest": manifest,
|
|
523
|
+
"baseline_condition": baseline,
|
|
524
|
+
"conditions": conditions,
|
|
525
|
+
"comparisons": {
|
|
526
|
+
name: _compare(conditions[baseline]["runs"], condition["runs"], bootstrap)
|
|
527
|
+
for name, condition in conditions.items()
|
|
528
|
+
if name != baseline
|
|
529
|
+
},
|
|
530
|
+
"includes_synthetic_data": any(
|
|
531
|
+
run["synthetic"] for condition in conditions.values() for run in condition["runs"]
|
|
532
|
+
),
|
|
533
|
+
"interpretation": "Descriptive paired comparisons. Bootstrap intervals assume exchangeable independent pairs; repeated tasks may require cluster-aware analysis. No significance decision is made.",
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def study_to_markdown(report: dict[str, Any]) -> str:
|
|
538
|
+
lines = [
|
|
539
|
+
f"# Study: {markdown_heading(report['name'])}",
|
|
540
|
+
"",
|
|
541
|
+
report["interpretation"],
|
|
542
|
+
"",
|
|
543
|
+
"Deltas are candidate minus baseline. Negative latency/cost deltas indicate reductions.",
|
|
544
|
+
"",
|
|
545
|
+
"Cost statistics use evaluable runs or pairs only; counts disclose missing observations.",
|
|
546
|
+
"",
|
|
547
|
+
]
|
|
548
|
+
if report["includes_synthetic_data"]:
|
|
549
|
+
lines.extend(
|
|
550
|
+
[
|
|
551
|
+
"This study includes synthetic data; fixture results are not application performance evidence.",
|
|
552
|
+
"",
|
|
553
|
+
]
|
|
554
|
+
)
|
|
555
|
+
if report.get("measurement_scope") == "declared_decision_step":
|
|
556
|
+
lines.extend(
|
|
557
|
+
[
|
|
558
|
+
"Cost and tokens use frozen caller-declared decision-step measurements, with reported/calculated bases retained per run. These are separate from model-only profile totals. Latency measures the callback; unavailable measurements are not zero. Shared baseline runs across studies are not additional independent observations.",
|
|
559
|
+
"",
|
|
560
|
+
]
|
|
561
|
+
)
|
|
562
|
+
for name, condition in report["conditions"].items():
|
|
563
|
+
lines.extend(
|
|
564
|
+
[f"## Condition: {markdown_heading(name)}", "", f"Runs: {condition['run_count']}", ""]
|
|
565
|
+
)
|
|
566
|
+
lines.extend(_metric_table(condition["metrics"]))
|
|
567
|
+
lines.extend(
|
|
568
|
+
[
|
|
569
|
+
"",
|
|
570
|
+
"Failure categories: "
|
|
571
|
+
+ markdown_code_span(json.dumps(condition["failure_categories"], sort_keys=True)),
|
|
572
|
+
"Success basis: "
|
|
573
|
+
+ markdown_code_span(json.dumps(condition["success_basis_counts"], sort_keys=True)),
|
|
574
|
+
"",
|
|
575
|
+
]
|
|
576
|
+
)
|
|
577
|
+
for name, comparison in report["comparisons"].items():
|
|
578
|
+
lines.extend(
|
|
579
|
+
[
|
|
580
|
+
f"## Paired comparison: {markdown_heading(name)}",
|
|
581
|
+
"",
|
|
582
|
+
f"Matched pairs: {comparison['pair_count']}",
|
|
583
|
+
"",
|
|
584
|
+
]
|
|
585
|
+
)
|
|
586
|
+
lines.extend(_metric_table(comparison["metrics"]))
|
|
587
|
+
lines.extend(
|
|
588
|
+
["", "### Unmatched runs", "", "| Side | Run | Reason |", "| --- | --- | --- |"]
|
|
589
|
+
)
|
|
590
|
+
for run in comparison["unmatched"]:
|
|
591
|
+
lines.append(
|
|
592
|
+
"| "
|
|
593
|
+
+ " | ".join(markdown_table_cell(run[key]) for key in ("side", "run_id", "reason"))
|
|
594
|
+
+ " |"
|
|
595
|
+
)
|
|
596
|
+
if not comparison["unmatched"]:
|
|
597
|
+
lines.append("| — | — | None |")
|
|
598
|
+
lines.append("")
|
|
599
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _metric_table(metrics: dict[str, Any]) -> list[str]:
|
|
603
|
+
def display(value: Any) -> str:
|
|
604
|
+
return "unavailable" if value is None else f"{value:.6g}"
|
|
605
|
+
|
|
606
|
+
lines = [
|
|
607
|
+
"| Metric | Count | Missing | Mean | Median | P05 | P95 | Bootstrap interval |",
|
|
608
|
+
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
|
|
609
|
+
]
|
|
610
|
+
for metric, summary in metrics.items():
|
|
611
|
+
interval = summary.get("bootstrap_interval")
|
|
612
|
+
bounds = (
|
|
613
|
+
"not requested"
|
|
614
|
+
if interval is None
|
|
615
|
+
else (
|
|
616
|
+
f"{interval['confidence']:.1%}: [{display(interval['lower'])}, {display(interval['upper'])}]"
|
|
617
|
+
if interval["status"] == "computed"
|
|
618
|
+
else "insufficient pairs"
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
cells = [
|
|
622
|
+
metric,
|
|
623
|
+
str(summary["count"]),
|
|
624
|
+
str(summary["missing_count"]),
|
|
625
|
+
*(display(summary[key]) for key in ("mean", "median", "p05", "p95")),
|
|
626
|
+
bounds,
|
|
627
|
+
]
|
|
628
|
+
lines.append("| " + " | ".join(markdown_table_cell(cell) for cell in cells) + " |")
|
|
629
|
+
return lines
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shared task-weighted descriptive statistics for offline evidence artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import statistics
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
|
|
9
|
+
from agentloop.studies import summarize_values
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _percentile(values, fraction):
|
|
13
|
+
position = (len(values) - 1) * fraction
|
|
14
|
+
low = int(position)
|
|
15
|
+
high = min(low + 1, len(values) - 1)
|
|
16
|
+
return values[low] + (values[high] - values[low]) * (position - low)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def task_weighted_summary(observations, settings):
|
|
20
|
+
"""Summarize validated (task ID, finite value or None) observations.
|
|
21
|
+
|
|
22
|
+
Missing observations remain in the planned denominator. Available values are
|
|
23
|
+
averaged within each task; bootstrap draws then give each observed task equal
|
|
24
|
+
weight. This does not remedy missingness or selection bias.
|
|
25
|
+
"""
|
|
26
|
+
values, grouped, planned_tasks = [], defaultdict(list), set()
|
|
27
|
+
for task, value in observations:
|
|
28
|
+
planned_tasks.add(task)
|
|
29
|
+
values.append(value)
|
|
30
|
+
if value is not None:
|
|
31
|
+
grouped[task].append(value)
|
|
32
|
+
means = [statistics.fmean(grouped[key]) for key in sorted(grouped)]
|
|
33
|
+
interval = {
|
|
34
|
+
"method": "percentile_bootstrap_equal_weight_task_means",
|
|
35
|
+
**settings,
|
|
36
|
+
"task_count": len(means),
|
|
37
|
+
"lower": None,
|
|
38
|
+
"upper": None,
|
|
39
|
+
"status": "insufficient_tasks",
|
|
40
|
+
}
|
|
41
|
+
if len(means) >= 2:
|
|
42
|
+
rng = random.Random(settings["seed"]) # nosec B311 - statistical resampling
|
|
43
|
+
samples = sorted(
|
|
44
|
+
statistics.fmean(rng.choices(means, k=len(means))) for _ in range(settings["samples"])
|
|
45
|
+
)
|
|
46
|
+
tail = (1 - settings["confidence"]) / 2
|
|
47
|
+
interval.update(
|
|
48
|
+
lower=_percentile(samples, tail),
|
|
49
|
+
upper=_percentile(samples, 1 - tail),
|
|
50
|
+
status="computed",
|
|
51
|
+
)
|
|
52
|
+
return {
|
|
53
|
+
"observation_summary": summarize_values(values),
|
|
54
|
+
"task_summary": summarize_values(means),
|
|
55
|
+
"interval": interval,
|
|
56
|
+
"planned_observation_count": len(values),
|
|
57
|
+
"planned_task_count": len(planned_tasks),
|
|
58
|
+
}
|