proofstep-core 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.
- proofstep_core/__init__.py +59 -0
- proofstep_core/aggregate.py +140 -0
- proofstep_core/calibration.py +849 -0
- proofstep_core/calibration_runner.py +471 -0
- proofstep_core/compare.py +205 -0
- proofstep_core/dataset.py +174 -0
- proofstep_core/evaluators/__init__.py +47 -0
- proofstep_core/evaluators/deterministic.py +404 -0
- proofstep_core/evaluators/judge.py +335 -0
- proofstep_core/evaluators/operational.py +127 -0
- proofstep_core/evaluators/statistical.py +434 -0
- proofstep_core/gates.py +545 -0
- proofstep_core/paths.py +98 -0
- proofstep_core/py.typed +0 -0
- proofstep_core/redaction.py +263 -0
- proofstep_core/runner.py +482 -0
- proofstep_core/sampling.py +200 -0
- proofstep_core/significance.py +477 -0
- proofstep_core/stats.py +142 -0
- proofstep_core/suite.py +277 -0
- proofstep_core/testing.py +118 -0
- proofstep_core/types.py +156 -0
- proofstep_core/versioning.py +63 -0
- proofstep_core-0.1.0.dist-info/METADATA +39 -0
- proofstep_core-0.1.0.dist-info/RECORD +26 -0
- proofstep_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Dataset loading and slicing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from proofstep_types import Example, content_hash
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Dataset(Sequence[Example]):
|
|
15
|
+
"""An ordered, immutable collection of examples.
|
|
16
|
+
|
|
17
|
+
Materialized in memory rather than streamed. A 10 000-example dataset at ~2 KB
|
|
18
|
+
each is ~20 MB, and streaming would complicate resumption, slicing, and content
|
|
19
|
+
hashing for a saving nobody currently needs (docs/OPEN_QUESTIONS.md Q8).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, examples: Iterable[Example], *, name: str = "", version: str = "") -> None:
|
|
23
|
+
self._examples: tuple[Example, ...] = tuple(examples)
|
|
24
|
+
self.name = name
|
|
25
|
+
self.version = version
|
|
26
|
+
self._check_unique_ids()
|
|
27
|
+
|
|
28
|
+
def _check_unique_ids(self) -> None:
|
|
29
|
+
seen: set[str] = set()
|
|
30
|
+
duplicates: list[str] = []
|
|
31
|
+
for example in self._examples:
|
|
32
|
+
if example.id in seen:
|
|
33
|
+
duplicates.append(example.id)
|
|
34
|
+
seen.add(example.id)
|
|
35
|
+
if duplicates:
|
|
36
|
+
shown = ", ".join(sorted(set(duplicates))[:5])
|
|
37
|
+
msg = (
|
|
38
|
+
f"Dataset {self.name or '<unnamed>'} has duplicate example ids: {shown}. "
|
|
39
|
+
"Ids must be unique — comparison between experiments matches on them, "
|
|
40
|
+
"and duplicates would silently pair unrelated results."
|
|
41
|
+
)
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
|
|
44
|
+
# -------------------------------------------------------------- Sequence
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return len(self._examples)
|
|
48
|
+
|
|
49
|
+
def __iter__(self) -> Iterator[Example]:
|
|
50
|
+
return iter(self._examples)
|
|
51
|
+
|
|
52
|
+
def __getitem__(self, index: int | slice) -> Any:
|
|
53
|
+
if isinstance(index, slice):
|
|
54
|
+
return Dataset(self._examples[index], name=self.name, version=self.version)
|
|
55
|
+
return self._examples[index]
|
|
56
|
+
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
label = f"{self.name}@{self.version}" if self.version else self.name or "<unnamed>"
|
|
59
|
+
return f"Dataset({label!r}, n={len(self)})"
|
|
60
|
+
|
|
61
|
+
# -------------------------------------------------------------- loading
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_jsonl(cls, path: str | Path, **kw: Any) -> Dataset:
|
|
65
|
+
"""Load newline-delimited JSON.
|
|
66
|
+
|
|
67
|
+
Errors name the line number: a dataset of 5 000 rows with one malformed
|
|
68
|
+
entry is otherwise miserable to debug.
|
|
69
|
+
"""
|
|
70
|
+
path = Path(path)
|
|
71
|
+
examples: list[Example] = []
|
|
72
|
+
with path.open(encoding="utf-8") as handle:
|
|
73
|
+
for lineno, line in enumerate(handle, start=1):
|
|
74
|
+
stripped = line.strip()
|
|
75
|
+
if not stripped or stripped.startswith("//"):
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
raw = json.loads(stripped)
|
|
79
|
+
except json.JSONDecodeError as exc:
|
|
80
|
+
msg = f"{path}:{lineno}: invalid JSON: {exc.msg}"
|
|
81
|
+
raise ValueError(msg) from exc
|
|
82
|
+
examples.append(cls._to_example(raw, source=f"{path}:{lineno}", index=lineno))
|
|
83
|
+
return cls(examples, name=kw.pop("name", path.stem), **kw)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_csv(cls, path: str | Path, **kw: Any) -> Dataset:
|
|
87
|
+
"""Load a flat CSV using ``input.*`` / ``expected.*`` / ``metadata.*`` columns.
|
|
88
|
+
|
|
89
|
+
Deliberately limited to one level. Guessing at nested CSV semantics produces
|
|
90
|
+
data-quality bugs that surface much later as mysterious eval failures, so
|
|
91
|
+
anything nested must use JSONL instead.
|
|
92
|
+
"""
|
|
93
|
+
path = Path(path)
|
|
94
|
+
examples: list[Example] = []
|
|
95
|
+
with path.open(encoding="utf-8", newline="") as handle:
|
|
96
|
+
for index, row in enumerate(csv.DictReader(handle), start=1):
|
|
97
|
+
nested: dict[str, dict[str, Any]] = {
|
|
98
|
+
"input": {},
|
|
99
|
+
"expected": {},
|
|
100
|
+
"metadata": {},
|
|
101
|
+
}
|
|
102
|
+
flat: dict[str, Any] = {}
|
|
103
|
+
for column, value in row.items():
|
|
104
|
+
if column is None:
|
|
105
|
+
continue
|
|
106
|
+
prefix, _, rest = column.partition(".")
|
|
107
|
+
if rest and prefix in nested:
|
|
108
|
+
nested[prefix][rest] = value
|
|
109
|
+
else:
|
|
110
|
+
flat[column] = value
|
|
111
|
+
raw: dict[str, Any] = {
|
|
112
|
+
"id": flat.pop("id", None),
|
|
113
|
+
"input": nested["input"] or flat,
|
|
114
|
+
"expected": nested["expected"] or None,
|
|
115
|
+
"metadata": nested["metadata"],
|
|
116
|
+
}
|
|
117
|
+
examples.append(cls._to_example(raw, source=f"{path}:{index}", index=index))
|
|
118
|
+
return cls(examples, name=kw.pop("name", path.stem), **kw)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def from_dicts(cls, rows: Iterable[dict[str, Any]], **kw: Any) -> Dataset:
|
|
122
|
+
return cls(
|
|
123
|
+
[
|
|
124
|
+
cls._to_example(row, source=f"row {i}", index=i)
|
|
125
|
+
for i, row in enumerate(rows, start=1)
|
|
126
|
+
],
|
|
127
|
+
**kw,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _to_example(raw: Any, *, source: str, index: int) -> Example:
|
|
132
|
+
if not isinstance(raw, dict):
|
|
133
|
+
msg = f"{source}: expected a JSON object, got {type(raw).__name__}"
|
|
134
|
+
raise TypeError(msg)
|
|
135
|
+
if "input" not in raw:
|
|
136
|
+
msg = f"{source}: example is missing the required 'input' field"
|
|
137
|
+
raise ValueError(msg)
|
|
138
|
+
return Example(
|
|
139
|
+
id=str(raw.get("id") or raw.get("external_id") or f"ex-{index:04d}"),
|
|
140
|
+
input=raw["input"],
|
|
141
|
+
expected=raw.get("expected"),
|
|
142
|
+
metadata=raw.get("metadata") or {},
|
|
143
|
+
source_trace_id=raw.get("source_trace_id"),
|
|
144
|
+
source_span_id=raw.get("source_span_id"),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# -------------------------------------------------------------- writing
|
|
148
|
+
|
|
149
|
+
def to_jsonl(self, path: str | Path) -> None:
|
|
150
|
+
with Path(path).open("w", encoding="utf-8") as handle:
|
|
151
|
+
for example in self._examples:
|
|
152
|
+
handle.write(example.canonical_json() + "\n")
|
|
153
|
+
|
|
154
|
+
# -------------------------------------------------------------- slicing
|
|
155
|
+
|
|
156
|
+
def filter(self, predicate: Callable[[Example], bool]) -> Dataset:
|
|
157
|
+
return Dataset(
|
|
158
|
+
[e for e in self._examples if predicate(e)], name=self.name, version=self.version
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def limit(self, n: int) -> Dataset:
|
|
162
|
+
return Dataset(self._examples[:n], name=self.name, version=self.version)
|
|
163
|
+
|
|
164
|
+
def by_id(self, example_id: str) -> Example | None:
|
|
165
|
+
return next((e for e in self._examples if e.id == example_id), None)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def ids(self) -> list[str]:
|
|
169
|
+
return [e.id for e in self._examples]
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def content_hash(self) -> str:
|
|
173
|
+
"""Hash of the exact content, used to prove two runs saw identical data."""
|
|
174
|
+
return content_hash(list(self._examples))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Built-in evaluators.
|
|
2
|
+
|
|
3
|
+
Reach for a judge only when the property is genuinely subjective. Everything in
|
|
4
|
+
`deterministic` and `statistical` is free, instant, and exactly reproducible.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from proofstep_core.evaluators.deterministic import (
|
|
8
|
+
Contains,
|
|
9
|
+
ExactMatch,
|
|
10
|
+
JsonSchemaMatch,
|
|
11
|
+
LengthWithin,
|
|
12
|
+
NumericRange,
|
|
13
|
+
RegexMatch,
|
|
14
|
+
SetComparison,
|
|
15
|
+
)
|
|
16
|
+
from proofstep_core.evaluators.judge import LLMJudge
|
|
17
|
+
from proofstep_core.evaluators.operational import OperationalEvaluator
|
|
18
|
+
from proofstep_core.evaluators.statistical import (
|
|
19
|
+
CalibrationEvaluator,
|
|
20
|
+
ClassificationEvaluator,
|
|
21
|
+
DiscriminationEvaluator,
|
|
22
|
+
RankingEvaluator,
|
|
23
|
+
confusion_matrix,
|
|
24
|
+
expected_calibration_error,
|
|
25
|
+
ndcg_at_k,
|
|
26
|
+
reciprocal_rank,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"CalibrationEvaluator",
|
|
31
|
+
"ClassificationEvaluator",
|
|
32
|
+
"Contains",
|
|
33
|
+
"DiscriminationEvaluator",
|
|
34
|
+
"ExactMatch",
|
|
35
|
+
"JsonSchemaMatch",
|
|
36
|
+
"LLMJudge",
|
|
37
|
+
"LengthWithin",
|
|
38
|
+
"NumericRange",
|
|
39
|
+
"OperationalEvaluator",
|
|
40
|
+
"RankingEvaluator",
|
|
41
|
+
"RegexMatch",
|
|
42
|
+
"SetComparison",
|
|
43
|
+
"confusion_matrix",
|
|
44
|
+
"expected_calibration_error",
|
|
45
|
+
"ndcg_at_k",
|
|
46
|
+
"reciprocal_rank",
|
|
47
|
+
]
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Deterministic evaluators — free, instant, and 100% reliable.
|
|
2
|
+
|
|
3
|
+
These run on every example and always run first. Most of what teams reach for an LLM
|
|
4
|
+
judge to check is mechanically checkable, and every metric implemented here is one
|
|
5
|
+
that a judge would do more slowly, more expensively, and less accurately
|
|
6
|
+
(docs/EVALUATION_ENGINE.md §2.1).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import unicodedata
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
from proofstep_core.paths import PathError, resolve, resolve_in_context
|
|
18
|
+
from proofstep_core.types import EvalContext, EvaluatorBase
|
|
19
|
+
from proofstep_types import Score
|
|
20
|
+
|
|
21
|
+
Normalization = Literal["none", "case", "whitespace", "punctuation", "all"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _FieldEvaluator(EvaluatorBase):
|
|
25
|
+
"""Shared plumbing for evaluators that read one field from the context."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, field: str = "output", **kw: Any) -> None:
|
|
28
|
+
super().__init__(**kw)
|
|
29
|
+
self.field = field
|
|
30
|
+
|
|
31
|
+
def _read(self, ctx: EvalContext) -> tuple[Any, Score | None]:
|
|
32
|
+
try:
|
|
33
|
+
value = resolve_in_context(
|
|
34
|
+
self.field,
|
|
35
|
+
output=ctx.output,
|
|
36
|
+
input_=ctx.example.input,
|
|
37
|
+
expected=ctx.expected,
|
|
38
|
+
metadata=ctx.metadata,
|
|
39
|
+
state=ctx.trace.state if ctx.trace else None,
|
|
40
|
+
)
|
|
41
|
+
except PathError as exc:
|
|
42
|
+
# A missing field is an error, not a zero: it means the suite is
|
|
43
|
+
# misconfigured or the task changed shape, and reporting it as a bad
|
|
44
|
+
# score would hide a broken suite behind a plausible-looking metric.
|
|
45
|
+
return None, Score.failure(self.name, str(exc))
|
|
46
|
+
return value, None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_expected(path: str, expected: dict[str, Any]) -> Any:
|
|
50
|
+
"""Resolve a path that names a field of `expected`.
|
|
51
|
+
|
|
52
|
+
A bare name like `intent` means `expected.intent`, not `output.intent`. Routing
|
|
53
|
+
it through the generic resolver treated it as output-relative and errored on
|
|
54
|
+
every example — a broken evaluator reported as a metric of zero measurements
|
|
55
|
+
rather than as a mistake.
|
|
56
|
+
"""
|
|
57
|
+
if path.startswith("expected."):
|
|
58
|
+
return resolve(expected, path.removeprefix("expected."))
|
|
59
|
+
return resolve(expected, path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalize(text: str, mode: Normalization) -> str:
|
|
63
|
+
if mode == "none":
|
|
64
|
+
return text
|
|
65
|
+
result = text
|
|
66
|
+
if mode in ("case", "all"):
|
|
67
|
+
result = result.casefold()
|
|
68
|
+
if mode in ("whitespace", "all"):
|
|
69
|
+
result = " ".join(result.split())
|
|
70
|
+
if mode in ("punctuation", "all"):
|
|
71
|
+
result = "".join(ch for ch in result if not unicodedata.category(ch).startswith("P"))
|
|
72
|
+
result = " ".join(result.split())
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ExactMatch(_FieldEvaluator):
|
|
77
|
+
"""Compare a field against the expected value."""
|
|
78
|
+
|
|
79
|
+
requires_expected = True
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
field: str = "output",
|
|
85
|
+
expected_field: str | None = None,
|
|
86
|
+
normalize: Normalization = "none",
|
|
87
|
+
name: str = "exact_match",
|
|
88
|
+
**kw: Any,
|
|
89
|
+
) -> None:
|
|
90
|
+
super().__init__(field=field, name=name, **kw)
|
|
91
|
+
self.expected_field = expected_field
|
|
92
|
+
self.normalize = normalize
|
|
93
|
+
|
|
94
|
+
async def evaluate(self, ctx: EvalContext) -> Score:
|
|
95
|
+
if failure := self._precondition_failure(ctx):
|
|
96
|
+
return failure
|
|
97
|
+
actual, error = self._read(ctx)
|
|
98
|
+
if error:
|
|
99
|
+
return error
|
|
100
|
+
|
|
101
|
+
assert ctx.expected is not None
|
|
102
|
+
if self.expected_field:
|
|
103
|
+
try:
|
|
104
|
+
wanted = _resolve_expected(self.expected_field, ctx.expected)
|
|
105
|
+
except PathError as exc:
|
|
106
|
+
return Score.failure(self.name, str(exc))
|
|
107
|
+
else:
|
|
108
|
+
tail = self.field.split(".")[-1]
|
|
109
|
+
wanted = ctx.expected.get(tail, ctx.expected)
|
|
110
|
+
|
|
111
|
+
if isinstance(actual, str) and isinstance(wanted, str):
|
|
112
|
+
matched = _normalize(actual, self.normalize) == _normalize(wanted, self.normalize)
|
|
113
|
+
else:
|
|
114
|
+
matched = actual == wanted
|
|
115
|
+
return Score.binary(self.name, matched, raw={"actual": actual, "expected": wanted})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class JsonSchemaMatch(_FieldEvaluator):
|
|
119
|
+
"""Validate a field against a JSON Schema.
|
|
120
|
+
|
|
121
|
+
Implements the subset of draft 2020-12 that suites actually use, so the pure
|
|
122
|
+
core stays dependency-free. Unknown keywords are ignored rather than rejected.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
schema: dict[str, Any],
|
|
128
|
+
*,
|
|
129
|
+
field: str = "output",
|
|
130
|
+
name: str = "valid_schema",
|
|
131
|
+
**kw: Any,
|
|
132
|
+
) -> None:
|
|
133
|
+
super().__init__(field=field, name=name, **kw)
|
|
134
|
+
self.schema = schema
|
|
135
|
+
|
|
136
|
+
async def evaluate(self, ctx: EvalContext) -> Score:
|
|
137
|
+
value, error = self._read(ctx)
|
|
138
|
+
if error:
|
|
139
|
+
return error
|
|
140
|
+
errors = _validate(value, self.schema, "$")
|
|
141
|
+
return Score.binary(self.name, not errors, raw={"errors": errors[:20]})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_TYPES: dict[str, type | tuple[type, ...]] = {
|
|
145
|
+
"object": dict,
|
|
146
|
+
"array": (list, tuple),
|
|
147
|
+
"string": str,
|
|
148
|
+
"number": (int, float),
|
|
149
|
+
"integer": int,
|
|
150
|
+
"boolean": bool,
|
|
151
|
+
"null": type(None),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _validate(value: Any, schema: dict[str, Any], path: str) -> list[str]: # noqa: PLR0912
|
|
156
|
+
errors: list[str] = []
|
|
157
|
+
|
|
158
|
+
if (expected := schema.get("type")) is not None:
|
|
159
|
+
wanted = _TYPES.get(expected)
|
|
160
|
+
# bool is a subclass of int in Python; JSON Schema treats them as distinct.
|
|
161
|
+
ok = isinstance(value, wanted) if wanted else True
|
|
162
|
+
if expected in ("number", "integer") and isinstance(value, bool):
|
|
163
|
+
ok = False
|
|
164
|
+
if not ok:
|
|
165
|
+
errors.append(f"{path}: expected {expected}, got {type(value).__name__}")
|
|
166
|
+
return errors
|
|
167
|
+
|
|
168
|
+
if isinstance(value, dict):
|
|
169
|
+
for key in schema.get("required", []):
|
|
170
|
+
if key not in value:
|
|
171
|
+
errors.append(f"{path}.{key}: required property missing")
|
|
172
|
+
properties = schema.get("properties", {})
|
|
173
|
+
for key, subschema in properties.items():
|
|
174
|
+
if key in value:
|
|
175
|
+
errors.extend(_validate(value[key], subschema, f"{path}.{key}"))
|
|
176
|
+
if schema.get("additionalProperties") is False:
|
|
177
|
+
for key in value:
|
|
178
|
+
if key not in properties:
|
|
179
|
+
errors.append(f"{path}.{key}: additional property not allowed")
|
|
180
|
+
|
|
181
|
+
if isinstance(value, list | tuple):
|
|
182
|
+
if (items := schema.get("items")) is not None:
|
|
183
|
+
for i, item in enumerate(value):
|
|
184
|
+
errors.extend(_validate(item, items, f"{path}[{i}]"))
|
|
185
|
+
if (n := schema.get("minItems")) is not None and len(value) < n:
|
|
186
|
+
errors.append(f"{path}: expected at least {n} items, got {len(value)}")
|
|
187
|
+
if (n := schema.get("maxItems")) is not None and len(value) > n:
|
|
188
|
+
errors.append(f"{path}: expected at most {n} items, got {len(value)}")
|
|
189
|
+
|
|
190
|
+
if isinstance(value, str):
|
|
191
|
+
if (n := schema.get("minLength")) is not None and len(value) < n:
|
|
192
|
+
errors.append(f"{path}: shorter than minLength {n}")
|
|
193
|
+
if (n := schema.get("maxLength")) is not None and len(value) > n:
|
|
194
|
+
errors.append(f"{path}: longer than maxLength {n}")
|
|
195
|
+
if (pattern := schema.get("pattern")) is not None and not re.search(pattern, value):
|
|
196
|
+
errors.append(f"{path}: does not match pattern {pattern!r}")
|
|
197
|
+
|
|
198
|
+
if isinstance(value, int | float) and not isinstance(value, bool):
|
|
199
|
+
if (n := schema.get("minimum")) is not None and value < n:
|
|
200
|
+
errors.append(f"{path}: {value} below minimum {n}")
|
|
201
|
+
if (n := schema.get("maximum")) is not None and value > n:
|
|
202
|
+
errors.append(f"{path}: {value} above maximum {n}")
|
|
203
|
+
|
|
204
|
+
if (allowed := schema.get("enum")) is not None and value not in allowed:
|
|
205
|
+
errors.append(f"{path}: {value!r} not in enum {allowed}")
|
|
206
|
+
|
|
207
|
+
return errors
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class RegexMatch(_FieldEvaluator):
|
|
211
|
+
"""Assert a field matches every ``allow`` pattern and no ``deny`` pattern.
|
|
212
|
+
|
|
213
|
+
Placeholder leakage — ``[Your Name]`` reaching a customer — is the archetypal
|
|
214
|
+
case: embarrassing in production, and a regex catches it for free.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def __init__(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
allow: Sequence[str] = (),
|
|
221
|
+
deny: Sequence[str] = (),
|
|
222
|
+
field: str = "output",
|
|
223
|
+
flags: int = 0,
|
|
224
|
+
name: str = "regex",
|
|
225
|
+
**kw: Any,
|
|
226
|
+
) -> None:
|
|
227
|
+
super().__init__(field=field, name=name, **kw)
|
|
228
|
+
self.allow = [re.compile(p, flags) for p in allow]
|
|
229
|
+
self.deny = [re.compile(p, flags) for p in deny]
|
|
230
|
+
|
|
231
|
+
async def evaluate(self, ctx: EvalContext) -> Score:
|
|
232
|
+
value, error = self._read(ctx)
|
|
233
|
+
if error:
|
|
234
|
+
return error
|
|
235
|
+
text = value if isinstance(value, str) else json.dumps(value, default=str)
|
|
236
|
+
|
|
237
|
+
violations = [p.pattern for p in self.deny if p.search(text)]
|
|
238
|
+
missing = [p.pattern for p in self.allow if not p.search(text)]
|
|
239
|
+
passed = not violations and not missing
|
|
240
|
+
detail: dict[str, Any] = {}
|
|
241
|
+
if violations:
|
|
242
|
+
detail["matched_denied"] = violations
|
|
243
|
+
if missing:
|
|
244
|
+
detail["missing_required"] = missing
|
|
245
|
+
return Score.binary(self.name, passed, raw=detail or None)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class Contains(_FieldEvaluator):
|
|
249
|
+
def __init__(
|
|
250
|
+
self,
|
|
251
|
+
substrings: Sequence[str],
|
|
252
|
+
*,
|
|
253
|
+
mode: Literal["all", "any"] = "all",
|
|
254
|
+
case_sensitive: bool = True,
|
|
255
|
+
field: str = "output",
|
|
256
|
+
name: str = "contains",
|
|
257
|
+
**kw: Any,
|
|
258
|
+
) -> None:
|
|
259
|
+
super().__init__(field=field, name=name, **kw)
|
|
260
|
+
self.substrings = list(substrings)
|
|
261
|
+
self.mode = mode
|
|
262
|
+
self.case_sensitive = case_sensitive
|
|
263
|
+
|
|
264
|
+
async def evaluate(self, ctx: EvalContext) -> Score:
|
|
265
|
+
value, error = self._read(ctx)
|
|
266
|
+
if error:
|
|
267
|
+
return error
|
|
268
|
+
text = value if isinstance(value, str) else json.dumps(value, default=str)
|
|
269
|
+
if not self.case_sensitive:
|
|
270
|
+
text = text.casefold()
|
|
271
|
+
needles = [s if self.case_sensitive else s.casefold() for s in self.substrings]
|
|
272
|
+
found = [s for s in needles if s in text]
|
|
273
|
+
passed = len(found) == len(needles) if self.mode == "all" else bool(found)
|
|
274
|
+
missing = [s for s in needles if s not in text]
|
|
275
|
+
return Score.binary(self.name, passed, raw={"found": found, "missing": missing})
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class LengthWithin(_FieldEvaluator):
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
*,
|
|
282
|
+
minimum: int | None = None,
|
|
283
|
+
maximum: int | None = None,
|
|
284
|
+
unit: Literal["chars", "words"] = "chars",
|
|
285
|
+
field: str = "output",
|
|
286
|
+
name: str = "length",
|
|
287
|
+
**kw: Any,
|
|
288
|
+
) -> None:
|
|
289
|
+
super().__init__(field=field, name=name, **kw)
|
|
290
|
+
if minimum is None and maximum is None:
|
|
291
|
+
msg = "LengthWithin needs at least one of minimum or maximum"
|
|
292
|
+
raise ValueError(msg)
|
|
293
|
+
self.minimum = minimum
|
|
294
|
+
self.maximum = maximum
|
|
295
|
+
self.unit = unit
|
|
296
|
+
|
|
297
|
+
async def evaluate(self, ctx: EvalContext) -> Score:
|
|
298
|
+
value, error = self._read(ctx)
|
|
299
|
+
if error:
|
|
300
|
+
return error
|
|
301
|
+
text = value if isinstance(value, str) else json.dumps(value, default=str)
|
|
302
|
+
actual = len(text.split()) if self.unit == "words" else len(text)
|
|
303
|
+
passed = (self.minimum is None or actual >= self.minimum) and (
|
|
304
|
+
self.maximum is None or actual <= self.maximum
|
|
305
|
+
)
|
|
306
|
+
return Score.binary(
|
|
307
|
+
self.name,
|
|
308
|
+
passed,
|
|
309
|
+
raw={"actual": actual, "unit": self.unit, "min": self.minimum, "max": self.maximum},
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class NumericRange(_FieldEvaluator):
|
|
314
|
+
def __init__(
|
|
315
|
+
self,
|
|
316
|
+
*,
|
|
317
|
+
minimum: float | None = None,
|
|
318
|
+
maximum: float | None = None,
|
|
319
|
+
inclusive: bool = True,
|
|
320
|
+
field: str = "output",
|
|
321
|
+
name: str = "numeric_range",
|
|
322
|
+
**kw: Any,
|
|
323
|
+
) -> None:
|
|
324
|
+
super().__init__(field=field, name=name, **kw)
|
|
325
|
+
self.minimum = minimum
|
|
326
|
+
self.maximum = maximum
|
|
327
|
+
self.inclusive = inclusive
|
|
328
|
+
|
|
329
|
+
async def evaluate(self, ctx: EvalContext) -> Score:
|
|
330
|
+
value, error = self._read(ctx)
|
|
331
|
+
if error:
|
|
332
|
+
return error
|
|
333
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
334
|
+
return Score.failure(
|
|
335
|
+
self.name, f"{self.field} is {type(value).__name__}, expected a number"
|
|
336
|
+
)
|
|
337
|
+
lo_ok = self.minimum is None or (
|
|
338
|
+
value >= self.minimum if self.inclusive else value > self.minimum
|
|
339
|
+
)
|
|
340
|
+
hi_ok = self.maximum is None or (
|
|
341
|
+
value <= self.maximum if self.inclusive else value < self.maximum
|
|
342
|
+
)
|
|
343
|
+
return Score.binary(self.name, lo_ok and hi_ok, raw={"actual": value})
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class SetComparison(_FieldEvaluator):
|
|
347
|
+
"""Compare a collection field against the expected collection."""
|
|
348
|
+
|
|
349
|
+
requires_expected = True
|
|
350
|
+
|
|
351
|
+
def __init__(
|
|
352
|
+
self,
|
|
353
|
+
*,
|
|
354
|
+
mode: Literal["equals", "subset", "superset", "jaccard"] = "equals",
|
|
355
|
+
field: str = "output",
|
|
356
|
+
expected_field: str | None = None,
|
|
357
|
+
name: str = "set_comparison",
|
|
358
|
+
**kw: Any,
|
|
359
|
+
) -> None:
|
|
360
|
+
super().__init__(field=field, name=name, **kw)
|
|
361
|
+
self.mode = mode
|
|
362
|
+
self.expected_field = expected_field
|
|
363
|
+
|
|
364
|
+
async def evaluate(self, ctx: EvalContext) -> Score:
|
|
365
|
+
if failure := self._precondition_failure(ctx):
|
|
366
|
+
return failure
|
|
367
|
+
value, error = self._read(ctx)
|
|
368
|
+
if error:
|
|
369
|
+
return error
|
|
370
|
+
assert ctx.expected is not None
|
|
371
|
+
tail = (self.expected_field or self.field).split(".")[-1]
|
|
372
|
+
wanted_raw = ctx.expected.get(tail)
|
|
373
|
+
if wanted_raw is None:
|
|
374
|
+
return Score.failure(self.name, f"expected has no field {tail!r}")
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
actual = {_hashable(v) for v in value}
|
|
378
|
+
wanted = {_hashable(v) for v in wanted_raw}
|
|
379
|
+
except TypeError as exc:
|
|
380
|
+
return Score.failure(self.name, f"values are not comparable as a set: {exc}")
|
|
381
|
+
|
|
382
|
+
detail = {
|
|
383
|
+
"missing": sorted(map(str, wanted - actual))[:20],
|
|
384
|
+
"unexpected": sorted(map(str, actual - wanted))[:20],
|
|
385
|
+
}
|
|
386
|
+
if self.mode == "jaccard":
|
|
387
|
+
union = actual | wanted
|
|
388
|
+
ratio = len(actual & wanted) / len(union) if union else 1.0
|
|
389
|
+
return Score(metric=self.name, value=ratio, passed=ratio == 1.0, raw=detail)
|
|
390
|
+
|
|
391
|
+
passed = {
|
|
392
|
+
"equals": actual == wanted,
|
|
393
|
+
"subset": actual <= wanted,
|
|
394
|
+
"superset": actual >= wanted,
|
|
395
|
+
}[self.mode]
|
|
396
|
+
return Score.binary(self.name, passed, raw=detail)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _hashable(value: Any) -> Any:
|
|
400
|
+
if isinstance(value, dict):
|
|
401
|
+
return json.dumps(value, sort_keys=True, default=str)
|
|
402
|
+
if isinstance(value, list):
|
|
403
|
+
return tuple(_hashable(v) for v in value)
|
|
404
|
+
return value
|