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