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
@@ -0,0 +1,241 @@
1
+ """AgentLoop-owned contracts for optional offline semantic judgments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ from dataclasses import asdict, dataclass
9
+ from typing import Any, Protocol
10
+
11
+ JUDGMENT_VERSION = "1.0"
12
+ JUDGMENT_KEY = "agentloop.judgments"
13
+
14
+
15
+ def canonical(value: Any) -> str:
16
+ try:
17
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
18
+ except (TypeError, ValueError, RecursionError):
19
+ raise ValueError("judgment data must contain finite JSON values") from None
20
+
21
+
22
+ def fingerprint(value: Any) -> str:
23
+ return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()
24
+
25
+
26
+ def text(value: Any, name: str, *, optional: bool = False) -> None:
27
+ if value is None and optional:
28
+ return
29
+ if not isinstance(value, str) or not value.strip() or len(value) > 4096:
30
+ raise ValueError(f"{name} must be nonempty text of at most 4096 characters")
31
+
32
+
33
+ def finite(value: Any) -> bool:
34
+ try:
35
+ return type(value) in {int, float} and math.isfinite(value)
36
+ except OverflowError:
37
+ return False
38
+
39
+
40
+ def is_hash(value: Any) -> bool:
41
+ return (
42
+ isinstance(value, str)
43
+ and len(value) == 64
44
+ and all(char in "0123456789abcdef" for char in value)
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class JudgeIdentity:
50
+ implementation: str
51
+ version: str
52
+ config_hash: str
53
+ provider: str = "local"
54
+ model_or_rule: str = "callback"
55
+ revision: str = "1"
56
+
57
+ def __post_init__(self):
58
+ for key, value in asdict(self).items():
59
+ text(value, key)
60
+ if not is_hash(self.config_hash):
61
+ raise ValueError("config_hash must be a SHA-256 digest")
62
+
63
+ @classmethod
64
+ def configured(cls, implementation: str, version: str, configuration: Any, **kwargs):
65
+ """Hash caller-owned configuration without retaining or forwarding its contents."""
66
+ return cls(implementation, version, fingerprint(configuration), **kwargs)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class JudgmentSpec:
71
+ question: str
72
+ kind: str
73
+ choices: tuple[str, ...] = ()
74
+ minimum: float | None = None
75
+ maximum: float | None = None
76
+
77
+ def __post_init__(self):
78
+ text(self.question, "question")
79
+ if self.kind not in {"boolean", "probability", "choice", "score"}:
80
+ raise ValueError("unsupported judgment kind")
81
+ if not isinstance(self.choices, (list, tuple)):
82
+ raise ValueError("choices must be a list or tuple")
83
+ object.__setattr__(self, "choices", tuple(self.choices))
84
+ if self.kind == "choice":
85
+ for item in self.choices:
86
+ text(item, "choice")
87
+ if not self.choices or len(set(self.choices)) != len(self.choices):
88
+ raise ValueError("choices must be nonempty and unique")
89
+ elif self.choices:
90
+ raise ValueError("choices apply only to choice judgments")
91
+ if self.kind == "score":
92
+ if not finite(self.minimum) or not finite(self.maximum) or self.minimum >= self.maximum:
93
+ raise ValueError("score requires finite minimum < maximum")
94
+ elif self.minimum is not None or self.maximum is not None:
95
+ raise ValueError("explicit bounds apply only to score judgments")
96
+
97
+ def accepts(self, value: Any) -> bool:
98
+ if self.kind == "boolean":
99
+ return type(value) is bool
100
+ if self.kind == "choice":
101
+ return isinstance(value, str) and value in self.choices
102
+ if not finite(value):
103
+ return False
104
+ low, high = (0, 1) if self.kind == "probability" else (self.minimum, self.maximum)
105
+ return low <= value <= high
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class JudgmentEvidence:
110
+ span_id: str
111
+ source_hash: str
112
+ operation_kind: str
113
+ status: str
114
+ duration_ms: float
115
+ summary: str | None = None
116
+
117
+ def __post_init__(self):
118
+ text(self.span_id, "span_id")
119
+ text(self.operation_kind, "operation_kind")
120
+ if not is_hash(self.source_hash):
121
+ raise ValueError("source_hash must be a SHA-256 digest")
122
+ if self.status not in {"ok", "error", "unknown"}:
123
+ raise ValueError("unsupported observed status")
124
+ if not finite(self.duration_ms) or self.duration_ms < 0:
125
+ raise ValueError("duration_ms must be finite and nonnegative")
126
+ text(self.summary, "summary", optional=True)
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class JudgmentRequest:
131
+ run_id: str
132
+ spec: JudgmentSpec
133
+ evidence: tuple[JudgmentEvidence, ...]
134
+
135
+ def __post_init__(self):
136
+ text(self.run_id, "run_id")
137
+ if type(self.spec) is not JudgmentSpec:
138
+ raise ValueError("spec must be JudgmentSpec")
139
+ if not isinstance(self.evidence, (tuple, list)):
140
+ raise ValueError("evidence must be a list or tuple")
141
+ object.__setattr__(self, "evidence", tuple(self.evidence))
142
+ if not self.evidence or any(type(item) is not JudgmentEvidence for item in self.evidence):
143
+ raise ValueError("at least one JudgmentEvidence is required")
144
+ if len({item.span_id for item in self.evidence}) != len(self.evidence):
145
+ raise ValueError("evidence span IDs must be unique")
146
+
147
+ def to_dict(self):
148
+ return json.loads(canonical(asdict(self)))
149
+
150
+ @classmethod
151
+ def from_dict(cls, value):
152
+ return cls(
153
+ value["run_id"],
154
+ JudgmentSpec(**value["spec"]),
155
+ tuple(JudgmentEvidence(**item) for item in value["evidence"]),
156
+ )
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class JudgeUsage:
161
+ input_tokens: int | None = None
162
+ output_tokens: int | None = None
163
+ cost_usd: float | None = None
164
+ token_basis: str = "unknown"
165
+ cost_basis: str = "unknown"
166
+
167
+ def __post_init__(self):
168
+ for count in (self.input_tokens, self.output_tokens):
169
+ if count is not None and (type(count) is not int or count < 0):
170
+ raise ValueError("known token counts must be nonnegative integers")
171
+ if self.cost_usd is not None and (not finite(self.cost_usd) or self.cost_usd < 0):
172
+ raise ValueError("known cost must be finite and nonnegative")
173
+ if self.token_basis not in {"unknown", "reported", "estimated", "not_dispatched"}:
174
+ raise ValueError("unsupported token basis")
175
+ if self.cost_basis not in {"unknown", "reported", "calculated", "not_dispatched"}:
176
+ raise ValueError("unsupported cost basis")
177
+ if (self.input_tokens is not None or self.output_tokens is not None) == (
178
+ # A model-usage provenance label, not an authentication credential.
179
+ self.token_basis == "unknown" # nosec B105
180
+ ):
181
+ raise ValueError("token counts and basis must agree")
182
+ if (self.cost_usd is not None) == (self.cost_basis == "unknown"):
183
+ raise ValueError("cost and basis must agree")
184
+ # This public label records that no model invocation occurred.
185
+ if self.token_basis == "not_dispatched" and (self.input_tokens, self.output_tokens) != ( # nosec B105
186
+ 0,
187
+ 0,
188
+ ):
189
+ raise ValueError("undispatched usage must be zero")
190
+ if self.cost_basis == "not_dispatched" and self.cost_usd != 0:
191
+ raise ValueError("undispatched cost must be zero")
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class JudgmentUncertainty:
196
+ confidence: float | None = None
197
+ calibration_status: str = "uncalibrated"
198
+ calibration_ref: str | None = None
199
+
200
+ def __post_init__(self):
201
+ if self.confidence is not None and (
202
+ not finite(self.confidence) or not 0 <= self.confidence <= 1
203
+ ):
204
+ raise ValueError("confidence must be in [0, 1]")
205
+ if self.calibration_status not in {"uncalibrated", "measured"}:
206
+ raise ValueError("unsupported calibration status")
207
+ text(self.calibration_ref, "calibration_ref", optional=True)
208
+ if self.calibration_status == "measured" and self.calibration_ref is None:
209
+ raise ValueError("measured calibration requires a caller-supplied evidence reference")
210
+
211
+
212
+ @dataclass(frozen=True)
213
+ class JudgmentAnswer:
214
+ value: Any = None
215
+ status: str = "known"
216
+ reason: str | None = None
217
+ usage: JudgeUsage = JudgeUsage()
218
+ uncertainty: JudgmentUncertainty = JudgmentUncertainty()
219
+
220
+ def __post_init__(self):
221
+ if self.status not in {"known", "unknown"}:
222
+ raise ValueError("answer status must be known or unknown")
223
+ if self.status == "unknown":
224
+ if self.value is not None or self.reason not in {"abstained", "insufficient_evidence"}:
225
+ raise ValueError("unknown answers require a reason and no value")
226
+ elif self.reason is not None:
227
+ raise ValueError("known answers cannot carry a failure reason")
228
+ if type(self.usage) is not JudgeUsage or type(self.uncertainty) is not JudgmentUncertainty:
229
+ raise ValueError("answer requires typed usage and uncertainty")
230
+ if self.value is not None and type(self.value) not in {str, bool, int, float}:
231
+ raise ValueError("answer must be a scalar")
232
+ canonical(self.value)
233
+
234
+
235
+ class JudgmentBackend(Protocol):
236
+ """Adapters own cooperative time limits and must return a typed answer."""
237
+
238
+ @property
239
+ def identity(self) -> JudgeIdentity: ...
240
+
241
+ def judge(self, request: JudgmentRequest, *, timeout_s: float | None) -> JudgmentAnswer: ...
@@ -0,0 +1,38 @@
1
+ """Text projection of saved judgment evidence; never executes a backend."""
2
+
3
+ from agentloop.markdown import markdown_table_cell
4
+
5
+
6
+ def judgment_markdown(evidence):
7
+ lines = [
8
+ "",
9
+ "## Offline semantic judgments",
10
+ "",
11
+ "Recorded execution facts, deterministic rule inferences, and semantic judge answers "
12
+ "are separate evidence categories. Judge answers do not establish task quality or "
13
+ "calibrated confidence. Judge latency and cost are analysis overhead, separate from workflow totals.",
14
+ "",
15
+ f"Evidence status: {markdown_table_cell(evidence['status'])}",
16
+ "",
17
+ "| Judge / version | Kind | Status | Value | Confidence basis | Cache hit | Incurred cost USD |",
18
+ "| --- | --- | --- | --- | --- | --- | --- |",
19
+ ]
20
+ for record in evidence["records"]:
21
+ judge, result = record["judge"], record["evaluation"]
22
+ cells = [
23
+ f"{judge['implementation']} / {judge['version']}",
24
+ record["request"]["spec"]["kind"],
25
+ record["effective_status"],
26
+ record["effective_value"],
27
+ result["uncertainty"]["calibration_status"],
28
+ record["invocation"]["cache_hit"],
29
+ record["invocation"]["usage"]["cost_usd"],
30
+ ]
31
+ lines.append(
32
+ "| "
33
+ + " | ".join(
34
+ markdown_table_cell("unavailable" if item is None else str(item)) for item in cells
35
+ )
36
+ + " |"
37
+ )
38
+ return lines
agentloop/judgments.py ADDED
@@ -0,0 +1,342 @@
1
+ """Explicit offline judgment execution, bounded caching and portable receipts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ import time
8
+ from collections import OrderedDict
9
+ from dataclasses import asdict, dataclass
10
+ from typing import Callable
11
+ from uuid import uuid4
12
+
13
+ from agentloop.events import AgentEvent, utc_now_iso
14
+ from agentloop.judgment_types import (
15
+ JUDGMENT_KEY,
16
+ JUDGMENT_VERSION,
17
+ JudgeIdentity,
18
+ JudgeUsage,
19
+ JudgmentAnswer,
20
+ JudgmentBackend,
21
+ JudgmentEvidence,
22
+ JudgmentRequest,
23
+ JudgmentSpec,
24
+ JudgmentUncertainty,
25
+ canonical,
26
+ fingerprint,
27
+ finite,
28
+ )
29
+ from agentloop.operations import OPERATION_KINDS, operation_kind
30
+
31
+ _NO_USAGE = JudgeUsage(0, 0, 0.0, "not_dispatched", "not_dispatched")
32
+ _FAILURES = {"disabled", "timeout", "error", "invalid_result"}
33
+
34
+
35
+ def _copy(value):
36
+ return json.loads(canonical(value))
37
+
38
+
39
+ def _event_hash(event):
40
+ # Use the importer normalization so an integer duration and its native JSON
41
+ # round trip have the same fingerprint. Bodies are hashed, never forwarded.
42
+ source = AgentEvent.from_dict(event.to_dict()).to_dict()
43
+ source["metadata"] = {
44
+ key: value
45
+ for key, value in source["metadata"].items()
46
+ if key not in {"otel_span_id", "otel_trace_id"}
47
+ }
48
+ return fingerprint(source)
49
+
50
+
51
+ def _evidence(event, summary, hashes):
52
+ if event.event_id not in hashes:
53
+ hashes[event.event_id] = _event_hash(event)
54
+ kind = operation_kind(event)
55
+ return JudgmentEvidence(
56
+ event.event_id,
57
+ hashes[event.event_id],
58
+ kind if kind in OPERATION_KINDS else "unknown",
59
+ event.status if event.status in {"ok", "error"} else "unknown",
60
+ float(event.duration_ms),
61
+ summary,
62
+ )
63
+
64
+
65
+ def judgment_request(trace, spec: JudgmentSpec, span_ids, *, summaries=None) -> JudgmentRequest:
66
+ """Select references/observations; only explicitly supplied summaries carry text."""
67
+ if not isinstance(span_ids, (list, tuple)) or not span_ids:
68
+ raise ValueError("span_ids must be a nonempty list or tuple")
69
+ if any(not isinstance(item, str) for item in span_ids) or len(set(span_ids)) != len(span_ids):
70
+ raise ValueError("span_ids must contain unique strings")
71
+ summaries = {} if summaries is None else summaries
72
+ if not isinstance(summaries, dict) or set(summaries) - set(span_ids):
73
+ raise ValueError("summaries must map selected span IDs to explicitly supplied text")
74
+ events = {event.event_id: event for event in trace.events}
75
+ if len(events) != len(trace.events) or set(span_ids) - events.keys():
76
+ raise ValueError("selected span IDs must resolve uniquely in the trace")
77
+ hashes = {}
78
+ evidence = [_evidence(events[span_id], summaries.get(span_id), hashes) for span_id in span_ids]
79
+ return JudgmentRequest(trace.run_id, spec, tuple(evidence))
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class LocalCallbackJudge:
84
+ """Trusted synchronous callback; no imports, provider client or worker threads."""
85
+
86
+ identity: JudgeIdentity
87
+ callback: Callable
88
+
89
+ def __post_init__(self):
90
+ if type(self.identity) is not JudgeIdentity or not callable(self.callback):
91
+ raise ValueError("local judge requires an identity and callable")
92
+ if inspect.iscoroutinefunction(self.callback) or inspect.isasyncgenfunction(self.callback):
93
+ raise ValueError("local callbacks must be synchronous")
94
+
95
+ def judge(self, request, *, timeout_s=None):
96
+ return self.callback(request, timeout_s=timeout_s)
97
+
98
+
99
+ class JudgmentSession:
100
+ """One backend and a bounded session-local LRU; execution requires enabled=True.
101
+
102
+ Not thread-safe. Use one session per analysis worker. No cache is shared
103
+ across backend instances or restored from untrusted trace metadata.
104
+ """
105
+
106
+ def __init__(self, backend: JudgmentBackend, *, cache_size: int = 128):
107
+ if type(cache_size) is not int or cache_size < 0:
108
+ raise ValueError("cache_size must be a nonnegative integer")
109
+ if type(backend.identity) is not JudgeIdentity or not callable(backend.judge):
110
+ raise ValueError("backend must implement JudgmentBackend")
111
+ self._backend = backend
112
+ self._cache_size = cache_size
113
+ self._cache = OrderedDict()
114
+
115
+ def clear_cache(self):
116
+ self._cache.clear()
117
+
118
+ def evaluate(self, request: JudgmentRequest, *, enabled=False, timeout_s=None):
119
+ if type(request) is not JudgmentRequest or type(enabled) is not bool:
120
+ raise ValueError("evaluate requires a typed request and boolean enabled flag")
121
+ if timeout_s is not None and (not finite(timeout_s) or timeout_s <= 0):
122
+ raise ValueError("timeout_s must be finite and positive")
123
+ identity = self._backend.identity
124
+ if type(identity) is not JudgeIdentity:
125
+ raise ValueError("backend identity must be JudgeIdentity")
126
+ request_data, judge = request.to_dict(), asdict(identity)
127
+ key = fingerprint(
128
+ {"schema_version": JUDGMENT_VERSION, "request": request_data, "judge": judge}
129
+ )
130
+ started = time.perf_counter()
131
+ hit = enabled and key in self._cache
132
+ if hit:
133
+ evaluation = _copy(self._cache[key])
134
+ self._cache.move_to_end(key)
135
+ else:
136
+ evaluation = self._evaluate(request, enabled, timeout_s)
137
+ if enabled and self._backend.identity != identity:
138
+ evaluation.update(status="invalid_result", value=None, reason="identity_changed")
139
+ if evaluation["status"] == "known" and self._cache_size:
140
+ self._cache[key] = _copy(evaluation)
141
+ if len(self._cache) > self._cache_size:
142
+ self._cache.popitem(last=False)
143
+ record = {
144
+ "schema_version": JUDGMENT_VERSION,
145
+ "evidence_kind": "semantic_judgment",
146
+ "request": request_data,
147
+ "judge": judge,
148
+ "cache_key": key,
149
+ "evaluation": evaluation,
150
+ "invocation": {
151
+ "id": uuid4().hex,
152
+ "cache_hit": hit,
153
+ "dispatched": enabled and not hit,
154
+ "latency_ms": (time.perf_counter() - started) * 1000,
155
+ "usage": asdict(_NO_USAGE) if hit or not enabled else evaluation["usage"],
156
+ },
157
+ }
158
+ record["record_hash"] = fingerprint(record)
159
+ return _copy(record)
160
+
161
+ def _evaluate(self, request, enabled, timeout_s):
162
+ started = time.perf_counter()
163
+ result = {
164
+ "id": uuid4().hex,
165
+ "evaluated_at": utc_now_iso(),
166
+ "status": "disabled",
167
+ "value": None,
168
+ "reason": "disabled",
169
+ "usage": asdict(_NO_USAGE if not enabled else JudgeUsage()),
170
+ "uncertainty": asdict(JudgmentUncertainty()),
171
+ }
172
+ if enabled:
173
+ try:
174
+ answer = self._backend.judge(request, timeout_s=timeout_s)
175
+ if type(answer) is not JudgmentAnswer:
176
+ if inspect.iscoroutine(answer) or inspect.isgenerator(answer):
177
+ answer.close()
178
+ result.update(status="invalid_result", reason="invalid_result")
179
+ else:
180
+ result.update(asdict(answer))
181
+ if answer.status == "known" and not request.spec.accepts(answer.value):
182
+ result.update(status="invalid_result", value=None, reason="invalid_result")
183
+ except TimeoutError:
184
+ result.update(status="timeout", reason="timeout")
185
+ except Exception:
186
+ # Neither exception text nor arbitrary class names leave the callback.
187
+ result.update(status="error", reason="error")
188
+ elapsed = time.perf_counter() - started
189
+ if enabled and timeout_s is not None and elapsed > timeout_s:
190
+ result.update(status="timeout", value=None, reason="timeout")
191
+ result["latency_ms"] = elapsed * 1000
192
+ return result
193
+
194
+
195
+ def validate_judgment_record(record):
196
+ """Validate portable integrity and typed semantics, not a judge's truthfulness."""
197
+ value = _copy(record)
198
+ try:
199
+ digest = value.pop("record_hash")
200
+ if digest != fingerprint(value) or value["schema_version"] != JUDGMENT_VERSION:
201
+ raise ValueError("unsupported or altered judgment record")
202
+ if value["evidence_kind"] != "semantic_judgment":
203
+ raise ValueError("invalid evidence kind")
204
+ request = JudgmentRequest.from_dict(value["request"])
205
+ judge = JudgeIdentity(**value["judge"])
206
+ key = fingerprint(
207
+ {
208
+ "schema_version": JUDGMENT_VERSION,
209
+ "request": request.to_dict(),
210
+ "judge": asdict(judge),
211
+ }
212
+ )
213
+ if value["cache_key"] != key:
214
+ raise ValueError("judgment cache identity mismatch")
215
+ result, invocation = value["evaluation"], value["invocation"]
216
+ usage = JudgeUsage(**result["usage"])
217
+ uncertainty = JudgmentUncertainty(**result["uncertainty"])
218
+ if result["status"] in {"known", "unknown"}:
219
+ answer = JudgmentAnswer(
220
+ result["value"], result["status"], result["reason"], usage, uncertainty
221
+ )
222
+ if answer.status == "known" and not request.spec.accepts(answer.value):
223
+ raise ValueError("answer violates typed bounds")
224
+ elif (
225
+ result["status"] not in _FAILURES
226
+ or result["value"] is not None
227
+ or result["reason"] not in _FAILURES | {"identity_changed"}
228
+ ):
229
+ raise ValueError("invalid failure result")
230
+ for part in (result, invocation):
231
+ if (
232
+ not isinstance(part["id"], str)
233
+ or not part["id"]
234
+ or not finite(part["latency_ms"])
235
+ or part["latency_ms"] < 0
236
+ ):
237
+ raise ValueError("invalid execution receipt")
238
+ if not isinstance(result["evaluated_at"], str):
239
+ raise ValueError("invalid evaluation timestamp")
240
+ if type(invocation["cache_hit"]) is not bool or type(invocation["dispatched"]) is not bool:
241
+ raise ValueError("invalid cache receipt")
242
+ incurred = JudgeUsage(**invocation["usage"])
243
+ if invocation["cache_hit"]:
244
+ if invocation["dispatched"] or result["status"] != "known":
245
+ raise ValueError("only successful undispatched evaluations can be cached")
246
+ if not invocation["dispatched"]:
247
+ if incurred != _NO_USAGE or (
248
+ not invocation["cache_hit"] and result["status"] != "disabled"
249
+ ):
250
+ raise ValueError("invalid undispatched receipt")
251
+ elif incurred != usage or result["status"] == "disabled":
252
+ raise ValueError("invalid dispatched receipt")
253
+ value["record_hash"] = digest
254
+ return value
255
+ except (KeyError, TypeError, AttributeError):
256
+ raise ValueError("invalid judgment record") from None
257
+
258
+
259
+ def _bound(trace, record, *, events=None, hashes=None):
260
+ request = JudgmentRequest.from_dict(record["request"])
261
+ if request.run_id != trace.run_id:
262
+ return False
263
+ if events is None:
264
+ events = {event.event_id: event for event in trace.events}
265
+ if len(events) != len(trace.events):
266
+ return False
267
+ hashes = {} if hashes is None else hashes
268
+ return all(
269
+ item.span_id in events and _evidence(events[item.span_id], item.summary, hashes) == item
270
+ for item in request.evidence
271
+ )
272
+
273
+
274
+ def attach_judgment(trace, record):
275
+ """Attach a validated receipt without replacing host metadata or prior results."""
276
+ owned = validate_judgment_record(record)
277
+ if not _bound(trace, owned):
278
+ raise ValueError("judgment evidence does not match the trace")
279
+ existing = trace.metadata.get(JUDGMENT_KEY)
280
+ if JUDGMENT_KEY not in trace.metadata:
281
+ existing = {"schema_version": JUDGMENT_VERSION, "records": []}
282
+ elif read_judgments(trace)["status"] in {"invalid", "unsupported"}:
283
+ raise ValueError("existing judgment evidence is invalid or unsupported")
284
+ existing = _copy(existing)
285
+ for previous in existing["records"]:
286
+ if previous["invocation"]["id"] == owned["invocation"]["id"]:
287
+ if previous != owned:
288
+ raise ValueError("conflicting invocation receipt")
289
+ return
290
+ existing["records"].append(owned)
291
+ trace.metadata[JUDGMENT_KEY] = existing
292
+
293
+
294
+ def read_judgments(trace):
295
+ """Read saved evidence only. Stale or failed judgments never become booleans."""
296
+ metadata = getattr(trace, "metadata", {})
297
+ envelope = metadata.get(JUDGMENT_KEY)
298
+ if JUDGMENT_KEY not in metadata:
299
+ return None
300
+ empty = {"status": "invalid", "records": [], "known_cost_usd": None, "cost_complete": False}
301
+ if not isinstance(envelope, dict):
302
+ return empty
303
+ if envelope.get("schema_version") != JUDGMENT_VERSION:
304
+ return {**empty, "status": "unsupported"}
305
+ try:
306
+ if not isinstance(envelope.get("records"), list):
307
+ return empty
308
+ records, identities, known_cost, complete_cost = [], set(), 0.0, True
309
+ events = {event.event_id: event for event in trace.events}
310
+ hashes = {}
311
+ for raw in envelope["records"]:
312
+ record = validate_judgment_record(raw)
313
+ identity = record["invocation"]["id"]
314
+ if identity in identities:
315
+ return empty
316
+ identities.add(identity)
317
+ try:
318
+ bound = _bound(trace, record, events=events, hashes=hashes)
319
+ except ValueError:
320
+ bound = False
321
+ result = record["evaluation"]
322
+ record["effective_status"] = result["status"] if bound else "stale"
323
+ record["effective_value"] = (
324
+ result["value"] if bound and result["status"] == "known" else None
325
+ )
326
+ cost = record["invocation"]["usage"]["cost_usd"]
327
+ if cost is None:
328
+ complete_cost = False
329
+ else:
330
+ known_cost += cost
331
+ records.append(record)
332
+ return {
333
+ "status": "complete"
334
+ if records and all(item["effective_status"] == "known" for item in records)
335
+ else "incomplete",
336
+ "records": records,
337
+ "known_cost_usd": known_cost if finite(known_cost) else None,
338
+ "cost_complete": complete_cost and finite(known_cost),
339
+ "cost_scope": "offline_judge_invocations_only",
340
+ }
341
+ except (ValueError, TypeError, KeyError, AttributeError):
342
+ return empty