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.
- aid_runtime/__init__.py +500 -0
- aid_runtime/agents/__init__.py +94 -0
- aid_runtime/agents/chat_model.py +351 -0
- aid_runtime/agents/deep_agents.py +600 -0
- aid_runtime/agents/engine.py +102 -0
- aid_runtime/agents/guardrails.py +519 -0
- aid_runtime/agents/ir.py +622 -0
- aid_runtime/agents/loop.py +1308 -0
- aid_runtime/agents/memory.py +241 -0
- aid_runtime/agents/policy.py +163 -0
- aid_runtime/agents/state.py +182 -0
- aid_runtime/cache.py +185 -0
- aid_runtime/concurrency.py +70 -0
- aid_runtime/control.py +329 -0
- aid_runtime/cost.py +168 -0
- aid_runtime/errors.py +337 -0
- aid_runtime/evals/__init__.py +72 -0
- aid_runtime/evals/dataset.py +77 -0
- aid_runtime/evals/gate.py +430 -0
- aid_runtime/evals/metrics.py +335 -0
- aid_runtime/evals/predicate.py +211 -0
- aid_runtime/evals/runner.py +224 -0
- aid_runtime/gateway.py +473 -0
- aid_runtime/pipeline.py +168 -0
- aid_runtime/prompts.py +185 -0
- aid_runtime/providers/__init__.py +20 -0
- aid_runtime/providers/base.py +57 -0
- aid_runtime/providers/fake.py +256 -0
- aid_runtime/providers/http.py +344 -0
- aid_runtime/py.typed +0 -0
- aid_runtime/retrieval/__init__.py +178 -0
- aid_runtime/retrieval/chunking.py +222 -0
- aid_runtime/retrieval/citations.py +131 -0
- aid_runtime/retrieval/connectors.py +131 -0
- aid_runtime/retrieval/embedding.py +293 -0
- aid_runtime/retrieval/gate.py +120 -0
- aid_runtime/retrieval/hybrid.py +164 -0
- aid_runtime/retrieval/metrics.py +213 -0
- aid_runtime/retrieval/pgvector.py +258 -0
- aid_runtime/retrieval/rerank.py +133 -0
- aid_runtime/retrieval/retriever.py +335 -0
- aid_runtime/retrieval/stores.py +161 -0
- aid_runtime/retrieval/types.py +433 -0
- aid_runtime/steps.py +284 -0
- aid_runtime/structured.py +150 -0
- aid_runtime/tools.py +271 -0
- aid_runtime/tracing.py +241 -0
- aid_runtime/types.py +332 -0
- aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
- aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
- aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
- 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
|