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/drift.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Immutable reviewed baselines and conservative cohort drift comparisons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
|
|
9
|
+
from agentloop.drift_types import DriftRule, label, timestamp
|
|
10
|
+
from agentloop.drift_windows import DRIFT_VERSION, seal, validate_window, verify
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, init=False)
|
|
14
|
+
class ReviewedBaseline:
|
|
15
|
+
_json: str
|
|
16
|
+
|
|
17
|
+
def __init__(self, window, *, rules: tuple[DriftRule, ...], review_ref: str):
|
|
18
|
+
owned = validate_window(window)
|
|
19
|
+
label(review_ref, "review_ref")
|
|
20
|
+
if (
|
|
21
|
+
not isinstance(rules, tuple)
|
|
22
|
+
or not 1 <= len(rules) <= 32
|
|
23
|
+
or any(not isinstance(rule, DriftRule) for rule in rules)
|
|
24
|
+
):
|
|
25
|
+
raise ValueError("rules must be a tuple of 1..32 DriftRule declarations")
|
|
26
|
+
if len({rule.metric for rule in rules}) != len(rules):
|
|
27
|
+
raise ValueError("baseline metrics must be unique")
|
|
28
|
+
value = seal(
|
|
29
|
+
{
|
|
30
|
+
"schema_version": DRIFT_VERSION,
|
|
31
|
+
"kind": "agentloop.reviewed_baseline",
|
|
32
|
+
"window": owned,
|
|
33
|
+
"rules": [asdict(rule) for rule in rules],
|
|
34
|
+
"review_ref": review_ref,
|
|
35
|
+
"review_basis": "Explicit host review assertion; not an authenticated deployment approval.",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
object.__setattr__(self, "_json", json.dumps(value, sort_keys=True, allow_nan=False))
|
|
39
|
+
|
|
40
|
+
def to_dict(self):
|
|
41
|
+
return json.loads(self._json)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def sha256(self):
|
|
45
|
+
return self.to_dict()["sha256"]
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, value):
|
|
49
|
+
verify(value, "agentloop.reviewed_baseline")
|
|
50
|
+
try:
|
|
51
|
+
result = cls(
|
|
52
|
+
value["window"],
|
|
53
|
+
rules=tuple(DriftRule(**rule) for rule in value["rules"]),
|
|
54
|
+
review_ref=value["review_ref"],
|
|
55
|
+
)
|
|
56
|
+
if result.to_dict() != value:
|
|
57
|
+
raise ValueError("reviewed baseline fields do not match the supported contract")
|
|
58
|
+
return result
|
|
59
|
+
except (KeyError, TypeError) as exc:
|
|
60
|
+
raise ValueError("invalid reviewed baseline") from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _metric(cohort, name):
|
|
64
|
+
aggregate = cohort["aggregate"]
|
|
65
|
+
runs = aggregate["counts"]["runs"]
|
|
66
|
+
result = {
|
|
67
|
+
"interval": None,
|
|
68
|
+
"observed_count": 0,
|
|
69
|
+
"total_count": runs,
|
|
70
|
+
"run_count": runs,
|
|
71
|
+
"coverage": 0.0 if runs else None,
|
|
72
|
+
"kind": "observed",
|
|
73
|
+
"unit": "fraction",
|
|
74
|
+
}
|
|
75
|
+
value = None
|
|
76
|
+
observed = runs
|
|
77
|
+
if name == "latency_p95_ms":
|
|
78
|
+
quantile = aggregate["latency"]["quantile_intervals"]["0.95"]
|
|
79
|
+
result["unit"] = "ms"
|
|
80
|
+
result["kind"] = "histogram_interval"
|
|
81
|
+
result["timing_basis"] = aggregate["categories"]["runtime_basis"]
|
|
82
|
+
if quantile is not None:
|
|
83
|
+
result["interval"] = [quantile["lower_ms"], quantile["upper_ms"]]
|
|
84
|
+
elif name == "latency_mean_ms":
|
|
85
|
+
value, result["unit"] = aggregate["latency"]["mean_ms"], "ms"
|
|
86
|
+
result["timing_basis"] = aggregate["categories"]["runtime_basis"]
|
|
87
|
+
elif name in {"input_tokens_mean", "output_tokens_mean"}:
|
|
88
|
+
key = name.removesuffix("_mean")
|
|
89
|
+
complete = aggregate["completeness"][key]
|
|
90
|
+
value = complete / runs if complete is not None and runs else None
|
|
91
|
+
observed = runs - aggregate["counts"]["inexact_token_runs"]
|
|
92
|
+
result["unit"] = "tokens_per_run"
|
|
93
|
+
elif name == "known_cost_mean_usd":
|
|
94
|
+
complete = aggregate["completeness"]["pricing_complete_model_cost_usd"]
|
|
95
|
+
value = complete / runs if complete is not None and runs else None
|
|
96
|
+
observed = runs - aggregate["counts"]["unknown_cost_runs"]
|
|
97
|
+
result["unit"], result["kind"] = (
|
|
98
|
+
"USD_per_run",
|
|
99
|
+
"known_model_cost_including_calculated_estimates",
|
|
100
|
+
)
|
|
101
|
+
result["cost_sources"] = {
|
|
102
|
+
**aggregate["cost"],
|
|
103
|
+
"call_counts": aggregate["categories"]["cost_calls"],
|
|
104
|
+
"inexact_token_runs": aggregate["counts"]["inexact_token_runs"],
|
|
105
|
+
}
|
|
106
|
+
elif name == "failure_rate":
|
|
107
|
+
counts = aggregate["categories"]["outcomes"]
|
|
108
|
+
observed = counts["success"] + counts["failure"]
|
|
109
|
+
value = counts["failure"] / observed if observed else None
|
|
110
|
+
elif name in {
|
|
111
|
+
"timeout_rate",
|
|
112
|
+
"abstention_rate",
|
|
113
|
+
"finding_incidence",
|
|
114
|
+
"instrumentation_complete_rate",
|
|
115
|
+
}:
|
|
116
|
+
key = {
|
|
117
|
+
"timeout_rate": "timeout",
|
|
118
|
+
"abstention_rate": "abstention",
|
|
119
|
+
"finding_incidence": "finding",
|
|
120
|
+
"instrumentation_complete_rate": "instrumentation",
|
|
121
|
+
}[name]
|
|
122
|
+
flag = cohort["flags"][key]
|
|
123
|
+
observed = flag["known"]
|
|
124
|
+
value = flag["true"] / observed if observed else None
|
|
125
|
+
elif name.startswith("quality:"):
|
|
126
|
+
quality = cohort["quality"].get(name.partition(":")[2], {"count": 0, "sum": 0})
|
|
127
|
+
observed = quality["count"]
|
|
128
|
+
value = quality["sum"] / observed if observed else None
|
|
129
|
+
result["kind"] = "caller_supplied_quality_or_proxy"
|
|
130
|
+
if value is not None:
|
|
131
|
+
result["interval"] = [value, value]
|
|
132
|
+
result.update(observed_count=observed, coverage=observed / runs if runs else None)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _distribution(cohort, name):
|
|
137
|
+
mix = cohort["mixes"][name.removesuffix("_mix")]
|
|
138
|
+
return {
|
|
139
|
+
"counts": mix["counts"],
|
|
140
|
+
"observed_count": sum(mix["counts"].values()),
|
|
141
|
+
"total_count": mix["total"],
|
|
142
|
+
"run_count": cohort["aggregate"]["counts"]["runs"],
|
|
143
|
+
"missing_count": mix["missing"],
|
|
144
|
+
"overflow_count": mix["overflow"],
|
|
145
|
+
"individual_evidence_missing_runs": cohort["individual_evidence_missing_runs"],
|
|
146
|
+
"kind": "observed_distribution",
|
|
147
|
+
"unit": "total_variation_distance",
|
|
148
|
+
"coverage": sum(mix["counts"].values()) / mix["total"] if mix["total"] else None,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _compare_one(left, right, rule):
|
|
153
|
+
result = {
|
|
154
|
+
"metric": rule.metric,
|
|
155
|
+
"rule": asdict(rule),
|
|
156
|
+
"baseline": left,
|
|
157
|
+
"current": right,
|
|
158
|
+
"status": "indeterminate",
|
|
159
|
+
"reason": None,
|
|
160
|
+
"delta_interval": None,
|
|
161
|
+
"relative_delta_pct": None,
|
|
162
|
+
"threshold_interval": None,
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
min(left["observed_count"], right["observed_count"], left["run_count"], right["run_count"])
|
|
166
|
+
< rule.minimum_observations
|
|
167
|
+
):
|
|
168
|
+
result["reason"] = "small_or_missing_sample"
|
|
169
|
+
return result
|
|
170
|
+
if min(left["coverage"] or 0, right["coverage"] or 0) < rule.minimum_coverage:
|
|
171
|
+
result["reason"] = "incomplete_evidence"
|
|
172
|
+
return result
|
|
173
|
+
if rule.metric.endswith("_mix"):
|
|
174
|
+
if any(
|
|
175
|
+
side["overflow_count"] or side["individual_evidence_missing_runs"]
|
|
176
|
+
for side in (left, right)
|
|
177
|
+
):
|
|
178
|
+
result["reason"] = "distribution_evidence_missing_or_overflowed"
|
|
179
|
+
return result
|
|
180
|
+
keys = left["counts"].keys() | right["counts"].keys()
|
|
181
|
+
distance = (
|
|
182
|
+
sum(
|
|
183
|
+
abs(
|
|
184
|
+
left["counts"].get(key, 0) / left["observed_count"]
|
|
185
|
+
- right["counts"].get(key, 0) / right["observed_count"]
|
|
186
|
+
)
|
|
187
|
+
for key in keys
|
|
188
|
+
)
|
|
189
|
+
/ 2
|
|
190
|
+
)
|
|
191
|
+
before, after = [0.0, 0.0], [distance, distance]
|
|
192
|
+
else:
|
|
193
|
+
before, after = left["interval"], right["interval"]
|
|
194
|
+
if before is None or after is None:
|
|
195
|
+
result["reason"] = "required_measurement_unavailable"
|
|
196
|
+
return result
|
|
197
|
+
delta = [after[0] - before[1], after[1] - before[0]]
|
|
198
|
+
result["delta_interval"] = delta
|
|
199
|
+
if before[0] == before[1] and before[0] != 0 and after[0] == after[1]:
|
|
200
|
+
relative = 100 * ((after[0] - before[0]) / abs(before[0]))
|
|
201
|
+
result["relative_delta_pct"] = relative if math.isfinite(relative) else None
|
|
202
|
+
if rule.relative_pct is not None and before[0] <= 0 and rule.absolute is None:
|
|
203
|
+
result["reason"] = "relative_threshold_has_zero_baseline"
|
|
204
|
+
return result
|
|
205
|
+
thresholds = [
|
|
206
|
+
max(rule.absolute or 0, abs(value) * ((rule.relative_pct or 0) / 100)) for value in before
|
|
207
|
+
]
|
|
208
|
+
if not all(
|
|
209
|
+
math.isfinite(value) and math.isfinite(value + rule.noise_margin) for value in thresholds
|
|
210
|
+
):
|
|
211
|
+
result["reason"] = "threshold_arithmetic_overflow"
|
|
212
|
+
return result
|
|
213
|
+
result["threshold_interval"] = thresholds
|
|
214
|
+
worsening = delta if rule.direction == "increase" else [-delta[1], -delta[0]]
|
|
215
|
+
if worsening[0] > thresholds[1] + rule.noise_margin:
|
|
216
|
+
result.update(status="alert", reason="observed_change_exceeds_reviewed_threshold")
|
|
217
|
+
elif worsening[1] <= max(0.0, thresholds[0] - rule.noise_margin):
|
|
218
|
+
result.update(status="within_bounds", reason="observed_change_within_reviewed_threshold")
|
|
219
|
+
else:
|
|
220
|
+
result["reason"] = "quantile_resolution_or_reviewed_noise_margin"
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _quality_coverage(window):
|
|
225
|
+
rows = [
|
|
226
|
+
{
|
|
227
|
+
"cohort": cohort,
|
|
228
|
+
"metric": metric["name"],
|
|
229
|
+
"kind": metric["kind"],
|
|
230
|
+
"observed_count": values["quality"][metric["name"]]["count"],
|
|
231
|
+
"total_count": values["aggregate"]["counts"]["runs"],
|
|
232
|
+
}
|
|
233
|
+
for cohort, values in window["cohorts"].items()
|
|
234
|
+
for metric in window["quality_metrics"]
|
|
235
|
+
]
|
|
236
|
+
status = (
|
|
237
|
+
"missing"
|
|
238
|
+
if not rows or not any(row["observed_count"] for row in rows)
|
|
239
|
+
else "partial"
|
|
240
|
+
if any(row["observed_count"] < row["total_count"] for row in rows)
|
|
241
|
+
else "complete_for_declared_metrics"
|
|
242
|
+
)
|
|
243
|
+
return {"status": status, "metrics": rows}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def compare_drift(baseline: ReviewedBaseline, current, *, expected_baseline_sha256=None):
|
|
247
|
+
if not isinstance(baseline, ReviewedBaseline):
|
|
248
|
+
raise TypeError("baseline must be ReviewedBaseline")
|
|
249
|
+
reference = baseline.to_dict()
|
|
250
|
+
if expected_baseline_sha256 is not None and reference["sha256"] != expected_baseline_sha256:
|
|
251
|
+
raise ValueError("reviewed baseline does not match the pinned hash")
|
|
252
|
+
current = validate_window(current)
|
|
253
|
+
previous = reference["window"]
|
|
254
|
+
chronological = timestamp(current["spec"]["started_at"]) >= timestamp(
|
|
255
|
+
previous["spec"]["ended_at"]
|
|
256
|
+
)
|
|
257
|
+
confounders = [
|
|
258
|
+
key
|
|
259
|
+
for key in ("workload_version", "config_version", "model_version")
|
|
260
|
+
if previous["spec"][key] != current["spec"][key]
|
|
261
|
+
]
|
|
262
|
+
metrics = []
|
|
263
|
+
for cohort in sorted(previous["cohorts"].keys() | current["cohorts"].keys()):
|
|
264
|
+
left, right = previous["cohorts"].get(cohort), current["cohorts"].get(cohort)
|
|
265
|
+
for raw_rule in reference["rules"]:
|
|
266
|
+
rule = DriftRule(**raw_rule)
|
|
267
|
+
missing = left is None or right is None
|
|
268
|
+
getter = _distribution if rule.metric.endswith("_mix") else _metric
|
|
269
|
+
before = getter(left, rule.metric) if left is not None else None
|
|
270
|
+
after = getter(right, rule.metric) if right is not None else None
|
|
271
|
+
if rule.metric.startswith("quality:"):
|
|
272
|
+
name = rule.metric.partition(":")[2]
|
|
273
|
+
for item, window in ((before, previous), (after, current)):
|
|
274
|
+
if item is not None:
|
|
275
|
+
definition = next(
|
|
276
|
+
(
|
|
277
|
+
metric
|
|
278
|
+
for metric in window["quality_metrics"]
|
|
279
|
+
if metric["name"] == name
|
|
280
|
+
),
|
|
281
|
+
None,
|
|
282
|
+
)
|
|
283
|
+
item["quality_definition"] = definition
|
|
284
|
+
item["kind"] = definition["kind"] if definition else "missing"
|
|
285
|
+
incompatibility = None
|
|
286
|
+
if not chronological:
|
|
287
|
+
incompatibility = "overlapping_or_nonlater_window"
|
|
288
|
+
elif missing:
|
|
289
|
+
incompatibility = "cohort_missing_from_one_window"
|
|
290
|
+
elif (
|
|
291
|
+
left["aggregate"]["semantics"] != right["aggregate"]["semantics"]
|
|
292
|
+
or left["aggregate"]["config"] != right["aggregate"]["config"]
|
|
293
|
+
):
|
|
294
|
+
incompatibility = "sampling_retention_pricing_or_aggregation_changed"
|
|
295
|
+
elif rule.metric.startswith("latency_") and {
|
|
296
|
+
key for key, count in before["timing_basis"].items() if count
|
|
297
|
+
} != {key for key, count in after["timing_basis"].items() if count}:
|
|
298
|
+
incompatibility = "runtime_measurement_basis_changed"
|
|
299
|
+
elif (
|
|
300
|
+
rule.metric.startswith("quality:")
|
|
301
|
+
and before["quality_definition"] != after["quality_definition"]
|
|
302
|
+
):
|
|
303
|
+
incompatibility = "quality_definition_changed"
|
|
304
|
+
elif rule.metric == "finding_incidence" and previous["settings"] != current["settings"]:
|
|
305
|
+
incompatibility = "finding_analysis_configuration_changed"
|
|
306
|
+
if incompatibility:
|
|
307
|
+
result = {
|
|
308
|
+
"metric": rule.metric,
|
|
309
|
+
"rule": raw_rule,
|
|
310
|
+
"baseline": before,
|
|
311
|
+
"current": after,
|
|
312
|
+
"status": "incomparable",
|
|
313
|
+
"reason": incompatibility,
|
|
314
|
+
"delta_interval": None,
|
|
315
|
+
"relative_delta_pct": None,
|
|
316
|
+
"threshold_interval": None,
|
|
317
|
+
}
|
|
318
|
+
else:
|
|
319
|
+
result = _compare_one(before, after, rule)
|
|
320
|
+
result["cohort"] = cohort
|
|
321
|
+
result["window_membership"] = {
|
|
322
|
+
"baseline": left["time_membership"] if left is not None else None,
|
|
323
|
+
"current": right["time_membership"] if right is not None else None,
|
|
324
|
+
}
|
|
325
|
+
metrics.append(result)
|
|
326
|
+
statuses = {metric["status"] for metric in metrics}
|
|
327
|
+
status = (
|
|
328
|
+
"alert"
|
|
329
|
+
if "alert" in statuses
|
|
330
|
+
else "indeterminate"
|
|
331
|
+
if statuses & {"indeterminate", "incomparable"} or not metrics
|
|
332
|
+
else "within_bounds"
|
|
333
|
+
)
|
|
334
|
+
return seal(
|
|
335
|
+
{
|
|
336
|
+
"schema_version": DRIFT_VERSION,
|
|
337
|
+
"kind": "agentloop.drift_report",
|
|
338
|
+
"status": status,
|
|
339
|
+
"baseline_sha256": reference["sha256"],
|
|
340
|
+
"baseline_review_ref": reference["review_ref"],
|
|
341
|
+
"baseline_window": {"sha256": previous["sha256"], **previous["spec"]},
|
|
342
|
+
"current_window": {"sha256": current["sha256"], **current["spec"]},
|
|
343
|
+
"potential_confounders": confounders,
|
|
344
|
+
"metrics": metrics,
|
|
345
|
+
"producer_markers": {
|
|
346
|
+
"baseline": {
|
|
347
|
+
cohort: data["producer_markers"] for cohort, data in previous["cohorts"].items()
|
|
348
|
+
},
|
|
349
|
+
"current": {
|
|
350
|
+
cohort: data["producer_markers"] for cohort, data in current["cohorts"].items()
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
"quality_evidence": {
|
|
354
|
+
"baseline_definitions": previous["quality_metrics"],
|
|
355
|
+
"current_definitions": current["quality_metrics"],
|
|
356
|
+
"baseline": _quality_coverage(previous),
|
|
357
|
+
"current": _quality_coverage(current),
|
|
358
|
+
},
|
|
359
|
+
"interpretation": "Descriptive threshold comparisons, not causal attribution, significance tests or a correctness inference from outcome drift. No deployment or notification action is taken.",
|
|
360
|
+
}
|
|
361
|
+
)
|
agentloop/drift_cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Local window collection, explicit baseline review references and drift reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from agentloop.aggregate_stats import AggregateConfig
|
|
11
|
+
from agentloop.aggregates import TraceAggregate, read_json
|
|
12
|
+
from agentloop.drift import ReviewedBaseline, compare_drift
|
|
13
|
+
from agentloop.drift_types import DriftRule, QualityMetric, WindowSpec
|
|
14
|
+
from agentloop.drift_windows import WindowBuilder
|
|
15
|
+
from agentloop.tracer import AgentTrace
|
|
16
|
+
|
|
17
|
+
drift_app = typer.Typer(help="Compare local evidence windows against immutable reviewed baselines.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def collect_window(path):
|
|
21
|
+
path = Path(path)
|
|
22
|
+
manifest = read_json(path)
|
|
23
|
+
if manifest.get("schema_version") != "1.0":
|
|
24
|
+
raise ValueError("unsupported window manifest")
|
|
25
|
+
raw_config = manifest.get("aggregate_config", {})
|
|
26
|
+
config = AggregateConfig(
|
|
27
|
+
**{
|
|
28
|
+
key: tuple(value) if key == "latency_bounds_ms" else value
|
|
29
|
+
for key, value in raw_config.items()
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
builder = WindowBuilder(
|
|
33
|
+
WindowSpec(**manifest["spec"]),
|
|
34
|
+
quality_metrics=tuple(
|
|
35
|
+
QualityMetric(**item) for item in manifest.get("quality_metrics", [])
|
|
36
|
+
),
|
|
37
|
+
config=config,
|
|
38
|
+
include_findings=manifest.get("include_findings", False),
|
|
39
|
+
)
|
|
40
|
+
records = manifest["records"]
|
|
41
|
+
if not isinstance(records, list) or len(records) > 100_000:
|
|
42
|
+
raise ValueError("manifest records must be a list of at most 100000 entries")
|
|
43
|
+
for record in records:
|
|
44
|
+
if (
|
|
45
|
+
not isinstance(record, dict)
|
|
46
|
+
or not set(record)
|
|
47
|
+
<= {
|
|
48
|
+
"trace",
|
|
49
|
+
"aggregate",
|
|
50
|
+
"cohort",
|
|
51
|
+
"timeout",
|
|
52
|
+
"abstention",
|
|
53
|
+
"quality",
|
|
54
|
+
"time_assignment_ref",
|
|
55
|
+
}
|
|
56
|
+
or ("trace" in record) == ("aggregate" in record)
|
|
57
|
+
):
|
|
58
|
+
raise ValueError("each record needs exactly one trace or aggregate path")
|
|
59
|
+
cohort = record.get("cohort", "all")
|
|
60
|
+
if "trace" in record:
|
|
61
|
+
if "time_assignment_ref" in record:
|
|
62
|
+
raise ValueError("native traces use their own completion timestamps")
|
|
63
|
+
builder.add(
|
|
64
|
+
AgentTrace.from_dict(read_json(path.parent / record["trace"])),
|
|
65
|
+
cohort=cohort,
|
|
66
|
+
timeout=record.get("timeout"),
|
|
67
|
+
abstention=record.get("abstention"),
|
|
68
|
+
quality=record.get("quality"),
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
if set(record) & {"timeout", "abstention", "quality"}:
|
|
72
|
+
raise ValueError("per-run annotations cannot be broadcast over an aggregate")
|
|
73
|
+
builder.add_aggregate(
|
|
74
|
+
TraceAggregate.from_dict(read_json(path.parent / record["aggregate"])),
|
|
75
|
+
cohort=cohort,
|
|
76
|
+
time_assignment_ref=record.get("time_assignment_ref"),
|
|
77
|
+
)
|
|
78
|
+
return builder.to_dict()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _write_new(path, value):
|
|
82
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
with path.open("x", encoding="utf-8") as handle:
|
|
84
|
+
json.dump(value, handle, indent=2, allow_nan=False)
|
|
85
|
+
handle.write("\n")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@drift_app.command("collect")
|
|
89
|
+
def collect_command(manifest: Path, out: Path = typer.Option(...)):
|
|
90
|
+
"""Create a content-bound window from disjoint trace/aggregate records."""
|
|
91
|
+
from agentloop.cli import console
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
result = collect_window(manifest)
|
|
95
|
+
_write_new(out, result)
|
|
96
|
+
except (OSError, ValueError, TypeError, KeyError, AttributeError) as exc:
|
|
97
|
+
raise typer.BadParameter(str(exc), param_hint="manifest/out") from None
|
|
98
|
+
console.print(f"Wrote window {result['sha256']} to {out}", markup=False)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@drift_app.command("baseline")
|
|
102
|
+
def baseline_command(
|
|
103
|
+
window: Path, rules: Path, review_ref: str = typer.Option(...), out: Path = typer.Option(...)
|
|
104
|
+
):
|
|
105
|
+
"""Freeze an explicit host-reviewed window and its thresholds; never overwrite."""
|
|
106
|
+
from agentloop.cli import console
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
definitions = read_json(rules)
|
|
110
|
+
if not isinstance(definitions, list) or len(definitions) > 32:
|
|
111
|
+
raise ValueError("rules must contain at most 32 declarations")
|
|
112
|
+
result = ReviewedBaseline(
|
|
113
|
+
read_json(window),
|
|
114
|
+
rules=tuple(DriftRule(**item) for item in definitions),
|
|
115
|
+
review_ref=review_ref,
|
|
116
|
+
)
|
|
117
|
+
_write_new(out, result.to_dict())
|
|
118
|
+
except (OSError, ValueError, TypeError, KeyError, AttributeError) as exc:
|
|
119
|
+
raise typer.BadParameter(str(exc), param_hint="window/rules/out") from None
|
|
120
|
+
console.print(f"Reviewed baseline SHA256: {result.sha256}", markup=False)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@drift_app.command("compare")
|
|
124
|
+
def compare_command(
|
|
125
|
+
baseline: Path,
|
|
126
|
+
current: Path,
|
|
127
|
+
baseline_sha256: str = typer.Option(..., help="Pin the reviewed baseline content hash."),
|
|
128
|
+
out: Path = typer.Option(...),
|
|
129
|
+
):
|
|
130
|
+
"""Write descriptive drift evidence; take no notification or deployment action."""
|
|
131
|
+
from agentloop.cli import console
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
result = compare_drift(
|
|
135
|
+
ReviewedBaseline.from_dict(read_json(baseline)),
|
|
136
|
+
read_json(current),
|
|
137
|
+
expected_baseline_sha256=baseline_sha256,
|
|
138
|
+
)
|
|
139
|
+
_write_new(out, result)
|
|
140
|
+
except (OSError, ValueError, TypeError, KeyError, AttributeError) as exc:
|
|
141
|
+
raise typer.BadParameter(str(exc), param_hint="baseline/current/out") from None
|
|
142
|
+
console.print(f"Drift status: {result['status']}", markup=False)
|
|
143
|
+
for metric in result["metrics"]:
|
|
144
|
+
console.print(
|
|
145
|
+
f"{metric['cohort']} / {metric['metric']}: {metric['status']} ({metric['reason']})",
|
|
146
|
+
markup=False,
|
|
147
|
+
)
|
agentloop/drift_types.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Explicit window, quality and threshold declarations for local drift reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def label(value, name):
|
|
11
|
+
if not isinstance(value, str) or not value.strip() or len(value) > 256:
|
|
12
|
+
raise ValueError(f"{name} must be a nonempty reference of at most 256 characters")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def timestamp(value):
|
|
16
|
+
label(value, "timestamp")
|
|
17
|
+
result = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
18
|
+
if result.tzinfo is None:
|
|
19
|
+
raise ValueError("window timestamps require a timezone")
|
|
20
|
+
return result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class WindowSpec:
|
|
25
|
+
window_id: str
|
|
26
|
+
version: str
|
|
27
|
+
started_at: str
|
|
28
|
+
ended_at: str
|
|
29
|
+
workload_version: str
|
|
30
|
+
config_version: str
|
|
31
|
+
model_version: str
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
for key, value in asdict(self).items():
|
|
35
|
+
label(value, key)
|
|
36
|
+
if timestamp(self.started_at) >= timestamp(self.ended_at):
|
|
37
|
+
raise ValueError("window end must follow its start")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class QualityMetric:
|
|
42
|
+
name: str
|
|
43
|
+
kind: str
|
|
44
|
+
version: str
|
|
45
|
+
source_ref: str
|
|
46
|
+
|
|
47
|
+
def __post_init__(self):
|
|
48
|
+
for key, value in asdict(self).items():
|
|
49
|
+
label(value, key)
|
|
50
|
+
if self.kind not in {"quality", "proxy"}:
|
|
51
|
+
raise ValueError("quality metric kind must be quality or proxy")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class DriftRule:
|
|
56
|
+
metric: str
|
|
57
|
+
direction: str = "increase"
|
|
58
|
+
absolute: float | None = None
|
|
59
|
+
relative_pct: float | None = None
|
|
60
|
+
noise_margin: float = 0.0
|
|
61
|
+
minimum_observations: int = 20
|
|
62
|
+
minimum_coverage: float = 1.0
|
|
63
|
+
|
|
64
|
+
def __post_init__(self):
|
|
65
|
+
label(self.metric, "metric")
|
|
66
|
+
allowed = {
|
|
67
|
+
"latency_mean_ms",
|
|
68
|
+
"latency_p95_ms",
|
|
69
|
+
"input_tokens_mean",
|
|
70
|
+
"output_tokens_mean",
|
|
71
|
+
"known_cost_mean_usd",
|
|
72
|
+
"failure_rate",
|
|
73
|
+
"timeout_rate",
|
|
74
|
+
"abstention_rate",
|
|
75
|
+
"finding_incidence",
|
|
76
|
+
"instrumentation_complete_rate",
|
|
77
|
+
"model_mix",
|
|
78
|
+
"stage_mix",
|
|
79
|
+
"outcome_mix",
|
|
80
|
+
"decision_outcome_mix",
|
|
81
|
+
}
|
|
82
|
+
if self.metric not in allowed and not self.metric.startswith("quality:"):
|
|
83
|
+
raise ValueError("unsupported drift metric")
|
|
84
|
+
if self.metric.startswith("quality:"):
|
|
85
|
+
label(self.metric.partition(":")[2], "quality name")
|
|
86
|
+
if self.direction not in {"increase", "decrease"}:
|
|
87
|
+
raise ValueError("direction must be increase or decrease")
|
|
88
|
+
if self.absolute is None and self.relative_pct is None:
|
|
89
|
+
raise ValueError("at least one absolute or relative threshold is required")
|
|
90
|
+
for key in ("absolute", "relative_pct", "noise_margin", "minimum_coverage"):
|
|
91
|
+
value = getattr(self, key)
|
|
92
|
+
if value is None and key in {"absolute", "relative_pct"}:
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
valid = type(value) in {int, float} and math.isfinite(value) and value >= 0
|
|
96
|
+
except OverflowError:
|
|
97
|
+
valid = False
|
|
98
|
+
if not valid:
|
|
99
|
+
raise ValueError(f"{key} must be finite and nonnegative")
|
|
100
|
+
if not 0 < self.minimum_coverage <= 1:
|
|
101
|
+
raise ValueError("minimum_coverage must be in (0,1]")
|
|
102
|
+
if (
|
|
103
|
+
type(self.minimum_observations) is not int
|
|
104
|
+
or not 2 <= self.minimum_observations <= 1_000_000
|
|
105
|
+
):
|
|
106
|
+
raise ValueError("minimum_observations must be 2..1000000")
|
|
107
|
+
if self.metric.endswith("_mix") and (
|
|
108
|
+
self.direction != "increase" or self.absolute is None or self.relative_pct is not None
|
|
109
|
+
):
|
|
110
|
+
raise ValueError("mix drift requires an absolute increase threshold")
|