assay-engine 0.5.0.dev2__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.
- assay/__init__.py +83 -0
- assay/_cli_app.py +102 -0
- assay/_cli_io.py +130 -0
- assay/_json.py +34 -0
- assay/_optional.py +69 -0
- assay/_version.py +5 -0
- assay/additive.py +104 -0
- assay/agreement.py +312 -0
- assay/calibration.py +167 -0
- assay/cli.py +55 -0
- assay/compose.py +31 -0
- assay/composite.py +228 -0
- assay/contracts.py +886 -0
- assay/errors.py +166 -0
- assay/limits.py +13 -0
- assay/measurement.py +1325 -0
- assay/metrics.py +238 -0
- assay/minimum.py +82 -0
- assay/models.py +111 -0
- assay/normalize.py +74 -0
- assay/py.typed +0 -0
- assay/ranking.py +361 -0
- assay/settings.py +90 -0
- assay/uncertainty.py +190 -0
- assay/weighted_mean.py +109 -0
- assay_engine-0.5.0.dev2.dist-info/METADATA +250 -0
- assay_engine-0.5.0.dev2.dist-info/RECORD +30 -0
- assay_engine-0.5.0.dev2.dist-info/WHEEL +4 -0
- assay_engine-0.5.0.dev2.dist-info/entry_points.txt +2 -0
- assay_engine-0.5.0.dev2.dist-info/licenses/LICENSE +21 -0
assay/contracts.py
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
"""Immutable, JSON-safe contracts using finite IEEE-754 binary64 numbers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Annotated, ClassVar, Literal, NoReturn, Self
|
|
10
|
+
|
|
11
|
+
from pydantic import (
|
|
12
|
+
BaseModel,
|
|
13
|
+
BeforeValidator,
|
|
14
|
+
ConfigDict,
|
|
15
|
+
Field,
|
|
16
|
+
SerializerFunctionWrapHandler,
|
|
17
|
+
TypeAdapter,
|
|
18
|
+
ValidationError,
|
|
19
|
+
model_serializer,
|
|
20
|
+
model_validator,
|
|
21
|
+
)
|
|
22
|
+
from pydantic.config import ExtraValues
|
|
23
|
+
from pydantic.fields import FieldInfo
|
|
24
|
+
|
|
25
|
+
from assay._json import decode_json
|
|
26
|
+
from assay.errors import ContractCode, ContractValidationError
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AdditiveRequest",
|
|
30
|
+
"AdditiveTerm",
|
|
31
|
+
"ClampPolicy",
|
|
32
|
+
"Component",
|
|
33
|
+
"Direction",
|
|
34
|
+
"ExplainedComponent",
|
|
35
|
+
"Interval",
|
|
36
|
+
"Method",
|
|
37
|
+
"MinimumRequest",
|
|
38
|
+
"NativeScale",
|
|
39
|
+
"Operation",
|
|
40
|
+
"ScoreRequest",
|
|
41
|
+
"ScoreResult",
|
|
42
|
+
"WeightedMeanRequest",
|
|
43
|
+
"parse_request",
|
|
44
|
+
"parse_request_json",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_MODEL_CONFIG = ConfigDict(
|
|
48
|
+
frozen=True,
|
|
49
|
+
extra="forbid",
|
|
50
|
+
hide_input_in_errors=True,
|
|
51
|
+
populate_by_name=True,
|
|
52
|
+
revalidate_instances="always",
|
|
53
|
+
serialize_by_alias=True,
|
|
54
|
+
)
|
|
55
|
+
_STABLE_ID = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
|
|
56
|
+
_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
57
|
+
_MAX_IDENTIFIER_LENGTH = 128
|
|
58
|
+
_MAX_LABEL_LENGTH = 256
|
|
59
|
+
_SURROGATE_MIN = 0xD800
|
|
60
|
+
_SURROGATE_MAX = 0xDFFF
|
|
61
|
+
_JsonData = str | bytes | bytearray
|
|
62
|
+
_OptionalBool = bool | None
|
|
63
|
+
_OptionalExtra = ExtraValues | None
|
|
64
|
+
_ValidationOptions = tuple[
|
|
65
|
+
_OptionalBool, _OptionalExtra, object | None, _OptionalBool, _OptionalBool
|
|
66
|
+
]
|
|
67
|
+
_PythonValidationOptions = tuple[
|
|
68
|
+
_OptionalBool,
|
|
69
|
+
_OptionalExtra,
|
|
70
|
+
_OptionalBool,
|
|
71
|
+
object | None,
|
|
72
|
+
_OptionalBool,
|
|
73
|
+
_OptionalBool,
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Direction(StrEnum):
|
|
78
|
+
"""Whether larger or smaller native values represent a better outcome."""
|
|
79
|
+
|
|
80
|
+
HIGHER_IS_BETTER = "higher_is_better"
|
|
81
|
+
LOWER_IS_BETTER = "lower_is_better"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ClampPolicy(StrEnum):
|
|
85
|
+
"""How a declared boundary handles an out-of-range value."""
|
|
86
|
+
|
|
87
|
+
REJECT = "reject"
|
|
88
|
+
CLAMP = "clamp"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Operation(StrEnum):
|
|
92
|
+
"""The explicit sign of an additive term."""
|
|
93
|
+
|
|
94
|
+
ADD = "add"
|
|
95
|
+
SUBTRACT = "subtract"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _fail(code: ContractCode) -> NoReturn:
|
|
99
|
+
raise ContractValidationError(code) from None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _decode_json(data: _JsonData) -> object:
|
|
103
|
+
return decode_json(data, ContractValidationError)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _finite(value: object) -> float:
|
|
107
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
108
|
+
_fail(ContractCode.INVALID_NUMBER)
|
|
109
|
+
try:
|
|
110
|
+
number = float(value)
|
|
111
|
+
except OverflowError:
|
|
112
|
+
number = math.nan
|
|
113
|
+
if not math.isfinite(number):
|
|
114
|
+
_fail(ContractCode.INVALID_NUMBER)
|
|
115
|
+
return _canonical_zero(number)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _canonical_zero(value: float) -> float:
|
|
119
|
+
return 0.0 if value == 0.0 else value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _positive(value: object) -> float:
|
|
123
|
+
number = _finite(value)
|
|
124
|
+
if number <= 0:
|
|
125
|
+
_fail(ContractCode.INVALID_WEIGHT)
|
|
126
|
+
return number
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _nonnegative(value: object) -> float:
|
|
130
|
+
number = _finite(value)
|
|
131
|
+
if number < 0:
|
|
132
|
+
_fail(ContractCode.INVALID_COEFFICIENT)
|
|
133
|
+
return number
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _stable_identifier(value: object) -> str:
|
|
137
|
+
if not isinstance(value, str) or len(value) > _MAX_IDENTIFIER_LENGTH:
|
|
138
|
+
_fail(ContractCode.INVALID_IDENTIFIER)
|
|
139
|
+
if _STABLE_ID.fullmatch(value) is None:
|
|
140
|
+
_fail(ContractCode.INVALID_IDENTIFIER)
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _contains_surrogate(value: str) -> bool:
|
|
145
|
+
return any(_SURROGATE_MIN <= ord(character) <= _SURROGATE_MAX for character in value)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _label(value: object) -> str:
|
|
149
|
+
if not isinstance(value, str) or not value.strip():
|
|
150
|
+
_fail(ContractCode.INVALID_LABEL)
|
|
151
|
+
if len(value) > _MAX_LABEL_LENGTH:
|
|
152
|
+
_fail(ContractCode.INVALID_LABEL)
|
|
153
|
+
if _contains_surrogate(value):
|
|
154
|
+
_fail(ContractCode.INVALID_TEXT)
|
|
155
|
+
return value
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _inputs_hash(value: object) -> str:
|
|
159
|
+
if not isinstance(value, str) or _SHA256.fullmatch(value) is None:
|
|
160
|
+
_fail(ContractCode.INVALID_INPUTS_HASH)
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _direction(value: object) -> Direction:
|
|
165
|
+
if isinstance(value, str):
|
|
166
|
+
try:
|
|
167
|
+
return Direction(str(value))
|
|
168
|
+
except ValueError:
|
|
169
|
+
pass
|
|
170
|
+
_fail(ContractCode.INVALID_DIRECTION)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _clamp_policy(value: object) -> ClampPolicy:
|
|
174
|
+
if isinstance(value, str):
|
|
175
|
+
try:
|
|
176
|
+
return ClampPolicy(str(value))
|
|
177
|
+
except ValueError:
|
|
178
|
+
pass
|
|
179
|
+
_fail(ContractCode.INVALID_CLAMP_POLICY)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _operation(value: object) -> Operation:
|
|
183
|
+
if isinstance(value, Operation):
|
|
184
|
+
return value
|
|
185
|
+
if isinstance(value, str):
|
|
186
|
+
try:
|
|
187
|
+
return Operation(value)
|
|
188
|
+
except ValueError:
|
|
189
|
+
pass
|
|
190
|
+
_fail(ContractCode.INVALID_OPERATION)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _method_identifier(value: object) -> str:
|
|
194
|
+
allowed = {"weighted_mean", "additive", "minimum"}
|
|
195
|
+
if isinstance(value, str) and value in allowed:
|
|
196
|
+
return value
|
|
197
|
+
_fail(ContractCode.INVALID_METHOD)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_FiniteNumber = Annotated[float, BeforeValidator(_finite)]
|
|
201
|
+
_PositiveWeight = Annotated[float, BeforeValidator(_positive)]
|
|
202
|
+
_NonnegativeCoefficient = Annotated[float, BeforeValidator(_nonnegative)]
|
|
203
|
+
_StableIdentifier = Annotated[str, BeforeValidator(_stable_identifier)]
|
|
204
|
+
_DisplayLabel = Annotated[str, BeforeValidator(_label)]
|
|
205
|
+
_ExplicitDirection = Annotated[Direction, BeforeValidator(_direction)]
|
|
206
|
+
_ExplicitClampPolicy = Annotated[ClampPolicy, BeforeValidator(_clamp_policy)]
|
|
207
|
+
_ExplicitOperation = Annotated[Operation, BeforeValidator(_operation)]
|
|
208
|
+
_InputsHash = Annotated[str, BeforeValidator(_inputs_hash)]
|
|
209
|
+
_MethodIdentifier = Annotated[
|
|
210
|
+
Literal["weighted_mean", "additive", "minimum"],
|
|
211
|
+
BeforeValidator(_method_identifier),
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _accepted_names(name: str, field: FieldInfo) -> tuple[str, ...]:
|
|
216
|
+
if isinstance(field.alias, str):
|
|
217
|
+
return (name, field.alias)
|
|
218
|
+
return (name,)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _allowed_names(fields: Mapping[str, FieldInfo]) -> frozenset[str]:
|
|
222
|
+
return frozenset(
|
|
223
|
+
accepted for name, field in fields.items() for accepted in _accepted_names(name, field)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _selected_names(
|
|
228
|
+
name: str, field: FieldInfo, by_alias: _OptionalBool, by_name: _OptionalBool
|
|
229
|
+
) -> tuple[str, ...]:
|
|
230
|
+
if not isinstance(field.alias, str):
|
|
231
|
+
return (name,)
|
|
232
|
+
names = (name,) if by_name is not False else ()
|
|
233
|
+
return (*names, field.alias) if by_alias is not False else names
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _selected_allowed_names(
|
|
237
|
+
fields: Mapping[str, FieldInfo], by_alias: _OptionalBool, by_name: _OptionalBool
|
|
238
|
+
) -> frozenset[str]:
|
|
239
|
+
return frozenset(
|
|
240
|
+
accepted
|
|
241
|
+
for name, field in fields.items()
|
|
242
|
+
for accepted in _selected_names(name, field, by_alias, by_name)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _contains_only_known_fields(
|
|
247
|
+
data: Mapping[object, object], fields: Mapping[str, FieldInfo]
|
|
248
|
+
) -> bool:
|
|
249
|
+
allowed = _allowed_names(fields)
|
|
250
|
+
return all(isinstance(key, str) and key in allowed for key in data)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _contains_required_fields(
|
|
254
|
+
data: Mapping[object, object], fields: Mapping[str, FieldInfo]
|
|
255
|
+
) -> bool:
|
|
256
|
+
return all(
|
|
257
|
+
not field.is_required() or any(name in data for name in _accepted_names(field_name, field))
|
|
258
|
+
for field_name, field in fields.items()
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _contains_alias_duplicate(
|
|
263
|
+
data: Mapping[object, object], fields: Mapping[str, FieldInfo]
|
|
264
|
+
) -> bool:
|
|
265
|
+
return any(
|
|
266
|
+
isinstance(field.alias, str)
|
|
267
|
+
and field.alias != name
|
|
268
|
+
and field.alias in data
|
|
269
|
+
and name in data
|
|
270
|
+
for name, field in fields.items()
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _require_mapping(data: object) -> Mapping[object, object]:
|
|
275
|
+
if not isinstance(data, Mapping):
|
|
276
|
+
_fail(ContractCode.INVALID_OBJECT)
|
|
277
|
+
return data
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _require_known_fields(data: Mapping[object, object], fields: Mapping[str, FieldInfo]) -> None:
|
|
281
|
+
if not _contains_only_known_fields(data, fields):
|
|
282
|
+
_fail(ContractCode.UNKNOWN_FIELD)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _require_selected_fields(
|
|
286
|
+
data: Mapping[object, object],
|
|
287
|
+
fields: Mapping[str, FieldInfo],
|
|
288
|
+
by_alias: _OptionalBool,
|
|
289
|
+
by_name: _OptionalBool,
|
|
290
|
+
) -> None:
|
|
291
|
+
allowed = _selected_allowed_names(fields, by_alias, by_name)
|
|
292
|
+
if not all(isinstance(key, str) and key in allowed for key in data):
|
|
293
|
+
_fail(ContractCode.UNKNOWN_FIELD)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _require_no_alias_duplicates(
|
|
297
|
+
data: Mapping[object, object], fields: Mapping[str, FieldInfo]
|
|
298
|
+
) -> None:
|
|
299
|
+
if _contains_alias_duplicate(data, fields):
|
|
300
|
+
_fail(ContractCode.DUPLICATE_FIELD)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _require_method(data: Mapping[object, object], expected: str | None) -> None:
|
|
304
|
+
if expected is not None and data.get("method") != expected:
|
|
305
|
+
_fail(ContractCode.INVALID_METHOD)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _require_required_fields(
|
|
309
|
+
data: Mapping[object, object], fields: Mapping[str, FieldInfo]
|
|
310
|
+
) -> None:
|
|
311
|
+
if not _contains_required_fields(data, fields):
|
|
312
|
+
_fail(ContractCode.MISSING_FIELD)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _require_alias_config(by_alias: _OptionalBool, by_name: _OptionalBool) -> None:
|
|
316
|
+
if by_alias is False and by_name is not True:
|
|
317
|
+
_fail(ContractCode.INVALID_ALIAS_CONFIG)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class _ContractModel(BaseModel):
|
|
321
|
+
"""Shared fail-closed shape validation for every public JSON model."""
|
|
322
|
+
|
|
323
|
+
model_config = _MODEL_CONFIG
|
|
324
|
+
_expected_method: ClassVar[str | None] = None
|
|
325
|
+
|
|
326
|
+
def __init__(self, **data: object) -> None:
|
|
327
|
+
error: ContractValidationError
|
|
328
|
+
try:
|
|
329
|
+
super().__init__(**data)
|
|
330
|
+
return
|
|
331
|
+
except ValidationError:
|
|
332
|
+
error = ContractValidationError(ContractCode.INVALID_CONTRACT)
|
|
333
|
+
raise error from None
|
|
334
|
+
|
|
335
|
+
@model_validator(mode="before")
|
|
336
|
+
@classmethod
|
|
337
|
+
def _validate_input_shape(cls, data: object) -> object:
|
|
338
|
+
if isinstance(data, cls):
|
|
339
|
+
return data
|
|
340
|
+
mapping = _require_mapping(data)
|
|
341
|
+
_require_no_alias_duplicates(mapping, cls.model_fields)
|
|
342
|
+
_require_known_fields(mapping, cls.model_fields)
|
|
343
|
+
_require_method(mapping, cls._expected_method)
|
|
344
|
+
_require_required_fields(mapping, cls.model_fields)
|
|
345
|
+
return mapping
|
|
346
|
+
|
|
347
|
+
@classmethod
|
|
348
|
+
def model_validate(
|
|
349
|
+
cls,
|
|
350
|
+
obj: object,
|
|
351
|
+
*,
|
|
352
|
+
strict: _OptionalBool = None,
|
|
353
|
+
extra: _OptionalExtra = None,
|
|
354
|
+
from_attributes: _OptionalBool = None,
|
|
355
|
+
context: object | None = None,
|
|
356
|
+
by_alias: _OptionalBool = None,
|
|
357
|
+
by_name: _OptionalBool = None,
|
|
358
|
+
) -> Self:
|
|
359
|
+
_require_alias_config(by_alias, by_name)
|
|
360
|
+
cls._validate_selected_input(obj, by_alias, by_name)
|
|
361
|
+
options = (strict, extra, from_attributes, context, by_alias, by_name)
|
|
362
|
+
return cls._validate_python(obj, options)
|
|
363
|
+
|
|
364
|
+
@classmethod
|
|
365
|
+
def _validate_selected_input(
|
|
366
|
+
cls, obj: object, by_alias: _OptionalBool, by_name: _OptionalBool
|
|
367
|
+
) -> None:
|
|
368
|
+
if not isinstance(obj, Mapping):
|
|
369
|
+
return
|
|
370
|
+
_require_no_alias_duplicates(obj, cls.model_fields)
|
|
371
|
+
_require_selected_fields(obj, cls.model_fields, by_alias, by_name)
|
|
372
|
+
|
|
373
|
+
@classmethod
|
|
374
|
+
def _validate_python(cls, obj: object, options: _PythonValidationOptions) -> Self:
|
|
375
|
+
strict, extra, from_attributes, context, by_alias, by_name = options
|
|
376
|
+
try:
|
|
377
|
+
return super().model_validate(
|
|
378
|
+
obj,
|
|
379
|
+
strict=strict,
|
|
380
|
+
extra=extra,
|
|
381
|
+
from_attributes=from_attributes,
|
|
382
|
+
context=context,
|
|
383
|
+
by_alias=by_alias,
|
|
384
|
+
by_name=by_name,
|
|
385
|
+
)
|
|
386
|
+
except ValidationError:
|
|
387
|
+
error = ContractValidationError(ContractCode.INVALID_CONTRACT)
|
|
388
|
+
raise error from None
|
|
389
|
+
|
|
390
|
+
@classmethod
|
|
391
|
+
def model_validate_json(
|
|
392
|
+
cls,
|
|
393
|
+
data: _JsonData,
|
|
394
|
+
*,
|
|
395
|
+
strict: _OptionalBool = None,
|
|
396
|
+
extra: _OptionalExtra = None,
|
|
397
|
+
context: object | None = None,
|
|
398
|
+
by_alias: _OptionalBool = None,
|
|
399
|
+
by_name: _OptionalBool = None,
|
|
400
|
+
) -> Self:
|
|
401
|
+
options = (strict, extra, context, by_alias, by_name)
|
|
402
|
+
return cls._validate_decoded_json(data, options)
|
|
403
|
+
|
|
404
|
+
@classmethod
|
|
405
|
+
def _validate_decoded_json(cls, data: _JsonData, options: _ValidationOptions) -> Self:
|
|
406
|
+
strict, extra, context, by_alias, by_name = options
|
|
407
|
+
return cls.model_validate(
|
|
408
|
+
_decode_json(data),
|
|
409
|
+
strict=strict,
|
|
410
|
+
extra=extra,
|
|
411
|
+
context=context,
|
|
412
|
+
by_alias=by_alias,
|
|
413
|
+
by_name=by_name,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def model_copy(self, *, update: Mapping[str, object] | None = None, deep: bool = False) -> Self:
|
|
417
|
+
candidate = super().model_copy(update=update, deep=deep)
|
|
418
|
+
return type(self).model_validate(candidate)
|
|
419
|
+
|
|
420
|
+
@model_serializer(mode="wrap")
|
|
421
|
+
def _serialize_validated(self, handler: SerializerFunctionWrapHandler) -> object:
|
|
422
|
+
validated = type(self).model_validate(self)
|
|
423
|
+
return handler(validated)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class NativeScale(_ContractModel):
|
|
427
|
+
"""The declared native range and direction for one measurement."""
|
|
428
|
+
|
|
429
|
+
model_config = _MODEL_CONFIG
|
|
430
|
+
|
|
431
|
+
minimum: _FiniteNumber
|
|
432
|
+
maximum: _FiniteNumber
|
|
433
|
+
direction: _ExplicitDirection
|
|
434
|
+
|
|
435
|
+
@model_validator(mode="after")
|
|
436
|
+
def _require_increasing_bounds(self) -> Self:
|
|
437
|
+
if self.maximum <= self.minimum:
|
|
438
|
+
_fail(ContractCode.INVALID_SCALE)
|
|
439
|
+
return self
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class Interval(_ContractModel):
|
|
443
|
+
"""A finite uncertainty interval; deterministic values use ``None``."""
|
|
444
|
+
|
|
445
|
+
model_config = _MODEL_CONFIG
|
|
446
|
+
|
|
447
|
+
low: _FiniteNumber
|
|
448
|
+
high: _FiniteNumber
|
|
449
|
+
|
|
450
|
+
@model_validator(mode="after")
|
|
451
|
+
def _require_ordered_bounds(self) -> Self:
|
|
452
|
+
if self.high <= self.low:
|
|
453
|
+
_fail(ContractCode.INVALID_INTERVAL)
|
|
454
|
+
return self
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _require_point_in_interval(value: float, interval: Interval | None) -> None:
|
|
458
|
+
if interval is not None and not interval.low <= value <= interval.high:
|
|
459
|
+
_fail(ContractCode.INVALID_INTERVAL)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class Component(_ContractModel):
|
|
463
|
+
"""A measurement on its declared native scale."""
|
|
464
|
+
|
|
465
|
+
model_config = _MODEL_CONFIG
|
|
466
|
+
|
|
467
|
+
id: _StableIdentifier
|
|
468
|
+
label: _DisplayLabel
|
|
469
|
+
value: _FiniteNumber
|
|
470
|
+
scale: NativeScale
|
|
471
|
+
interval: Interval | None = None
|
|
472
|
+
weight: _PositiveWeight | None = None
|
|
473
|
+
|
|
474
|
+
@model_validator(mode="after")
|
|
475
|
+
def _require_interval_to_contain_value(self) -> Self:
|
|
476
|
+
_require_point_in_interval(self.value, self.interval)
|
|
477
|
+
return self
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class AdditiveTerm(_ContractModel):
|
|
481
|
+
"""One explicitly signed term in a left-to-right additive score."""
|
|
482
|
+
|
|
483
|
+
model_config = _MODEL_CONFIG
|
|
484
|
+
|
|
485
|
+
id: _StableIdentifier
|
|
486
|
+
label: _DisplayLabel
|
|
487
|
+
value: _FiniteNumber
|
|
488
|
+
coefficient: _NonnegativeCoefficient
|
|
489
|
+
operation: _ExplicitOperation
|
|
490
|
+
interval: Interval | None = None
|
|
491
|
+
|
|
492
|
+
@model_validator(mode="after")
|
|
493
|
+
def _require_interval_to_contain_value(self) -> Self:
|
|
494
|
+
_require_point_in_interval(self.value, self.interval)
|
|
495
|
+
return self
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class ExplainedComponent(_ContractModel):
|
|
499
|
+
"""The contribution of one declared input to a composed result."""
|
|
500
|
+
|
|
501
|
+
model_config = _MODEL_CONFIG
|
|
502
|
+
|
|
503
|
+
id: _StableIdentifier
|
|
504
|
+
raw: _FiniteNumber
|
|
505
|
+
normalized: _FiniteNumber | None
|
|
506
|
+
declared_weight: _PositiveWeight | None
|
|
507
|
+
operation: _ExplicitOperation
|
|
508
|
+
coefficient: _NonnegativeCoefficient
|
|
509
|
+
contribution: _FiniteNumber
|
|
510
|
+
contribution_interval: Interval | None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class Method(_ContractModel):
|
|
514
|
+
"""The closed combiner identity and caller-declared method version."""
|
|
515
|
+
|
|
516
|
+
model_config = _MODEL_CONFIG
|
|
517
|
+
|
|
518
|
+
id: _MethodIdentifier
|
|
519
|
+
version: _StableIdentifier
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
_IdentifiedContracts = (
|
|
523
|
+
tuple[Component, ...] | tuple[AdditiveTerm, ...] | tuple[ExplainedComponent, ...]
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _require_identifiers(items: _IdentifiedContracts, empty_code: ContractCode) -> None:
|
|
528
|
+
if not items:
|
|
529
|
+
_fail(empty_code)
|
|
530
|
+
identifiers = tuple(item.id for item in items)
|
|
531
|
+
if len(identifiers) != len(set(identifiers)):
|
|
532
|
+
_fail(ContractCode.DUPLICATE_IDENTIFIER)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _component_is_in_scale(component: Component) -> bool:
|
|
536
|
+
values = [component.value]
|
|
537
|
+
if component.interval is not None:
|
|
538
|
+
values.extend((component.interval.low, component.interval.high))
|
|
539
|
+
return all(component.scale.minimum <= value <= component.scale.maximum for value in values)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _require_component_ranges(components: tuple[Component, ...], clamp: ClampPolicy) -> None:
|
|
543
|
+
if clamp is not ClampPolicy.REJECT:
|
|
544
|
+
return
|
|
545
|
+
if not all(_component_is_in_scale(component) for component in components):
|
|
546
|
+
_fail(ContractCode.OUT_OF_RANGE)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class WeightedMeanRequest(_ContractModel):
|
|
550
|
+
"""A positive weighted mean of normalized components."""
|
|
551
|
+
|
|
552
|
+
model_config = _MODEL_CONFIG
|
|
553
|
+
_expected_method: ClassVar[str | None] = "weighted_mean"
|
|
554
|
+
|
|
555
|
+
method: Literal["weighted_mean"]
|
|
556
|
+
method_version: _StableIdentifier
|
|
557
|
+
components: tuple[Component, ...]
|
|
558
|
+
clamp: _ExplicitClampPolicy
|
|
559
|
+
|
|
560
|
+
@model_validator(mode="after")
|
|
561
|
+
def _validate_components(self) -> Self:
|
|
562
|
+
_require_identifiers(self.components, ContractCode.EMPTY_COMPONENTS)
|
|
563
|
+
if any(component.weight is None for component in self.components):
|
|
564
|
+
_fail(ContractCode.MISSING_WEIGHT)
|
|
565
|
+
_require_component_ranges(self.components, self.clamp)
|
|
566
|
+
return self
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
class AdditiveRequest(_ContractModel):
|
|
570
|
+
"""An ordered additive score with explicit term operations."""
|
|
571
|
+
|
|
572
|
+
model_config = _MODEL_CONFIG
|
|
573
|
+
_expected_method: ClassVar[str | None] = "additive"
|
|
574
|
+
|
|
575
|
+
method: Literal["additive"]
|
|
576
|
+
method_version: _StableIdentifier
|
|
577
|
+
terms: tuple[AdditiveTerm, ...]
|
|
578
|
+
clamp: _ExplicitClampPolicy | None
|
|
579
|
+
intercept: _FiniteNumber = 0.0
|
|
580
|
+
|
|
581
|
+
@model_validator(mode="after")
|
|
582
|
+
def _validate_terms(self) -> Self:
|
|
583
|
+
_require_identifiers(self.terms, ContractCode.EMPTY_TERMS)
|
|
584
|
+
return self
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class MinimumRequest(_ContractModel):
|
|
588
|
+
"""A bottleneck score selecting the first minimum normalized component."""
|
|
589
|
+
|
|
590
|
+
model_config = _MODEL_CONFIG
|
|
591
|
+
_expected_method: ClassVar[str | None] = "minimum"
|
|
592
|
+
|
|
593
|
+
method: Literal["minimum"]
|
|
594
|
+
method_version: _StableIdentifier
|
|
595
|
+
components: tuple[Component, ...]
|
|
596
|
+
clamp: _ExplicitClampPolicy
|
|
597
|
+
|
|
598
|
+
@model_validator(mode="after")
|
|
599
|
+
def _validate_components(self) -> Self:
|
|
600
|
+
_require_identifiers(self.components, ContractCode.EMPTY_COMPONENTS)
|
|
601
|
+
_require_component_ranges(self.components, self.clamp)
|
|
602
|
+
return self
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
class ScoreResult(_ContractModel):
|
|
606
|
+
"""A deterministic, portable explanation of one composed score."""
|
|
607
|
+
|
|
608
|
+
model_config = _MODEL_CONFIG
|
|
609
|
+
|
|
610
|
+
schema_version: Literal["assay.result/v1"] = Field(default="assay.result/v1", alias="schema")
|
|
611
|
+
method: Method
|
|
612
|
+
score: _FiniteNumber
|
|
613
|
+
interval: Interval | None = None
|
|
614
|
+
clamp: _ExplicitClampPolicy | None
|
|
615
|
+
intercept: _FiniteNumber | None
|
|
616
|
+
weight_total: _PositiveWeight | None
|
|
617
|
+
components: tuple[ExplainedComponent, ...]
|
|
618
|
+
inputs_hash: _InputsHash
|
|
619
|
+
selected_component_id: _StableIdentifier | None = None
|
|
620
|
+
|
|
621
|
+
@model_validator(mode="after")
|
|
622
|
+
def _validate_components(self) -> Self:
|
|
623
|
+
_require_identifiers(self.components, ContractCode.EMPTY_COMPONENTS)
|
|
624
|
+
_require_result_invariants(self)
|
|
625
|
+
return self
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
_ContributionBounds = tuple[float, float] | None
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _require_result(condition: bool) -> None:
|
|
632
|
+
if not condition:
|
|
633
|
+
_fail(ContractCode.INVALID_RESULT)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _result_number(value: float) -> float:
|
|
637
|
+
if not math.isfinite(value):
|
|
638
|
+
_fail(ContractCode.INVALID_RESULT)
|
|
639
|
+
return 0.0 if value == 0.0 else value
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _result_add(values: tuple[float, ...], initial: float = 0.0) -> float:
|
|
643
|
+
total = initial
|
|
644
|
+
for value in values:
|
|
645
|
+
total = _result_number(total + value)
|
|
646
|
+
return total
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _row_bounds(row: ExplainedComponent) -> tuple[float, float]:
|
|
650
|
+
if row.contribution_interval is None:
|
|
651
|
+
return row.contribution, row.contribution
|
|
652
|
+
return row.contribution_interval.low, row.contribution_interval.high
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _matches_interval(actual: Interval | None, expected: _ContributionBounds) -> bool:
|
|
656
|
+
if expected is None or expected[0] == expected[1]:
|
|
657
|
+
return actual is None
|
|
658
|
+
return actual is not None and (actual.low, actual.high) == expected
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _sum_interval(rows: tuple[ExplainedComponent, ...]) -> _ContributionBounds:
|
|
662
|
+
if not _has_contribution_intervals(rows):
|
|
663
|
+
return None
|
|
664
|
+
lows, highs = zip(*(_row_bounds(row) for row in rows), strict=True)
|
|
665
|
+
return _result_add(lows), _result_add(highs)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _bounded_row(row: ExplainedComponent, maximum: float) -> bool:
|
|
669
|
+
interval = row.contribution_interval
|
|
670
|
+
return interval is None or (
|
|
671
|
+
0.0 <= interval.low <= row.contribution <= interval.high <= maximum
|
|
672
|
+
and interval.low < interval.high
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _contains_contribution(row: ExplainedComponent) -> bool:
|
|
677
|
+
interval = row.contribution_interval
|
|
678
|
+
return interval is None or interval.low <= row.contribution <= interval.high
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _has_contribution_intervals(rows: tuple[ExplainedComponent, ...]) -> bool:
|
|
682
|
+
return any(row.contribution_interval is not None for row in rows)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _weighted_row(row: ExplainedComponent, total: float) -> bool:
|
|
686
|
+
normalized = row.normalized
|
|
687
|
+
if normalized is None:
|
|
688
|
+
return False
|
|
689
|
+
contribution = _result_number(normalized * row.coefficient)
|
|
690
|
+
return _weighted_row_shape(row, total) and _unit_value(normalized, row, contribution)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _weighted_row_shape(row: ExplainedComponent, total: float) -> bool:
|
|
694
|
+
weight = row.declared_weight
|
|
695
|
+
if weight is None:
|
|
696
|
+
return False
|
|
697
|
+
return row.operation is Operation.ADD and row.coefficient == _result_number(weight / total)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _unit_value(normalized: float, row: ExplainedComponent, contribution: float) -> bool:
|
|
701
|
+
return 0.0 <= normalized <= 1.0 and row.contribution == contribution
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _require_weighted_result(result: ScoreResult) -> None:
|
|
705
|
+
rows = result.components
|
|
706
|
+
_require_result(_weighted_shape(result))
|
|
707
|
+
total = _weight_total(result)
|
|
708
|
+
_require_result(total == _result_add(_declared_weights(rows)))
|
|
709
|
+
_require_result(_valid_weighted_rows(rows, total))
|
|
710
|
+
_require_result(result.score == _result_add(tuple(row.contribution for row in rows)))
|
|
711
|
+
_require_result(_matches_interval(result.interval, _sum_interval(rows)))
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _weighted_shape(result: ScoreResult) -> bool:
|
|
715
|
+
return (
|
|
716
|
+
result.selected_component_id is None
|
|
717
|
+
and result.intercept is None
|
|
718
|
+
and result.clamp is not None
|
|
719
|
+
and result.weight_total is not None
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _weight_total(result: ScoreResult) -> float:
|
|
724
|
+
if result.weight_total is None: # pragma: no cover - guarded by weighted shape
|
|
725
|
+
_fail(ContractCode.INVALID_RESULT)
|
|
726
|
+
return result.weight_total
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _declared_weights(rows: tuple[ExplainedComponent, ...]) -> tuple[float, ...]:
|
|
730
|
+
if any(row.declared_weight is None for row in rows):
|
|
731
|
+
_fail(ContractCode.INVALID_RESULT)
|
|
732
|
+
return tuple(row.declared_weight for row in rows if row.declared_weight is not None)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _valid_weighted_rows(rows: tuple[ExplainedComponent, ...], total: float) -> bool:
|
|
736
|
+
return all(_weighted_row(row, total) and _bounded_row(row, row.coefficient) for row in rows)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _additive_row(row: ExplainedComponent) -> bool:
|
|
740
|
+
contribution = _result_number(row.raw * row.coefficient)
|
|
741
|
+
return (
|
|
742
|
+
row.normalized is None
|
|
743
|
+
and row.declared_weight is None
|
|
744
|
+
and row.contribution == contribution
|
|
745
|
+
and _contains_contribution(row)
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _signed_add(total: float, row: ExplainedComponent, value: float) -> float:
|
|
750
|
+
if row.operation is Operation.ADD:
|
|
751
|
+
return _result_number(total + value)
|
|
752
|
+
return _result_number(total - value)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _additive_point(result: ScoreResult) -> float:
|
|
756
|
+
if result.intercept is None: # pragma: no cover - guarded by the result invariant
|
|
757
|
+
_fail(ContractCode.INVALID_RESULT)
|
|
758
|
+
total = result.intercept
|
|
759
|
+
for row in result.components:
|
|
760
|
+
total = _signed_add(total, row, row.contribution)
|
|
761
|
+
return _final_result(total, result.clamp)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _final_result(value: float, policy: ClampPolicy | None) -> float:
|
|
765
|
+
if policy is None:
|
|
766
|
+
return value
|
|
767
|
+
if policy is ClampPolicy.CLAMP:
|
|
768
|
+
return 0.0 if value <= 0.0 else min(1.0, value)
|
|
769
|
+
_require_result(0.0 <= value <= 1.0)
|
|
770
|
+
return value
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _additive_interval(result: ScoreResult) -> _ContributionBounds:
|
|
774
|
+
if not _has_contribution_intervals(result.components):
|
|
775
|
+
return None
|
|
776
|
+
if result.intercept is None: # pragma: no cover - guarded by the result invariant
|
|
777
|
+
_fail(ContractCode.INVALID_RESULT)
|
|
778
|
+
low = high = result.intercept
|
|
779
|
+
for row in result.components:
|
|
780
|
+
low, high = _advance_result_bounds(low, high, row)
|
|
781
|
+
return _final_result(low, result.clamp), _final_result(high, result.clamp)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _advance_result_bounds(low: float, high: float, row: ExplainedComponent) -> tuple[float, float]:
|
|
785
|
+
row_low, row_high = _row_bounds(row)
|
|
786
|
+
if row.operation is Operation.ADD:
|
|
787
|
+
return _signed_add(low, row, row_low), _signed_add(high, row, row_high)
|
|
788
|
+
return _signed_add(low, row, row_high), _signed_add(high, row, row_low)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _require_additive_result(result: ScoreResult) -> None:
|
|
792
|
+
shape = _additive_shape(result)
|
|
793
|
+
_require_result(shape and all(_additive_row(row) for row in result.components))
|
|
794
|
+
_require_result(result.score == _additive_point(result))
|
|
795
|
+
_require_result(_matches_interval(result.interval, _additive_interval(result)))
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _additive_shape(result: ScoreResult) -> bool:
|
|
799
|
+
return (
|
|
800
|
+
result.selected_component_id is None
|
|
801
|
+
and result.intercept is not None
|
|
802
|
+
and result.weight_total is None
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _minimum_row(row: ExplainedComponent) -> bool:
|
|
807
|
+
normalized = row.normalized
|
|
808
|
+
if normalized is None:
|
|
809
|
+
return False
|
|
810
|
+
return _minimum_row_shape(row) and _unit_value(normalized, row, normalized)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _minimum_row_shape(row: ExplainedComponent) -> bool:
|
|
814
|
+
return row.operation is Operation.ADD and row.coefficient == 1.0 and row.declared_weight is None
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _minimum_interval(rows: tuple[ExplainedComponent, ...]) -> _ContributionBounds:
|
|
818
|
+
if not _has_contribution_intervals(rows):
|
|
819
|
+
return None
|
|
820
|
+
bounds = tuple(_row_bounds(row) for row in rows)
|
|
821
|
+
return _minimum_lows(bounds), _minimum_highs(bounds)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _minimum_lows(bounds: tuple[tuple[float, float], ...]) -> float:
|
|
825
|
+
return min(low for low, _ in bounds)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _minimum_highs(bounds: tuple[tuple[float, float], ...]) -> float:
|
|
829
|
+
return min(high for _, high in bounds)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _require_minimum_result(result: ScoreResult) -> None:
|
|
833
|
+
rows = result.components
|
|
834
|
+
_require_result(_minimum_shape(result))
|
|
835
|
+
_require_result(all(_minimum_row(row) for row in rows))
|
|
836
|
+
_require_result(all(_bounded_row(row, 1.0) for row in rows))
|
|
837
|
+
selected = min(rows, key=lambda row: row.contribution)
|
|
838
|
+
_require_result(result.selected_component_id == selected.id)
|
|
839
|
+
_require_result(result.score == selected.normalized == selected.contribution)
|
|
840
|
+
_require_result(_matches_interval(result.interval, _minimum_interval(rows)))
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _minimum_shape(result: ScoreResult) -> bool:
|
|
844
|
+
return result.clamp is not None and result.intercept is None and result.weight_total is None
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _require_result_invariants(result: ScoreResult) -> None:
|
|
848
|
+
if result.method.id == "weighted_mean":
|
|
849
|
+
_require_weighted_result(result)
|
|
850
|
+
elif result.method.id == "additive":
|
|
851
|
+
_require_additive_result(result)
|
|
852
|
+
else:
|
|
853
|
+
_require_minimum_result(result)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
ScoreRequest = Annotated[
|
|
857
|
+
WeightedMeanRequest | AdditiveRequest | MinimumRequest,
|
|
858
|
+
Field(discriminator="method"),
|
|
859
|
+
]
|
|
860
|
+
_REQUEST_ADAPTER: TypeAdapter[ScoreRequest] = TypeAdapter(ScoreRequest)
|
|
861
|
+
_METHODS = frozenset(("weighted_mean", "additive", "minimum"))
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _validate_request_method(data: object) -> object:
|
|
865
|
+
if not isinstance(data, Mapping):
|
|
866
|
+
_fail(ContractCode.INVALID_METHOD)
|
|
867
|
+
method = data.get("method")
|
|
868
|
+
if not isinstance(method, str) or method not in _METHODS:
|
|
869
|
+
_fail(ContractCode.INVALID_METHOD)
|
|
870
|
+
return data
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def parse_request(data: object) -> ScoreRequest:
|
|
874
|
+
"""Parse an in-memory request without exposing Pydantic's internal errors."""
|
|
875
|
+
prepared = _validate_request_method(data)
|
|
876
|
+
error: ContractValidationError
|
|
877
|
+
try:
|
|
878
|
+
return _REQUEST_ADAPTER.validate_python(prepared)
|
|
879
|
+
except ValidationError:
|
|
880
|
+
error = ContractValidationError(ContractCode.INVALID_CONTRACT)
|
|
881
|
+
raise error from None
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def parse_request_json(data: _JsonData) -> ScoreRequest:
|
|
885
|
+
"""Parse a JSON request into its explicitly discriminated request type."""
|
|
886
|
+
return parse_request(_decode_json(data))
|