avow 0.1.0__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 ADDED
@@ -0,0 +1,17 @@
1
+ """Assay: the scoring engine that refuses to lie — the score face of the Avow envelope.
2
+
3
+ The scoring surface needs the heavy scientific stack (scikit-learn / scipy / numpy),
4
+ installed via the ``avow[assay]`` extra. If it is missing we fail with a coded
5
+ ``ScoringExtraMissing`` instead of a raw ``ModuleNotFoundError`` traceback."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from assay.errors import ScoringExtraMissing
10
+ from avow import __version__
11
+
12
+ try:
13
+ from assay.api import composite_score, replay, score, verify
14
+ except ModuleNotFoundError as exc: # pragma: no cover - only without the [assay] extra
15
+ raise ScoringExtraMissing("install avow[assay] to use the scoring face") from exc
16
+
17
+ __all__ = ["__version__", "composite_score", "replay", "score", "verify"]
assay/api.py ADDED
@@ -0,0 +1,169 @@
1
+ """The Assay facade: score, composite_score, verify, replay.
2
+
3
+ This is the only module most callers touch. It wires the reused primitives into a
4
+ signed, reproducible receipt and offers offline verification and replay."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from nacl.signing import SigningKey
9
+
10
+ from assay.calibration import CalibrationReport, calibration_report
11
+ from assay.composite import SubScore, composite
12
+ from assay.errors import (
13
+ InsufficientSamples,
14
+ ReplayMismatch,
15
+ SignatureInvalid,
16
+ UnknownMetric,
17
+ )
18
+ from assay.metrics import ClassificationScores, binary_scores, correctness
19
+ from assay.models import CompositeRequest, ScoreRequest, SubScoreInput
20
+ from assay.receipt import (
21
+ CalibrationDetail,
22
+ ClassificationDetail,
23
+ CompositeDetail,
24
+ ReceiptPayload,
25
+ ReliabilityPoint,
26
+ ScoreReceipt,
27
+ SubScorePart,
28
+ payload_digest,
29
+ sign_payload,
30
+ )
31
+ from assay.settings import AssaySettings
32
+ from assay.uncertainty import Abstention, Estimate, mean_interval
33
+ from avow import __version__
34
+ from avow.canonical import content_hash
35
+ from avow.verify import verify_receipt
36
+
37
+ # The metric label a caller names is not free text: it selects which computation
38
+ # Assay performs and is signed into the receipt. Only registered metrics are
39
+ # accepted, so a receipt's `metric` field is verified, never merely asserted.
40
+ _CLASSIFICATION_METRICS = frozenset({"binary"})
41
+ _COMPOSITE_METRICS = frozenset({"weighted_composite"})
42
+
43
+
44
+ def _require_metric(name: str, allowed: frozenset[str]) -> None:
45
+ if name not in allowed:
46
+ raise UnknownMetric(f"unknown metric {name!r}")
47
+
48
+
49
+ def _classification_detail(scores: ClassificationScores) -> ClassificationDetail:
50
+ return ClassificationDetail(
51
+ precision=scores.precision,
52
+ recall=scores.recall,
53
+ f1=scores.f1,
54
+ pr_auc=scores.pr_auc,
55
+ roc_auc=scores.roc_auc,
56
+ )
57
+
58
+
59
+ def _calibration_detail(report: CalibrationReport) -> CalibrationDetail:
60
+ points = tuple(
61
+ ReliabilityPoint(
62
+ mean_predicted=b.mean_predicted,
63
+ fraction_positive=b.fraction_positive,
64
+ count=b.count,
65
+ )
66
+ for b in report.bins
67
+ )
68
+ return CalibrationDetail(ece=report.ece, brier=report.brier, reliability=points)
69
+
70
+
71
+ def _headline(
72
+ estimate: Estimate,
73
+ ) -> tuple[float | None, float | None, float | None, bool]:
74
+ if isinstance(estimate, Abstention):
75
+ return None, None, None, True
76
+ return estimate.point, estimate.low, estimate.high, False
77
+
78
+
79
+ def _estimate(request: ScoreRequest, settings: AssaySettings) -> Estimate:
80
+ hits = correctness(request.y_true, request.y_score, threshold=request.threshold)
81
+ return mean_interval(
82
+ hits,
83
+ min_samples=settings.min_samples,
84
+ n_resamples=settings.bootstrap_resamples,
85
+ confidence_level=settings.confidence_level,
86
+ seed=settings.bootstrap_seed,
87
+ )
88
+
89
+
90
+ def _classification_payload(request: ScoreRequest, settings: AssaySettings) -> ReceiptPayload:
91
+ scores = binary_scores(request.y_true, request.y_score, threshold=request.threshold)
92
+ report = calibration_report(request.y_true, request.y_score, n_bins=settings.ece_bins)
93
+ point, low, high, abstained = _headline(_estimate(request, settings))
94
+ return ReceiptPayload(
95
+ assay_version=__version__,
96
+ metric=request.metric,
97
+ metric_version=request.metric_version,
98
+ inputs_hash=content_hash(request.model_dump(mode="json")),
99
+ score=point,
100
+ interval_low=low,
101
+ interval_high=high,
102
+ abstained=abstained,
103
+ abstain_reason=InsufficientSamples.code if abstained else None,
104
+ classification=_classification_detail(scores),
105
+ calibration=_calibration_detail(report),
106
+ )
107
+
108
+
109
+ def score(
110
+ request: ScoreRequest, *, signing_key: SigningKey, settings: AssaySettings
111
+ ) -> ScoreReceipt:
112
+ """Score a classification request into a signed, verifiable receipt."""
113
+ _require_metric(request.metric, _CLASSIFICATION_METRICS)
114
+ return sign_payload(_classification_payload(request, settings), signing_key)
115
+
116
+
117
+ def _as_subscore(s: SubScoreInput) -> SubScore:
118
+ return SubScore(
119
+ name=s.name,
120
+ value=s.value,
121
+ low=s.low,
122
+ high=s.high,
123
+ scale_min=s.scale_min,
124
+ scale_max=s.scale_max,
125
+ weight=s.weight,
126
+ )
127
+
128
+
129
+ def _composite_payload(request: CompositeRequest) -> ReceiptPayload:
130
+ result = composite([_as_subscore(s) for s in request.subscores])
131
+ parts = tuple(
132
+ SubScorePart(name=p.name, normalized_value=p.normalized_value, weight=p.weight)
133
+ for p in result.parts
134
+ )
135
+ return ReceiptPayload(
136
+ assay_version=__version__,
137
+ metric=request.metric,
138
+ metric_version=request.metric_version,
139
+ inputs_hash=content_hash(request.model_dump(mode="json")),
140
+ score=result.value,
141
+ interval_low=result.low,
142
+ interval_high=result.high,
143
+ composite=CompositeDetail(parts=parts),
144
+ )
145
+
146
+
147
+ def composite_score(request: CompositeRequest, *, signing_key: SigningKey) -> ScoreReceipt:
148
+ """Score a weighted multi-scale composite into a signed receipt."""
149
+ _require_metric(request.metric, _COMPOSITE_METRICS)
150
+ return sign_payload(_composite_payload(request), signing_key)
151
+
152
+
153
+ def verify(receipt: ScoreReceipt, *, expected_public_key: str) -> bool:
154
+ """Return whether a receipt verifies offline against a **pinned** signer.
155
+
156
+ ``expected_public_key`` is the signer's public key the caller trusts, obtained
157
+ out-of-band (e.g. the ``.pub`` file from ``keygen``) — never read from the
158
+ receipt itself, whose embedded key an attacker could swap."""
159
+ try:
160
+ verify_receipt(receipt, expected_public_key=expected_public_key)
161
+ except (SignatureInvalid, ReplayMismatch):
162
+ return False
163
+ return True
164
+
165
+
166
+ def replay(request: ScoreRequest, receipt: ScoreReceipt, *, settings: AssaySettings) -> bool:
167
+ """Recompute the payload from inputs and confirm it reproduces the signed hash."""
168
+ recomputed = _classification_payload(request, settings)
169
+ return payload_digest(recomputed) == receipt.payload_hash
assay/calibration.py ADDED
@@ -0,0 +1,70 @@
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
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+ from sklearn.calibration import calibration_curve
16
+ from sklearn.metrics import brier_score_loss
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ReliabilityBin:
21
+ """One reliability-diagram point."""
22
+
23
+ mean_predicted: float
24
+ fraction_positive: float
25
+ count: int
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CalibrationReport:
30
+ """ECE, Brier, and the reliability diagram for a set of predictions."""
31
+
32
+ ece: float
33
+ brier: float
34
+ bins: tuple[ReliabilityBin, ...]
35
+
36
+
37
+ def _bin_populations(score_arr: np.ndarray, n_bins: int) -> list[int]:
38
+ # Bin with sklearn's own scheme (searchsorted on interior edges) rather than a
39
+ # parallel np.histogram: at an exact bin edge the two disagree, which would
40
+ # misalign these counts with calibration_curve's non-empty bins.
41
+ edges = np.linspace(0.0, 1.0, n_bins + 1)
42
+ bin_ids = np.searchsorted(edges[1:-1], score_arr)
43
+ counts = np.bincount(bin_ids, minlength=n_bins)
44
+ return [int(c) for c in counts if c > 0]
45
+
46
+
47
+ def _bins(prob_pred: np.ndarray, prob_true: np.ndarray, weights: list[int]) -> list[ReliabilityBin]:
48
+ return [
49
+ ReliabilityBin(mean_predicted=float(p), fraction_positive=float(t), count=w)
50
+ for p, t, w in zip(prob_pred, prob_true, weights, strict=True)
51
+ ]
52
+
53
+
54
+ def _ece(bins: list[ReliabilityBin], total: int) -> float:
55
+ return sum(b.count / total * abs(b.mean_predicted - b.fraction_positive) for b in bins)
56
+
57
+
58
+ def calibration_report(
59
+ y_true: Sequence[int], y_score: Sequence[float], *, n_bins: int
60
+ ) -> CalibrationReport:
61
+ """Build the ECE / Brier / reliability report for binary predictions."""
62
+ true_arr = np.asarray(y_true, dtype=float)
63
+ score_arr = np.asarray(y_score, dtype=float)
64
+ prob_true, prob_pred = calibration_curve(true_arr, score_arr, n_bins=n_bins, strategy="uniform")
65
+ bins = _bins(prob_pred, prob_true, _bin_populations(score_arr, n_bins))
66
+ return CalibrationReport(
67
+ ece=_ece(bins, total=len(score_arr)),
68
+ brier=float(brier_score_loss(true_arr, score_arr)),
69
+ bins=tuple(bins),
70
+ )
assay/cli.py ADDED
@@ -0,0 +1,97 @@
1
+ """Typer CLI. Thin command layer: parse files into typed models, delegate to the
2
+ facade, translate results to exit codes. No business logic lives here."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from assay.api import composite_score
12
+ from assay.api import score as score_receipt
13
+ from assay.api import verify as verify_receipt_bool
14
+ from assay.models import CompositeRequest, ScoreRequest
15
+ from assay.receipt import ScoreReceipt
16
+ from assay.settings import AssaySettings
17
+ from avow.keys import (
18
+ generate_signing_key,
19
+ load_signing_key,
20
+ read_public_key,
21
+ save_public_key,
22
+ save_signing_key,
23
+ )
24
+ from avow.ledger import append
25
+
26
+ app = typer.Typer(help="Assay — the scoring engine that refuses to lie.")
27
+
28
+
29
+ @app.command()
30
+ def keygen(
31
+ out: Annotated[Path, typer.Option(help="Where to write the signing key.")] = Path(
32
+ "signing.key"
33
+ ),
34
+ pub: Annotated[
35
+ Path | None,
36
+ typer.Option(help="Where to write the public key (default: <out>.pub)."),
37
+ ] = None,
38
+ ) -> None:
39
+ """Generate a new Ed25519 signing key (private seed 0600) plus its public key.
40
+
41
+ The public key is written separately so a verifier can pin it out-of-band and
42
+ never has to trust the key embedded in a receipt."""
43
+ key = generate_signing_key()
44
+ save_signing_key(key, path=out)
45
+ pub_path = pub if pub is not None else Path(f"{out}.pub")
46
+ save_public_key(key, path=pub_path)
47
+ typer.echo(f"wrote signing key: {out}")
48
+ typer.echo(f"wrote public key: {pub_path}")
49
+
50
+
51
+ @app.command()
52
+ def score(
53
+ request: Annotated[Path, typer.Option("--request", help="ScoreRequest JSON.")],
54
+ key: Annotated[Path, typer.Option("--key", help="Signing key file.")],
55
+ out: Annotated[Path, typer.Option("--out", help="Receipt output path.")],
56
+ ledger: Annotated[Path, typer.Option("--ledger", help="Ledger JSONL path.")],
57
+ ) -> None:
58
+ """Score a classification request and write a signed receipt."""
59
+ parsed = ScoreRequest.model_validate_json(request.read_text(encoding="utf-8"))
60
+ receipt = score_receipt(parsed, signing_key=load_signing_key(key), settings=AssaySettings())
61
+ out.write_text(receipt.model_dump_json(indent=2), encoding="utf-8")
62
+ append(receipt, path=ledger)
63
+ typer.echo(f"wrote receipt: {out}")
64
+
65
+
66
+ @app.command()
67
+ def composite(
68
+ request: Annotated[Path, typer.Option("--request", help="CompositeRequest JSON.")],
69
+ key: Annotated[Path, typer.Option("--key", help="Signing key file.")],
70
+ out: Annotated[Path, typer.Option("--out", help="Receipt output path.")],
71
+ ) -> None:
72
+ """Score a weighted multi-scale composite and write a signed receipt."""
73
+ parsed = CompositeRequest.model_validate_json(request.read_text(encoding="utf-8"))
74
+ receipt = composite_score(parsed, signing_key=load_signing_key(key))
75
+ out.write_text(receipt.model_dump_json(indent=2), encoding="utf-8")
76
+ typer.echo(f"wrote receipt: {out}")
77
+
78
+
79
+ @app.command()
80
+ def verify(
81
+ receipt: Annotated[Path, typer.Option("--receipt", help="Receipt JSON to verify.")],
82
+ public_key: Annotated[
83
+ Path,
84
+ typer.Option("--public-key", help="Pinned signer public-key file (the .pub from keygen)."),
85
+ ],
86
+ ) -> None:
87
+ """Verify a receipt offline against a pinned signer; exit non-zero if it fails.
88
+
89
+ The expected public key is read from ``--public-key`` (out-of-band), never from
90
+ the receipt's own field, so a re-signed forgery cannot authenticate itself."""
91
+ parsed = ScoreReceipt.model_validate_json(receipt.read_text(encoding="utf-8"))
92
+ expected = read_public_key(public_key)
93
+ if verify_receipt_bool(parsed, expected_public_key=expected):
94
+ typer.echo("OK: receipt verified")
95
+ return
96
+ typer.echo("FAIL: receipt did not verify")
97
+ raise typer.Exit(code=1)
assay/composite.py ADDED
@@ -0,0 +1,101 @@
1
+ """Weighted multi-scale composite with propagated uncertainty.
2
+
3
+ Each sub-score is normalized to [0,1] by its own scale, then combined as a
4
+ positive-weighted mean. Because the mean is monotone in each input, the composite
5
+ interval is the same weighted mean applied to the sub-scores' lows and highs —
6
+ exact interval arithmetic, no fabricated tightening."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Final
13
+
14
+ from assay.errors import InvalidScoreRequest
15
+
16
+ MIN_SUBSCORES: Final[int] = 3
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SubScore:
21
+ """One input sub-score with its native scale and an uncertainty interval."""
22
+
23
+ name: str
24
+ value: float
25
+ low: float
26
+ high: float
27
+ scale_min: float
28
+ scale_max: float
29
+ weight: float
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class NormalizedSubScore:
34
+ """A sub-score after normalization to [0,1]."""
35
+
36
+ name: str
37
+ normalized_value: float
38
+ weight: float
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class CompositeScore:
43
+ """The composite value with its propagated interval and normalized parts."""
44
+
45
+ value: float
46
+ low: float
47
+ high: float
48
+ parts: tuple[NormalizedSubScore, ...]
49
+
50
+
51
+ def _require_min_count(subscores: Sequence[SubScore]) -> None:
52
+ if len(subscores) < MIN_SUBSCORES:
53
+ raise InvalidScoreRequest("composite needs at least three sub-scores")
54
+
55
+
56
+ def _require_increasing_scales(subscores: Sequence[SubScore]) -> None:
57
+ if any(s.scale_max <= s.scale_min for s in subscores):
58
+ raise InvalidScoreRequest("scale_max must exceed scale_min")
59
+
60
+
61
+ def _require_positive_weight(subscores: Sequence[SubScore]) -> None:
62
+ if any(s.weight <= 0 for s in subscores):
63
+ raise InvalidScoreRequest("each sub-score weight must be positive")
64
+
65
+
66
+ def _require_ordered_intervals(subscores: Sequence[SubScore]) -> None:
67
+ if any(not (s.low <= s.value <= s.high) for s in subscores):
68
+ raise InvalidScoreRequest("each sub-score must satisfy low <= value <= high")
69
+
70
+
71
+ def _validate(subscores: Sequence[SubScore]) -> None:
72
+ _require_min_count(subscores)
73
+ _require_increasing_scales(subscores)
74
+ _require_positive_weight(subscores)
75
+ _require_ordered_intervals(subscores)
76
+
77
+
78
+ def _normalize(x: float, s: SubScore) -> float:
79
+ return min(1.0, max(0.0, (x - s.scale_min) / (s.scale_max - s.scale_min)))
80
+
81
+
82
+ def _weighted(
83
+ subscores: Sequence[SubScore], total_w: float, pick: Callable[[SubScore], float]
84
+ ) -> float:
85
+ return sum(s.weight * _normalize(pick(s), s) for s in subscores) / total_w
86
+
87
+
88
+ def _part(s: SubScore) -> NormalizedSubScore:
89
+ return NormalizedSubScore(s.name, _normalize(s.value, s), s.weight)
90
+
91
+
92
+ def composite(subscores: Sequence[SubScore]) -> CompositeScore:
93
+ """Combine >= 3 multi-scale sub-scores into one interval-carrying composite."""
94
+ _validate(subscores)
95
+ total_w = sum(s.weight for s in subscores)
96
+ return CompositeScore(
97
+ value=_weighted(subscores, total_w, lambda s: s.value),
98
+ low=_weighted(subscores, total_w, lambda s: s.low),
99
+ high=_weighted(subscores, total_w, lambda s: s.high),
100
+ parts=tuple(_part(s) for s in subscores),
101
+ )
assay/errors.py ADDED
@@ -0,0 +1,61 @@
1
+ """Coded domain-error catalog for the *scoring* face. Every failure raises a typed
2
+ ``AssayError`` with a stable string ``code`` so callers (and the CLI) branch on cause
3
+ without string-matching messages.
4
+
5
+ The trust-envelope failures (``SignatureInvalid``, ``ReplayMismatch``,
6
+ ``CanonicalizationFailed``, ``LedgerIntegrityError``) belong to the shared envelope and
7
+ live in ``avow.errors``; they are re-exported here so the score face's callers keep a
8
+ single import site."""
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import ClassVar
13
+
14
+ from avow.errors import (
15
+ CanonicalizationFailed,
16
+ LedgerIntegrityError,
17
+ ReplayMismatch,
18
+ SignatureInvalid,
19
+ )
20
+
21
+ __all__ = [
22
+ "AssayError",
23
+ "CanonicalizationFailed",
24
+ "InsufficientSamples",
25
+ "InvalidScoreRequest",
26
+ "LedgerIntegrityError",
27
+ "ReplayMismatch",
28
+ "ScoringExtraMissing",
29
+ "SignatureInvalid",
30
+ "UnknownMetric",
31
+ ]
32
+
33
+
34
+ class AssayError(Exception):
35
+ """Base class for every Assay scoring-face domain error."""
36
+
37
+ code: ClassVar[str] = "assay.error"
38
+
39
+
40
+ class InvalidScoreRequest(AssayError):
41
+ """Inputs are malformed (length mismatch, empty, single-class)."""
42
+
43
+ code: ClassVar[str] = "assay.invalid_request"
44
+
45
+
46
+ class UnknownMetric(AssayError):
47
+ """Requested metric name is not registered."""
48
+
49
+ code: ClassVar[str] = "assay.unknown_metric"
50
+
51
+
52
+ class InsufficientSamples(AssayError):
53
+ """Sample count is below the abstention floor."""
54
+
55
+ code: ClassVar[str] = "assay.insufficient_samples"
56
+
57
+
58
+ class ScoringExtraMissing(AssayError):
59
+ """The scoring face was imported without its heavy extra installed."""
60
+
61
+ code: ClassVar[str] = "assay.scoring_extra_missing"
assay/metrics.py ADDED
@@ -0,0 +1,78 @@
1
+ """Deterministic classification metrics — thin wrappers over scikit-learn.
2
+
3
+ Assay reimplements no metric math; it validates inputs, delegates to sklearn, and
4
+ returns an immutable, fully-typed result."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+
11
+ from sklearn.metrics import (
12
+ accuracy_score,
13
+ average_precision_score,
14
+ precision_recall_fscore_support,
15
+ roc_auc_score,
16
+ )
17
+
18
+ from assay.errors import InvalidScoreRequest
19
+
20
+ _MIN_CLASSES = 2
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ClassificationScores:
25
+ """Immutable bundle of binary-classification metrics."""
26
+
27
+ accuracy: float
28
+ precision: float
29
+ recall: float
30
+ f1: float
31
+ pr_auc: float
32
+ roc_auc: float
33
+
34
+
35
+ def _validate(y_true: Sequence[int], y_score: Sequence[float]) -> None:
36
+ if len(y_true) != len(y_score):
37
+ raise InvalidScoreRequest("y_true and y_score length mismatch")
38
+ if len(y_true) == 0:
39
+ raise InvalidScoreRequest("inputs are empty")
40
+ if len(set(y_true)) < _MIN_CLASSES:
41
+ raise InvalidScoreRequest("need both classes present for AUC metrics")
42
+
43
+
44
+ def _threshold(y_score: Sequence[float], threshold: float) -> list[int]:
45
+ return [1 if s >= threshold else 0 for s in y_score]
46
+
47
+
48
+ def _prf(y_true: Sequence[int], y_pred: Sequence[int]) -> tuple[float, float, float]:
49
+ precision, recall, f1, _ = precision_recall_fscore_support(
50
+ y_true, y_pred, average="binary", zero_division=0.0
51
+ )
52
+ return float(precision), float(recall), float(f1)
53
+
54
+
55
+ def binary_scores(
56
+ y_true: Sequence[int], y_score: Sequence[float], *, threshold: float = 0.5
57
+ ) -> ClassificationScores:
58
+ """Compute accuracy, precision, recall, F1, PR-AUC and ROC-AUC."""
59
+ _validate(y_true, y_score)
60
+ y_pred = _threshold(y_score, threshold)
61
+ precision, recall, f1 = _prf(y_true, y_pred)
62
+ return ClassificationScores(
63
+ accuracy=float(accuracy_score(y_true, y_pred)),
64
+ precision=precision,
65
+ recall=recall,
66
+ f1=f1,
67
+ pr_auc=float(average_precision_score(y_true, y_score)),
68
+ roc_auc=float(roc_auc_score(y_true, y_score)),
69
+ )
70
+
71
+
72
+ def correctness(
73
+ y_true: Sequence[int], y_score: Sequence[float], *, threshold: float = 0.5
74
+ ) -> tuple[float, ...]:
75
+ """Return a per-example 1.0/0.0 correctness vector (for bootstrapping)."""
76
+ _validate(y_true, y_score)
77
+ y_pred = _threshold(y_score, threshold)
78
+ return tuple(float(int(p == t)) for p, t in zip(y_pred, y_true, strict=True))
assay/models.py ADDED
@@ -0,0 +1,42 @@
1
+ """Typed request models at the input boundary. Frozen and ``extra="forbid"`` so a
2
+ malformed or ambiguous request is rejected before any computation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+
9
+ class ScoreRequest(BaseModel):
10
+ """A classification scoring request."""
11
+
12
+ model_config = ConfigDict(frozen=True, extra="forbid")
13
+
14
+ metric: str
15
+ metric_version: str
16
+ y_true: tuple[int, ...]
17
+ y_score: tuple[float, ...]
18
+ threshold: float = 0.5
19
+
20
+
21
+ class SubScoreInput(BaseModel):
22
+ """One sub-score with its native scale and interval, for a composite."""
23
+
24
+ model_config = ConfigDict(frozen=True, extra="forbid")
25
+
26
+ name: str
27
+ value: float
28
+ low: float
29
+ high: float
30
+ scale_min: float
31
+ scale_max: float
32
+ weight: float
33
+
34
+
35
+ class CompositeRequest(BaseModel):
36
+ """A weighted multi-scale composite request."""
37
+
38
+ model_config = ConfigDict(frozen=True, extra="forbid")
39
+
40
+ metric: str = "weighted_composite"
41
+ metric_version: str
42
+ subscores: tuple[SubScoreInput, ...]
assay/py.typed ADDED
File without changes