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/composite.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Shared deterministic composition helpers and the legacy composite adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import struct
|
|
9
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Final, NoReturn
|
|
12
|
+
|
|
13
|
+
from assay.contracts import (
|
|
14
|
+
AdditiveRequest,
|
|
15
|
+
AdditiveTerm,
|
|
16
|
+
Component,
|
|
17
|
+
Interval,
|
|
18
|
+
MinimumRequest,
|
|
19
|
+
ScoreRequest,
|
|
20
|
+
WeightedMeanRequest,
|
|
21
|
+
)
|
|
22
|
+
from assay.errors import ContractCode, ContractValidationError, InvalidScoreRequest
|
|
23
|
+
|
|
24
|
+
MIN_SUBSCORES: Final = 3
|
|
25
|
+
_PREIMAGE_VERSION: Final = "assay.request/v1"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fail(code: ContractCode) -> NoReturn:
|
|
29
|
+
raise ContractValidationError(code) from None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def canonical_zero(value: float) -> float:
|
|
33
|
+
"""Return portable positive zero without changing any other finite value."""
|
|
34
|
+
return 0.0 if value == 0.0 else value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def finite_output(value: float) -> float:
|
|
38
|
+
"""Refuse non-finite arithmetic before it reaches a result contract."""
|
|
39
|
+
if not math.isfinite(value):
|
|
40
|
+
_fail(ContractCode.INVALID_NUMBER)
|
|
41
|
+
return canonical_zero(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def left_add(values: Iterable[float], initial: float = 0.0) -> float:
|
|
45
|
+
"""Add in declared order using direct IEEE-754 operations."""
|
|
46
|
+
total = finite_output(initial)
|
|
47
|
+
for value in values:
|
|
48
|
+
total = finite_output(total + value)
|
|
49
|
+
return total
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def interval_or_none(low: float, high: float) -> Interval | None:
|
|
53
|
+
"""Represent a collapsed propagated interval as deterministic output."""
|
|
54
|
+
ordered_low = finite_output(min(low, high))
|
|
55
|
+
ordered_high = finite_output(max(low, high))
|
|
56
|
+
return None if ordered_low == ordered_high else Interval(low=ordered_low, high=ordered_high)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _float_token(value: float) -> str:
|
|
60
|
+
return f"f64:{struct.pack('!d', value).hex()}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _interval_token(interval: Interval | None) -> object:
|
|
64
|
+
if interval is None:
|
|
65
|
+
return None
|
|
66
|
+
return (_float_token(interval.low), _float_token(interval.high))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _component_token(component: Component) -> object:
|
|
70
|
+
scale = component.scale
|
|
71
|
+
weight = None if component.weight is None else _float_token(component.weight)
|
|
72
|
+
return (
|
|
73
|
+
component.id,
|
|
74
|
+
component.label,
|
|
75
|
+
_float_token(component.value),
|
|
76
|
+
(_float_token(scale.minimum), _float_token(scale.maximum), scale.direction.value),
|
|
77
|
+
_interval_token(component.interval),
|
|
78
|
+
weight,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _term_token(term: AdditiveTerm) -> object:
|
|
83
|
+
return (
|
|
84
|
+
term.id,
|
|
85
|
+
term.label,
|
|
86
|
+
_float_token(term.value),
|
|
87
|
+
_float_token(term.coefficient),
|
|
88
|
+
term.operation.value,
|
|
89
|
+
_interval_token(term.interval),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _weighted_token(request: WeightedMeanRequest) -> object:
|
|
94
|
+
components = tuple(_component_token(item) for item in request.components)
|
|
95
|
+
return (
|
|
96
|
+
_PREIMAGE_VERSION,
|
|
97
|
+
request.method,
|
|
98
|
+
request.method_version,
|
|
99
|
+
request.clamp.value,
|
|
100
|
+
components,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _additive_token(request: AdditiveRequest) -> object:
|
|
105
|
+
policy = None if request.clamp is None else request.clamp.value
|
|
106
|
+
terms = tuple(_term_token(item) for item in request.terms)
|
|
107
|
+
return (
|
|
108
|
+
_PREIMAGE_VERSION,
|
|
109
|
+
request.method,
|
|
110
|
+
request.method_version,
|
|
111
|
+
policy,
|
|
112
|
+
_float_token(request.intercept),
|
|
113
|
+
terms,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _minimum_token(request: MinimumRequest) -> object:
|
|
118
|
+
components = tuple(_component_token(item) for item in request.components)
|
|
119
|
+
return (
|
|
120
|
+
_PREIMAGE_VERSION,
|
|
121
|
+
request.method,
|
|
122
|
+
request.method_version,
|
|
123
|
+
request.clamp.value,
|
|
124
|
+
components,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def inputs_preimage(request: ScoreRequest) -> str:
|
|
129
|
+
"""Encode a request as UTF-8 JSON arrays with every float as big-endian f64 hex."""
|
|
130
|
+
if isinstance(request, WeightedMeanRequest):
|
|
131
|
+
token = _weighted_token(request)
|
|
132
|
+
elif isinstance(request, AdditiveRequest):
|
|
133
|
+
token = _additive_token(request)
|
|
134
|
+
else:
|
|
135
|
+
token = _minimum_token(request)
|
|
136
|
+
return json.dumps(token, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def inputs_hash(request: ScoreRequest) -> str:
|
|
140
|
+
"""Hash the documented, order-preserving, cross-language request preimage."""
|
|
141
|
+
digest = hashlib.sha256(inputs_preimage(request).encode()).hexdigest()
|
|
142
|
+
return f"sha256:{digest}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class SubScore:
|
|
147
|
+
"""Legacy v0 input retained until the optional-metrics migration."""
|
|
148
|
+
|
|
149
|
+
name: str
|
|
150
|
+
value: float
|
|
151
|
+
low: float
|
|
152
|
+
high: float
|
|
153
|
+
scale_min: float
|
|
154
|
+
scale_max: float
|
|
155
|
+
weight: float
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class NormalizedSubScore:
|
|
160
|
+
"""Legacy normalized part retained for source compatibility."""
|
|
161
|
+
|
|
162
|
+
name: str
|
|
163
|
+
normalized_value: float
|
|
164
|
+
weight: float
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class CompositeScore:
|
|
169
|
+
"""Legacy v0 result retained for source compatibility."""
|
|
170
|
+
|
|
171
|
+
value: float
|
|
172
|
+
low: float
|
|
173
|
+
high: float
|
|
174
|
+
parts: tuple[NormalizedSubScore, ...]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _require_legacy_count(subscores: Sequence[SubScore]) -> None:
|
|
178
|
+
if len(subscores) < MIN_SUBSCORES:
|
|
179
|
+
raise InvalidScoreRequest
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _require_legacy_scales(subscores: Sequence[SubScore]) -> None:
|
|
183
|
+
if any(score.scale_max <= score.scale_min for score in subscores):
|
|
184
|
+
raise InvalidScoreRequest
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _require_legacy_weights(subscores: Sequence[SubScore]) -> None:
|
|
188
|
+
if any(score.weight <= 0 for score in subscores):
|
|
189
|
+
raise InvalidScoreRequest
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _require_legacy_intervals(subscores: Sequence[SubScore]) -> None:
|
|
193
|
+
if any(not score.low <= score.value <= score.high for score in subscores):
|
|
194
|
+
raise InvalidScoreRequest
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _validate_legacy(subscores: Sequence[SubScore]) -> None:
|
|
198
|
+
_require_legacy_count(subscores)
|
|
199
|
+
_require_legacy_scales(subscores)
|
|
200
|
+
_require_legacy_weights(subscores)
|
|
201
|
+
_require_legacy_intervals(subscores)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _legacy_normalize(value: float, score: SubScore) -> float:
|
|
205
|
+
result = (value - score.scale_min) / (score.scale_max - score.scale_min)
|
|
206
|
+
return min(1.0, max(0.0, result))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _legacy_weighted(
|
|
210
|
+
subscores: Sequence[SubScore], total: float, pick: Callable[[SubScore], float]
|
|
211
|
+
) -> float:
|
|
212
|
+
return sum(score.weight * _legacy_normalize(pick(score), score) for score in subscores) / total
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _legacy_part(score: SubScore) -> NormalizedSubScore:
|
|
216
|
+
return NormalizedSubScore(score.name, _legacy_normalize(score.value, score), score.weight)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def composite(subscores: Sequence[SubScore]) -> CompositeScore:
|
|
220
|
+
"""Run the legacy v0 weighted composite while callers migrate to ``compose``."""
|
|
221
|
+
_validate_legacy(subscores)
|
|
222
|
+
total = sum(score.weight for score in subscores)
|
|
223
|
+
return CompositeScore(
|
|
224
|
+
value=_legacy_weighted(subscores, total, lambda score: score.value),
|
|
225
|
+
low=_legacy_weighted(subscores, total, lambda score: score.low),
|
|
226
|
+
high=_legacy_weighted(subscores, total, lambda score: score.high),
|
|
227
|
+
parts=tuple(_legacy_part(score) for score in subscores),
|
|
228
|
+
)
|