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
@@ -0,0 +1,335 @@
1
+ """Eval metrics.
2
+
3
+ A metric turns ``(case, prediction)`` into a score in ``[0, 1]``. Each metric
4
+ declares whether it is *deterministic*, and the gate uses that flag to refuse to
5
+ be built on a metric that calls a model. This is the enforcement point for a
6
+ Phase 0 decision: implement ``llm-judge``, never make CI depend on it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from collections.abc import Awaitable, Callable, Sequence
14
+ from typing import Any, Protocol, runtime_checkable
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+
18
+ from aid_runtime.errors import EvalError
19
+ from aid_runtime.evals.dataset import EvalCase
20
+ from aid_runtime.evals.predicate import evaluate_predicate
21
+ from aid_runtime.structured import run_structured
22
+
23
+ __all__ = [
24
+ "AssertMetric",
25
+ "ExactMatch",
26
+ "JsonSchemaValid",
27
+ "JudgeVerdict",
28
+ "LlmJudge",
29
+ "Metric",
30
+ "MetricResult",
31
+ "NormalizedContains",
32
+ "default_metrics",
33
+ "metric_names",
34
+ "normalize",
35
+ ]
36
+
37
+ _WHITESPACE = re.compile(r"\s+")
38
+
39
+
40
+ class MetricResult(BaseModel):
41
+ """One metric's verdict on one prediction."""
42
+
43
+ model_config = ConfigDict(frozen=True, extra="forbid")
44
+
45
+ metric: str
46
+ score: float = Field(ge=0.0, le=1.0)
47
+ passed: bool
48
+ detail: str = ""
49
+
50
+
51
+ @runtime_checkable
52
+ class Metric(Protocol):
53
+ """Scores a single prediction."""
54
+
55
+ @property
56
+ def name(self) -> str:
57
+ """Stable identifier used in baselines and thresholds."""
58
+ ...
59
+
60
+ @property
61
+ def deterministic(self) -> bool:
62
+ """Whether the same prediction always yields the same score.
63
+
64
+ A model-backed metric is not deterministic, and the gate refuses to
65
+ threshold on one: a CI signal that itself varies run to run cannot
66
+ distinguish a regression from its own noise.
67
+ """
68
+ ...
69
+
70
+ async def score(self, case: EvalCase, prediction: str) -> MetricResult: ...
71
+
72
+
73
+ def normalize(text: str) -> str:
74
+ """Casefold and collapse whitespace, then strip surrounding punctuation."""
75
+ collapsed = _WHITESPACE.sub(" ", text.strip()).casefold()
76
+ return collapsed.strip(" .!?\"'")
77
+
78
+
79
+ class ExactMatch:
80
+ """Normalized string equality against ``case.expected``."""
81
+
82
+ def __init__(self, *, name: str = "exact-match") -> None:
83
+ self._name = name
84
+
85
+ @property
86
+ def name(self) -> str:
87
+ return self._name
88
+
89
+ @property
90
+ def deterministic(self) -> bool:
91
+ return True
92
+
93
+ async def score(self, case: EvalCase, prediction: str) -> MetricResult:
94
+ if case.expected is None:
95
+ raise EvalError(f"case {case.id!r} has no expected value for {self.name}")
96
+ ok = normalize(prediction) == normalize(case.expected)
97
+ return MetricResult(
98
+ metric=self.name,
99
+ score=1.0 if ok else 0.0,
100
+ passed=ok,
101
+ detail="" if ok else f"expected {case.expected!r}",
102
+ )
103
+
104
+
105
+ class NormalizedContains:
106
+ """Whether the normalized prediction contains the normalized expectation."""
107
+
108
+ def __init__(self, *, name: str = "contains") -> None:
109
+ self._name = name
110
+
111
+ @property
112
+ def name(self) -> str:
113
+ return self._name
114
+
115
+ @property
116
+ def deterministic(self) -> bool:
117
+ return True
118
+
119
+ async def score(self, case: EvalCase, prediction: str) -> MetricResult:
120
+ if case.expected is None:
121
+ raise EvalError(f"case {case.id!r} has no expected value for {self.name}")
122
+ ok = normalize(case.expected) in normalize(prediction)
123
+ return MetricResult(
124
+ metric=self.name,
125
+ score=1.0 if ok else 0.0,
126
+ passed=ok,
127
+ detail="" if ok else f"missing {case.expected!r}",
128
+ )
129
+
130
+
131
+ class JsonSchemaValid:
132
+ """Whether the prediction parses as JSON and satisfies the case's schema.
133
+
134
+ The schema lives in the case's metadata under ``json_schema`` as a JSON
135
+ string, which keeps datasets in a single file format.
136
+ """
137
+
138
+ def __init__(self, *, name: str = "json-valid") -> None:
139
+ self._name = name
140
+
141
+ @property
142
+ def name(self) -> str:
143
+ return self._name
144
+
145
+ @property
146
+ def deterministic(self) -> bool:
147
+ return True
148
+
149
+ async def score(self, case: EvalCase, prediction: str) -> MetricResult:
150
+ schema = case.metadata.get("json_schema")
151
+ try:
152
+ parsed = json.loads(prediction)
153
+ except json.JSONDecodeError as exc:
154
+ return MetricResult(
155
+ metric=self.name, score=0.0, passed=False, detail=f"invalid JSON: {exc.msg}"
156
+ )
157
+ if schema is None:
158
+ return MetricResult(metric=self.name, score=1.0, passed=True, detail="parsed")
159
+
160
+ wanted = json.loads(schema)
161
+ problems = _schema_problems(wanted, parsed, path="$")
162
+ return MetricResult(
163
+ metric=self.name,
164
+ score=1.0 if not problems else 0.0,
165
+ passed=not problems,
166
+ detail="; ".join(problems),
167
+ )
168
+
169
+
170
+ class AssertMetric:
171
+ """Whether a declared predicate holds for the case and its prediction.
172
+
173
+ The condition arrives as the IR's predicate AST -- the very tree the spec
174
+ layer parsed and checked -- so this metric needs no parser and cannot
175
+ disagree with the one that accepted the spec. A prediction that makes the
176
+ predicate false scores 0.0 and reports the spec's own ``message`` verbatim,
177
+ because the point of letting the author write the message is that a failure
178
+ quotes them.
179
+
180
+ A predicate that cannot be decided raises instead of scoring. See
181
+ ``aid_runtime.evals.predicate`` for why that distinction is load-bearing.
182
+ """
183
+
184
+ def __init__(self, *, expr: Any, message: str, name: str = "assert") -> None:
185
+ self._expr = expr
186
+ self._message = message
187
+ self._name = name
188
+
189
+ @property
190
+ def name(self) -> str:
191
+ return self._name
192
+
193
+ @property
194
+ def deterministic(self) -> bool:
195
+ return True
196
+
197
+ async def score(self, case: EvalCase, prediction: str) -> MetricResult:
198
+ ok = evaluate_predicate(self._expr, case=case, prediction=prediction)
199
+ return MetricResult(
200
+ metric=self.name,
201
+ score=1.0 if ok else 0.0,
202
+ passed=ok,
203
+ detail="" if ok else self._message,
204
+ )
205
+
206
+
207
+ class JudgeVerdict(BaseModel):
208
+ """The structured output an LLM judge must return."""
209
+
210
+ model_config = ConfigDict(frozen=True, extra="forbid")
211
+
212
+ score: float = Field(ge=0.0, le=1.0)
213
+ reason: str = ""
214
+
215
+
216
+ JudgeCall = Callable[[str], Awaitable[str]]
217
+
218
+
219
+ class LlmJudge:
220
+ """Scores a prediction with a model, as a *metric only*.
221
+
222
+ ``deterministic`` is ``False`` and must stay that way. Using this metric for a
223
+ CI threshold would make the gate's own verdict probabilistic; the gate raises
224
+ rather than allow it. It is implemented because a judge is the right way to
225
+ explore quality locally and to produce a second opinion next to a
226
+ deterministic gate -- not because it can be trusted as the gate.
227
+ """
228
+
229
+ def __init__(
230
+ self,
231
+ call: JudgeCall,
232
+ *,
233
+ rubric: str,
234
+ name: str = "llm-judge",
235
+ retries: int = 2,
236
+ ) -> None:
237
+ self._call = call
238
+ self._rubric = rubric
239
+ self._name = name
240
+ self._retries = retries
241
+
242
+ @property
243
+ def name(self) -> str:
244
+ return self._name
245
+
246
+ @property
247
+ def deterministic(self) -> bool:
248
+ return False
249
+
250
+ async def score(self, case: EvalCase, prediction: str) -> MetricResult:
251
+ prompt = self._build_prompt(case, prediction)
252
+ try:
253
+ verdict = await run_structured(self._call, JudgeVerdict, prompt, retries=self._retries)
254
+ except Exception as exc:
255
+ return MetricResult(
256
+ metric=self.name, score=0.0, passed=False, detail=f"judge failed: {exc}"
257
+ )
258
+ return MetricResult(
259
+ metric=self.name,
260
+ score=verdict.score,
261
+ passed=verdict.score >= 0.5,
262
+ detail=verdict.reason,
263
+ )
264
+
265
+ def _build_prompt(self, case: EvalCase, prediction: str) -> str:
266
+ expected = case.expected if case.expected is not None else "<not provided>"
267
+ return (
268
+ f"{self._rubric}\n\n"
269
+ f"Input: {case.input}\n"
270
+ f"Reference: {expected}\n"
271
+ f"Candidate: {prediction}\n\n"
272
+ 'Reply with JSON: {"score": <0.0-1.0>, "reason": "<short>"}'
273
+ )
274
+
275
+
276
+ def default_metrics() -> tuple[Metric, ...]:
277
+ """The deterministic gate metrics: everything that needs no model call."""
278
+ return (ExactMatch(), NormalizedContains(), JsonSchemaValid())
279
+
280
+
281
+ def metric_names(metrics: Sequence[Metric]) -> tuple[str, ...]:
282
+ return tuple(metric.name for metric in metrics)
283
+
284
+
285
+ def _schema_problems(schema: Any, value: Any, *, path: str, prefix: str = "") -> list[str]:
286
+ """Check the JSON Schema subset a dataset realistically uses."""
287
+ problems: list[str] = []
288
+ if not isinstance(schema, dict):
289
+ return problems
290
+
291
+ declared = schema.get("type")
292
+ if isinstance(declared, str) and not _type_ok(value, declared):
293
+ problems.append(f"{prefix}{path}: expected {declared}, got {type(value).__name__}")
294
+
295
+ if isinstance(value, dict):
296
+ for name in schema.get("required") or []:
297
+ if name not in value:
298
+ problems.append(f"{prefix}{path}: missing required property {name!r}")
299
+ properties = schema.get("properties")
300
+ if isinstance(properties, dict):
301
+ for name, subschema in properties.items():
302
+ if name in value:
303
+ problems.extend(
304
+ _schema_problems(
305
+ subschema, value[name], path=f"{path}.{name}", prefix=prefix
306
+ )
307
+ )
308
+
309
+ if isinstance(value, list):
310
+ items = schema.get("items")
311
+ if isinstance(items, dict):
312
+ for index, item in enumerate(value):
313
+ problems.extend(
314
+ _schema_problems(items, item, path=f"{path}[{index}]", prefix=prefix)
315
+ )
316
+
317
+ return problems
318
+
319
+
320
+ def _type_ok(value: Any, declared: str) -> bool:
321
+ if declared == "object":
322
+ return isinstance(value, dict)
323
+ if declared == "array":
324
+ return isinstance(value, list)
325
+ if declared == "string":
326
+ return isinstance(value, str)
327
+ if declared == "integer":
328
+ return isinstance(value, int) and not isinstance(value, bool)
329
+ if declared == "number":
330
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
331
+ if declared == "boolean":
332
+ return isinstance(value, bool)
333
+ if declared == "null":
334
+ return value is None
335
+ return True
@@ -0,0 +1,211 @@
1
+ """The PredicateExpr evaluator an ``assert`` metric runs.
2
+
3
+ The AST is data, not code: the IR parses and validates it, the generator emits it as
4
+ a plain literal, and this module walks it. There is no ``eval`` and no ``exec``
5
+ anywhere on that path, so a predicate can only ever do what this file implements --
6
+ read the prediction, the case id and one metadata key, call four total string
7
+ builtins, and compare. Anything else is a node kind this walker does not know, and
8
+ an unknown node raises rather than passing.
9
+
10
+ Raising matters more than it looks. A false assertion and an *undecidable* one are
11
+ different outcomes: ``false`` is a legitimate result the gate must see, while a
12
+ missing metadata key or a string where a number belongs means the eval itself is
13
+ misconfigured. Returning ``False`` for either would report a broken eval as a
14
+ failing candidate, and returning ``True`` would hide it, so both raise ``EvalError``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from aid_runtime.errors import EvalError
22
+ from aid_runtime.evals.dataset import EvalCase
23
+
24
+ __all__ = ["evaluate_predicate", "type_name"]
25
+
26
+ #: A value a predicate can carry. Numbers are normalized to ``float`` so that a JSON
27
+ #: literal ``3`` and ``length(...)`` compare equal; booleans are kept apart from
28
+ #: numbers on purpose, because ``1 == true`` is false in the other runtime and a
29
+ #: grammar that answered differently in each language would be worse than no grammar.
30
+ PredicateValue = float | str | bool
31
+
32
+ _ORDERING = frozenset({"<", "<=", ">", ">="})
33
+
34
+
35
+ def type_name(value: Any) -> str:
36
+ """The name a diagnostic uses for a value's type.
37
+
38
+ Booleans are checked before numbers because ``bool`` subclasses ``int`` in
39
+ Python and would otherwise be reported as a number.
40
+ """
41
+ if isinstance(value, bool):
42
+ return "boolean"
43
+ if isinstance(value, (int, float)):
44
+ return "number"
45
+ if isinstance(value, str):
46
+ return "string"
47
+ return type(value).__name__
48
+
49
+
50
+ def evaluate_predicate(node: Any, *, case: EvalCase, prediction: str) -> bool:
51
+ """Evaluate ``node`` against one case and prediction, or raise if it cannot be.
52
+
53
+ A non-boolean result is an error rather than a truthiness test: the expression's
54
+ type is fixed at build time, so a non-boolean here means the AST and the walker
55
+ disagree, and guessing which one is right is exactly the kind of guess that makes
56
+ a gate untrustworthy.
57
+ """
58
+ value = _value(node, case=case, prediction=prediction)
59
+ if not isinstance(value, bool):
60
+ raise EvalError(f"predicate expected a boolean, got {type_name(value)}")
61
+ return value
62
+
63
+
64
+ def _value(node: Any, *, case: EvalCase, prediction: str) -> PredicateValue:
65
+ if not isinstance(node, dict):
66
+ raise EvalError(f"predicate node must be an object, got {type(node).__name__}")
67
+ kind = node.get("kind")
68
+ match kind:
69
+ case "string":
70
+ return _literal(node, "string")
71
+ case "number":
72
+ return _number(node)
73
+ case "boolean":
74
+ return _boolean_literal(node)
75
+ case "invalid":
76
+ # Unreachable through a built metric -- the build drops an invalid
77
+ # expression -- but a hand-written AST must not become a silent pass.
78
+ raise EvalError(f"invalid predicate: {node.get('reason', 'unknown reason')}")
79
+ case "path":
80
+ return _path(node, case=case, prediction=prediction)
81
+ case "call":
82
+ return _call(node, case=case, prediction=prediction)
83
+ case "comparison":
84
+ return _compare(node, case=case, prediction=prediction)
85
+ case "not":
86
+ operand = _boolean(node, "operand", case=case, prediction=prediction)
87
+ return not operand
88
+ case "and":
89
+ left = _boolean(node, "left", case=case, prediction=prediction)
90
+ right = _boolean(node, "right", case=case, prediction=prediction)
91
+ # Both sides are always evaluated; see the module docstring of the
92
+ # TypeScript mirror for why short-circuiting would hide a misconfiguration.
93
+ return left and right
94
+ case "or":
95
+ left = _boolean(node, "left", case=case, prediction=prediction)
96
+ right = _boolean(node, "right", case=case, prediction=prediction)
97
+ return left or right
98
+ # Deliberately outside the match, so an unknown kind is a raise rather than a
99
+ # fall-through that would return None and read as falsy.
100
+ raise EvalError(f"unknown predicate node {kind!r}")
101
+
102
+
103
+ def _literal(node: dict[str, Any], wanted: str) -> str:
104
+ value = node.get("value")
105
+ if not isinstance(value, str):
106
+ raise EvalError(f"predicate {wanted} literal must be a {wanted}, got {type_name(value)}")
107
+ return value
108
+
109
+
110
+ def _number(node: dict[str, Any]) -> float:
111
+ value = node.get("value")
112
+ # ``bool`` is an ``int`` in Python, so an explicit exclusion is what keeps
113
+ # ``True`` from arriving as the number 1.
114
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
115
+ raise EvalError(f"predicate number literal must be a number, got {type_name(value)}")
116
+ return float(value)
117
+
118
+
119
+ def _boolean_literal(node: dict[str, Any]) -> bool:
120
+ value = node.get("value")
121
+ if not isinstance(value, bool):
122
+ raise EvalError(f"predicate boolean literal must be a boolean, got {type_name(value)}")
123
+ return value
124
+
125
+
126
+ def _path(node: dict[str, Any], *, case: EvalCase, prediction: str) -> str:
127
+ path = node.get("path")
128
+ if path == "prediction":
129
+ return prediction
130
+ if path == "case.id":
131
+ return case.id
132
+ if path == "case.metadata":
133
+ name = node.get("name")
134
+ if not isinstance(name, str):
135
+ raise EvalError("case.metadata path must name a key")
136
+ if name not in case.metadata:
137
+ raise EvalError(f"unbound case.metadata key '{name}'")
138
+ return case.metadata[name]
139
+ raise EvalError(f"unknown predicate path {path!r}")
140
+
141
+
142
+ def _call(node: dict[str, Any], *, case: EvalCase, prediction: str) -> str | float:
143
+ name = node.get("name")
144
+ args = node.get("args")
145
+ if not isinstance(args, list):
146
+ raise EvalError(f"predicate call {name!r} must carry an argument list")
147
+ values = [_value(arg, case=case, prediction=prediction) for arg in args]
148
+ if name == "length":
149
+ _arity(name, values, 1)
150
+ text = _string(name, values[0])
151
+ return float(len(text))
152
+ if name in {"contains", "startswith", "endswith"}:
153
+ _arity(name, values, 2)
154
+ text = _string(name, values[0])
155
+ needle = _string(name, values[1])
156
+ if name == "contains":
157
+ return needle in text
158
+ if name == "startswith":
159
+ return text.startswith(needle)
160
+ return text.endswith(needle)
161
+ raise EvalError(f"unknown predicate builtin {name!r}")
162
+
163
+
164
+ def _arity(name: str, values: list[PredicateValue], wanted: int) -> None:
165
+ if len(values) != wanted:
166
+ raise EvalError(f"{name}() takes {wanted} argument(s), got {len(values)}")
167
+
168
+
169
+ def _string(name: str, value: PredicateValue) -> str:
170
+ if not isinstance(value, str):
171
+ raise EvalError(f"{name}() requires strings, got {type_name(value)}")
172
+ return value
173
+
174
+
175
+ def _compare(node: dict[str, Any], *, case: EvalCase, prediction: str) -> bool:
176
+ op = node.get("op")
177
+ left = _value(node.get("left"), case=case, prediction=prediction)
178
+ right = _value(node.get("right"), case=case, prediction=prediction)
179
+ if op in _ORDERING:
180
+ if (
181
+ isinstance(left, bool)
182
+ or isinstance(right, bool)
183
+ or not isinstance(left, (int, float))
184
+ or not isinstance(right, (int, float))
185
+ ):
186
+ raise EvalError(
187
+ f"ordering comparison {op!r} requires two numbers, "
188
+ f"got {type_name(left)} and {type_name(right)}"
189
+ )
190
+ if op == "<":
191
+ return float(left) < float(right)
192
+ if op == "<=":
193
+ return float(left) <= float(right)
194
+ if op == ">":
195
+ return float(left) > float(right)
196
+ return float(left) >= float(right)
197
+ if op not in {"==", "!="}:
198
+ raise EvalError(f"unknown predicate operator {op!r}")
199
+ if type_name(left) != type_name(right):
200
+ raise EvalError(
201
+ f"type mismatch: cannot apply {op!r} to {type_name(left)} and {type_name(right)}"
202
+ )
203
+ equal = left == right
204
+ return equal if op == "==" else not equal
205
+
206
+
207
+ def _boolean(node: dict[str, Any], key: str, *, case: EvalCase, prediction: str) -> bool:
208
+ value = _value(node.get(key), case=case, prediction=prediction)
209
+ if not isinstance(value, bool):
210
+ raise EvalError(f"predicate expected a boolean, got {type_name(value)}")
211
+ return value