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.
@@ -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 ()
@@ -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
File without changes