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/measurement.py
ADDED
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
"""Strict three-family measurement contracts and optional metric dispatch.
|
|
2
|
+
|
|
3
|
+
Result replay proves wire shape and necessary coherence among serialized fields. It cannot
|
|
4
|
+
recompute a metric methodology without the raw labels, rankings, scores, or rater assignments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from bisect import bisect_left
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Annotated, ClassVar, Final, Literal, Self, cast, overload
|
|
14
|
+
|
|
15
|
+
from pydantic import (
|
|
16
|
+
BaseModel,
|
|
17
|
+
BeforeValidator,
|
|
18
|
+
ConfigDict,
|
|
19
|
+
Field,
|
|
20
|
+
SerializerFunctionWrapHandler,
|
|
21
|
+
ValidationError,
|
|
22
|
+
model_serializer,
|
|
23
|
+
model_validator,
|
|
24
|
+
)
|
|
25
|
+
from pydantic.config import ExtraValues
|
|
26
|
+
|
|
27
|
+
from assay._json import decode_json
|
|
28
|
+
from assay.agreement import AgreementReport, agreement_report
|
|
29
|
+
from assay.calibration import CalibrationReport, ReliabilityBin, calibration_report
|
|
30
|
+
from assay.errors import (
|
|
31
|
+
AssayError,
|
|
32
|
+
ContractValidationError,
|
|
33
|
+
EmptyRelevantSet,
|
|
34
|
+
InvalidAgreementRequest,
|
|
35
|
+
InvalidRankingRequest,
|
|
36
|
+
InvalidScoreRequest,
|
|
37
|
+
InvalidSettings,
|
|
38
|
+
UnknownMetric,
|
|
39
|
+
)
|
|
40
|
+
from assay.limits import (
|
|
41
|
+
MAX_BOOTSTRAP_RESAMPLES,
|
|
42
|
+
MAX_BOOTSTRAP_WORK_CELLS,
|
|
43
|
+
MAX_CALIBRATION_BINS,
|
|
44
|
+
MAX_ITEMS,
|
|
45
|
+
MAX_RANKING_K,
|
|
46
|
+
MAX_RELEVANCE_GAIN,
|
|
47
|
+
MAX_SCALE_LEVELS,
|
|
48
|
+
MAX_SEED,
|
|
49
|
+
)
|
|
50
|
+
from assay.metrics import ClassificationScores, binary_scores, correctness, require_metrics_extra
|
|
51
|
+
from assay.models import ItemRating, RankedQuery, RelevanceJudgment
|
|
52
|
+
from assay.ranking import RankingReport, ranking_report
|
|
53
|
+
from assay.settings import AssaySettings
|
|
54
|
+
from assay.uncertainty import Abstention, Estimate, Interval, mean_interval
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"AgreementMeasurementRequest",
|
|
58
|
+
"AgreementMeasurementResult",
|
|
59
|
+
"BinaryMeasurementRequest",
|
|
60
|
+
"BinaryMeasurementResult",
|
|
61
|
+
"BinaryMetricControls",
|
|
62
|
+
"MeasurementRequest",
|
|
63
|
+
"MeasurementResult",
|
|
64
|
+
"OrdinalRating",
|
|
65
|
+
"RankingMeasurementRequest",
|
|
66
|
+
"RankingMeasurementResult",
|
|
67
|
+
"RankingMetricControls",
|
|
68
|
+
"RankingQueryInput",
|
|
69
|
+
"RelevanceInput",
|
|
70
|
+
"UncertaintyControls",
|
|
71
|
+
"measure",
|
|
72
|
+
"parse_measurement_json",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
_CONFIG = ConfigDict(
|
|
76
|
+
frozen=True,
|
|
77
|
+
extra="forbid",
|
|
78
|
+
from_attributes=True,
|
|
79
|
+
hide_input_in_errors=True,
|
|
80
|
+
populate_by_name=True,
|
|
81
|
+
revalidate_instances="always",
|
|
82
|
+
)
|
|
83
|
+
_STABLE_ID = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
|
|
84
|
+
_MAX_TEXT_LENGTH = 256
|
|
85
|
+
_MIN_SCALE_LEVELS = 2
|
|
86
|
+
_BINARY_CLASS_COUNT = 2
|
|
87
|
+
_SURROGATE_MIN = 0xD800
|
|
88
|
+
_SURROGATE_MAX = 0xDFFF
|
|
89
|
+
_SUMMARY_ULPS: Final[int] = 32
|
|
90
|
+
_SUMMARY_TOLERANCE: Final[float] = _SUMMARY_ULPS * math.ulp(1.0)
|
|
91
|
+
_RANKING_SUMMARY_FIELDS: Final[tuple[str, ...]] = (
|
|
92
|
+
"precision_at_k",
|
|
93
|
+
"recall_at_k",
|
|
94
|
+
"f1_at_k",
|
|
95
|
+
"ndcg_at_k",
|
|
96
|
+
"reciprocal_rank",
|
|
97
|
+
"average_precision",
|
|
98
|
+
)
|
|
99
|
+
_RANKING_REPORT_FIELDS: Final[tuple[str, ...]] = (
|
|
100
|
+
"mean_precision_at_k",
|
|
101
|
+
"mean_recall_at_k",
|
|
102
|
+
"mean_f1_at_k",
|
|
103
|
+
"mean_ndcg_at_k",
|
|
104
|
+
"mrr",
|
|
105
|
+
"mean_average_precision",
|
|
106
|
+
)
|
|
107
|
+
_OptionalBool = bool | None
|
|
108
|
+
_OptionalExtra = ExtraValues | None
|
|
109
|
+
_PythonValidationOptions = tuple[
|
|
110
|
+
_OptionalBool,
|
|
111
|
+
_OptionalExtra,
|
|
112
|
+
_OptionalBool,
|
|
113
|
+
object | None,
|
|
114
|
+
_OptionalBool,
|
|
115
|
+
_OptionalBool,
|
|
116
|
+
]
|
|
117
|
+
_JsonValidationOptions = tuple[
|
|
118
|
+
_OptionalBool, _OptionalExtra, object | None, _OptionalBool, _OptionalBool
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _converted_float(value: int | float, error: type[Exception]) -> float:
|
|
123
|
+
failure: Exception
|
|
124
|
+
try:
|
|
125
|
+
return float(value)
|
|
126
|
+
except OverflowError:
|
|
127
|
+
failure = error()
|
|
128
|
+
raise failure from None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _finite(value: object, error: type[AssayError] = InvalidScoreRequest) -> float:
|
|
132
|
+
if isinstance(value, bool):
|
|
133
|
+
raise error
|
|
134
|
+
if not isinstance(value, (int, float)):
|
|
135
|
+
raise error
|
|
136
|
+
number = _converted_float(value, error)
|
|
137
|
+
if not math.isfinite(number):
|
|
138
|
+
raise error
|
|
139
|
+
return 0.0 if number == 0.0 else number
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _probability(value: object) -> float:
|
|
143
|
+
number = _finite(value)
|
|
144
|
+
if not 0.0 <= number <= 1.0:
|
|
145
|
+
raise InvalidScoreRequest
|
|
146
|
+
return number
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _binary_label(value: object) -> int:
|
|
150
|
+
if isinstance(value, bool) or not isinstance(value, int) or value not in (0, 1):
|
|
151
|
+
raise InvalidScoreRequest
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _positive_int(value: object) -> int:
|
|
156
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
157
|
+
raise InvalidSettings
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _nonnegative_int(value: object) -> int:
|
|
162
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
163
|
+
raise InvalidSettings
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _confidence(value: object) -> float:
|
|
168
|
+
number = _finite(value, InvalidSettings)
|
|
169
|
+
if not 0.0 < number < 1.0:
|
|
170
|
+
raise InvalidSettings
|
|
171
|
+
return number
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _ranking_finite(value: object) -> float:
|
|
175
|
+
return _finite(value, InvalidRankingRequest)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _identifier(value: object) -> str:
|
|
179
|
+
if not isinstance(value, str) or len(value) > _MAX_TEXT_LENGTH:
|
|
180
|
+
raise ContractValidationError
|
|
181
|
+
if _STABLE_ID.fullmatch(value) is None:
|
|
182
|
+
raise ContractValidationError
|
|
183
|
+
return value
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _safe_text(value: object) -> str:
|
|
187
|
+
if not isinstance(value, str):
|
|
188
|
+
raise ContractValidationError
|
|
189
|
+
if not value or len(value) > _MAX_TEXT_LENGTH:
|
|
190
|
+
raise ContractValidationError
|
|
191
|
+
if _contains_surrogate(value):
|
|
192
|
+
raise ContractValidationError
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _contains_surrogate(value: str) -> bool:
|
|
197
|
+
return any(_SURROGATE_MIN <= ord(character) <= _SURROGATE_MAX for character in value)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _require_unique_ranking(values: tuple[str, ...]) -> None:
|
|
201
|
+
if len(values) != len(set(values)):
|
|
202
|
+
raise InvalidRankingRequest
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _require_relevant(judgments: tuple[RelevanceInput, ...]) -> None:
|
|
206
|
+
if not any(row.gain > 0.0 for row in judgments):
|
|
207
|
+
raise EmptyRelevantSet
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _require_unique_agreement(values: tuple[str, ...]) -> None:
|
|
211
|
+
if len(values) != len(set(values)):
|
|
212
|
+
raise InvalidAgreementRequest
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _require_known_ratings(values: tuple[str, ...], scale: tuple[str, ...]) -> None:
|
|
216
|
+
if not set(values) <= set(scale):
|
|
217
|
+
raise InvalidAgreementRequest
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _require_workload(sample_count: int, resamples: int) -> None:
|
|
221
|
+
if sample_count * resamples > MAX_BOOTSTRAP_WORK_CELLS:
|
|
222
|
+
raise InvalidSettings
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _require_result_workload(sample_count: int, resamples: int, error: type[AssayError]) -> None:
|
|
226
|
+
if sample_count * resamples > MAX_BOOTSTRAP_WORK_CELLS:
|
|
227
|
+
raise error
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
type _Probability = Annotated[float, BeforeValidator(_probability)]
|
|
231
|
+
type _RankingFinite = Annotated[float, BeforeValidator(_ranking_finite)]
|
|
232
|
+
type _BinaryLabel = Annotated[int, BeforeValidator(_binary_label)]
|
|
233
|
+
type _PositiveInt = Annotated[int, BeforeValidator(_positive_int)]
|
|
234
|
+
type _NonnegativeInt = Annotated[int, BeforeValidator(_nonnegative_int)]
|
|
235
|
+
type _Confidence = Annotated[float, BeforeValidator(_confidence)]
|
|
236
|
+
type _Identifier = Annotated[str, BeforeValidator(_identifier)]
|
|
237
|
+
type _SafeText = Annotated[str, BeforeValidator(_safe_text)]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _result_json(data: str | bytes | bytearray, error: type[AssayError]) -> Mapping[str, object]:
|
|
241
|
+
decoded = decode_json(data, error)
|
|
242
|
+
if not isinstance(decoded, Mapping):
|
|
243
|
+
raise error
|
|
244
|
+
return cast(Mapping[str, object], decoded)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class _MeasurementModel(BaseModel):
|
|
248
|
+
model_config = _CONFIG
|
|
249
|
+
_error: ClassVar[type[AssayError]] = ContractValidationError
|
|
250
|
+
|
|
251
|
+
def __init__(self, **data: object) -> None:
|
|
252
|
+
try:
|
|
253
|
+
super().__init__(**data)
|
|
254
|
+
except (ValidationError, OverflowError):
|
|
255
|
+
raise self._error from None
|
|
256
|
+
|
|
257
|
+
@classmethod
|
|
258
|
+
def model_validate(
|
|
259
|
+
cls,
|
|
260
|
+
obj: object,
|
|
261
|
+
*,
|
|
262
|
+
strict: _OptionalBool = None,
|
|
263
|
+
extra: _OptionalExtra = None,
|
|
264
|
+
from_attributes: _OptionalBool = None,
|
|
265
|
+
context: object | None = None,
|
|
266
|
+
by_alias: _OptionalBool = None,
|
|
267
|
+
by_name: _OptionalBool = None,
|
|
268
|
+
) -> Self:
|
|
269
|
+
options = (strict, extra, from_attributes, context, by_alias, by_name)
|
|
270
|
+
return cls._validate_python(obj, options)
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def _validate_python(cls, obj: object, options: _PythonValidationOptions) -> Self:
|
|
274
|
+
strict, extra, from_attributes, context, by_alias, by_name = options
|
|
275
|
+
try:
|
|
276
|
+
return super().model_validate(
|
|
277
|
+
obj,
|
|
278
|
+
strict=strict,
|
|
279
|
+
extra=extra,
|
|
280
|
+
from_attributes=from_attributes,
|
|
281
|
+
context=context,
|
|
282
|
+
by_alias=by_alias,
|
|
283
|
+
by_name=by_name,
|
|
284
|
+
)
|
|
285
|
+
except (ValidationError, OverflowError):
|
|
286
|
+
raise cls._error from None
|
|
287
|
+
|
|
288
|
+
@classmethod
|
|
289
|
+
def model_validate_json(
|
|
290
|
+
cls,
|
|
291
|
+
data: str | bytes | bytearray,
|
|
292
|
+
*,
|
|
293
|
+
strict: _OptionalBool = None,
|
|
294
|
+
extra: _OptionalExtra = None,
|
|
295
|
+
context: object | None = None,
|
|
296
|
+
by_alias: _OptionalBool = None,
|
|
297
|
+
by_name: _OptionalBool = None,
|
|
298
|
+
) -> Self:
|
|
299
|
+
options = (strict, extra, context, by_alias, by_name)
|
|
300
|
+
return cls._validate_decoded_json(data, options)
|
|
301
|
+
|
|
302
|
+
@classmethod
|
|
303
|
+
def _validate_decoded_json(
|
|
304
|
+
cls, data: str | bytes | bytearray, options: _JsonValidationOptions
|
|
305
|
+
) -> Self:
|
|
306
|
+
strict, extra, context, by_alias, by_name = options
|
|
307
|
+
decoded = _result_json(data, cls._error)
|
|
308
|
+
return cls.model_validate(
|
|
309
|
+
decoded,
|
|
310
|
+
strict=strict,
|
|
311
|
+
extra=extra,
|
|
312
|
+
context=context,
|
|
313
|
+
by_alias=by_alias,
|
|
314
|
+
by_name=by_name,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def model_copy(self, *, update: Mapping[str, object] | None = None, deep: bool = False) -> Self:
|
|
318
|
+
candidate = super().model_copy(update=update, deep=deep)
|
|
319
|
+
return type(self).model_validate(candidate)
|
|
320
|
+
|
|
321
|
+
@model_serializer(mode="wrap")
|
|
322
|
+
def _serialize_validated(self, handler: SerializerFunctionWrapHandler) -> object:
|
|
323
|
+
return handler(type(self).model_validate(self))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class UncertaintyControls(_MeasurementModel):
|
|
327
|
+
"""Explicit controls shared by ordinal agreement requests."""
|
|
328
|
+
|
|
329
|
+
_error = InvalidSettings
|
|
330
|
+
min_samples: Annotated[_PositiveInt, Field(le=MAX_ITEMS)] = 30
|
|
331
|
+
bootstrap_resamples: Annotated[_PositiveInt, Field(le=MAX_BOOTSTRAP_RESAMPLES)] = 9999
|
|
332
|
+
confidence_level: _Confidence = 0.95
|
|
333
|
+
bootstrap_seed: Annotated[_NonnegativeInt, Field(le=MAX_SEED)] = 12345
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class BinaryMetricControls(_MeasurementModel):
|
|
337
|
+
"""Explicit binary calibration and uncertainty controls."""
|
|
338
|
+
|
|
339
|
+
_error = InvalidSettings
|
|
340
|
+
min_samples: Annotated[_PositiveInt, Field(le=MAX_ITEMS)] = 30
|
|
341
|
+
bootstrap_resamples: Annotated[_PositiveInt, Field(le=MAX_BOOTSTRAP_RESAMPLES)] = 9999
|
|
342
|
+
confidence_level: _Confidence = 0.95
|
|
343
|
+
ece_bins: Annotated[_PositiveInt, Field(le=MAX_CALIBRATION_BINS)] = 15
|
|
344
|
+
bootstrap_seed: Annotated[_NonnegativeInt, Field(le=MAX_SEED)] = 12345
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class RankingMetricControls(_MeasurementModel):
|
|
348
|
+
"""Explicit ranking uncertainty controls."""
|
|
349
|
+
|
|
350
|
+
_error = InvalidSettings
|
|
351
|
+
min_samples: Annotated[_PositiveInt, Field(le=MAX_ITEMS)] = 30
|
|
352
|
+
bootstrap_resamples: Annotated[_PositiveInt, Field(le=MAX_BOOTSTRAP_RESAMPLES)] = 9999
|
|
353
|
+
confidence_level: _Confidence = 0.95
|
|
354
|
+
bootstrap_seed: Annotated[_NonnegativeInt, Field(le=MAX_SEED)] = 12345
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class RelevanceInput(_MeasurementModel):
|
|
358
|
+
"""One bounded graded relevance judgment."""
|
|
359
|
+
|
|
360
|
+
_error = InvalidRankingRequest
|
|
361
|
+
doc_id: _SafeText
|
|
362
|
+
gain: Annotated[_RankingFinite, Field(ge=0.0, le=MAX_RELEVANCE_GAIN)] = 1.0
|
|
363
|
+
|
|
364
|
+
@model_validator(mode="after")
|
|
365
|
+
def _require_integer_gain(self) -> RelevanceInput:
|
|
366
|
+
if self.gain != int(self.gain):
|
|
367
|
+
raise InvalidRankingRequest
|
|
368
|
+
return self
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class RankingQueryInput(_MeasurementModel):
|
|
372
|
+
"""One typed ranked list and its complete judgment set."""
|
|
373
|
+
|
|
374
|
+
_error = InvalidRankingRequest
|
|
375
|
+
query: _SafeText
|
|
376
|
+
judgments: Annotated[tuple[RelevanceInput, ...], Field(min_length=1, max_length=MAX_ITEMS)]
|
|
377
|
+
ranked: Annotated[tuple[_SafeText, ...], Field(min_length=1, max_length=MAX_ITEMS)]
|
|
378
|
+
|
|
379
|
+
@model_validator(mode="after")
|
|
380
|
+
def _require_distinct_documents(self) -> RankingQueryInput:
|
|
381
|
+
judged = tuple(row.doc_id for row in self.judgments)
|
|
382
|
+
_require_unique_ranking(judged)
|
|
383
|
+
_require_unique_ranking(self.ranked)
|
|
384
|
+
_require_relevant(self.judgments)
|
|
385
|
+
return self
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class OrdinalRating(_MeasurementModel):
|
|
389
|
+
"""One item and the two declared ordinal ratings."""
|
|
390
|
+
|
|
391
|
+
_error = InvalidAgreementRequest
|
|
392
|
+
item: _SafeText
|
|
393
|
+
rater_a: _SafeText
|
|
394
|
+
rater_b: _SafeText
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class BinaryMeasurementRequest(_MeasurementModel):
|
|
398
|
+
"""Classification, calibration, and accuracy-interval inputs."""
|
|
399
|
+
|
|
400
|
+
_error = InvalidScoreRequest
|
|
401
|
+
metric: Literal["binary"]
|
|
402
|
+
metric_version: _Identifier
|
|
403
|
+
y_true: Annotated[tuple[_BinaryLabel, ...], Field(min_length=1, max_length=MAX_ITEMS)]
|
|
404
|
+
y_score: Annotated[tuple[_Probability, ...], Field(min_length=1, max_length=MAX_ITEMS)]
|
|
405
|
+
threshold: _Probability = 0.5
|
|
406
|
+
controls: BinaryMetricControls = BinaryMetricControls()
|
|
407
|
+
|
|
408
|
+
@model_validator(mode="after")
|
|
409
|
+
def _require_binary_shape(self) -> BinaryMeasurementRequest:
|
|
410
|
+
if len(self.y_true) != len(self.y_score):
|
|
411
|
+
raise InvalidScoreRequest
|
|
412
|
+
if len(set(self.y_true)) != _BINARY_CLASS_COUNT:
|
|
413
|
+
raise InvalidScoreRequest
|
|
414
|
+
_require_workload(len(self.y_true), self.controls.bootstrap_resamples)
|
|
415
|
+
return self
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class RankingMeasurementRequest(_MeasurementModel):
|
|
419
|
+
"""Ranked-retrieval inputs with a declared cut-off."""
|
|
420
|
+
|
|
421
|
+
_error = InvalidRankingRequest
|
|
422
|
+
metric: Literal["ranking"]
|
|
423
|
+
metric_version: _Identifier
|
|
424
|
+
queries: Annotated[tuple[RankingQueryInput, ...], Field(min_length=1, max_length=MAX_ITEMS)]
|
|
425
|
+
k: Annotated[_PositiveInt, Field(le=MAX_RANKING_K)] = 10
|
|
426
|
+
controls: RankingMetricControls = RankingMetricControls()
|
|
427
|
+
|
|
428
|
+
@model_validator(mode="after")
|
|
429
|
+
def _require_ranking_workload(self) -> Self:
|
|
430
|
+
_require_workload(len(self.queries), self.controls.bootstrap_resamples)
|
|
431
|
+
return self
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class AgreementMeasurementRequest(_MeasurementModel):
|
|
435
|
+
"""Ordinal inter-rater inputs with a declared scale."""
|
|
436
|
+
|
|
437
|
+
_error = InvalidAgreementRequest
|
|
438
|
+
metric: Literal["agreement"]
|
|
439
|
+
metric_version: _Identifier
|
|
440
|
+
scale: Annotated[
|
|
441
|
+
tuple[_SafeText, ...], Field(min_length=_MIN_SCALE_LEVELS, max_length=MAX_SCALE_LEVELS)
|
|
442
|
+
]
|
|
443
|
+
ratings: Annotated[tuple[OrdinalRating, ...], Field(min_length=1, max_length=MAX_ITEMS)]
|
|
444
|
+
controls: UncertaintyControls = UncertaintyControls()
|
|
445
|
+
|
|
446
|
+
@model_validator(mode="after")
|
|
447
|
+
def _require_ordinal_shape(self) -> AgreementMeasurementRequest:
|
|
448
|
+
items = tuple(row.item for row in self.ratings)
|
|
449
|
+
values = tuple(value for row in self.ratings for value in (row.rater_a, row.rater_b))
|
|
450
|
+
_require_unique_agreement(self.scale)
|
|
451
|
+
_require_unique_agreement(items)
|
|
452
|
+
_require_known_ratings(values, self.scale)
|
|
453
|
+
_require_workload(len(self.ratings), self.controls.bootstrap_resamples)
|
|
454
|
+
return self
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
type MeasurementRequest = Annotated[
|
|
458
|
+
BinaryMeasurementRequest | RankingMeasurementRequest | AgreementMeasurementRequest,
|
|
459
|
+
Field(discriminator="metric"),
|
|
460
|
+
]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _proof_float(value: object) -> float:
|
|
464
|
+
if isinstance(value, bool):
|
|
465
|
+
raise ValueError
|
|
466
|
+
if not isinstance(value, (int, float)):
|
|
467
|
+
raise ValueError
|
|
468
|
+
number = _converted_float(value, ValueError)
|
|
469
|
+
if not math.isfinite(number):
|
|
470
|
+
raise ValueError
|
|
471
|
+
return 0.0 if number == 0.0 else number
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _proof_int(value: object) -> int:
|
|
475
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
476
|
+
raise ValueError
|
|
477
|
+
return value
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _proof_text(value: object) -> str:
|
|
481
|
+
if not isinstance(value, str) or not value or len(value) > _MAX_TEXT_LENGTH:
|
|
482
|
+
raise ValueError
|
|
483
|
+
if _contains_surrogate(value):
|
|
484
|
+
raise ValueError
|
|
485
|
+
return value
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
type _ProofFloat = Annotated[float, BeforeValidator(_proof_float)]
|
|
489
|
+
type _ProofProbability = Annotated[_ProofFloat, Field(ge=0.0, le=1.0)]
|
|
490
|
+
type _ProofSignedUnit = Annotated[_ProofFloat, Field(ge=-1.0, le=1.0)]
|
|
491
|
+
type _ProofCount = Annotated[int, BeforeValidator(_proof_int), Field(ge=0, le=MAX_ITEMS)]
|
|
492
|
+
type _ProofPositiveCount = Annotated[int, BeforeValidator(_proof_int), Field(ge=1, le=MAX_ITEMS)]
|
|
493
|
+
type _ProofText = Annotated[str, BeforeValidator(_proof_text)]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class _ProofModel(BaseModel):
|
|
497
|
+
model_config = _CONFIG
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class _IntervalProof(_ProofModel):
|
|
501
|
+
kind: Literal["interval"]
|
|
502
|
+
point: _ProofProbability
|
|
503
|
+
low: _ProofProbability
|
|
504
|
+
high: _ProofProbability
|
|
505
|
+
|
|
506
|
+
@model_validator(mode="after")
|
|
507
|
+
def _require_ordered_bounds(self) -> Self:
|
|
508
|
+
if self.low > self.high:
|
|
509
|
+
raise ValueError
|
|
510
|
+
return self
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class _AbstentionProof(_ProofModel):
|
|
514
|
+
kind: Literal["abstention"]
|
|
515
|
+
reason: _ProofText
|
|
516
|
+
n_samples: _ProofCount
|
|
517
|
+
min_samples: _ProofPositiveCount
|
|
518
|
+
|
|
519
|
+
@model_validator(mode="after")
|
|
520
|
+
def _require_honest_floor(self) -> Self:
|
|
521
|
+
if self.n_samples >= self.min_samples:
|
|
522
|
+
raise ValueError
|
|
523
|
+
return self
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
type _EstimateProof = Annotated[
|
|
527
|
+
_IntervalProof | _AbstentionProof,
|
|
528
|
+
Field(discriminator="kind"),
|
|
529
|
+
]
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class _CountsProof(_ProofModel):
|
|
533
|
+
true_positives: _ProofCount
|
|
534
|
+
false_positives: _ProofCount
|
|
535
|
+
true_negatives: _ProofCount
|
|
536
|
+
false_negatives: _ProofCount
|
|
537
|
+
|
|
538
|
+
@model_validator(mode="after")
|
|
539
|
+
def _require_population(self) -> Self:
|
|
540
|
+
if not 0 < _count_total(self) <= MAX_ITEMS:
|
|
541
|
+
raise ValueError
|
|
542
|
+
return self
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _count_total(counts: _CountsProof) -> int:
|
|
546
|
+
return sum(
|
|
547
|
+
(
|
|
548
|
+
counts.true_positives,
|
|
549
|
+
counts.false_positives,
|
|
550
|
+
counts.true_negatives,
|
|
551
|
+
counts.false_negatives,
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _ratio(numerator: int, denominator: int) -> float:
|
|
557
|
+
return 0.0 if denominator == 0 else numerator / denominator
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _harmonic(precision: float, recall: float) -> float:
|
|
561
|
+
return 0.0 if precision + recall == 0.0 else 2.0 * precision * recall / (precision + recall)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _classification_expected(counts: _CountsProof) -> tuple[float, ...]:
|
|
565
|
+
precision = _ratio(counts.true_positives, counts.true_positives + counts.false_positives)
|
|
566
|
+
recall = _ratio(counts.true_positives, counts.true_positives + counts.false_negatives)
|
|
567
|
+
accuracy = (counts.true_positives + counts.true_negatives) / _count_total(counts)
|
|
568
|
+
actual_positives = counts.true_positives + counts.false_negatives
|
|
569
|
+
false_negative_rate = _ratio(counts.false_negatives, actual_positives)
|
|
570
|
+
return accuracy, precision, recall, _harmonic(precision, recall), false_negative_rate
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class _ClassificationProof(_ProofModel):
|
|
574
|
+
accuracy: _ProofProbability
|
|
575
|
+
precision: _ProofProbability
|
|
576
|
+
recall: _ProofProbability
|
|
577
|
+
f1: _ProofProbability
|
|
578
|
+
pr_auc: _ProofProbability
|
|
579
|
+
roc_auc: _ProofProbability
|
|
580
|
+
counts: _CountsProof
|
|
581
|
+
false_negative_rate: _ProofProbability
|
|
582
|
+
|
|
583
|
+
@model_validator(mode="after")
|
|
584
|
+
def _require_count_rates(self) -> Self:
|
|
585
|
+
observed = (self.accuracy, self.precision, self.recall, self.f1, self.false_negative_rate)
|
|
586
|
+
if not _summaries_match(observed, _classification_expected(self.counts)):
|
|
587
|
+
raise ValueError
|
|
588
|
+
return self
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
class _ReliabilityBinProof(_ProofModel):
|
|
592
|
+
mean_predicted: _ProofProbability
|
|
593
|
+
fraction_positive: _ProofProbability
|
|
594
|
+
count: _ProofPositiveCount
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class _CalibrationProof(_ProofModel):
|
|
598
|
+
ece: _ProofProbability
|
|
599
|
+
brier: _ProofProbability
|
|
600
|
+
bins: Annotated[
|
|
601
|
+
tuple[_ReliabilityBinProof, ...], Field(min_length=1, max_length=MAX_CALIBRATION_BINS)
|
|
602
|
+
]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _expected_ece(calibration: _CalibrationProof, total: int) -> float:
|
|
606
|
+
return sum(
|
|
607
|
+
row.count / total * abs(row.mean_predicted - row.fraction_positive)
|
|
608
|
+
for row in calibration.bins
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _summary_matches(actual: float, expected: float) -> bool:
|
|
613
|
+
return math.isclose(actual, expected, rel_tol=0.0, abs_tol=_SUMMARY_TOLERANCE)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _summaries_match(observed: tuple[float, ...], expected: tuple[float, ...]) -> bool:
|
|
617
|
+
pairs = zip(observed, expected, strict=True)
|
|
618
|
+
return all(_summary_matches(actual, wanted) for actual, wanted in pairs)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _exceeds(value: float, bound: float) -> bool:
|
|
622
|
+
return value > bound and not _summary_matches(value, bound)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _zero_states_match(first: float, second: float) -> bool:
|
|
626
|
+
return (first == 0.0) == (second == 0.0)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _require_collapsed_interval(estimate: _EstimateProof, point: float) -> None:
|
|
630
|
+
if not isinstance(estimate, _IntervalProof):
|
|
631
|
+
return
|
|
632
|
+
if not _summaries_match((estimate.low, estimate.high), (point, point)):
|
|
633
|
+
raise ValueError
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _positive_population(calibration: _CalibrationProof) -> float:
|
|
637
|
+
return math.fsum(row.fraction_positive * row.count for row in calibration.bins)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _require_calibration_total(report: _BinaryReportProof, total: int) -> None:
|
|
641
|
+
if sum(row.count for row in report.calibration.bins) != total:
|
|
642
|
+
raise ValueError
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _require_calibration_positives(report: _BinaryReportProof) -> None:
|
|
646
|
+
counts = report.classification.counts
|
|
647
|
+
actual_positives = counts.true_positives + counts.false_negatives
|
|
648
|
+
if not _summary_matches(_positive_population(report.calibration), actual_positives):
|
|
649
|
+
raise ValueError
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _require_calibration_ece(report: _BinaryReportProof, total: int) -> None:
|
|
653
|
+
if report.calibration.ece != _expected_ece(report.calibration, total):
|
|
654
|
+
raise ValueError
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _require_binary_classes(report: _BinaryReportProof) -> None:
|
|
658
|
+
counts = report.classification.counts
|
|
659
|
+
if counts.true_positives + counts.false_negatives == 0:
|
|
660
|
+
raise ValueError
|
|
661
|
+
if counts.true_negatives + counts.false_positives == 0:
|
|
662
|
+
raise ValueError
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _require_integer_bin_populations(report: _BinaryReportProof) -> None:
|
|
666
|
+
for row in report.calibration.bins:
|
|
667
|
+
positives = round(row.fraction_positive * row.count)
|
|
668
|
+
if not _summary_matches(row.fraction_positive, positives / row.count):
|
|
669
|
+
raise ValueError
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _minimum_bin_brier(row: _ReliabilityBinProof) -> float:
|
|
673
|
+
gap = row.mean_predicted - row.fraction_positive
|
|
674
|
+
if gap == 0.0:
|
|
675
|
+
return 0.0
|
|
676
|
+
population = 1.0 - row.fraction_positive if gap > 0.0 else row.fraction_positive
|
|
677
|
+
return gap * gap / population
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _require_calibration_loss(report: _BinaryReportProof) -> None:
|
|
681
|
+
calibration = report.calibration
|
|
682
|
+
total = sum(row.count for row in calibration.bins)
|
|
683
|
+
floor = math.fsum(row.count * _minimum_bin_brier(row) for row in calibration.bins) / total
|
|
684
|
+
if _exceeds(calibration.ece, math.sqrt(calibration.brier)):
|
|
685
|
+
raise ValueError
|
|
686
|
+
if _exceeds(floor, calibration.brier):
|
|
687
|
+
raise ValueError
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _class_populations(counts: _CountsProof) -> tuple[int, int]:
|
|
691
|
+
positives = counts.true_positives + counts.false_negatives
|
|
692
|
+
negatives = counts.true_negatives + counts.false_positives
|
|
693
|
+
return positives, negatives
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _require_roc_grid(report: _BinaryReportProof) -> None:
|
|
697
|
+
score = report.classification.roc_auc
|
|
698
|
+
positives, negatives = _class_populations(report.classification.counts)
|
|
699
|
+
half_pairs = 2 * positives * negatives
|
|
700
|
+
pair_credits = round(score * half_pairs)
|
|
701
|
+
if not _summary_matches(score, pair_credits / half_pairs):
|
|
702
|
+
raise ValueError
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _roc_bounds(counts: _CountsProof) -> tuple[float, float]:
|
|
706
|
+
positives, negatives = _class_populations(counts)
|
|
707
|
+
pairs = positives * negatives
|
|
708
|
+
lower = counts.true_positives * counts.true_negatives / pairs
|
|
709
|
+
flexible = counts.true_positives * counts.false_positives
|
|
710
|
+
flexible += counts.false_negatives * counts.true_negatives
|
|
711
|
+
return lower, (counts.true_positives * counts.true_negatives + flexible) / pairs
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _require_perfect_threshold_ap(scores: _ClassificationProof) -> None:
|
|
715
|
+
counts = scores.counts
|
|
716
|
+
perfect = counts.false_positives == counts.false_negatives == 0
|
|
717
|
+
if perfect and not _summary_matches(scores.pr_auc, 1.0):
|
|
718
|
+
raise ValueError
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _require_auc_coherence(report: _BinaryReportProof) -> None:
|
|
722
|
+
scores = report.classification
|
|
723
|
+
if scores.pr_auc == 0.0:
|
|
724
|
+
raise ValueError
|
|
725
|
+
_require_perfect_threshold_ap(scores)
|
|
726
|
+
_require_roc_grid(report)
|
|
727
|
+
lower, upper = _roc_bounds(scores.counts)
|
|
728
|
+
if _exceeds(lower, scores.roc_auc) or _exceeds(scores.roc_auc, upper):
|
|
729
|
+
raise ValueError
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _require_binary_interval(report: _BinaryReportProof) -> None:
|
|
733
|
+
accuracy = report.classification.accuracy
|
|
734
|
+
if accuracy in (0.0, 1.0):
|
|
735
|
+
_require_collapsed_interval(report.accuracy_interval, accuracy)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
class _BinaryReportProof(_ProofModel):
|
|
739
|
+
classification: _ClassificationProof
|
|
740
|
+
calibration: _CalibrationProof
|
|
741
|
+
accuracy_interval: _EstimateProof
|
|
742
|
+
|
|
743
|
+
@model_validator(mode="after")
|
|
744
|
+
def _require_same_population(self) -> Self:
|
|
745
|
+
total = _count_total(self.classification.counts)
|
|
746
|
+
_require_binary_classes(self)
|
|
747
|
+
_require_calibration_total(self, total)
|
|
748
|
+
_require_calibration_positives(self)
|
|
749
|
+
_require_integer_bin_populations(self)
|
|
750
|
+
_require_calibration_ece(self, total)
|
|
751
|
+
_require_calibration_loss(self)
|
|
752
|
+
_require_auc_coherence(self)
|
|
753
|
+
_require_binary_interval(self)
|
|
754
|
+
return self
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
class _QueryProof(_ProofModel):
|
|
758
|
+
query: _ProofText
|
|
759
|
+
precision_at_k: _ProofProbability
|
|
760
|
+
recall_at_k: _ProofProbability
|
|
761
|
+
f1_at_k: _ProofProbability
|
|
762
|
+
ndcg_at_k: _ProofProbability
|
|
763
|
+
reciprocal_rank: _ProofProbability
|
|
764
|
+
average_precision: _ProofProbability
|
|
765
|
+
|
|
766
|
+
@model_validator(mode="after")
|
|
767
|
+
def _require_f1_summary(self) -> Self:
|
|
768
|
+
if not _summary_matches(self.f1_at_k, _harmonic(self.precision_at_k, self.recall_at_k)):
|
|
769
|
+
raise ValueError
|
|
770
|
+
_require_query_zero_states(self)
|
|
771
|
+
return self
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _require_query_zero_states(row: _QueryProof) -> None:
|
|
775
|
+
if not _zero_states_match(row.precision_at_k, row.recall_at_k):
|
|
776
|
+
raise ValueError
|
|
777
|
+
if not _zero_states_match(row.precision_at_k, row.ndcg_at_k):
|
|
778
|
+
raise ValueError
|
|
779
|
+
if not _zero_states_match(row.reciprocal_rank, row.average_precision):
|
|
780
|
+
raise ValueError
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _reciprocal_position(value: float) -> int | None:
|
|
784
|
+
if value == 0.0:
|
|
785
|
+
return None
|
|
786
|
+
position = round(1.0 / value)
|
|
787
|
+
if not 0 < position <= MAX_ITEMS or not _summary_matches(value, 1.0 / position):
|
|
788
|
+
raise ValueError
|
|
789
|
+
return position
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _precision_hits(row: _QueryProof, k: int) -> int:
|
|
793
|
+
hits = round(row.precision_at_k * k)
|
|
794
|
+
if not _summary_matches(row.precision_at_k, hits / k):
|
|
795
|
+
raise ValueError
|
|
796
|
+
return hits
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _require_recall_population(row: _QueryProof, hits: int) -> int | None:
|
|
800
|
+
if hits == 0:
|
|
801
|
+
return None
|
|
802
|
+
relevant = round(hits / row.recall_at_k)
|
|
803
|
+
valid = hits <= relevant <= MAX_ITEMS
|
|
804
|
+
if not valid or not _summary_matches(row.recall_at_k, hits / relevant):
|
|
805
|
+
raise ValueError
|
|
806
|
+
return relevant
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _require_hit_position(row: _QueryProof, hits: int, k: int) -> int | None:
|
|
810
|
+
position = _reciprocal_position(row.reciprocal_rank)
|
|
811
|
+
if hits == 0:
|
|
812
|
+
_require_position_after_cut(position, k)
|
|
813
|
+
return position
|
|
814
|
+
_require_position_by(position, k - hits + 1)
|
|
815
|
+
return position
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _require_position_after_cut(position: int | None, k: int) -> None:
|
|
819
|
+
if position is not None and position <= k:
|
|
820
|
+
raise ValueError
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _require_position_by(position: int | None, latest: int) -> None:
|
|
824
|
+
if position is None or position > latest:
|
|
825
|
+
raise ValueError
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _require_one_relevant(row: _QueryProof, relevant: int | None, position: int | None) -> None:
|
|
829
|
+
if relevant != 1 or position is None:
|
|
830
|
+
return
|
|
831
|
+
expected_ndcg = 1.0 / math.log2(position + 1.0)
|
|
832
|
+
observed = (row.average_precision, row.ndcg_at_k)
|
|
833
|
+
if not _summaries_match(observed, (row.reciprocal_rank, expected_ndcg)):
|
|
834
|
+
raise ValueError
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _require_query_counts(row: _QueryProof, k: int) -> None:
|
|
838
|
+
hits = _precision_hits(row, k)
|
|
839
|
+
relevant = _require_recall_population(row, hits)
|
|
840
|
+
position = _require_hit_position(row, hits, k)
|
|
841
|
+
_require_one_relevant(row, relevant, position)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _mean(values: tuple[float, ...]) -> float:
|
|
845
|
+
return math.fsum(values) / len(values)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _query_values(rows: tuple[_QueryProof, ...], field: str) -> tuple[float, ...]:
|
|
849
|
+
return tuple(cast(float, getattr(row, field)) for row in rows)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _ranking_expected(rows: tuple[_QueryProof, ...]) -> tuple[float, ...]:
|
|
853
|
+
return tuple(_mean(_query_values(rows, field)) for field in _RANKING_SUMMARY_FIELDS)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _ranking_observed(report: _RankingReportProof) -> tuple[float, ...]:
|
|
857
|
+
return tuple(cast(float, getattr(report, field)) for field in _RANKING_REPORT_FIELDS)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
class _RankingReportProof(_ProofModel):
|
|
861
|
+
k: Annotated[_ProofPositiveCount, Field(le=MAX_RANKING_K)]
|
|
862
|
+
n_queries: _ProofPositiveCount
|
|
863
|
+
per_query: Annotated[tuple[_QueryProof, ...], Field(min_length=1, max_length=MAX_ITEMS)]
|
|
864
|
+
mean_precision_at_k: _ProofProbability
|
|
865
|
+
mean_recall_at_k: _ProofProbability
|
|
866
|
+
mean_f1_at_k: _ProofProbability
|
|
867
|
+
mean_ndcg_at_k: _ProofProbability
|
|
868
|
+
mrr: _ProofProbability
|
|
869
|
+
mean_average_precision: _ProofProbability
|
|
870
|
+
ndcg_interval: _EstimateProof
|
|
871
|
+
|
|
872
|
+
@model_validator(mode="after")
|
|
873
|
+
def _require_query_population(self) -> Self:
|
|
874
|
+
_require_query_rows(self)
|
|
875
|
+
if not _summaries_match(_ranking_observed(self), _ranking_expected(self.per_query)):
|
|
876
|
+
raise ValueError
|
|
877
|
+
_require_ranking_interval(self)
|
|
878
|
+
return self
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _require_query_rows(report: _RankingReportProof) -> None:
|
|
882
|
+
if report.n_queries != len(report.per_query):
|
|
883
|
+
raise ValueError
|
|
884
|
+
for row in report.per_query:
|
|
885
|
+
_require_query_counts(row, report.k)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _require_ranking_interval(report: _RankingReportProof) -> None:
|
|
889
|
+
values = _query_values(report.per_query, "ndcg_at_k")
|
|
890
|
+
if len(set(values)) == 1:
|
|
891
|
+
_require_collapsed_interval(report.ndcg_interval, values[0])
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
class _AgreementReportProof(_ProofModel):
|
|
895
|
+
scale: Annotated[
|
|
896
|
+
tuple[_ProofText, ...], Field(min_length=_MIN_SCALE_LEVELS, max_length=MAX_SCALE_LEVELS)
|
|
897
|
+
]
|
|
898
|
+
n_items: _ProofPositiveCount
|
|
899
|
+
n_exact_matches: _ProofCount
|
|
900
|
+
percent_agreement: _ProofProbability
|
|
901
|
+
weighted_agreement: _ProofProbability
|
|
902
|
+
quadratic_kappa: _ProofSignedUnit | None
|
|
903
|
+
kappa_undefined_reason: _ProofText | None
|
|
904
|
+
kendall_tau_b: _ProofSignedUnit | None
|
|
905
|
+
tau_undefined_reason: _ProofText | None
|
|
906
|
+
weighted_agreement_interval: _EstimateProof
|
|
907
|
+
|
|
908
|
+
@model_validator(mode="after")
|
|
909
|
+
def _require_agreement_population(self) -> Self:
|
|
910
|
+
if len(self.scale) != len(set(self.scale)) or self.n_exact_matches > self.n_items:
|
|
911
|
+
raise ValueError
|
|
912
|
+
if self.percent_agreement != self.n_exact_matches / self.n_items:
|
|
913
|
+
raise ValueError
|
|
914
|
+
_require_agreement_bounds(self)
|
|
915
|
+
_require_reason_pair(self.quadratic_kappa, self.kappa_undefined_reason)
|
|
916
|
+
_require_reason_pair(self.kendall_tau_b, self.tau_undefined_reason)
|
|
917
|
+
_require_agreement_interval(self)
|
|
918
|
+
return self
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _require_agreement_bounds(report: _AgreementReportProof) -> None:
|
|
922
|
+
lower = report.percent_agreement
|
|
923
|
+
if _exceeds(lower, report.weighted_agreement):
|
|
924
|
+
raise ValueError
|
|
925
|
+
if _exceeds(report.weighted_agreement, _maximum_weighted_agreement(report)):
|
|
926
|
+
raise ValueError
|
|
927
|
+
_require_weight_lattice(report)
|
|
928
|
+
_require_kappa_coherence(report)
|
|
929
|
+
if report.n_exact_matches != report.n_items:
|
|
930
|
+
return
|
|
931
|
+
_require_all_exact_statistics(report)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _maximum_weighted_agreement(report: _AgreementReportProof) -> float:
|
|
935
|
+
distance = len(report.scale) - 1
|
|
936
|
+
mismatch = 1.0 - 1.0 / (distance * distance)
|
|
937
|
+
exact = report.percent_agreement
|
|
938
|
+
return exact + (1.0 - exact) * mismatch
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _require_weight_lattice(report: _AgreementReportProof) -> None:
|
|
942
|
+
distance = len(report.scale) - 1
|
|
943
|
+
units = report.n_items * distance * distance
|
|
944
|
+
cost = round((1.0 - report.weighted_agreement) * units)
|
|
945
|
+
if not _summary_matches(report.weighted_agreement, 1.0 - cost / units):
|
|
946
|
+
raise ValueError
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _require_kappa_coherence(report: _AgreementReportProof) -> None:
|
|
950
|
+
kappa = report.quadratic_kappa
|
|
951
|
+
if report.n_exact_matches != report.n_items and kappa is None:
|
|
952
|
+
raise ValueError
|
|
953
|
+
if kappa is not None and _exceeds(kappa, report.weighted_agreement):
|
|
954
|
+
raise ValueError
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _require_all_exact_statistics(report: _AgreementReportProof) -> None:
|
|
958
|
+
if (report.quadratic_kappa is None) != (report.kendall_tau_b is None):
|
|
959
|
+
raise ValueError
|
|
960
|
+
_require_perfect_if_defined(report.quadratic_kappa)
|
|
961
|
+
_require_perfect_if_defined(report.kendall_tau_b)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _require_agreement_interval(report: _AgreementReportProof) -> None:
|
|
965
|
+
if report.weighted_agreement in (0.0, 1.0):
|
|
966
|
+
_require_collapsed_interval(report.weighted_agreement_interval, report.weighted_agreement)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _require_perfect_if_defined(value: float | None) -> None:
|
|
970
|
+
if value is not None and not _summary_matches(value, 1.0):
|
|
971
|
+
raise ValueError
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _require_reason_pair(value: float | None, reason: str | None) -> None:
|
|
975
|
+
if (value is None) != (reason is not None):
|
|
976
|
+
raise ValueError
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _prove(data: object, proof: type[_ProofModel], error: type[AssayError]) -> None:
|
|
980
|
+
try:
|
|
981
|
+
proof.model_validate(data)
|
|
982
|
+
except (ValidationError, OverflowError):
|
|
983
|
+
raise error from None
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _field(data: object, name: str) -> object:
|
|
987
|
+
if isinstance(data, Mapping):
|
|
988
|
+
return data.get(name)
|
|
989
|
+
return getattr(data, name, None)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _require_estimate(
|
|
993
|
+
estimate: Estimate,
|
|
994
|
+
count: int,
|
|
995
|
+
minimum: int,
|
|
996
|
+
point: float,
|
|
997
|
+
error: type[AssayError],
|
|
998
|
+
) -> None:
|
|
999
|
+
if estimate.kind == "abstention":
|
|
1000
|
+
_require_abstention(estimate, count, minimum, error)
|
|
1001
|
+
return
|
|
1002
|
+
_require_interval(estimate, count, minimum, point, error)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _require_abstention(
|
|
1006
|
+
estimate: Abstention, count: int, minimum: int, error: type[AssayError]
|
|
1007
|
+
) -> None:
|
|
1008
|
+
if count >= minimum or estimate.n_samples != count or estimate.min_samples != minimum:
|
|
1009
|
+
raise error
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _require_interval(
|
|
1013
|
+
estimate: Interval,
|
|
1014
|
+
count: int,
|
|
1015
|
+
minimum: int,
|
|
1016
|
+
point: float,
|
|
1017
|
+
error: type[AssayError],
|
|
1018
|
+
) -> None:
|
|
1019
|
+
if count < minimum or estimate.point != point:
|
|
1020
|
+
raise error
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
class BinaryResultControls(_MeasurementModel):
|
|
1024
|
+
_error = InvalidScoreRequest
|
|
1025
|
+
threshold: _Probability
|
|
1026
|
+
min_samples: Annotated[_PositiveInt, Field(le=MAX_ITEMS)]
|
|
1027
|
+
bootstrap_resamples: Annotated[_PositiveInt, Field(le=MAX_BOOTSTRAP_RESAMPLES)]
|
|
1028
|
+
confidence_level: _Confidence
|
|
1029
|
+
ece_bins: Annotated[_PositiveInt, Field(le=MAX_CALIBRATION_BINS)]
|
|
1030
|
+
bootstrap_seed: Annotated[_NonnegativeInt, Field(le=MAX_SEED)]
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _uniform_edge(index: int, n_bins: int) -> float:
|
|
1034
|
+
if index == n_bins:
|
|
1035
|
+
return 1.0
|
|
1036
|
+
return index * (1.0 / n_bins)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _compatible_bin_range(row: ReliabilityBin, edges: tuple[float, ...]) -> tuple[int, int]:
|
|
1040
|
+
tolerance = max(_SUMMARY_TOLERANCE, row.count * math.ulp(1.0))
|
|
1041
|
+
lower = bisect_left(edges, max(0.0, row.mean_predicted - tolerance))
|
|
1042
|
+
upper = bisect_left(edges, min(1.0, row.mean_predicted + tolerance))
|
|
1043
|
+
return lower, upper
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _require_distinct_bins(rows: tuple[ReliabilityBin, ...], n_bins: int) -> None:
|
|
1047
|
+
edges = tuple(_uniform_edge(index, n_bins) for index in range(1, n_bins))
|
|
1048
|
+
next_index = 0
|
|
1049
|
+
for row in rows:
|
|
1050
|
+
lower, upper = _compatible_bin_range(row, edges)
|
|
1051
|
+
assigned = max(next_index, lower)
|
|
1052
|
+
if assigned > upper:
|
|
1053
|
+
raise InvalidScoreRequest
|
|
1054
|
+
next_index = assigned + 1
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
class RankingResultControls(_MeasurementModel):
|
|
1058
|
+
_error = InvalidRankingRequest
|
|
1059
|
+
k: Annotated[_PositiveInt, Field(le=MAX_RANKING_K)]
|
|
1060
|
+
min_samples: Annotated[_PositiveInt, Field(le=MAX_ITEMS)]
|
|
1061
|
+
bootstrap_resamples: Annotated[_PositiveInt, Field(le=MAX_BOOTSTRAP_RESAMPLES)]
|
|
1062
|
+
confidence_level: _Confidence
|
|
1063
|
+
bootstrap_seed: Annotated[_NonnegativeInt, Field(le=MAX_SEED)]
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
class AgreementResultControls(_MeasurementModel):
|
|
1067
|
+
_error = InvalidAgreementRequest
|
|
1068
|
+
min_samples: Annotated[_PositiveInt, Field(le=MAX_ITEMS)]
|
|
1069
|
+
bootstrap_resamples: Annotated[_PositiveInt, Field(le=MAX_BOOTSTRAP_RESAMPLES)]
|
|
1070
|
+
confidence_level: _Confidence
|
|
1071
|
+
bootstrap_seed: Annotated[_NonnegativeInt, Field(le=MAX_SEED)]
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
class BinaryMeasurementReport(_MeasurementModel):
|
|
1075
|
+
_error = InvalidScoreRequest
|
|
1076
|
+
classification: ClassificationScores
|
|
1077
|
+
calibration: CalibrationReport
|
|
1078
|
+
accuracy_interval: Estimate
|
|
1079
|
+
|
|
1080
|
+
@model_validator(mode="before")
|
|
1081
|
+
@classmethod
|
|
1082
|
+
def _prove_report(cls, data: object) -> object:
|
|
1083
|
+
_prove(data, _BinaryReportProof, InvalidScoreRequest)
|
|
1084
|
+
return data
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
class BinaryMeasurementResult(_MeasurementModel):
|
|
1088
|
+
_error = InvalidScoreRequest
|
|
1089
|
+
schema_version: Literal["assay.measurement/v1"] = Field(
|
|
1090
|
+
default="assay.measurement/v1", alias="schema"
|
|
1091
|
+
)
|
|
1092
|
+
metric: Literal["binary"]
|
|
1093
|
+
metric_version: _Identifier
|
|
1094
|
+
controls: BinaryResultControls
|
|
1095
|
+
report: BinaryMeasurementReport
|
|
1096
|
+
|
|
1097
|
+
@model_validator(mode="after")
|
|
1098
|
+
def _require_binary_invariants(self) -> Self:
|
|
1099
|
+
counts = self.report.classification.counts
|
|
1100
|
+
count = sum(vars(counts).values())
|
|
1101
|
+
_require_estimate(
|
|
1102
|
+
self.report.accuracy_interval,
|
|
1103
|
+
count,
|
|
1104
|
+
self.controls.min_samples,
|
|
1105
|
+
self.report.classification.accuracy,
|
|
1106
|
+
InvalidScoreRequest,
|
|
1107
|
+
)
|
|
1108
|
+
_require_distinct_bins(self.report.calibration.bins, self.controls.ece_bins)
|
|
1109
|
+
_require_result_workload(count, self.controls.bootstrap_resamples, InvalidScoreRequest)
|
|
1110
|
+
return self
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
class RankingMeasurementResult(_MeasurementModel):
|
|
1114
|
+
_error = InvalidRankingRequest
|
|
1115
|
+
schema_version: Literal["assay.measurement/v1"] = Field(
|
|
1116
|
+
default="assay.measurement/v1", alias="schema"
|
|
1117
|
+
)
|
|
1118
|
+
metric: Literal["ranking"]
|
|
1119
|
+
metric_version: _Identifier
|
|
1120
|
+
controls: RankingResultControls
|
|
1121
|
+
report: RankingReport
|
|
1122
|
+
|
|
1123
|
+
@model_validator(mode="before")
|
|
1124
|
+
@classmethod
|
|
1125
|
+
def _prove_report(cls, data: object) -> object:
|
|
1126
|
+
_prove(_field(data, "report"), _RankingReportProof, InvalidRankingRequest)
|
|
1127
|
+
return data
|
|
1128
|
+
|
|
1129
|
+
@model_validator(mode="after")
|
|
1130
|
+
def _require_ranking_invariants(self) -> Self:
|
|
1131
|
+
if self.controls.k != self.report.k:
|
|
1132
|
+
raise InvalidRankingRequest
|
|
1133
|
+
_require_estimate(
|
|
1134
|
+
self.report.ndcg_interval,
|
|
1135
|
+
self.report.n_queries,
|
|
1136
|
+
self.controls.min_samples,
|
|
1137
|
+
self.report.mean_ndcg_at_k,
|
|
1138
|
+
InvalidRankingRequest,
|
|
1139
|
+
)
|
|
1140
|
+
_require_result_workload(
|
|
1141
|
+
self.report.n_queries, self.controls.bootstrap_resamples, InvalidRankingRequest
|
|
1142
|
+
)
|
|
1143
|
+
return self
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
class AgreementMeasurementResult(_MeasurementModel):
|
|
1147
|
+
_error = InvalidAgreementRequest
|
|
1148
|
+
schema_version: Literal["assay.measurement/v1"] = Field(
|
|
1149
|
+
default="assay.measurement/v1", alias="schema"
|
|
1150
|
+
)
|
|
1151
|
+
metric: Literal["agreement"]
|
|
1152
|
+
metric_version: _Identifier
|
|
1153
|
+
controls: AgreementResultControls
|
|
1154
|
+
report: AgreementReport
|
|
1155
|
+
|
|
1156
|
+
@model_validator(mode="before")
|
|
1157
|
+
@classmethod
|
|
1158
|
+
def _prove_report(cls, data: object) -> object:
|
|
1159
|
+
_prove(_field(data, "report"), _AgreementReportProof, InvalidAgreementRequest)
|
|
1160
|
+
return data
|
|
1161
|
+
|
|
1162
|
+
@model_validator(mode="after")
|
|
1163
|
+
def _require_agreement_invariants(self) -> Self:
|
|
1164
|
+
_require_estimate(
|
|
1165
|
+
self.report.weighted_agreement_interval,
|
|
1166
|
+
self.report.n_items,
|
|
1167
|
+
self.controls.min_samples,
|
|
1168
|
+
self.report.weighted_agreement,
|
|
1169
|
+
InvalidAgreementRequest,
|
|
1170
|
+
)
|
|
1171
|
+
_require_result_workload(
|
|
1172
|
+
self.report.n_items, self.controls.bootstrap_resamples, InvalidAgreementRequest
|
|
1173
|
+
)
|
|
1174
|
+
return self
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
type MeasurementResult = Annotated[
|
|
1178
|
+
BinaryMeasurementResult | RankingMeasurementResult | AgreementMeasurementResult,
|
|
1179
|
+
Field(discriminator="metric"),
|
|
1180
|
+
]
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _settings(
|
|
1184
|
+
controls: UncertaintyControls | BinaryMetricControls | RankingMetricControls,
|
|
1185
|
+
) -> AssaySettings:
|
|
1186
|
+
data = controls.model_dump()
|
|
1187
|
+
data.setdefault("ece_bins", 15)
|
|
1188
|
+
data.setdefault("ranking_k", 10)
|
|
1189
|
+
return AssaySettings(**data)
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _binary_controls(request: BinaryMeasurementRequest) -> BinaryResultControls:
|
|
1193
|
+
return BinaryResultControls(threshold=request.threshold, **request.controls.model_dump())
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _ranking_controls(request: RankingMeasurementRequest) -> RankingResultControls:
|
|
1197
|
+
return RankingResultControls(k=request.k, **request.controls.model_dump())
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _agreement_controls(request: AgreementMeasurementRequest) -> AgreementResultControls:
|
|
1201
|
+
return AgreementResultControls(**request.controls.model_dump())
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _ranking_query(query: RankingQueryInput) -> RankedQuery:
|
|
1205
|
+
judgments = tuple(
|
|
1206
|
+
RelevanceJudgment(doc_id=row.doc_id, gain=row.gain) for row in query.judgments
|
|
1207
|
+
)
|
|
1208
|
+
return RankedQuery(query=query.query, judgments=judgments, ranked=query.ranked)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _ordinal_rating(rating: OrdinalRating) -> ItemRating:
|
|
1212
|
+
return ItemRating(item=rating.item, rater_a=rating.rater_a, rater_b=rating.rater_b)
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def _accuracy_interval(request: BinaryMeasurementRequest) -> Estimate:
|
|
1216
|
+
samples = correctness(request.y_true, request.y_score, threshold=request.threshold)
|
|
1217
|
+
return mean_interval(
|
|
1218
|
+
samples,
|
|
1219
|
+
min_samples=request.controls.min_samples,
|
|
1220
|
+
n_resamples=request.controls.bootstrap_resamples,
|
|
1221
|
+
confidence_level=request.controls.confidence_level,
|
|
1222
|
+
seed=request.controls.bootstrap_seed,
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _binary_report(request: BinaryMeasurementRequest) -> BinaryMeasurementReport:
|
|
1227
|
+
scores = binary_scores(request.y_true, request.y_score, threshold=request.threshold)
|
|
1228
|
+
calibration = calibration_report(
|
|
1229
|
+
request.y_true, request.y_score, n_bins=request.controls.ece_bins
|
|
1230
|
+
)
|
|
1231
|
+
return BinaryMeasurementReport(
|
|
1232
|
+
classification=scores,
|
|
1233
|
+
calibration=calibration,
|
|
1234
|
+
accuracy_interval=_accuracy_interval(request),
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _measure_binary(request: BinaryMeasurementRequest) -> BinaryMeasurementResult:
|
|
1239
|
+
return BinaryMeasurementResult(
|
|
1240
|
+
metric="binary",
|
|
1241
|
+
metric_version=request.metric_version,
|
|
1242
|
+
controls=_binary_controls(request),
|
|
1243
|
+
report=_binary_report(request),
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def _measure_ranking(request: RankingMeasurementRequest) -> RankingMeasurementResult:
|
|
1248
|
+
queries = tuple(_ranking_query(query) for query in request.queries)
|
|
1249
|
+
report = ranking_report(queries, settings=_settings(request.controls), k=request.k)
|
|
1250
|
+
return RankingMeasurementResult(
|
|
1251
|
+
metric="ranking",
|
|
1252
|
+
metric_version=request.metric_version,
|
|
1253
|
+
controls=_ranking_controls(request),
|
|
1254
|
+
report=report,
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _measure_agreement(request: AgreementMeasurementRequest) -> AgreementMeasurementResult:
|
|
1259
|
+
ratings = tuple(_ordinal_rating(rating) for rating in request.ratings)
|
|
1260
|
+
report = agreement_report(ratings, scale=request.scale, settings=_settings(request.controls))
|
|
1261
|
+
return AgreementMeasurementResult(
|
|
1262
|
+
metric="agreement",
|
|
1263
|
+
metric_version=request.metric_version,
|
|
1264
|
+
controls=_agreement_controls(request),
|
|
1265
|
+
report=report,
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
@overload
|
|
1270
|
+
def measure(request: BinaryMeasurementRequest) -> BinaryMeasurementResult: ...
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
@overload
|
|
1274
|
+
def measure(request: RankingMeasurementRequest) -> RankingMeasurementResult: ...
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
@overload
|
|
1278
|
+
def measure(request: AgreementMeasurementRequest) -> AgreementMeasurementResult: ...
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def measure(request: MeasurementRequest) -> MeasurementResult:
|
|
1282
|
+
"""Execute one typed family after proving every optional dependency exists."""
|
|
1283
|
+
request = _revalidate_request(request)
|
|
1284
|
+
require_metrics_extra()
|
|
1285
|
+
if request.metric == "binary":
|
|
1286
|
+
return _measure_binary(request)
|
|
1287
|
+
if request.metric == "ranking":
|
|
1288
|
+
return _measure_ranking(request)
|
|
1289
|
+
return _measure_agreement(request)
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _revalidate_request(request: MeasurementRequest) -> MeasurementRequest:
|
|
1293
|
+
models = (BinaryMeasurementRequest, RankingMeasurementRequest, AgreementMeasurementRequest)
|
|
1294
|
+
model = type(request)
|
|
1295
|
+
if model not in models:
|
|
1296
|
+
raise ContractValidationError
|
|
1297
|
+
return model.model_validate(request)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _decoded(data: str | bytes | bytearray) -> Mapping[str, object]:
|
|
1301
|
+
decoded = decode_json(data, ContractValidationError)
|
|
1302
|
+
if not isinstance(decoded, Mapping):
|
|
1303
|
+
raise ContractValidationError
|
|
1304
|
+
return cast(Mapping[str, object], decoded)
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _parse_model(data: Mapping[str, object], model: type[_MeasurementModel]) -> MeasurementRequest:
|
|
1308
|
+
try:
|
|
1309
|
+
return cast(MeasurementRequest, model.model_validate(data))
|
|
1310
|
+
except ValidationError:
|
|
1311
|
+
raise model._error from None
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def parse_measurement_json(data: str | bytes | bytearray) -> MeasurementRequest:
|
|
1315
|
+
"""Parse the closed discriminator without importing scientific dependencies."""
|
|
1316
|
+
decoded = _decoded(data)
|
|
1317
|
+
models: Mapping[str, type[_MeasurementModel]] = {
|
|
1318
|
+
"binary": BinaryMeasurementRequest,
|
|
1319
|
+
"ranking": RankingMeasurementRequest,
|
|
1320
|
+
"agreement": AgreementMeasurementRequest,
|
|
1321
|
+
}
|
|
1322
|
+
metric = decoded.get("metric")
|
|
1323
|
+
if not isinstance(metric, str) or metric not in models:
|
|
1324
|
+
raise UnknownMetric
|
|
1325
|
+
return _parse_model(decoded, models[metric])
|