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.
@@ -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