agentloop-profiler 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
agentloop/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
+ )
@@ -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")