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