eval-toolkit 0.27.1__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.
- eval_toolkit/__init__.py +238 -0
- eval_toolkit/__main__.py +156 -0
- eval_toolkit/_version.py +5 -0
- eval_toolkit/analysis.py +196 -0
- eval_toolkit/artifacts.py +376 -0
- eval_toolkit/bootstrap.py +1344 -0
- eval_toolkit/calibration.py +1143 -0
- eval_toolkit/claims.py +670 -0
- eval_toolkit/config.py +112 -0
- eval_toolkit/docs.py +305 -0
- eval_toolkit/evidence.py +90 -0
- eval_toolkit/harness.py +1193 -0
- eval_toolkit/leakage.py +1052 -0
- eval_toolkit/loaders.py +424 -0
- eval_toolkit/manifest.py +622 -0
- eval_toolkit/metrics.py +1720 -0
- eval_toolkit/operating_points.py +192 -0
- eval_toolkit/paths.py +125 -0
- eval_toolkit/plotting.py +991 -0
- eval_toolkit/protocols.py +98 -0
- eval_toolkit/provenance.py +255 -0
- eval_toolkit/py.typed +0 -0
- eval_toolkit/schemas/manifest.v1.json +155 -0
- eval_toolkit/schemas/manifest.v2.json +186 -0
- eval_toolkit/schemas/manifest.v3.json +186 -0
- eval_toolkit/schemas/results.v1.json +87 -0
- eval_toolkit/schemas/results_full.v1.json +83 -0
- eval_toolkit/seeds.py +119 -0
- eval_toolkit/splits.py +520 -0
- eval_toolkit/text_dedup.py +1403 -0
- eval_toolkit/thresholds.py +819 -0
- eval_toolkit-0.27.1.dist-info/METADATA +314 -0
- eval_toolkit-0.27.1.dist-info/RECORD +36 -0
- eval_toolkit-0.27.1.dist-info/WHEEL +4 -0
- eval_toolkit-0.27.1.dist-info/entry_points.txt +2 -0
- eval_toolkit-0.27.1.dist-info/licenses/LICENSE +21 -0
eval_toolkit/claims.py
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""Generic evidence gates for classification claims.
|
|
2
|
+
|
|
3
|
+
This module does not render reports and does not encode domain claims. It
|
|
4
|
+
evaluates caller-supplied claim specs against result/manifest payloads and
|
|
5
|
+
returns machine-readable pass/fail evidence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ClaimReport",
|
|
16
|
+
"ClaimSpec",
|
|
17
|
+
"EvidenceGate",
|
|
18
|
+
"GateResult",
|
|
19
|
+
"evaluate_claims",
|
|
20
|
+
"external_diagnostic_gate",
|
|
21
|
+
"headline_present_gate",
|
|
22
|
+
"low_fpr_feasibility_gate",
|
|
23
|
+
"metric_threshold_gate",
|
|
24
|
+
"minimum_slice_size_gate",
|
|
25
|
+
"no_leakage_errors_gate",
|
|
26
|
+
"no_scorer_errors_gate",
|
|
27
|
+
"paired_diff_present_gate",
|
|
28
|
+
"required_metric_gate",
|
|
29
|
+
"required_scorer_gate",
|
|
30
|
+
"required_slice_gate",
|
|
31
|
+
"source_role_gate",
|
|
32
|
+
"strict_artifact_gate",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
GateSeverity = Literal["error", "warning", "info"]
|
|
36
|
+
GateCheck = Callable[[Mapping[str, Any], Mapping[str, Any] | None], "GateResult"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class GateResult:
|
|
41
|
+
"""Result of one evidence gate."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
passed: bool
|
|
45
|
+
severity: GateSeverity = "error"
|
|
46
|
+
message: str = ""
|
|
47
|
+
evidence: dict[str, object] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, object]:
|
|
50
|
+
"""JSON-serializable representation."""
|
|
51
|
+
return {
|
|
52
|
+
"name": self.name,
|
|
53
|
+
"passed": self.passed,
|
|
54
|
+
"severity": self.severity,
|
|
55
|
+
"message": self.message,
|
|
56
|
+
"evidence": self.evidence,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class EvidenceGate:
|
|
62
|
+
"""Named callable gate used inside a :class:`ClaimSpec`."""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
check: GateCheck
|
|
66
|
+
description: str = ""
|
|
67
|
+
severity: GateSeverity = "error"
|
|
68
|
+
|
|
69
|
+
def evaluate(
|
|
70
|
+
self,
|
|
71
|
+
result: Mapping[str, Any],
|
|
72
|
+
manifest: Mapping[str, Any] | None = None,
|
|
73
|
+
) -> GateResult:
|
|
74
|
+
"""Run the gate and normalize unexpected exceptions to failures."""
|
|
75
|
+
try:
|
|
76
|
+
gate_result = self.check(result, manifest)
|
|
77
|
+
except (KeyError, ValueError, TypeError, RuntimeError, AttributeError, LookupError) as exc:
|
|
78
|
+
return GateResult(
|
|
79
|
+
name=self.name,
|
|
80
|
+
passed=False,
|
|
81
|
+
severity=self.severity,
|
|
82
|
+
message=f"{type(exc).__name__}: {exc}",
|
|
83
|
+
)
|
|
84
|
+
if gate_result.name == self.name and gate_result.severity == self.severity:
|
|
85
|
+
return gate_result
|
|
86
|
+
return GateResult(
|
|
87
|
+
name=self.name,
|
|
88
|
+
passed=gate_result.passed,
|
|
89
|
+
severity=self.severity,
|
|
90
|
+
message=gate_result.message,
|
|
91
|
+
evidence=gate_result.evidence,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class ClaimSpec:
|
|
97
|
+
"""A claim plus the gates required before it can be treated as supported."""
|
|
98
|
+
|
|
99
|
+
name: str
|
|
100
|
+
gates: tuple[EvidenceGate, ...]
|
|
101
|
+
mode: str = "claim"
|
|
102
|
+
description: str = ""
|
|
103
|
+
|
|
104
|
+
def __post_init__(self) -> None:
|
|
105
|
+
"""Validate minimum claim shape."""
|
|
106
|
+
if not self.name:
|
|
107
|
+
raise ValueError("ClaimSpec.name must be non-empty")
|
|
108
|
+
if not self.gates:
|
|
109
|
+
raise ValueError("ClaimSpec.gates must be non-empty")
|
|
110
|
+
if not self.mode:
|
|
111
|
+
raise ValueError("ClaimSpec.mode must be non-empty")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class ClaimReport:
|
|
116
|
+
"""Machine-readable result of evaluating claim specs."""
|
|
117
|
+
|
|
118
|
+
claims: dict[str, list[GateResult]]
|
|
119
|
+
|
|
120
|
+
def has_failures(self, *, include_warnings: bool = False) -> bool:
|
|
121
|
+
"""Return True if any claim has a failing error gate."""
|
|
122
|
+
failing_severities = {"error", "warning"} if include_warnings else {"error"}
|
|
123
|
+
return any(
|
|
124
|
+
(not result.passed) and result.severity in failing_severities
|
|
125
|
+
for results in self.claims.values()
|
|
126
|
+
for result in results
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def to_dict(self) -> dict[str, object]:
|
|
130
|
+
"""JSON-serializable representation."""
|
|
131
|
+
return {
|
|
132
|
+
"claims": {
|
|
133
|
+
claim: [result.to_dict() for result in results]
|
|
134
|
+
for claim, results in self.claims.items()
|
|
135
|
+
},
|
|
136
|
+
"has_failures": self.has_failures(),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def evaluate_claims(
|
|
141
|
+
result: object,
|
|
142
|
+
claim_specs: Sequence[ClaimSpec],
|
|
143
|
+
*,
|
|
144
|
+
manifest: object | None = None,
|
|
145
|
+
) -> ClaimReport:
|
|
146
|
+
"""Evaluate claim specs against a result payload and optional manifest."""
|
|
147
|
+
result_dict = _as_mapping(result)
|
|
148
|
+
manifest_dict = _as_mapping(manifest) if manifest is not None else None
|
|
149
|
+
claims: dict[str, list[GateResult]] = {}
|
|
150
|
+
for spec in claim_specs:
|
|
151
|
+
claims[spec.name] = [gate.evaluate(result_dict, manifest_dict) for gate in spec.gates]
|
|
152
|
+
return ClaimReport(claims=claims)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def required_slice_gate(slice_name: str, *, severity: GateSeverity = "error") -> EvidenceGate:
|
|
156
|
+
"""Require ``by_slice.<slice_name>`` to exist."""
|
|
157
|
+
|
|
158
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
159
|
+
by_slice = result.get("by_slice", {})
|
|
160
|
+
passed = isinstance(by_slice, Mapping) and slice_name in by_slice
|
|
161
|
+
return GateResult(
|
|
162
|
+
name=f"required_slice:{slice_name}",
|
|
163
|
+
passed=passed,
|
|
164
|
+
severity=severity,
|
|
165
|
+
message="slice present" if passed else f"missing slice {slice_name!r}",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return EvidenceGate(name=f"required_slice:{slice_name}", check=_check, severity=severity)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def required_scorer_gate(
|
|
172
|
+
slice_name: str,
|
|
173
|
+
scorer_name: str,
|
|
174
|
+
*,
|
|
175
|
+
severity: GateSeverity = "error",
|
|
176
|
+
) -> EvidenceGate:
|
|
177
|
+
"""Require a scorer result under a slice."""
|
|
178
|
+
|
|
179
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
180
|
+
block = _get_path(result, f"by_slice.{slice_name}.by_scorer.{scorer_name}")
|
|
181
|
+
passed = isinstance(block, Mapping)
|
|
182
|
+
return GateResult(
|
|
183
|
+
name=f"required_scorer:{slice_name}:{scorer_name}",
|
|
184
|
+
passed=passed,
|
|
185
|
+
severity=severity,
|
|
186
|
+
message="scorer present" if passed else "missing scorer result",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return EvidenceGate(
|
|
190
|
+
name=f"required_scorer:{slice_name}:{scorer_name}",
|
|
191
|
+
check=_check,
|
|
192
|
+
severity=severity,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def required_metric_gate(
|
|
197
|
+
slice_name: str,
|
|
198
|
+
scorer_name: str,
|
|
199
|
+
metric_path: str,
|
|
200
|
+
*,
|
|
201
|
+
severity: GateSeverity = "error",
|
|
202
|
+
) -> EvidenceGate:
|
|
203
|
+
"""Require a metric path under one scorer result."""
|
|
204
|
+
path = f"by_slice.{slice_name}.by_scorer.{scorer_name}.{metric_path}"
|
|
205
|
+
|
|
206
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
207
|
+
value = _get_path(result, path)
|
|
208
|
+
passed = value is not None
|
|
209
|
+
return GateResult(
|
|
210
|
+
name=f"required_metric:{slice_name}:{scorer_name}:{metric_path}",
|
|
211
|
+
passed=passed,
|
|
212
|
+
severity=severity,
|
|
213
|
+
message="metric present" if passed else f"missing metric path {path!r}",
|
|
214
|
+
evidence={"path": path, "value": value} if passed else {"path": path},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return EvidenceGate(
|
|
218
|
+
name=f"required_metric:{slice_name}:{scorer_name}:{metric_path}",
|
|
219
|
+
check=_check,
|
|
220
|
+
severity=severity,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def minimum_slice_size_gate(
|
|
225
|
+
slice_name: str,
|
|
226
|
+
*,
|
|
227
|
+
min_n: int = 0,
|
|
228
|
+
min_positive: int = 0,
|
|
229
|
+
min_negative: int = 0,
|
|
230
|
+
severity: GateSeverity = "error",
|
|
231
|
+
) -> EvidenceGate:
|
|
232
|
+
"""Require minimum total/positive/negative counts for a slice."""
|
|
233
|
+
|
|
234
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
235
|
+
block = _get_path(result, f"by_slice.{slice_name}")
|
|
236
|
+
if not isinstance(block, Mapping):
|
|
237
|
+
return GateResult(
|
|
238
|
+
name=f"minimum_slice_size:{slice_name}",
|
|
239
|
+
passed=False,
|
|
240
|
+
severity=severity,
|
|
241
|
+
message=f"missing slice {slice_name!r}",
|
|
242
|
+
)
|
|
243
|
+
n = _as_int(block.get("n"))
|
|
244
|
+
n_positive = _as_int(block.get("n_positive"))
|
|
245
|
+
n_negative = None if n is None or n_positive is None else n - n_positive
|
|
246
|
+
passed = (
|
|
247
|
+
n is not None
|
|
248
|
+
and n_positive is not None
|
|
249
|
+
and n_negative is not None
|
|
250
|
+
and n >= min_n
|
|
251
|
+
and n_positive >= min_positive
|
|
252
|
+
and n_negative >= min_negative
|
|
253
|
+
)
|
|
254
|
+
return GateResult(
|
|
255
|
+
name=f"minimum_slice_size:{slice_name}",
|
|
256
|
+
passed=passed,
|
|
257
|
+
severity=severity,
|
|
258
|
+
message="slice size sufficient" if passed else "slice size below requirement",
|
|
259
|
+
evidence={
|
|
260
|
+
"n": n,
|
|
261
|
+
"n_positive": n_positive,
|
|
262
|
+
"n_negative": n_negative,
|
|
263
|
+
"min_n": min_n,
|
|
264
|
+
"min_positive": min_positive,
|
|
265
|
+
"min_negative": min_negative,
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return EvidenceGate(name=f"minimum_slice_size:{slice_name}", check=_check, severity=severity)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def low_fpr_feasibility_gate(
|
|
273
|
+
slice_name: str,
|
|
274
|
+
*,
|
|
275
|
+
max_fpr: float,
|
|
276
|
+
confidence: float = 0.95,
|
|
277
|
+
severity: GateSeverity = "error",
|
|
278
|
+
) -> EvidenceGate:
|
|
279
|
+
"""Require enough negatives for a low-FPR claim to be statistically feasible.
|
|
280
|
+
|
|
281
|
+
This is not an observed-performance gate. It asks whether a slice could
|
|
282
|
+
support a claim of ``FPR <= max_fpr`` even in the best empirical case of
|
|
283
|
+
zero false positives. The best-case upper bound is the Wilson score upper
|
|
284
|
+
confidence bound for ``0 / n_negative``.
|
|
285
|
+
|
|
286
|
+
Raises
|
|
287
|
+
------
|
|
288
|
+
ValueError
|
|
289
|
+
If ``max_fpr`` is not in (0, 1] or ``confidence`` is not in (0, 1).
|
|
290
|
+
"""
|
|
291
|
+
if not 0.0 < max_fpr <= 1.0:
|
|
292
|
+
raise ValueError(f"max_fpr must be in (0, 1], got {max_fpr}")
|
|
293
|
+
if not 0.0 < confidence < 1.0:
|
|
294
|
+
raise ValueError(f"confidence must be in (0, 1), got {confidence}")
|
|
295
|
+
|
|
296
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
297
|
+
block = _get_path(result, f"by_slice.{slice_name}")
|
|
298
|
+
if not isinstance(block, Mapping):
|
|
299
|
+
return GateResult(
|
|
300
|
+
name=f"low_fpr_feasibility:{slice_name}",
|
|
301
|
+
passed=False,
|
|
302
|
+
severity=severity,
|
|
303
|
+
message=f"missing slice {slice_name!r}",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
n = _as_int(block.get("n"))
|
|
307
|
+
n_positive = _as_int(block.get("n_positive"))
|
|
308
|
+
n_negative = None if n is None or n_positive is None else n - n_positive
|
|
309
|
+
empirical_step = None if n_negative is None or n_negative <= 0 else 1.0 / n_negative
|
|
310
|
+
best_case_high = (
|
|
311
|
+
None
|
|
312
|
+
if n_negative is None or n_negative <= 0
|
|
313
|
+
else _wilson_zero_success_upper(n_negative, confidence=confidence)
|
|
314
|
+
)
|
|
315
|
+
passed = best_case_high is not None and best_case_high <= max_fpr
|
|
316
|
+
return GateResult(
|
|
317
|
+
name=f"low_fpr_feasibility:{slice_name}",
|
|
318
|
+
passed=passed,
|
|
319
|
+
severity=severity,
|
|
320
|
+
message=(
|
|
321
|
+
"negative count can support requested FPR"
|
|
322
|
+
if passed
|
|
323
|
+
else "negative count cannot support requested FPR"
|
|
324
|
+
),
|
|
325
|
+
evidence={
|
|
326
|
+
"n": n,
|
|
327
|
+
"n_positive": n_positive,
|
|
328
|
+
"n_negative": n_negative,
|
|
329
|
+
"max_fpr": max_fpr,
|
|
330
|
+
"confidence": confidence,
|
|
331
|
+
"empirical_fpr_step": empirical_step,
|
|
332
|
+
"best_case_fpr_ci_high": best_case_high,
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return EvidenceGate(name=f"low_fpr_feasibility:{slice_name}", check=_check, severity=severity)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def paired_diff_present_gate(
|
|
340
|
+
slice_name: str,
|
|
341
|
+
diff_key: str,
|
|
342
|
+
*,
|
|
343
|
+
severity: GateSeverity = "error",
|
|
344
|
+
) -> EvidenceGate:
|
|
345
|
+
"""Require a paired-difference comparison under a slice."""
|
|
346
|
+
path = f"by_slice.{slice_name}.paired_diffs.{diff_key}"
|
|
347
|
+
|
|
348
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
349
|
+
value = _get_path(result, path)
|
|
350
|
+
passed = isinstance(value, Mapping) and "skipped" not in value and "error" not in value
|
|
351
|
+
return GateResult(
|
|
352
|
+
name=f"paired_diff_present:{slice_name}:{diff_key}",
|
|
353
|
+
passed=passed,
|
|
354
|
+
severity=severity,
|
|
355
|
+
message="paired diff present" if passed else "missing, skipped, or errored paired diff",
|
|
356
|
+
evidence={"path": path},
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return EvidenceGate(
|
|
360
|
+
name=f"paired_diff_present:{slice_name}:{diff_key}",
|
|
361
|
+
check=_check,
|
|
362
|
+
severity=severity,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def headline_present_gate(
|
|
367
|
+
path: str = "headline", *, severity: GateSeverity = "error"
|
|
368
|
+
) -> EvidenceGate:
|
|
369
|
+
"""Require a non-null headline/comparison block at an arbitrary path."""
|
|
370
|
+
|
|
371
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
372
|
+
value = _get_path(result, path)
|
|
373
|
+
passed = value is not None
|
|
374
|
+
return GateResult(
|
|
375
|
+
name=f"headline_present:{path}",
|
|
376
|
+
passed=passed,
|
|
377
|
+
severity=severity,
|
|
378
|
+
message="headline present" if passed else f"missing headline at {path!r}",
|
|
379
|
+
evidence={"path": path},
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
return EvidenceGate(name=f"headline_present:{path}", check=_check, severity=severity)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def metric_threshold_gate(
|
|
386
|
+
slice_name: str,
|
|
387
|
+
scorer_name: str,
|
|
388
|
+
metric_path: str,
|
|
389
|
+
*,
|
|
390
|
+
op: Literal["<", "<=", ">", ">=", "=="],
|
|
391
|
+
threshold: float,
|
|
392
|
+
severity: GateSeverity = "error",
|
|
393
|
+
) -> EvidenceGate:
|
|
394
|
+
"""Require a numeric metric to satisfy a threshold comparison."""
|
|
395
|
+
path = f"by_slice.{slice_name}.by_scorer.{scorer_name}.{metric_path}"
|
|
396
|
+
|
|
397
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
398
|
+
raw = _get_path(result, path)
|
|
399
|
+
value = _as_float(raw)
|
|
400
|
+
passed = value is not None and _compare(value, op, threshold)
|
|
401
|
+
return GateResult(
|
|
402
|
+
name=f"metric_threshold:{slice_name}:{scorer_name}:{metric_path}",
|
|
403
|
+
passed=passed,
|
|
404
|
+
severity=severity,
|
|
405
|
+
message="metric threshold satisfied" if passed else "metric threshold failed",
|
|
406
|
+
evidence={"path": path, "value": value, "op": op, "threshold": threshold},
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
return EvidenceGate(
|
|
410
|
+
name=f"metric_threshold:{slice_name}:{scorer_name}:{metric_path}",
|
|
411
|
+
check=_check,
|
|
412
|
+
severity=severity,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def no_scorer_errors_gate(*, severity: GateSeverity = "error") -> EvidenceGate:
|
|
417
|
+
"""Fail if any scorer block contains an ``error`` field."""
|
|
418
|
+
|
|
419
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
420
|
+
errors: list[str] = []
|
|
421
|
+
by_slice = result.get("by_slice", {})
|
|
422
|
+
if isinstance(by_slice, Mapping):
|
|
423
|
+
for slice_name, slice_block in by_slice.items():
|
|
424
|
+
if not isinstance(slice_block, Mapping):
|
|
425
|
+
continue
|
|
426
|
+
by_scorer = slice_block.get("by_scorer", {})
|
|
427
|
+
if not isinstance(by_scorer, Mapping):
|
|
428
|
+
continue
|
|
429
|
+
for scorer_name, scorer_block in by_scorer.items():
|
|
430
|
+
if isinstance(scorer_block, Mapping) and "error" in scorer_block:
|
|
431
|
+
errors.append(f"{slice_name}.{scorer_name}: {scorer_block['error']}")
|
|
432
|
+
return GateResult(
|
|
433
|
+
name="no_scorer_errors",
|
|
434
|
+
passed=not errors,
|
|
435
|
+
severity=severity,
|
|
436
|
+
message="no scorer errors" if not errors else f"{len(errors)} scorer error(s)",
|
|
437
|
+
evidence={"errors": errors},
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
return EvidenceGate(name="no_scorer_errors", check=_check, severity=severity)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def no_leakage_errors_gate(*, severity: GateSeverity = "error") -> EvidenceGate:
|
|
444
|
+
"""Fail if result config or manifest leakage report has error-severity findings."""
|
|
445
|
+
|
|
446
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
447
|
+
reports = []
|
|
448
|
+
config = result.get("config", {})
|
|
449
|
+
if isinstance(config, Mapping):
|
|
450
|
+
reports.append(config.get("leakage_report"))
|
|
451
|
+
if manifest is not None:
|
|
452
|
+
reports.append(manifest.get("leakage_report"))
|
|
453
|
+
errors: list[object] = []
|
|
454
|
+
for report in reports:
|
|
455
|
+
if not isinstance(report, Mapping):
|
|
456
|
+
continue
|
|
457
|
+
findings = report.get("findings", [])
|
|
458
|
+
if not isinstance(findings, Sequence):
|
|
459
|
+
continue
|
|
460
|
+
for finding in findings:
|
|
461
|
+
if isinstance(finding, Mapping) and finding.get("severity") == "error":
|
|
462
|
+
errors.append(dict(finding))
|
|
463
|
+
return GateResult(
|
|
464
|
+
name="no_leakage_errors",
|
|
465
|
+
passed=not errors,
|
|
466
|
+
severity=severity,
|
|
467
|
+
message="no leakage errors" if not errors else f"{len(errors)} leakage error(s)",
|
|
468
|
+
evidence={"errors": errors},
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
return EvidenceGate(name="no_leakage_errors", check=_check, severity=severity)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def source_role_gate(
|
|
475
|
+
required_roles: Sequence[str],
|
|
476
|
+
*,
|
|
477
|
+
severity: GateSeverity = "error",
|
|
478
|
+
) -> EvidenceGate:
|
|
479
|
+
"""Require source-role metadata with the requested roles in the manifest."""
|
|
480
|
+
required = tuple(required_roles)
|
|
481
|
+
|
|
482
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
483
|
+
roles = set()
|
|
484
|
+
if manifest is not None:
|
|
485
|
+
source_roles = manifest.get("source_roles", [])
|
|
486
|
+
if isinstance(source_roles, Sequence):
|
|
487
|
+
for record in source_roles:
|
|
488
|
+
if isinstance(record, Mapping) and isinstance(record.get("role"), str):
|
|
489
|
+
roles.add(str(record["role"]))
|
|
490
|
+
missing = sorted(set(required) - roles)
|
|
491
|
+
return GateResult(
|
|
492
|
+
name="source_role_presence",
|
|
493
|
+
passed=not missing,
|
|
494
|
+
severity=severity,
|
|
495
|
+
message="required source roles present" if not missing else "missing source roles",
|
|
496
|
+
evidence={
|
|
497
|
+
"required_roles": list(required),
|
|
498
|
+
"present_roles": sorted(roles),
|
|
499
|
+
"missing": missing,
|
|
500
|
+
},
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
return EvidenceGate(name="source_role_presence", check=_check, severity=severity)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def external_diagnostic_gate(
|
|
507
|
+
path: str,
|
|
508
|
+
*,
|
|
509
|
+
op: Literal["<", "<=", ">", ">=", "=="] | None = None,
|
|
510
|
+
threshold: float | None = None,
|
|
511
|
+
severity: GateSeverity = "error",
|
|
512
|
+
) -> EvidenceGate:
|
|
513
|
+
"""Require an external diagnostic payload, optionally thresholded.
|
|
514
|
+
|
|
515
|
+
The gate first checks the result payload and then the manifest payload.
|
|
516
|
+
That keeps diagnostics generic: consumers can store them in results when
|
|
517
|
+
computed during analysis, or in manifests when they are precomputed source
|
|
518
|
+
evidence.
|
|
519
|
+
|
|
520
|
+
Raises
|
|
521
|
+
------
|
|
522
|
+
ValueError
|
|
523
|
+
If ``path`` is empty, or if exactly one of ``op``/``threshold`` is
|
|
524
|
+
supplied (both required together, or both ``None`` for
|
|
525
|
+
presence-only check).
|
|
526
|
+
"""
|
|
527
|
+
if not path:
|
|
528
|
+
raise ValueError("path must be non-empty")
|
|
529
|
+
if (op is None) != (threshold is None):
|
|
530
|
+
raise ValueError("op and threshold must be supplied together")
|
|
531
|
+
|
|
532
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
533
|
+
value = _get_path(result, path)
|
|
534
|
+
payload = "result"
|
|
535
|
+
if value is None and manifest is not None:
|
|
536
|
+
value = _get_path(manifest, path)
|
|
537
|
+
payload = "manifest"
|
|
538
|
+
if op is None or threshold is None:
|
|
539
|
+
passed = value is not None
|
|
540
|
+
message = "external diagnostic present" if passed else "missing external diagnostic"
|
|
541
|
+
evidence: dict[str, object] = {"path": path, "payload": payload}
|
|
542
|
+
if passed:
|
|
543
|
+
evidence["value"] = value
|
|
544
|
+
else:
|
|
545
|
+
numeric = _as_float(value)
|
|
546
|
+
passed = numeric is not None and _compare(numeric, op, threshold)
|
|
547
|
+
message = (
|
|
548
|
+
"external diagnostic threshold satisfied"
|
|
549
|
+
if passed
|
|
550
|
+
else "external diagnostic threshold failed"
|
|
551
|
+
)
|
|
552
|
+
evidence = {
|
|
553
|
+
"path": path,
|
|
554
|
+
"payload": payload,
|
|
555
|
+
"value": numeric,
|
|
556
|
+
"op": op,
|
|
557
|
+
"threshold": threshold,
|
|
558
|
+
}
|
|
559
|
+
return GateResult(
|
|
560
|
+
name=f"external_diagnostic:{path}",
|
|
561
|
+
passed=passed,
|
|
562
|
+
severity=severity,
|
|
563
|
+
message=message,
|
|
564
|
+
evidence=evidence,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
return EvidenceGate(name=f"external_diagnostic:{path}", check=_check, severity=severity)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def strict_artifact_gate(*, severity: GateSeverity = "error") -> EvidenceGate:
|
|
571
|
+
"""Fail if result or manifest contains non-finite numeric values."""
|
|
572
|
+
|
|
573
|
+
def _check(result: Mapping[str, Any], manifest: Mapping[str, Any] | None) -> GateResult:
|
|
574
|
+
findings = _non_finite_paths(result, prefix="result")
|
|
575
|
+
if manifest is not None:
|
|
576
|
+
findings.extend(_non_finite_paths(manifest, prefix="manifest"))
|
|
577
|
+
return GateResult(
|
|
578
|
+
name="strict_artifact",
|
|
579
|
+
passed=not findings,
|
|
580
|
+
severity=severity,
|
|
581
|
+
message="artifacts are strict JSON safe" if not findings else "non-finite values found",
|
|
582
|
+
evidence={"non_finite_paths": findings},
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
return EvidenceGate(name="strict_artifact", check=_check, severity=severity)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _as_mapping(obj: object) -> Mapping[str, Any]:
|
|
589
|
+
if isinstance(obj, Mapping):
|
|
590
|
+
return obj
|
|
591
|
+
to_dict = getattr(obj, "to_dict", None)
|
|
592
|
+
if callable(to_dict):
|
|
593
|
+
out = to_dict()
|
|
594
|
+
if isinstance(out, Mapping):
|
|
595
|
+
return out
|
|
596
|
+
raise TypeError(f"expected mapping or object with to_dict(), got {type(obj).__name__}")
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _get_path(payload: Mapping[str, Any], path: str) -> object | None:
|
|
600
|
+
cur: object = payload
|
|
601
|
+
for part in path.split("."):
|
|
602
|
+
if not isinstance(cur, Mapping) or part not in cur:
|
|
603
|
+
return None
|
|
604
|
+
cur = cur[part]
|
|
605
|
+
return cur
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _as_int(value: object) -> int | None:
|
|
609
|
+
if isinstance(value, bool):
|
|
610
|
+
return None
|
|
611
|
+
if isinstance(value, int):
|
|
612
|
+
return value
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _as_float(value: object) -> float | None:
|
|
617
|
+
if isinstance(value, bool):
|
|
618
|
+
return None
|
|
619
|
+
if isinstance(value, (int, float)):
|
|
620
|
+
out = float(value)
|
|
621
|
+
if np_isfinite(out):
|
|
622
|
+
return out
|
|
623
|
+
return None
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _non_finite_paths(value: object, *, prefix: str) -> list[str]:
|
|
627
|
+
"""Return dotted/indexed paths to non-finite floats in a JSON-like object."""
|
|
628
|
+
if isinstance(value, bool):
|
|
629
|
+
return []
|
|
630
|
+
if isinstance(value, float):
|
|
631
|
+
return [] if np_isfinite(value) else [prefix]
|
|
632
|
+
if isinstance(value, Mapping):
|
|
633
|
+
paths: list[str] = []
|
|
634
|
+
for key, child in value.items():
|
|
635
|
+
paths.extend(_non_finite_paths(child, prefix=f"{prefix}.{key}"))
|
|
636
|
+
return paths
|
|
637
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
638
|
+
paths = []
|
|
639
|
+
for idx, child in enumerate(value):
|
|
640
|
+
paths.extend(_non_finite_paths(child, prefix=f"{prefix}[{idx}]"))
|
|
641
|
+
return paths
|
|
642
|
+
return []
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def np_isfinite(value: float) -> bool:
|
|
646
|
+
"""Tiny local finite check to avoid importing numpy for gate traversal."""
|
|
647
|
+
return value == value and value not in (float("inf"), float("-inf"))
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _wilson_zero_success_upper(n: int, *, confidence: float) -> float:
|
|
651
|
+
"""Wilson upper confidence bound for zero successes in ``n`` trials."""
|
|
652
|
+
from scipy.stats import norm # noqa: PLC0415
|
|
653
|
+
|
|
654
|
+
z = float(norm.ppf(0.5 + confidence / 2.0))
|
|
655
|
+
z2 = z * z
|
|
656
|
+
return z2 / (n + z2)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _compare(value: float, op: str, threshold: float) -> bool:
|
|
660
|
+
if op == "<":
|
|
661
|
+
return value < threshold
|
|
662
|
+
if op == "<=":
|
|
663
|
+
return value <= threshold
|
|
664
|
+
if op == ">":
|
|
665
|
+
return value > threshold
|
|
666
|
+
if op == ">=":
|
|
667
|
+
return value >= threshold
|
|
668
|
+
if op == "==":
|
|
669
|
+
return value == threshold
|
|
670
|
+
raise ValueError(f"unsupported operator {op!r}")
|