proofstep-core 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.
- proofstep_core/__init__.py +59 -0
- proofstep_core/aggregate.py +140 -0
- proofstep_core/calibration.py +849 -0
- proofstep_core/calibration_runner.py +471 -0
- proofstep_core/compare.py +205 -0
- proofstep_core/dataset.py +174 -0
- proofstep_core/evaluators/__init__.py +47 -0
- proofstep_core/evaluators/deterministic.py +404 -0
- proofstep_core/evaluators/judge.py +335 -0
- proofstep_core/evaluators/operational.py +127 -0
- proofstep_core/evaluators/statistical.py +434 -0
- proofstep_core/gates.py +545 -0
- proofstep_core/paths.py +98 -0
- proofstep_core/py.typed +0 -0
- proofstep_core/redaction.py +263 -0
- proofstep_core/runner.py +482 -0
- proofstep_core/sampling.py +200 -0
- proofstep_core/significance.py +477 -0
- proofstep_core/stats.py +142 -0
- proofstep_core/suite.py +277 -0
- proofstep_core/testing.py +118 -0
- proofstep_core/types.py +156 -0
- proofstep_core/versioning.py +63 -0
- proofstep_core-0.1.0.dist-info/METADATA +39 -0
- proofstep_core-0.1.0.dist-info/RECORD +26 -0
- proofstep_core-0.1.0.dist-info/WHEEL +4 -0
proofstep_core/gates.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""The quality gate engine — the component that turns metrics into a merge decision.
|
|
2
|
+
|
|
3
|
+
Two properties matter more than anything else here:
|
|
4
|
+
|
|
5
|
+
1. **A metric a gate names but cannot find is an ERROR, not a pass.** A typo'd metric
|
|
6
|
+
key that silently passes is worse than no gate at all, because it produces false
|
|
7
|
+
assurance that nobody will ever check.
|
|
8
|
+
|
|
9
|
+
2. **Protected metrics use absolute floors, not regression thresholds.** Aggregate
|
|
10
|
+
averages hide rare-class collapse: a 3%-prevalence class going from 0.99 to 0.20
|
|
11
|
+
recall moves macro accuracy by ~0.3%, passing any `max_regression: 0.02` gate
|
|
12
|
+
while the system silently ignores unsubscribe requests. Only a sliced, absolute,
|
|
13
|
+
blocking floor catches that (docs/EVALUATION_ENGINE.md §7).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Collection, Mapping
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
from proofstep_core.significance import SignificanceResult
|
|
22
|
+
from proofstep_types import (
|
|
23
|
+
CalibrationStatus,
|
|
24
|
+
ExitCode,
|
|
25
|
+
GateResult,
|
|
26
|
+
GateRule,
|
|
27
|
+
GateSet,
|
|
28
|
+
Metric,
|
|
29
|
+
Severity,
|
|
30
|
+
Verdict,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class GateReport:
|
|
36
|
+
"""The full verdict of a gate set against a candidate run."""
|
|
37
|
+
|
|
38
|
+
results: list[GateResult] = field(default_factory=list)
|
|
39
|
+
verdict: Verdict = Verdict.PASS
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def passed(self) -> bool:
|
|
43
|
+
return not self.verdict.is_blocking
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def blocking_failures(self) -> list[GateResult]:
|
|
47
|
+
return [r for r in self.results if r.verdict in ("fail", "error") and r.blocking]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def warnings(self) -> list[GateResult]:
|
|
51
|
+
return [r for r in self.results if r.verdict in ("fail", "error") and not r.blocking]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def exit_code(self) -> int:
|
|
55
|
+
if self.verdict is Verdict.ERROR:
|
|
56
|
+
return ExitCode.EXECUTION_ERROR
|
|
57
|
+
if self.verdict is Verdict.FAIL:
|
|
58
|
+
return ExitCode.BLOCKING_FAILURE
|
|
59
|
+
return ExitCode.PASS
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def evaluate_gates(
|
|
63
|
+
gate_set: GateSet,
|
|
64
|
+
candidate: list[Metric],
|
|
65
|
+
baseline: list[Metric] | None = None,
|
|
66
|
+
*,
|
|
67
|
+
dataset_match: bool = True,
|
|
68
|
+
judge_metrics: Collection[str] = (),
|
|
69
|
+
calibrations: Mapping[str, CalibrationStatus] | None = None,
|
|
70
|
+
significance: Mapping[str, SignificanceResult] | None = None,
|
|
71
|
+
) -> GateReport:
|
|
72
|
+
"""Apply every rule in the set and combine the verdicts.
|
|
73
|
+
|
|
74
|
+
`judge_metrics` names the metrics produced by LLM judges. The gate engine cannot
|
|
75
|
+
infer it — a `Metric` is just a key and a number — and it is needed because gating on
|
|
76
|
+
a judge nobody has checked is the specific thing calibration exists to catch.
|
|
77
|
+
|
|
78
|
+
`significance` carries the paired tests, keyed by metric. Computed by the caller rather than
|
|
79
|
+
here, because the test needs *per-example* results and this function deliberately takes only
|
|
80
|
+
aggregates — that boundary is what lets the same gate engine run against a report loaded from
|
|
81
|
+
disk. A rule that asked for a test but got no result is treated as untested, never as passed.
|
|
82
|
+
"""
|
|
83
|
+
index = {(m.key, _key(m.slice)): m for m in candidate}
|
|
84
|
+
base_index = {(m.key, _key(m.slice)): m for m in (baseline or [])}
|
|
85
|
+
|
|
86
|
+
results = [_apply(rule, index, base_index, significance or {}) for rule in gate_set.rules]
|
|
87
|
+
results.extend(
|
|
88
|
+
_calibration_results(gate_set, judge_metrics=judge_metrics, calibrations=calibrations or {})
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if gate_set.require_dataset_match and not dataset_match:
|
|
92
|
+
results.append(
|
|
93
|
+
GateResult(
|
|
94
|
+
metric_key="__dataset_match__",
|
|
95
|
+
verdict=Verdict.ERROR.value,
|
|
96
|
+
severity=Severity.BLOCK,
|
|
97
|
+
rule="require_dataset_match",
|
|
98
|
+
message=(
|
|
99
|
+
"Candidate and baseline ran against different dataset content. "
|
|
100
|
+
"The comparison is not meaningful; re-run the baseline against "
|
|
101
|
+
"the same locked version, or set require_dataset_match: false."
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return GateReport(results=results, verdict=_combine(results))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _calibration_results(
|
|
110
|
+
gate_set: GateSet,
|
|
111
|
+
*,
|
|
112
|
+
judge_metrics: Collection[str],
|
|
113
|
+
calibrations: Mapping[str, CalibrationStatus],
|
|
114
|
+
) -> list[GateResult]:
|
|
115
|
+
"""One result per gated judge metric, describing the state of its calibration.
|
|
116
|
+
|
|
117
|
+
Four distinct states, because they have four different fixes:
|
|
118
|
+
|
|
119
|
+
- **no calibration** — go and calibrate it
|
|
120
|
+
- **calibration for a different evaluator version** — the rubric or model changed, so
|
|
121
|
+
the old evidence does not apply; re-calibrate
|
|
122
|
+
- **calibrated and failing the requirement** — fix the judge or the rubric
|
|
123
|
+
- **calibrated and satisfying it** — nothing to do, but the numbers still appear in
|
|
124
|
+
the report so the reader can see what "trusted" rests on
|
|
125
|
+
|
|
126
|
+
Whether these block depends on `require_calibration`. When it is off they are
|
|
127
|
+
warnings, never silence: a merge gated on an unvalidated number is worth saying out
|
|
128
|
+
loud even when nobody has asked for it to be enforced.
|
|
129
|
+
"""
|
|
130
|
+
requirement = gate_set.calibration_requirement
|
|
131
|
+
gated_judges = sorted(
|
|
132
|
+
{rule.metric_key for rule in gate_set.rules if rule.metric_key in set(judge_metrics)}
|
|
133
|
+
)
|
|
134
|
+
if not gated_judges:
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
blocking = requirement is not None and requirement.required
|
|
138
|
+
severity = Severity.BLOCK if blocking else Severity.WARN
|
|
139
|
+
results: list[GateResult] = []
|
|
140
|
+
|
|
141
|
+
for metric_key in gated_judges:
|
|
142
|
+
status = calibrations.get(metric_key)
|
|
143
|
+
|
|
144
|
+
if status is None or not status.calibrated:
|
|
145
|
+
results.append(
|
|
146
|
+
GateResult(
|
|
147
|
+
metric_key=metric_key,
|
|
148
|
+
verdict=(Verdict.ERROR if blocking else Verdict.FAIL).value,
|
|
149
|
+
severity=severity,
|
|
150
|
+
rule="uncalibrated_judge",
|
|
151
|
+
message=(
|
|
152
|
+
f"{metric_key} is gated on an LLM judge with no calibration. "
|
|
153
|
+
"The number has never been checked against a human, so the "
|
|
154
|
+
"gate is blocking merges on an unvalidated measurement. Run "
|
|
155
|
+
"`proofstep calibrate` against a labelled set."
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if status.is_stale:
|
|
162
|
+
results.append(
|
|
163
|
+
GateResult(
|
|
164
|
+
metric_key=metric_key,
|
|
165
|
+
verdict=(Verdict.ERROR if blocking else Verdict.FAIL).value,
|
|
166
|
+
severity=severity,
|
|
167
|
+
rule="stale_calibration",
|
|
168
|
+
message=(
|
|
169
|
+
f"{metric_key} has a calibration for evaluator version "
|
|
170
|
+
f"{status.evaluator_version_hash} but the run used "
|
|
171
|
+
f"{status.stale_for_version}. The rubric, model, or judge "
|
|
172
|
+
"parameters changed, which means the ruler changed; the old "
|
|
173
|
+
"calibration says nothing about the new judge."
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if status.satisfied is False:
|
|
180
|
+
results.append(
|
|
181
|
+
GateResult(
|
|
182
|
+
metric_key=metric_key,
|
|
183
|
+
verdict=(Verdict.ERROR if blocking else Verdict.FAIL).value,
|
|
184
|
+
severity=severity,
|
|
185
|
+
rule="calibration_requirement",
|
|
186
|
+
actual=status.kappa,
|
|
187
|
+
message=(
|
|
188
|
+
f"{metric_key} judge calibration does not meet the requirement: "
|
|
189
|
+
+ "; ".join(status.failures)
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
results.append(
|
|
196
|
+
GateResult(
|
|
197
|
+
metric_key=metric_key,
|
|
198
|
+
verdict=Verdict.PASS.value,
|
|
199
|
+
severity=Severity.WARN,
|
|
200
|
+
rule="calibrated",
|
|
201
|
+
actual=status.kappa,
|
|
202
|
+
message=_calibrated_message(status),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return results
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _calibrated_message(status: CalibrationStatus) -> str:
|
|
210
|
+
kappa = "undefined" if status.kappa is None else f"{status.kappa:.3f}"
|
|
211
|
+
agreement = "?" if status.agreement is None else f"{status.agreement:.3f}"
|
|
212
|
+
ceiling = " (at the human ceiling)" if status.at_human_ceiling else ""
|
|
213
|
+
suffix = f" — {'; '.join(status.warnings)}" if status.warnings else ""
|
|
214
|
+
return (
|
|
215
|
+
f"{status.metric_key} judge calibrated on {status.n_examples} examples: "
|
|
216
|
+
f"agreement {agreement}, κ {kappa}{ceiling}{suffix}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _apply( # noqa: PLR0911 — one early return per gate clause reads better than nesting
|
|
221
|
+
rule: GateRule,
|
|
222
|
+
candidate: dict[tuple[str, tuple[tuple[str, str], ...]], Metric],
|
|
223
|
+
baseline: dict[tuple[str, tuple[tuple[str, str], ...]], Metric],
|
|
224
|
+
significance: Mapping[str, SignificanceResult],
|
|
225
|
+
) -> GateResult:
|
|
226
|
+
lookup = (rule.metric_key, _key(rule.slice))
|
|
227
|
+
metric = candidate.get(lookup)
|
|
228
|
+
|
|
229
|
+
if metric is None:
|
|
230
|
+
available = ", ".join(sorted({k for k, _ in candidate})[:10]) or "<none>"
|
|
231
|
+
return _result(
|
|
232
|
+
rule,
|
|
233
|
+
Verdict.ERROR,
|
|
234
|
+
"metric_missing",
|
|
235
|
+
message=(
|
|
236
|
+
f"No metric named {rule.full_key!r} was produced. Available metrics: "
|
|
237
|
+
f"{available}. A gate on a metric that does not exist cannot pass."
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if metric.count == 0:
|
|
242
|
+
return _result(
|
|
243
|
+
rule,
|
|
244
|
+
Verdict.ERROR,
|
|
245
|
+
"no_data",
|
|
246
|
+
actual=None,
|
|
247
|
+
message=(
|
|
248
|
+
f"{rule.full_key} has no successful evaluations "
|
|
249
|
+
f"({metric.error_count} errored). Nothing was measured."
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if metric.error_rate > rule.max_error_rate:
|
|
254
|
+
return _result(
|
|
255
|
+
rule,
|
|
256
|
+
Verdict.ERROR,
|
|
257
|
+
"error_rate",
|
|
258
|
+
actual=metric.value,
|
|
259
|
+
threshold=rule.max_error_rate,
|
|
260
|
+
message=(
|
|
261
|
+
f"{rule.full_key}: {metric.error_count} of "
|
|
262
|
+
f"{metric.count + metric.error_count} evaluations errored "
|
|
263
|
+
f"({metric.error_rate:.1%} > {rule.max_error_rate:.1%}). The score is "
|
|
264
|
+
"not trustworthy enough to gate on."
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Absolute thresholds first: they do not depend on a baseline, which is exactly
|
|
269
|
+
# why protected metrics use them. A bad merge that becomes the baseline cannot
|
|
270
|
+
# weaken an absolute floor.
|
|
271
|
+
if rule.minimum is not None and metric.value < rule.minimum:
|
|
272
|
+
return _result(
|
|
273
|
+
rule,
|
|
274
|
+
Verdict.FAIL,
|
|
275
|
+
"minimum",
|
|
276
|
+
actual=metric.value,
|
|
277
|
+
threshold=rule.minimum,
|
|
278
|
+
message=f"{rule.full_key} {metric.value:.4g} < minimum {rule.minimum:.4g}",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if rule.maximum is not None and metric.value > rule.maximum:
|
|
282
|
+
return _result(
|
|
283
|
+
rule,
|
|
284
|
+
Verdict.FAIL,
|
|
285
|
+
"maximum",
|
|
286
|
+
actual=metric.value,
|
|
287
|
+
threshold=rule.maximum,
|
|
288
|
+
message=f"{rule.full_key} {metric.value:.4g} > maximum {rule.maximum:.4g}",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if rule.needs_baseline or rule.require_baseline:
|
|
292
|
+
base = baseline.get(lookup)
|
|
293
|
+
if base is None:
|
|
294
|
+
if rule.require_baseline:
|
|
295
|
+
return _result(
|
|
296
|
+
rule,
|
|
297
|
+
Verdict.ERROR,
|
|
298
|
+
"baseline_missing",
|
|
299
|
+
actual=metric.value,
|
|
300
|
+
message=(
|
|
301
|
+
f"{rule.full_key} requires a baseline but none was found. "
|
|
302
|
+
"Run the suite on the baseline branch first, or promote an "
|
|
303
|
+
"experiment to be the baseline."
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
# No baseline and none required: absolute checks already passed, and a
|
|
307
|
+
# regression check against nothing is vacuous rather than failing.
|
|
308
|
+
return _result(
|
|
309
|
+
rule,
|
|
310
|
+
Verdict.PASS,
|
|
311
|
+
"no_baseline",
|
|
312
|
+
actual=metric.value,
|
|
313
|
+
message=f"{rule.full_key} {metric.value:.4g} (no baseline to compare against)",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if regression := _check_regression(rule, metric, base, significance.get(rule.metric_key)):
|
|
317
|
+
return regression
|
|
318
|
+
|
|
319
|
+
return _result(
|
|
320
|
+
rule,
|
|
321
|
+
Verdict.PASS,
|
|
322
|
+
"regression",
|
|
323
|
+
actual=metric.value,
|
|
324
|
+
baseline=base.value,
|
|
325
|
+
message=(
|
|
326
|
+
f"{rule.full_key} {metric.value:.4g} vs baseline {base.value:.4g} "
|
|
327
|
+
f"({metric.value - base.value:+.4g})"
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return _result(
|
|
332
|
+
rule,
|
|
333
|
+
Verdict.PASS,
|
|
334
|
+
"threshold",
|
|
335
|
+
actual=metric.value,
|
|
336
|
+
message=f"{rule.full_key} {metric.value:.4g}",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _check_regression(
|
|
341
|
+
rule: GateRule,
|
|
342
|
+
metric: Metric,
|
|
343
|
+
base: Metric,
|
|
344
|
+
test: SignificanceResult | None = None,
|
|
345
|
+
) -> GateResult | None:
|
|
346
|
+
drop = base.value - metric.value
|
|
347
|
+
|
|
348
|
+
if power := _check_power(rule, metric, base, test):
|
|
349
|
+
return power
|
|
350
|
+
|
|
351
|
+
if noise := _explained_by_noise(rule, metric, base, test, drop):
|
|
352
|
+
return noise
|
|
353
|
+
|
|
354
|
+
if rule.max_absolute_regression is not None and drop > rule.max_absolute_regression:
|
|
355
|
+
return _result(
|
|
356
|
+
rule,
|
|
357
|
+
Verdict.FAIL,
|
|
358
|
+
"max_absolute_regression",
|
|
359
|
+
actual=metric.value,
|
|
360
|
+
baseline=base.value,
|
|
361
|
+
threshold=rule.max_absolute_regression,
|
|
362
|
+
message=(
|
|
363
|
+
f"{rule.full_key} regressed {drop:.4g} from baseline {base.value:.4g} "
|
|
364
|
+
f"(allowed {rule.max_absolute_regression:.4g})"
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if rule.max_relative_regression is not None and base.value != 0:
|
|
369
|
+
relative = drop / abs(base.value)
|
|
370
|
+
if relative > rule.max_relative_regression:
|
|
371
|
+
return _result(
|
|
372
|
+
rule,
|
|
373
|
+
Verdict.FAIL,
|
|
374
|
+
"max_relative_regression",
|
|
375
|
+
actual=metric.value,
|
|
376
|
+
baseline=base.value,
|
|
377
|
+
threshold=rule.max_relative_regression,
|
|
378
|
+
message=(
|
|
379
|
+
f"{rule.full_key} regressed {relative:.1%} from baseline "
|
|
380
|
+
f"{base.value:.4g} (allowed {rule.max_relative_regression:.1%})"
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _result(
|
|
387
|
+
rule: GateRule,
|
|
388
|
+
verdict: Verdict,
|
|
389
|
+
clause: str,
|
|
390
|
+
*,
|
|
391
|
+
actual: float | None = None,
|
|
392
|
+
baseline: float | None = None,
|
|
393
|
+
threshold: float | None = None,
|
|
394
|
+
message: str = "",
|
|
395
|
+
) -> GateResult:
|
|
396
|
+
return GateResult(
|
|
397
|
+
metric_key=rule.metric_key,
|
|
398
|
+
slice=rule.slice,
|
|
399
|
+
verdict=verdict.value,
|
|
400
|
+
severity=rule.severity,
|
|
401
|
+
rule=clause,
|
|
402
|
+
threshold=threshold,
|
|
403
|
+
actual=actual,
|
|
404
|
+
baseline=baseline,
|
|
405
|
+
message=message,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _combine(results: list[GateResult]) -> Verdict:
|
|
410
|
+
"""Worst verdict wins, but a non-blocking failure only ever produces WARN.
|
|
411
|
+
|
|
412
|
+
Severity is what separates "this is bad and you may not merge" from "this is bad
|
|
413
|
+
and you should know". Collapsing them would either block on noise or hide real
|
|
414
|
+
failures, and both destroy trust in the gate.
|
|
415
|
+
"""
|
|
416
|
+
verdict = Verdict.PASS
|
|
417
|
+
for result in results:
|
|
418
|
+
if result.verdict not in ("fail", "error"):
|
|
419
|
+
continue
|
|
420
|
+
if not result.blocking:
|
|
421
|
+
verdict = max(verdict, Verdict.WARN, key=_rank)
|
|
422
|
+
elif result.verdict == "error":
|
|
423
|
+
verdict = max(verdict, Verdict.ERROR, key=_rank)
|
|
424
|
+
else:
|
|
425
|
+
verdict = max(verdict, Verdict.FAIL, key=_rank)
|
|
426
|
+
return verdict
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
_RANK = {Verdict.PASS: 0, Verdict.WARN: 1, Verdict.FAIL: 2, Verdict.ERROR: 3}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _rank(v: Verdict) -> int:
|
|
433
|
+
return _RANK[v]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _check_power(
|
|
437
|
+
rule: GateRule,
|
|
438
|
+
metric: Metric,
|
|
439
|
+
base: Metric,
|
|
440
|
+
test: SignificanceResult | None,
|
|
441
|
+
) -> GateResult | None:
|
|
442
|
+
"""Refuse to certify a gate the run could never have failed.
|
|
443
|
+
|
|
444
|
+
`max_absolute_regression: 0.02` over twenty noisy examples is a promise the data cannot keep.
|
|
445
|
+
The rule looks satisfied, the build goes green, and nobody learns that the check was incapable
|
|
446
|
+
of detecting the thing it names. Opt-in via `require_power`, because on a small suite the honest
|
|
447
|
+
answer is often "add examples", and forcing that on every existing gate would be a breaking
|
|
448
|
+
change dressed up as rigour.
|
|
449
|
+
"""
|
|
450
|
+
if not rule.require_power:
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
threshold = rule.max_absolute_regression
|
|
454
|
+
if threshold is None:
|
|
455
|
+
return None
|
|
456
|
+
|
|
457
|
+
if test is None or test.minimum_detectable_effect is None:
|
|
458
|
+
return _result(
|
|
459
|
+
rule,
|
|
460
|
+
Verdict.ERROR,
|
|
461
|
+
"power_unknown",
|
|
462
|
+
actual=metric.value,
|
|
463
|
+
baseline=base.value,
|
|
464
|
+
threshold=threshold,
|
|
465
|
+
message=(
|
|
466
|
+
f"{rule.full_key} requires a powered comparison, but no paired test was available "
|
|
467
|
+
"(too few examples ran on both sides, or the runs share no examples)."
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
if test.underpowered_for(threshold):
|
|
472
|
+
return _result(
|
|
473
|
+
rule,
|
|
474
|
+
Verdict.ERROR,
|
|
475
|
+
"underpowered",
|
|
476
|
+
actual=metric.value,
|
|
477
|
+
baseline=base.value,
|
|
478
|
+
threshold=threshold,
|
|
479
|
+
message=(
|
|
480
|
+
f"{rule.full_key} gates on a {threshold:.4g} regression, but {test.n_pairs} paired "
|
|
481
|
+
f"example(s) could only detect {test.minimum_detectable_effect:.4g}. This gate "
|
|
482
|
+
"cannot see what it claims to guard — add examples or widen the threshold."
|
|
483
|
+
),
|
|
484
|
+
)
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _explained_by_noise(
|
|
489
|
+
rule: GateRule,
|
|
490
|
+
metric: Metric,
|
|
491
|
+
base: Metric,
|
|
492
|
+
test: SignificanceResult | None,
|
|
493
|
+
drop: float,
|
|
494
|
+
) -> GateResult | None:
|
|
495
|
+
"""Pass a threshold-breaking regression that the data cannot distinguish from noise.
|
|
496
|
+
|
|
497
|
+
Only when the rule opted in with `significance`. This is the half a threshold cannot do: at
|
|
498
|
+
forty examples a two-point drop is one example flipping, and blocking a merge on it teaches
|
|
499
|
+
people to bypass the gate that also carries the real checks.
|
|
500
|
+
|
|
501
|
+
Reported as PASS with the numbers attached rather than silently — "we measured a drop and could
|
|
502
|
+
not tell it from noise" is a different statement from "nothing moved", and a reader deciding
|
|
503
|
+
whether to trust the green tick needs the difference.
|
|
504
|
+
"""
|
|
505
|
+
if rule.significance is None:
|
|
506
|
+
return None
|
|
507
|
+
if rule.max_absolute_regression is None or drop <= rule.max_absolute_regression:
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
if test is None:
|
|
511
|
+
# Asked for a test and got none. Untested is not passed: a rule that quietly downgrades to
|
|
512
|
+
# a plain threshold when the data is missing is a rule nobody can reason about.
|
|
513
|
+
return _result(
|
|
514
|
+
rule,
|
|
515
|
+
Verdict.ERROR,
|
|
516
|
+
"significance_unavailable",
|
|
517
|
+
actual=metric.value,
|
|
518
|
+
baseline=base.value,
|
|
519
|
+
message=(
|
|
520
|
+
f"{rule.full_key} asks for a significance test, but no paired comparison was "
|
|
521
|
+
"available. The two runs must share examples for this rule to be decidable."
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
if test.is_significant(rule.significance):
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
p_value = test.adjusted_p_value if test.adjusted_p_value is not None else test.p_value
|
|
529
|
+
return _result(
|
|
530
|
+
rule,
|
|
531
|
+
Verdict.PASS,
|
|
532
|
+
"not_significant",
|
|
533
|
+
actual=metric.value,
|
|
534
|
+
baseline=base.value,
|
|
535
|
+
threshold=rule.max_absolute_regression,
|
|
536
|
+
message=(
|
|
537
|
+
f"{rule.full_key} measured a {drop:.4g} regression over {test.n_pairs} paired "
|
|
538
|
+
f"example(s), which this run cannot distinguish from noise "
|
|
539
|
+
f"(p={p_value:.3g} > {rule.significance:g}). Not failing the build on it."
|
|
540
|
+
),
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _key(slice_: dict[str, str] | None) -> tuple[tuple[str, str], ...]:
|
|
545
|
+
return tuple(sorted(slice_.items())) if slice_ else ()
|
proofstep_core/paths.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Dotted field-path resolution against an evaluation context.
|
|
2
|
+
|
|
3
|
+
Suites address data with strings like ``output.email.body`` or ``input.evidence``.
|
|
4
|
+
Resolution is deliberately strict: a path that does not exist raises rather than
|
|
5
|
+
returning None, because a typo'd field silently scoring zero across every example is
|
|
6
|
+
indistinguishable from a genuine failure, and that is the more dangerous outcome.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_ROOTS = frozenset({"output", "input", "expected", "metadata", "state"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PathError(LookupError):
|
|
17
|
+
"""A field path did not resolve."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve(root: Any, path: str) -> Any:
|
|
21
|
+
"""Resolve a dotted path against a value.
|
|
22
|
+
|
|
23
|
+
Supports mapping keys, object attributes, and integer list indices.
|
|
24
|
+
"""
|
|
25
|
+
current = root
|
|
26
|
+
walked: list[str] = []
|
|
27
|
+
for part in path.split("."):
|
|
28
|
+
if not part:
|
|
29
|
+
msg = f"Empty segment in field path {path!r}"
|
|
30
|
+
raise PathError(msg)
|
|
31
|
+
walked.append(part)
|
|
32
|
+
current = _step(current, part, path, walked)
|
|
33
|
+
return current
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _step(current: Any, part: str, path: str, walked: list[str]) -> Any:
|
|
37
|
+
where = ".".join(walked[:-1]) or "<root>"
|
|
38
|
+
if isinstance(current, dict):
|
|
39
|
+
if part not in current:
|
|
40
|
+
available = ", ".join(sorted(map(str, current))[:8]) or "<empty>"
|
|
41
|
+
msg = f"{path!r}: no key {part!r} at {where}. Available: {available}"
|
|
42
|
+
raise PathError(msg)
|
|
43
|
+
return current[part]
|
|
44
|
+
if isinstance(current, list | tuple):
|
|
45
|
+
if not part.lstrip("-").isdigit():
|
|
46
|
+
msg = f"{path!r}: {where} is a list; {part!r} is not an index"
|
|
47
|
+
raise PathError(msg)
|
|
48
|
+
index = int(part)
|
|
49
|
+
try:
|
|
50
|
+
return current[index]
|
|
51
|
+
except IndexError as exc:
|
|
52
|
+
msg = f"{path!r}: index {index} out of range at {where} (len {len(current)})"
|
|
53
|
+
raise PathError(msg) from exc
|
|
54
|
+
if hasattr(current, part):
|
|
55
|
+
return getattr(current, part)
|
|
56
|
+
msg = f"{path!r}: cannot resolve {part!r} on {type(current).__name__} at {where}"
|
|
57
|
+
raise PathError(msg)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_in_context(
|
|
61
|
+
path: str,
|
|
62
|
+
*,
|
|
63
|
+
output: Any,
|
|
64
|
+
input_: dict[str, Any] | None = None,
|
|
65
|
+
expected: dict[str, Any] | None = None,
|
|
66
|
+
metadata: dict[str, Any] | None = None,
|
|
67
|
+
state: dict[str, Any] | None = None,
|
|
68
|
+
) -> Any:
|
|
69
|
+
"""Resolve a path rooted at one of the known context namespaces.
|
|
70
|
+
|
|
71
|
+
A bare path with no recognized root is treated as relative to ``output``, since
|
|
72
|
+
that is what suites reference most and requiring ``output.`` everywhere is noise.
|
|
73
|
+
"""
|
|
74
|
+
head, _, rest = path.partition(".")
|
|
75
|
+
if head not in _ROOTS:
|
|
76
|
+
return resolve(output, path)
|
|
77
|
+
|
|
78
|
+
roots: dict[str, Any] = {
|
|
79
|
+
"output": output,
|
|
80
|
+
"input": input_ or {},
|
|
81
|
+
"expected": expected or {},
|
|
82
|
+
"metadata": metadata or {},
|
|
83
|
+
"state": state or {},
|
|
84
|
+
}
|
|
85
|
+
base = roots[head]
|
|
86
|
+
return resolve(base, rest) if rest else base
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def try_resolve(root: Any, path: str, default: Any = None) -> Any:
|
|
90
|
+
"""Resolve, returning ``default`` when the path is absent.
|
|
91
|
+
|
|
92
|
+
Use only where absence is genuinely meaningful — policy conditions reading
|
|
93
|
+
optional metadata, for instance. Evaluators should prefer `resolve`.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
return resolve(root, path)
|
|
97
|
+
except PathError:
|
|
98
|
+
return default
|
proofstep_core/py.typed
ADDED
|
File without changes
|