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/agreement.py ADDED
@@ -0,0 +1,312 @@
1
+ """Ordinal agreement — did two graders mean the same thing, or just share a habit?
2
+
3
+ ``metrics.py`` scores predictions against ground truth. This module scores two *raters*
4
+ against each other, when what they emit is a band on an ordered scale (weak / moderate /
5
+ strong) rather than a number. That shape has no ground truth to score against: nobody
6
+ knows the true band, only whether two independent graders landed in the same place.
7
+
8
+ **Why percent agreement is the wrong statistic here**, and the reason this module exists:
9
+
10
+ 1. It is blind to *distance*. On a three-band scale, one grader saying "strong" while the
11
+ other says "moderate" is a near miss; "strong" against "weak" is a total miss. Percent
12
+ agreement scores both as simply "not a match".
13
+ 2. It counts agreement that chance alone would produce. Two graders who both call 90% of
14
+ everything "weak" will match about 80% of the time while agreeing about nothing.
15
+
16
+ One-line definitions, since none of these terms carry themselves:
17
+
18
+ - **quadratic-weighted Cohen's kappa** — agreement after subtracting the agreement two
19
+ independent graders with these same habits would have produced, with each miss charged
20
+ by the *square* of how many bands apart it was. 1.0 is perfect, 0.0 is exactly chance,
21
+ and negative means the two graders did worse than if they had ignored each other.
22
+ - **Kendall's tau-b** — of every pair of items, how often the two graders put them in the
23
+ same relative order. +1 perfectly concordant, -1 perfectly inverted. The ``b`` is the
24
+ tie correction, which a three-band scale over many items needs badly.
25
+
26
+ They answer different questions and the report carries both: two graders who agree on
27
+ every *ordering* but sit one band apart on the level score tau-b 1.0 and kappa 2/3.
28
+
29
+ *The engines.* Kappa is ``sklearn.metrics.cohen_kappa_score(weights="quadratic")`` and
30
+ tau-b is ``scipy.stats.kendalltau(variant="b")`` — both already pinned dependencies, both
31
+ the reference implementation their field validates against, and neither needs a line of
32
+ correction at the boundary. Assay contributes what they do not: a declared band order
33
+ that cannot be guessed wrong, a refusal for every input whose answer would be undefined,
34
+ and an uncertainty interval on the same honesty floor the rest of the package uses.
35
+
36
+ *The trap this module is built around.* ``cohen_kappa_score`` derives the ordinal
37
+ distance between two bands from their **positions in its ``labels`` argument**. Leave
38
+ that argument off and it sorts the band names alphabetically, so "moderate < strong <
39
+ weak" — and the same ratings come back with a completely different, entirely plausible
40
+ number. Sorted-by-accident is not a scale. The caller declares the order, always.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import math
46
+ from collections.abc import Mapping, Sequence
47
+ from dataclasses import dataclass
48
+ from typing import TYPE_CHECKING, Protocol, cast
49
+
50
+ from pydantic import BaseModel, ConfigDict
51
+
52
+ from assay._optional import call_dependency, dependency_failed, load_callable, load_module
53
+ from assay.errors import InvalidAgreementRequest
54
+ from assay.limits import MAX_ITEMS, MAX_SCALE_LEVELS
55
+ from assay.models import ItemRating
56
+ from assay.uncertainty import Estimate, mean_interval
57
+
58
+ if TYPE_CHECKING:
59
+ from assay.settings import AssaySettings
60
+
61
+ type Scale = Sequence[str]
62
+ """The band names in order, weakest first. The order IS the measurement."""
63
+
64
+ _MIN_LEVELS = 2
65
+ """Below two bands there is nothing to disagree about, and the quadratic weight's
66
+ ``L - 1`` denominator is zero."""
67
+
68
+ _KAPPA_UNDEFINED = (
69
+ "both raters put every item in the same single band, so agreement by chance is total "
70
+ "and kappa's correction divides by zero"
71
+ )
72
+ _TAU_UNDEFINED = (
73
+ "at least one rater used a single band for every item, so there is no rank variation "
74
+ "for tau-b to concord"
75
+ )
76
+
77
+
78
+ class _StatisticResult(Protocol):
79
+ statistic: object
80
+
81
+
82
+ __all__ = [
83
+ "AgreementReport",
84
+ "Scale",
85
+ "agreement_report",
86
+ "kendall_tau_b",
87
+ "percent_agreement",
88
+ "quadratic_kappa",
89
+ "weighted_agreement",
90
+ ]
91
+
92
+
93
+ class AgreementReport(BaseModel):
94
+ """Two graders on one ordinal scale, and how much of their agreement is real.
95
+
96
+ ``weighted_agreement_interval`` is a bootstrap confidence interval over the per-item
97
+ quadratic agreement, or an ``Abstention`` when there are fewer items than the sample
98
+ floor — the same uncertainty story the classification and ranking faces tell, not a
99
+ third one. It is deliberately NOT an interval on kappa: kappa is not the mean of any
100
+ per-item quantity, so no bootstrap of a mean can put an interval on it, and pairing
101
+ it with one would be exactly the fake precision this package refuses to print.
102
+
103
+ ``quadratic_kappa`` and ``kendall_tau_b`` are ``None`` when the ratings are too
104
+ degenerate for the statistic to exist, and the matching ``*_undefined_reason`` says
105
+ which degeneracy it was. A bare ``None`` reads as "not computed"; the reason says
106
+ "cannot exist", which is a different fact about the data."""
107
+
108
+ model_config = ConfigDict(frozen=True, extra="forbid")
109
+
110
+ scale: tuple[str, ...]
111
+ n_items: int
112
+ n_exact_matches: int
113
+ percent_agreement: float
114
+ weighted_agreement: float
115
+ quadratic_kappa: float | None
116
+ kappa_undefined_reason: str | None
117
+ kendall_tau_b: float | None
118
+ tau_undefined_reason: str | None
119
+ weighted_agreement_interval: Estimate
120
+
121
+
122
+ def _require_scale(scale: Scale) -> None:
123
+ if not _MIN_LEVELS <= len(scale) <= MAX_SCALE_LEVELS:
124
+ raise InvalidAgreementRequest(
125
+ f"an ordinal scale needs at least {_MIN_LEVELS} bands, got {len(scale)}"
126
+ )
127
+ if len(set(scale)) != len(scale):
128
+ raise InvalidAgreementRequest("the scale names the same band twice")
129
+
130
+
131
+ def _require_paired(rater_a: Sequence[str], rater_b: Sequence[str]) -> None:
132
+ if len(rater_a) != len(rater_b):
133
+ raise InvalidAgreementRequest(
134
+ f"the raters graded different numbers of items: {len(rater_a)} vs {len(rater_b)}"
135
+ )
136
+ if not rater_a:
137
+ raise InvalidAgreementRequest("no items were graded; there is nothing to agree about")
138
+ if len(rater_a) > MAX_ITEMS:
139
+ raise InvalidAgreementRequest
140
+
141
+
142
+ def _ordinals(ratings: Sequence[str], positions: Mapping[str, int]) -> list[int]:
143
+ """Band names as positions on the declared scale, refusing any band not on it.
144
+
145
+ The refusal is load-bearing, not defensive. ``cohen_kappa_score`` silently DROPS
146
+ every row whose label is outside its ``labels`` argument, so an unknown band would
147
+ return a perfectly healthy number computed over fewer items than were handed in."""
148
+ unknown = sorted(set(ratings) - set(positions))
149
+ if unknown:
150
+ raise InvalidAgreementRequest(f"bands that are not on the declared scale: {unknown}")
151
+ return [positions[band] for band in ratings]
152
+
153
+
154
+ def _validate(
155
+ rater_a: Sequence[str], rater_b: Sequence[str], scale: Scale
156
+ ) -> tuple[list[int], list[int]]:
157
+ """Check everything, then return both raters as positions on the declared scale."""
158
+ _require_scale(scale)
159
+ _require_paired(rater_a, rater_b)
160
+ positions = {band: index for index, band in enumerate(scale)}
161
+ return _ordinals(rater_a, positions), _ordinals(rater_b, positions)
162
+
163
+
164
+ def _per_item_weights(rater_a: Sequence[int], rater_b: Sequence[int], levels: int) -> list[float]:
165
+ """Cohen's quadratic weight per item: ``1 - ((i - j) / (L - 1)) ** 2``.
166
+
167
+ 1.0 for an exact match, falling with the SQUARE of the distance between bands — on a
168
+ three-band scale an adjacent miss keeps 0.75 and an opposite-ends miss keeps 0.0."""
169
+ span = levels - 1
170
+ return [1.0 - ((a - b) / span) ** 2 for a, b in zip(rater_a, rater_b, strict=True)]
171
+
172
+
173
+ def percent_agreement(rater_a: Sequence[str], rater_b: Sequence[str], *, scale: Scale) -> float:
174
+ """The fraction of items both raters put in exactly the same band.
175
+
176
+ Carried because it is the number people reach for — and because the report exists to
177
+ show, side by side, why it is not enough."""
178
+ load_module("numpy")
179
+ ordinals_a, ordinals_b = _validate(rater_a, rater_b, scale)
180
+ matches = sum(a == b for a, b in zip(ordinals_a, ordinals_b, strict=True))
181
+ return matches / len(ordinals_a)
182
+
183
+
184
+ def weighted_agreement(rater_a: Sequence[str], rater_b: Sequence[str], *, scale: Scale) -> float:
185
+ """Mean per-item quadratic agreement: like percent agreement, but a near miss counts.
186
+
187
+ Still uncorrected for chance — that correction is what kappa adds on top of it."""
188
+ ordinals_a, ordinals_b = _validate(rater_a, rater_b, scale)
189
+ return _numpy_mean(_per_item_weights(ordinals_a, ordinals_b, len(scale)))
190
+
191
+
192
+ def quadratic_kappa(
193
+ rater_a: Sequence[str], rater_b: Sequence[str], *, scale: Scale
194
+ ) -> float | None:
195
+ """Sklearn quadratic kappa, or ``None`` when chance agreement is total."""
196
+ _validate(rater_a, rater_b, scale)
197
+ if len(set(rater_a) | set(rater_b)) < _MIN_LEVELS:
198
+ return None
199
+ return _kappa_score(rater_a, rater_b, scale)
200
+
201
+
202
+ def _kappa_score(rater_a: Sequence[str], rater_b: Sequence[str], scale: Scale) -> float:
203
+ return _finite_float(
204
+ _call(
205
+ "sklearn.metrics",
206
+ "cohen_kappa_score",
207
+ rater_a,
208
+ rater_b,
209
+ labels=list(scale),
210
+ weights="quadratic",
211
+ )
212
+ )
213
+
214
+
215
+ def kendall_tau_b(rater_a: Sequence[str], rater_b: Sequence[str], *, scale: Scale) -> float | None:
216
+ """Tie-corrected rank concordance, or ``None`` for a constant rater."""
217
+ ordinals_a, ordinals_b = _validate(rater_a, rater_b, scale)
218
+ if min(len(set(ordinals_a)), len(set(ordinals_b))) < _MIN_LEVELS:
219
+ return None
220
+ raw = _call("scipy.stats", "kendalltau", ordinals_a, ordinals_b, variant="b")
221
+ statistic = call_dependency(_statistic, raw)
222
+ if dependency_failed(statistic):
223
+ raise InvalidAgreementRequest
224
+ return _finite_float(statistic)
225
+
226
+
227
+ def _statistic(value: object) -> object:
228
+ return cast(_StatisticResult, value).statistic
229
+
230
+
231
+ def _call(module: str, name: str, *args: object, **kwargs: object) -> object:
232
+ result = call_dependency(load_callable(module, name), *args, **kwargs)
233
+ if dependency_failed(result):
234
+ raise InvalidAgreementRequest
235
+ return result
236
+
237
+
238
+ def _finite_float(value: object) -> float:
239
+ converted = call_dependency(float, value)
240
+ if dependency_failed(converted) or not math.isfinite(cast(float, converted)):
241
+ raise InvalidAgreementRequest
242
+ return cast(float, converted)
243
+
244
+
245
+ def _numpy_mean(values: Sequence[float]) -> float:
246
+ return _finite_float(_call("numpy", "mean", values))
247
+
248
+
249
+ def _require_distinct_items(ratings: Sequence[ItemRating]) -> None:
250
+ items = [row.item for row in ratings]
251
+ if len(set(items)) != len(items):
252
+ raise InvalidAgreementRequest("the same item is graded twice")
253
+
254
+
255
+ def _interval(per_item: Sequence[float], settings: AssaySettings) -> Estimate:
256
+ return mean_interval(
257
+ per_item,
258
+ min_samples=settings.min_samples,
259
+ n_resamples=settings.bootstrap_resamples,
260
+ confidence_level=settings.confidence_level,
261
+ seed=settings.bootstrap_seed,
262
+ )
263
+
264
+
265
+ @dataclass(frozen=True)
266
+ class _ReportInputs:
267
+ rater_a: tuple[str, ...]
268
+ rater_b: tuple[str, ...]
269
+ scale: tuple[str, ...]
270
+ per_item: tuple[float, ...]
271
+
272
+
273
+ def _statistics(data: _ReportInputs) -> tuple[float | None, float | None]:
274
+ kappa = quadratic_kappa(data.rater_a, data.rater_b, scale=data.scale)
275
+ tau = kendall_tau_b(data.rater_a, data.rater_b, scale=data.scale)
276
+ return kappa, tau
277
+
278
+
279
+ def _report(data: _ReportInputs, settings: AssaySettings) -> AgreementReport:
280
+ kappa, tau = _statistics(data)
281
+ return AgreementReport(
282
+ scale=data.scale,
283
+ n_items=len(data.per_item),
284
+ n_exact_matches=sum(a == b for a, b in zip(data.rater_a, data.rater_b, strict=True)),
285
+ percent_agreement=percent_agreement(data.rater_a, data.rater_b, scale=data.scale),
286
+ weighted_agreement=_numpy_mean(data.per_item),
287
+ quadratic_kappa=kappa,
288
+ kappa_undefined_reason=None if kappa is not None else _KAPPA_UNDEFINED,
289
+ kendall_tau_b=tau,
290
+ tau_undefined_reason=None if tau is not None else _TAU_UNDEFINED,
291
+ weighted_agreement_interval=_interval(data.per_item, settings),
292
+ )
293
+
294
+
295
+ def _report_inputs(ratings: Sequence[ItemRating], scale: Scale) -> _ReportInputs:
296
+ _require_distinct_items(ratings)
297
+ rater_a = [row.rater_a for row in ratings]
298
+ rater_b = [row.rater_b for row in ratings]
299
+ ordinals_a, ordinals_b = _validate(rater_a, rater_b, scale)
300
+ weights = _per_item_weights(ordinals_a, ordinals_b, len(scale))
301
+ return _ReportInputs(tuple(rater_a), tuple(rater_b), tuple(scale), tuple(weights))
302
+
303
+
304
+ def agreement_report(
305
+ ratings: Sequence[ItemRating], *, scale: Scale, settings: AssaySettings
306
+ ) -> AgreementReport:
307
+ """Score one set of doubly-graded items: the counts, both statistics, and an interval.
308
+
309
+ ``ratings`` is item-keyed rather than two loose parallel lists, because the item id is
310
+ what makes "the same item graded twice" detectable — a duplicate would let one
311
+ disputed item vote twice and quietly reweight the whole measurement."""
312
+ return _report(_report_inputs(ratings, scale), settings)
assay/calibration.py ADDED
@@ -0,0 +1,167 @@
1
+ """Calibration evidence: Expected Calibration Error (ECE), a reliability diagram,
2
+ and the Brier score.
3
+
4
+ Reliability points come from ``sklearn.calibration.calibration_curve`` (which drops
5
+ empty bins), bin populations from ``numpy.histogram`` over the same uniform edges,
6
+ and Brier from ``sklearn.metrics.brier_score_loss``. ECE is the population-weighted
7
+ gap between predicted confidence and observed frequency."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+ from typing import SupportsInt, cast
15
+
16
+ from assay._optional import call_dependency, dependency_failed, load_callable
17
+ from assay.errors import InvalidScoreRequest
18
+ from assay.limits import MAX_CALIBRATION_BINS, MAX_ITEMS
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ReliabilityBin:
23
+ """One reliability-diagram point."""
24
+
25
+ mean_predicted: float
26
+ fraction_positive: float
27
+ count: int
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CalibrationReport:
32
+ """ECE, Brier, and the reliability diagram for a set of predictions."""
33
+
34
+ ece: float
35
+ brier: float
36
+ bins: tuple[ReliabilityBin, ...]
37
+
38
+
39
+ def _validate_shape(y_true: Sequence[int], y_score: Sequence[float], n_bins: int) -> None:
40
+ if len(y_true) != len(y_score) or not y_true:
41
+ raise InvalidScoreRequest
42
+ if len(y_true) > MAX_ITEMS:
43
+ raise InvalidScoreRequest
44
+ _validate_bin_count(n_bins)
45
+
46
+
47
+ def _validate_bin_count(n_bins: int) -> None:
48
+ if isinstance(n_bins, bool) or not 0 < n_bins <= MAX_CALIBRATION_BINS:
49
+ raise InvalidScoreRequest
50
+
51
+
52
+ def _validate_labels(y_true: Sequence[int]) -> None:
53
+ if set(y_true) - {0, 1}:
54
+ raise InvalidScoreRequest
55
+
56
+
57
+ def _validate_scores(y_score: Sequence[float]) -> None:
58
+ if not all(math.isfinite(score) and 0.0 <= score <= 1.0 for score in y_score):
59
+ raise InvalidScoreRequest
60
+
61
+
62
+ def _validate(y_true: Sequence[int], y_score: Sequence[float], n_bins: int) -> None:
63
+ _validate_shape(y_true, y_score, n_bins)
64
+ _validate_labels(y_true)
65
+ _validate_scores(y_score)
66
+
67
+
68
+ def _bin_populations(score_arr: object, n_bins: int) -> list[int]:
69
+ # Bin with sklearn's own scheme (searchsorted on interior edges) rather than a
70
+ # parallel np.histogram: at an exact bin edge the two disagree, which would
71
+ # misalign these counts with calibration_curve's non-empty bins.
72
+ edges = _call("numpy", "linspace", 0.0, 1.0, n_bins + 1)
73
+ interior = call_dependency(_interior, edges)
74
+ if dependency_failed(interior):
75
+ raise InvalidScoreRequest
76
+ bin_ids = _call("numpy", "searchsorted", interior, score_arr)
77
+ counts = _call("numpy", "bincount", bin_ids, minlength=n_bins)
78
+ converted = call_dependency(_positive_ints, counts)
79
+ if dependency_failed(converted):
80
+ raise InvalidScoreRequest
81
+ return cast(list[int], converted)
82
+
83
+
84
+ def _interior(values: object) -> object:
85
+ return values[1:-1] # type: ignore[index]
86
+
87
+
88
+ def _positive_ints(values: object) -> list[int]:
89
+ converted = [_as_int(value) for value in cast(Sequence[object], values)]
90
+ return [value for value in converted if value > 0]
91
+
92
+
93
+ def _as_int(value: object) -> int:
94
+ return int(cast(SupportsInt, value))
95
+
96
+
97
+ def _bins(
98
+ prob_pred: Sequence[float], prob_true: Sequence[float], weights: list[int]
99
+ ) -> list[ReliabilityBin]:
100
+ bins = [
101
+ ReliabilityBin(mean_predicted=float(p), fraction_positive=float(t), count=w)
102
+ for p, t, w in zip(prob_pred, prob_true, weights, strict=True)
103
+ ]
104
+ if not all(
105
+ math.isfinite(row.mean_predicted) and math.isfinite(row.fraction_positive) for row in bins
106
+ ):
107
+ raise InvalidScoreRequest
108
+ return bins
109
+
110
+
111
+ def _ece(bins: list[ReliabilityBin], total: int) -> float:
112
+ result = sum(b.count / total * abs(b.mean_predicted - b.fraction_positive) for b in bins)
113
+ if not math.isfinite(result):
114
+ raise InvalidScoreRequest
115
+ return result
116
+
117
+
118
+ def _call(module: str, name: str, *args: object, **kwargs: object) -> object:
119
+ result = call_dependency(load_callable(module, name), *args, **kwargs)
120
+ if dependency_failed(result):
121
+ raise InvalidScoreRequest
122
+ return result
123
+
124
+
125
+ def _curve(
126
+ true_arr: object, score_arr: object, n_bins: int
127
+ ) -> tuple[Sequence[float], Sequence[float]]:
128
+ raw = _call(
129
+ "sklearn.calibration",
130
+ "calibration_curve",
131
+ true_arr,
132
+ score_arr,
133
+ n_bins=n_bins,
134
+ strategy="uniform",
135
+ )
136
+ converted = call_dependency(_pair, raw)
137
+ if dependency_failed(converted):
138
+ raise InvalidScoreRequest
139
+ return cast(tuple[Sequence[float], Sequence[float]], converted)
140
+
141
+
142
+ def _pair(value: object) -> tuple[Sequence[float], Sequence[float]]:
143
+ first, second = cast(Sequence[Sequence[float]], value)
144
+ return first, second
145
+
146
+
147
+ def _finite_float(value: object) -> float:
148
+ converted = call_dependency(float, value)
149
+ if dependency_failed(converted) or not math.isfinite(cast(float, converted)):
150
+ raise InvalidScoreRequest
151
+ return cast(float, converted)
152
+
153
+
154
+ def calibration_report(
155
+ y_true: Sequence[int], y_score: Sequence[float], *, n_bins: int
156
+ ) -> CalibrationReport:
157
+ """Build the ECE / Brier / reliability report for binary predictions."""
158
+ _validate(y_true, y_score, n_bins)
159
+ true_arr = _call("numpy", "asarray", y_true, dtype=float)
160
+ score_arr = _call("numpy", "asarray", y_score, dtype=float)
161
+ prob_true, prob_pred = _curve(true_arr, score_arr, n_bins)
162
+ bins = _bins(prob_pred, prob_true, _bin_populations(score_arr, n_bins))
163
+ return CalibrationReport(
164
+ ece=_ece(bins, total=len(y_score)),
165
+ brier=_finite_float(_call("sklearn.metrics", "brier_score_loss", true_arr, score_arr)),
166
+ bins=tuple(bins),
167
+ )
assay/cli.py ADDED
@@ -0,0 +1,55 @@
1
+ """Dependency-light entry point with an early historical-command boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from importlib import import_module
7
+ from typing import Protocol, cast
8
+
9
+ from assay.errors import AssayError, CliExtraMissing, CommandMovedToAvow, CommandReplaced
10
+
11
+ _EXIT_USAGE = 2
12
+ _MIGRATIONS = {
13
+ "keygen": f"FAIL: {CommandMovedToAvow.code}; use `avow keygen ...`\n",
14
+ "sign": f"FAIL: {CommandMovedToAvow.code}; use `avow sign ...`\n",
15
+ "verify": f"FAIL: {CommandMovedToAvow.code}; use `avow verify ...`\n",
16
+ "verify-ledger": f"FAIL: {CommandMovedToAvow.code}; use `avow ledger verify ...`\n",
17
+ "score": f"FAIL: {CommandReplaced.code}; use `assay measure ...`\n",
18
+ "composite": f"FAIL: {CommandReplaced.code}; use `assay compose ...`\n",
19
+ }
20
+
21
+
22
+ class _CommandRunner(Protocol):
23
+ def __call__(self, arguments: tuple[str, ...]) -> int: ...
24
+
25
+
26
+ def _migration_message(arguments: tuple[str, ...]) -> str | None:
27
+ if not arguments:
28
+ return None
29
+ return _MIGRATIONS.get(arguments[0])
30
+
31
+
32
+ def _run(arguments: tuple[str, ...]) -> int:
33
+ try:
34
+ module = import_module("assay._cli_app")
35
+ except ModuleNotFoundError:
36
+ raise CliExtraMissing from None
37
+ runner = cast(_CommandRunner, module.run)
38
+ return runner(arguments)
39
+
40
+
41
+ def _fail(error: AssayError) -> int:
42
+ sys.stderr.write(f"FAIL: {error.code}\n")
43
+ return _EXIT_USAGE
44
+
45
+
46
+ def main() -> int:
47
+ """Reject migrated commands before loading the scoring command adapter."""
48
+ message = _migration_message(tuple(sys.argv[1:]))
49
+ if message is not None:
50
+ sys.stderr.write(message)
51
+ return _EXIT_USAGE
52
+ try:
53
+ return _run(tuple(sys.argv[1:]))
54
+ except AssayError as error:
55
+ return _fail(error)
assay/compose.py ADDED
@@ -0,0 +1,31 @@
1
+ """One explicit dispatcher for Assay's closed composition methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import overload
6
+
7
+ from assay.additive import additive
8
+ from assay.contracts import AdditiveRequest, MinimumRequest, ScoreResult, WeightedMeanRequest
9
+ from assay.minimum import minimum
10
+ from assay.weighted_mean import weighted_mean
11
+
12
+
13
+ @overload
14
+ def compose(request: WeightedMeanRequest) -> ScoreResult: ...
15
+
16
+
17
+ @overload
18
+ def compose(request: AdditiveRequest) -> ScoreResult: ...
19
+
20
+
21
+ @overload
22
+ def compose(request: MinimumRequest) -> ScoreResult: ...
23
+
24
+
25
+ def compose(request: WeightedMeanRequest | AdditiveRequest | MinimumRequest) -> ScoreResult:
26
+ """Dispatch one validated request without accepting executable formulas."""
27
+ if isinstance(request, WeightedMeanRequest):
28
+ return weighted_mean(request)
29
+ if isinstance(request, AdditiveRequest):
30
+ return additive(request)
31
+ return minimum(request)