aid-runtime-python 0.1.39__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 (52) hide show
  1. aid_runtime/__init__.py +500 -0
  2. aid_runtime/agents/__init__.py +94 -0
  3. aid_runtime/agents/chat_model.py +351 -0
  4. aid_runtime/agents/deep_agents.py +600 -0
  5. aid_runtime/agents/engine.py +102 -0
  6. aid_runtime/agents/guardrails.py +519 -0
  7. aid_runtime/agents/ir.py +622 -0
  8. aid_runtime/agents/loop.py +1308 -0
  9. aid_runtime/agents/memory.py +241 -0
  10. aid_runtime/agents/policy.py +163 -0
  11. aid_runtime/agents/state.py +182 -0
  12. aid_runtime/cache.py +185 -0
  13. aid_runtime/concurrency.py +70 -0
  14. aid_runtime/control.py +329 -0
  15. aid_runtime/cost.py +168 -0
  16. aid_runtime/errors.py +337 -0
  17. aid_runtime/evals/__init__.py +72 -0
  18. aid_runtime/evals/dataset.py +77 -0
  19. aid_runtime/evals/gate.py +430 -0
  20. aid_runtime/evals/metrics.py +335 -0
  21. aid_runtime/evals/predicate.py +211 -0
  22. aid_runtime/evals/runner.py +224 -0
  23. aid_runtime/gateway.py +473 -0
  24. aid_runtime/pipeline.py +168 -0
  25. aid_runtime/prompts.py +185 -0
  26. aid_runtime/providers/__init__.py +20 -0
  27. aid_runtime/providers/base.py +57 -0
  28. aid_runtime/providers/fake.py +256 -0
  29. aid_runtime/providers/http.py +344 -0
  30. aid_runtime/py.typed +0 -0
  31. aid_runtime/retrieval/__init__.py +178 -0
  32. aid_runtime/retrieval/chunking.py +222 -0
  33. aid_runtime/retrieval/citations.py +131 -0
  34. aid_runtime/retrieval/connectors.py +131 -0
  35. aid_runtime/retrieval/embedding.py +293 -0
  36. aid_runtime/retrieval/gate.py +120 -0
  37. aid_runtime/retrieval/hybrid.py +164 -0
  38. aid_runtime/retrieval/metrics.py +213 -0
  39. aid_runtime/retrieval/pgvector.py +258 -0
  40. aid_runtime/retrieval/rerank.py +133 -0
  41. aid_runtime/retrieval/retriever.py +335 -0
  42. aid_runtime/retrieval/stores.py +161 -0
  43. aid_runtime/retrieval/types.py +433 -0
  44. aid_runtime/steps.py +284 -0
  45. aid_runtime/structured.py +150 -0
  46. aid_runtime/tools.py +271 -0
  47. aid_runtime/tracing.py +241 -0
  48. aid_runtime/types.py +332 -0
  49. aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
  50. aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
  51. aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
  52. aid_runtime_python-0.1.39.dist-info/top_level.txt +1 -0
aid_runtime/cost.py ADDED
@@ -0,0 +1,168 @@
1
+ """Cost accounting and budget enforcement.
2
+
3
+ Cost is the budget this runtime enforces in Phase 0. A latency p95 budget is
4
+ deliberately absent: it is only meaningful next to a dashboard, and a gate nobody
5
+ reads is not a gate.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Annotated
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
14
+
15
+ from aid_runtime.errors import BudgetExceededError
16
+ from aid_runtime.types import ModelSpec, TokenUsage
17
+
18
+ __all__ = [
19
+ "Budget",
20
+ "CostLedger",
21
+ "LedgerEntry",
22
+ "SpendSummary",
23
+ "estimate_cost_usd",
24
+ "estimate_prompt_cost_usd",
25
+ "estimate_tokens",
26
+ ]
27
+
28
+ # Prices are quoted per 1k tokens, so a call costs fractions of a cent. Six
29
+ # decimals keeps a single call representable without letting float error
30
+ # accumulate visibly over a long run.
31
+ _USD_PRECISION = 6
32
+
33
+ NonNegativeUsd = Annotated[float, Field(ge=0.0)]
34
+
35
+
36
+ class Budget(BaseModel):
37
+ """A spending ceiling for one scope (a run, a request, a nightly job)."""
38
+
39
+ model_config = ConfigDict(frozen=True, extra="forbid")
40
+
41
+ max_usd: NonNegativeUsd | None = None
42
+ max_total_tokens: int | None = Field(default=None, gt=0)
43
+ scope: str = "run"
44
+
45
+ @model_validator(mode="after")
46
+ def _at_least_one_ceiling(self) -> Budget:
47
+ if self.max_usd is None and self.max_total_tokens is None:
48
+ raise ValueError("a budget must set max_usd, max_total_tokens, or both")
49
+ return self
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class LedgerEntry:
54
+ """One charged model call."""
55
+
56
+ alias: str
57
+ model: str
58
+ provider: str
59
+ usage: TokenUsage
60
+ cost_usd: float
61
+
62
+
63
+ class SpendSummary(BaseModel):
64
+ """Aggregate spend, suitable for a log line or an OTel span attribute."""
65
+
66
+ model_config = ConfigDict(frozen=True, extra="forbid")
67
+
68
+ calls: int = Field(ge=0)
69
+ input_tokens: int = Field(ge=0)
70
+ output_tokens: int = Field(ge=0)
71
+ cost_usd: NonNegativeUsd
72
+ by_model: dict[str, float] = Field(default_factory=dict)
73
+
74
+ @property
75
+ def total_tokens(self) -> int:
76
+ return self.input_tokens + self.output_tokens
77
+
78
+
79
+ def estimate_tokens(text: str) -> int:
80
+ """Four characters per token, the same over-estimate used before a call."""
81
+ return (len(text) + 3) // 4
82
+
83
+
84
+ def estimate_cost_usd(spec: ModelSpec, usage: TokenUsage) -> float:
85
+ """Price a call against a pinned model's published cost."""
86
+ return spec.cost.cost_usd(usage)
87
+
88
+
89
+ def estimate_prompt_cost_usd(spec: ModelSpec, text: str) -> float:
90
+ """Rough pre-flight price for a prompt, used to fail before spending.
91
+
92
+ Four characters per token is the usual English approximation. It is
93
+ deliberately an over-estimate: refusing a call that would have fit is a
94
+ recoverable annoyance; overshooting a hard budget is not.
95
+ """
96
+ estimated = TokenUsage(input_tokens=estimate_tokens(text), output_tokens=0)
97
+ return spec.cost.cost_usd(estimated)
98
+
99
+
100
+ class CostLedger:
101
+ """Accumulates spend and refuses to let a budget be crossed.
102
+
103
+ The ledger is intentionally not thread-safe and not async-safe: a gateway owns
104
+ one ledger per logical run, and sharing one across concurrent runs would make
105
+ "spent so far" meaningless.
106
+ """
107
+
108
+ def __init__(self) -> None:
109
+ self._entries: list[LedgerEntry] = []
110
+
111
+ @property
112
+ def entries(self) -> tuple[LedgerEntry, ...]:
113
+ return tuple(self._entries)
114
+
115
+ @property
116
+ def cost_usd(self) -> float:
117
+ return round(sum(entry.cost_usd for entry in self._entries), _USD_PRECISION)
118
+
119
+ @property
120
+ def total_tokens(self) -> int:
121
+ return sum(entry.usage.total_tokens for entry in self._entries)
122
+
123
+ def check(self, budget: Budget) -> None:
124
+ """Raise if current spend already violates ``budget``."""
125
+ self._check_usd(budget)
126
+ self._check_tokens(budget)
127
+
128
+ def charge(self, budget: Budget | None, entry: LedgerEntry) -> None:
129
+ """Record a call, raising :class:`BudgetExceededError` if it overshoots.
130
+
131
+ The entry is recorded before the check so the ledger reflects what was
132
+ actually spent when the error propagates -- a caller that catches the
133
+ error still sees truthful accounting.
134
+ """
135
+ self._entries.append(entry)
136
+ if budget is None:
137
+ return
138
+ self.check(budget)
139
+
140
+ def summary(self) -> SpendSummary:
141
+ by_model: dict[str, float] = {}
142
+ for entry in self._entries:
143
+ by_model[entry.model] = round(
144
+ by_model.get(entry.model, 0.0) + entry.cost_usd, _USD_PRECISION
145
+ )
146
+ return SpendSummary(
147
+ calls=len(self._entries),
148
+ input_tokens=sum(e.usage.input_tokens for e in self._entries),
149
+ output_tokens=sum(e.usage.output_tokens for e in self._entries),
150
+ cost_usd=self.cost_usd,
151
+ by_model=by_model,
152
+ )
153
+
154
+ def _check_usd(self, budget: Budget) -> None:
155
+ if budget.max_usd is None:
156
+ return
157
+ spent = self.cost_usd
158
+ if spent > budget.max_usd:
159
+ raise BudgetExceededError(budget.scope, budget.max_usd, spent)
160
+
161
+ def _check_tokens(self, budget: Budget) -> None:
162
+ if budget.max_total_tokens is None:
163
+ return
164
+ spent_tokens = self.total_tokens
165
+ if spent_tokens > budget.max_total_tokens:
166
+ raise BudgetExceededError(
167
+ budget.scope, float(budget.max_total_tokens), float(spent_tokens), unit="tokens"
168
+ )
aid_runtime/errors.py ADDED
@@ -0,0 +1,337 @@
1
+ """Exception hierarchy for the AID Python runtime.
2
+
3
+ Every error this library raises derives from :class:`AidRuntimeError`, so an
4
+ application can distinguish "the framework refused to proceed" from "the network
5
+ was flaky" with a single `except`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+
12
+ __all__ = [
13
+ "AgentBudgetDegradedError",
14
+ "AgentHandoffUnsupportedError",
15
+ "AgentTransferCycleError",
16
+ "AidRuntimeError",
17
+ "BudgetExceededError",
18
+ "CapabilityMismatchError",
19
+ "ConfigurationError",
20
+ "DeepAgentsEngineError",
21
+ "DeepAgentsMaxStepsError",
22
+ "DeepAgentsUnavailableError",
23
+ "DeepAgentsVersionMismatchError",
24
+ "EvalError",
25
+ "FeatureDeferredError",
26
+ "GateConfigurationError",
27
+ "PipelineError",
28
+ "PolicyEvaluationError",
29
+ "PromptNotFoundError",
30
+ "PromptRenderError",
31
+ "ProviderError",
32
+ "ProviderResponseError",
33
+ "StructuredOutputError",
34
+ "ToolArgumentError",
35
+ "ToolExecutionError",
36
+ "ToolNotFoundError",
37
+ "UnknownModelAliasError",
38
+ ]
39
+
40
+
41
+ class AidRuntimeError(Exception):
42
+ """Base class for every error raised by this library."""
43
+
44
+
45
+ class ConfigurationError(AidRuntimeError):
46
+ """The runtime was configured with something it cannot use."""
47
+
48
+
49
+ class UnknownModelAliasError(ConfigurationError):
50
+ """An alias was requested that the gateway has no pinned model for.
51
+
52
+ Model pinning means the alias table is closed: an unknown alias is a
53
+ configuration error, never a silent pass-through to a provider default.
54
+ """
55
+
56
+ def __init__(self, alias: str, known: Sequence[str]) -> None:
57
+ listed = ", ".join(sorted(known)) or "<none>"
58
+ super().__init__(f"unknown model alias {alias!r}; pinned aliases: {listed}")
59
+ self.alias = alias
60
+ self.known = tuple(sorted(known))
61
+
62
+
63
+ class CapabilityMismatchError(ConfigurationError):
64
+ """A pinned model does not declare a capability the caller requires."""
65
+
66
+ def __init__(self, alias: str, missing: Sequence[str]) -> None:
67
+ listed = ", ".join(sorted(missing))
68
+ super().__init__(f"model alias {alias!r} does not support: {listed}")
69
+ self.alias = alias
70
+ self.missing = tuple(sorted(missing))
71
+
72
+
73
+ class BudgetExceededError(AidRuntimeError):
74
+ """A cost or token budget was crossed.
75
+
76
+ Raised after a call is charged, so the ledger it reports has already recorded
77
+ the spend that crossed the line -- a caller that catches this still sees
78
+ truthful accounting rather than a pre-call estimate.
79
+ """
80
+
81
+ def __init__(self, scope: str, limit: float, spent: float, unit: str = "usd") -> None:
82
+ if unit == "usd":
83
+ limit_text = f"${limit:.6f}"
84
+ spent_text = f"${spent:.6f}"
85
+ over_text = f"${spent - limit:.6f}"
86
+ else:
87
+ limit_text = f"{limit:.0f} {unit}"
88
+ spent_text = f"{spent:.0f} {unit}"
89
+ over_text = f"{spent - limit:.0f} {unit}"
90
+ super().__init__(
91
+ f"budget {scope!r} exceeded: limit {limit_text}, spent {spent_text} "
92
+ f"(over by {over_text})"
93
+ )
94
+ self.scope = scope
95
+ self.limit = limit
96
+ self.spent = spent
97
+ self.unit = unit
98
+
99
+
100
+ class FeatureDeferredError(AidRuntimeError):
101
+ """A capability is declared for the IR contract but not implemented yet.
102
+
103
+ Raised for constructs the schema admits but the runtime does not yet serve:
104
+ TTL expiry and redaction on memory, several retrieval strategies and sources,
105
+ and the like. A generated app that declares one of these fails loudly at the
106
+ point of use instead of returning an approximate answer.
107
+ """
108
+
109
+
110
+ class ProviderError(AidRuntimeError):
111
+ """A provider call failed after exhausting retries."""
112
+
113
+
114
+ class ProviderResponseError(ProviderError):
115
+ """The provider answered with a body we could not interpret."""
116
+
117
+ def __init__(self, message: str, body: str | None = None) -> None:
118
+ super().__init__(message if body is None else f"{message}: {body[:500]}")
119
+ self.body = body
120
+
121
+
122
+ class PromptNotFoundError(AidRuntimeError):
123
+ """No prompt is registered under the requested name and version."""
124
+
125
+ def __init__(self, name: str, version: str | None, known: Sequence[str]) -> None:
126
+ suffix = " (latest)" if version is None else f" version {version!r}"
127
+ listed = ", ".join(sorted(known)) or "<none>"
128
+ super().__init__(f"no prompt {name!r}{suffix}; registered: {listed}")
129
+ self.name = name
130
+ self.version = version
131
+
132
+
133
+ class PromptRenderError(AidRuntimeError):
134
+ """A prompt template could not be rendered from the supplied variables."""
135
+
136
+
137
+ class StructuredOutputError(AidRuntimeError):
138
+ """A model response could not be coerced into the requested output type."""
139
+
140
+ def __init__(self, message: str, *, attempts: int, problems: Sequence[str] = ()) -> None:
141
+ detail = f" after {attempts} attempt(s)"
142
+ if problems:
143
+ detail += ": " + "; ".join(problems)
144
+ super().__init__(message + detail)
145
+ self.attempts = attempts
146
+ self.problems = tuple(problems)
147
+
148
+
149
+ class ToolNotFoundError(AidRuntimeError):
150
+ """A tool call named a tool the executor does not have."""
151
+
152
+ def __init__(self, name: str, known: Sequence[str]) -> None:
153
+ listed = ", ".join(sorted(known)) or "<none>"
154
+ super().__init__(f"unknown tool {name!r}; registered: {listed}")
155
+ self.name = name
156
+
157
+
158
+ class ToolArgumentError(AidRuntimeError):
159
+ """Tool call arguments failed schema validation."""
160
+
161
+ def __init__(self, tool: str, problems: Sequence[str]) -> None:
162
+ super().__init__(f"invalid arguments for tool {tool!r}: {'; '.join(problems)}")
163
+ self.tool = tool
164
+ self.problems = tuple(problems)
165
+
166
+
167
+ class ToolExecutionError(AidRuntimeError):
168
+ """A tool handler raised."""
169
+
170
+
171
+ class PipelineError(AidRuntimeError):
172
+ """A pipeline step failed or the pipeline was wired incorrectly."""
173
+
174
+
175
+ class PolicyEvaluationError(AidRuntimeError):
176
+ """A parsed ``PolicyExpr`` referenced something the evaluator cannot decide.
177
+
178
+ The IR's policy grammar is closed, and a comparison between incompatible
179
+ values has no truth value. Failing closed here is what keeps a HITL gate from
180
+ silently deciding *not* to ask for approval.
181
+ """
182
+
183
+
184
+ class GuardrailBlockedError(AidRuntimeError):
185
+ """A guardrail stopped the run before (or after) the guarded effect.
186
+
187
+ Raised by the ``block`` action, and by any guardrail the engine cannot
188
+ evaluate. An undecidable policy is a block, never an allow: the failure mode
189
+ this class exists to prevent is a check that quietly returns "no violation"
190
+ because its input did not match what it expected.
191
+ """
192
+
193
+ def __init__(self, guardrail: str, stage: str, message: str) -> None:
194
+ super().__init__(f"guardrail {guardrail!r} blocked the {stage} stage: {message}")
195
+ self.guardrail = guardrail
196
+ self.stage = stage
197
+ self.message = message
198
+
199
+
200
+ class ToolPermissionError(AidRuntimeError):
201
+ """An agent asked for a tool it was not granted, or one its auth policy denies.
202
+
203
+ Raised before the handler body runs. The check is deliberately not a tool
204
+ result: a model can repair a bad argument, but it cannot repair a missing
205
+ grant, so the caller is the party that has to fix this.
206
+ """
207
+
208
+ def __init__(self, agent: str, tool: str, reason: str) -> None:
209
+ super().__init__(f"agent {agent!r} may not call tool {tool!r}: {reason}")
210
+ self.agent = agent
211
+ self.tool = tool
212
+ self.reason = reason
213
+
214
+
215
+ class AgentTransferCycleError(AidRuntimeError):
216
+ """A transfer chain revisited an agent that already held control.
217
+
218
+ ``agents/handoff-cycles`` rejects this at build time; the runtime refuses it
219
+ again rather than trusting that every caller came through the gate.
220
+ """
221
+
222
+ def __init__(self, path: Sequence[str]) -> None:
223
+ super().__init__(f"transfer cycle: {' -> '.join(path)}")
224
+ self.path = tuple(path)
225
+
226
+
227
+ class AgentBudgetDegradedError(AidRuntimeError):
228
+ """A budget was crossed and there is no cheaper alias left to route to.
229
+
230
+ ``ir/agents-budget-degrade`` rejects this at build time for declared specs;
231
+ this is the run-time counterpart for a hand-built IR.
232
+ """
233
+
234
+
235
+ class EvalError(AidRuntimeError):
236
+ """An eval run could not be completed."""
237
+
238
+
239
+ class AgentHandoffUnsupportedError(AidRuntimeError):
240
+ """An agent asked for ``handoffs`` on an engine that has no transfer primitive.
241
+
242
+ Deep Agents has ``subagents`` -- delegation that *returns* -- and no transfer:
243
+ control never moves, the caller always resumes and always authors the final
244
+ answer. Emulating transfer with a delegate would change who answers, so the
245
+ engine refuses instead of silently degrading ``handoffs`` into a delegate.
246
+ Transfer therefore stays native-only, and the IR gate plus this error are the
247
+ two places that say so.
248
+ """
249
+
250
+ def __init__(self, agent: str, targets: Sequence[str], engine: str) -> None:
251
+ super().__init__(
252
+ f"agent {agent!r} declares handoffs {list(targets)} but runs on the {engine!r} "
253
+ "engine, which has no transfer primitive; transfer is native-only, and "
254
+ "delegation (delegates) is the deepagents equivalent"
255
+ )
256
+ self.agent = agent
257
+ self.targets = tuple(targets)
258
+ self.engine = engine
259
+
260
+
261
+ class DeepAgentsEngineError(AidRuntimeError):
262
+ """The ``deepagents`` engine cannot be used as configured.
263
+
264
+ Base class so a caller can catch "the backend refused to start" without
265
+ enumerating the two reasons it can refuse.
266
+ """
267
+
268
+
269
+ class DeepAgentsUnavailableError(DeepAgentsEngineError):
270
+ """The ``deepagents`` optional extra is not installed.
271
+
272
+ A named error rather than an ``ImportError`` because the fix is an install
273
+ instruction the caller can act on, and because an agent silently falling back
274
+ to the native loop would leave ``engine`` in the IR meaning nothing.
275
+ """
276
+
277
+ def __init__(self, extra: str, distribution: str) -> None:
278
+ super().__init__(
279
+ f"this agent runs on the 'deepagents' engine, but the {distribution!r} package "
280
+ f"is not installed; install the extra with: pip install '{extra}'"
281
+ )
282
+ self.extra = extra
283
+ self.distribution = distribution
284
+
285
+
286
+ class DeepAgentsMaxStepsError(DeepAgentsEngineError):
287
+ """The harness asked for one model call more than ``maxSteps`` allows.
288
+
289
+ Raised by the adapter rather than the harness, because the harness's own call
290
+ limiter does not fail: it appends a synthetic "model call limits exceeded"
291
+ answer and lets the run finish, which would report an exhausted agent as a
292
+ successful one. The engine translates this into ``RunStatus.MAX_STEPS``, the
293
+ same outcome the native loop reports for the same condition.
294
+ """
295
+
296
+ def __init__(self, alias: str, max_steps: int) -> None:
297
+ super().__init__(
298
+ f"the deepagents harness requested model call {max_steps + 1} on alias "
299
+ f"{alias!r}, exceeding maxSteps {max_steps}; the run is exhausted"
300
+ )
301
+ self.alias = alias
302
+ self.max_steps = max_steps
303
+
304
+
305
+ class DeepAgentsVersionMismatchError(DeepAgentsEngineError):
306
+ """``engineVersion`` does not match the release this runtime is pinned to.
307
+
308
+ The IR gate refuses ``engine: deepagents`` without a pinned ``engineVersion``
309
+ precisely so that this comparison can exist: an unpinned engine is a backend
310
+ whose behaviour can change under a stored eval baseline. A mismatch is
311
+ therefore a hard failure, never a warning, and it fires in both directions --
312
+ a declared version this runtime is not built for, and a run-time that cannot
313
+ find the declaration at all.
314
+ """
315
+
316
+ def __init__(self, agent: str, declared: str, pinned: str) -> None:
317
+ detail = (
318
+ "the IR declares no engineVersion"
319
+ if not declared
320
+ else f"the IR declares engineVersion {declared!r}"
321
+ )
322
+ super().__init__(
323
+ f"agent {agent!r}: {detail}, but this runtime is pinned to deepagents=={pinned}; "
324
+ f"set engineVersion to {pinned!r} or install the runtime that matches"
325
+ )
326
+ self.agent = agent
327
+ self.declared = declared
328
+ self.pinned = pinned
329
+
330
+
331
+ class GateConfigurationError(EvalError):
332
+ """The gate was configured in a way that cannot be trusted as a CI signal.
333
+
334
+ The load-bearing case: a threshold that references a metric whose result is
335
+ itself produced by a model. Gating on one source of non-determinism using
336
+ another is not a gate.
337
+ """
@@ -0,0 +1,72 @@
1
+ """Eval harness: datasets, metrics, N-run sampling, and the baseline gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from aid_runtime.evals.dataset import Dataset, EvalCase
6
+ from aid_runtime.evals.gate import (
7
+ BaselineGate,
8
+ EvalBaseline,
9
+ EvalRun,
10
+ GateResult,
11
+ MetricBaseline,
12
+ MetricGateResult,
13
+ MetricRun,
14
+ Threshold,
15
+ gate_from_config,
16
+ z_critical,
17
+ )
18
+ from aid_runtime.evals.metrics import (
19
+ AssertMetric,
20
+ ExactMatch,
21
+ JsonSchemaValid,
22
+ JudgeVerdict,
23
+ LlmJudge,
24
+ Metric,
25
+ MetricResult,
26
+ NormalizedContains,
27
+ default_metrics,
28
+ metric_names,
29
+ normalize,
30
+ )
31
+ from aid_runtime.evals.runner import (
32
+ CaseOutcome,
33
+ DatasetRun,
34
+ GatewayRunner,
35
+ Runner,
36
+ evaluate_once,
37
+ gateway_judge,
38
+ run_evaluation,
39
+ )
40
+
41
+ __all__ = [
42
+ "AssertMetric",
43
+ "BaselineGate",
44
+ "CaseOutcome",
45
+ "Dataset",
46
+ "DatasetRun",
47
+ "EvalBaseline",
48
+ "EvalCase",
49
+ "EvalRun",
50
+ "ExactMatch",
51
+ "GateResult",
52
+ "GatewayRunner",
53
+ "JsonSchemaValid",
54
+ "JudgeVerdict",
55
+ "LlmJudge",
56
+ "Metric",
57
+ "MetricBaseline",
58
+ "MetricGateResult",
59
+ "MetricResult",
60
+ "MetricRun",
61
+ "NormalizedContains",
62
+ "Runner",
63
+ "Threshold",
64
+ "default_metrics",
65
+ "evaluate_once",
66
+ "gate_from_config",
67
+ "gateway_judge",
68
+ "metric_names",
69
+ "normalize",
70
+ "run_evaluation",
71
+ "z_critical",
72
+ ]
@@ -0,0 +1,77 @@
1
+ """Eval datasets: the probabilistic analogue of a golden fixture.
2
+
3
+ A dataset is versioned and committed. The version matters as much as the cases:
4
+ comparing a run against a baseline recorded on a *different* dataset version is
5
+ the most common way an eval gate becomes noise, so the gate refuses it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
14
+
15
+ from aid_runtime.errors import EvalError
16
+
17
+ __all__ = ["Dataset", "EvalCase"]
18
+
19
+
20
+ class EvalCase(BaseModel):
21
+ """One input, its expected output, and any tags the metrics key off."""
22
+
23
+ model_config = ConfigDict(frozen=True, extra="forbid")
24
+
25
+ id: str = Field(min_length=1)
26
+ input: str
27
+ expected: str | None = None
28
+ tags: tuple[str, ...] = ()
29
+ metadata: dict[str, str] = Field(default_factory=dict)
30
+
31
+
32
+ class Dataset(BaseModel):
33
+ """An ordered, versioned set of cases."""
34
+
35
+ model_config = ConfigDict(frozen=True, extra="forbid")
36
+
37
+ name: str = Field(min_length=1)
38
+ version: str = Field(min_length=1)
39
+ cases: tuple[EvalCase, ...] = Field(min_length=1)
40
+
41
+ @field_validator("cases", mode="before")
42
+ @classmethod
43
+ def _coerce_cases(cls, value: object) -> object:
44
+ if isinstance(value, list):
45
+ return tuple(value)
46
+ return value
47
+
48
+ def __len__(self) -> int:
49
+ return len(self.cases)
50
+
51
+ def ids(self) -> tuple[str, ...]:
52
+ return tuple(case.id for case in self.cases)
53
+
54
+ def case(self, case_id: str) -> EvalCase:
55
+ for candidate in self.cases:
56
+ if candidate.id == case_id:
57
+ return candidate
58
+ raise EvalError(f"dataset {self.name!r} has no case {case_id!r}")
59
+
60
+ def by_tag(self, tag: str) -> tuple[EvalCase, ...]:
61
+ return tuple(case for case in self.cases if tag in case.tags)
62
+
63
+ @classmethod
64
+ def from_records(cls, name: str, version: str, records: list[dict[str, object]]) -> Dataset:
65
+ return cls.model_validate({"name": name, "version": version, "cases": records})
66
+
67
+ @classmethod
68
+ def from_json(cls, payload: str) -> Dataset:
69
+ return cls.model_validate_json(payload)
70
+
71
+ @classmethod
72
+ def from_path(cls, path: Path | str) -> Dataset:
73
+ return cls.from_json(Path(path).read_text(encoding="utf-8"))
74
+
75
+ def to_json(self) -> str:
76
+ """Canonical JSON: sorted keys, so a dataset diff is reviewable."""
77
+ return json.dumps(self.model_dump(mode="json"), indent=2, sort_keys=True) + "\n"