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/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))