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,471 @@
1
+ """Running a judge against a labelled calibration set.
2
+
3
+ Separate from `calibration.py` so the maths stays pure and offline-testable. This module
4
+ is the part that costs money.
5
+
6
+ The calibration set is an ordinary dataset file with human labels attached, which means
7
+ `proofstep calibrate` reads the same JSONL a suite reads. That is deliberate: a
8
+ calibration set built from production traces should be promotable to a golden dataset
9
+ without a format conversion, and a separate schema would guarantee the two drift apart.
10
+
11
+ The one non-obvious rule here: **the judge is never shown the human label.** It is
12
+ passed the same fields it would see in a real run, resolved through the same allow-list.
13
+ A judge that can see the answer key during calibration produces a report certifying
14
+ nothing.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import contextlib
21
+ import json
22
+ from collections.abc import Iterable, Sequence
23
+ from dataclasses import dataclass
24
+ from decimal import Decimal
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from proofstep_core.calibration import (
29
+ CalibrationReport,
30
+ ClassBreakdown,
31
+ ConfusionMatrix,
32
+ JudgeVerdict,
33
+ LabelledExample,
34
+ PairwiseProbe,
35
+ PositionBiasReport,
36
+ calibrate,
37
+ )
38
+ from proofstep_core.evaluators.judge import LLMJudge
39
+ from proofstep_core.types import EvalContext, ModelClient
40
+ from proofstep_types import Example
41
+
42
+ #: Field in `metadata` holding the primary human label.
43
+ LABEL_KEY = "human_label"
44
+ SECOND_LABEL_KEY = "second_human_label"
45
+ ADJUDICATED_KEY = "adjudicated_label"
46
+ #: Where the judge's own output lives when the set was built from recorded outputs.
47
+ OUTPUT_KEY = "output"
48
+
49
+
50
+ class CalibrationDataError(ValueError):
51
+ """The calibration file cannot be used. Always names the offending example."""
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class CalibrationCase:
56
+ """One calibration example: the labels, plus the payload the judge will see."""
57
+
58
+ labelled: LabelledExample
59
+ example: Example
60
+ output: Any
61
+
62
+
63
+ def load_calibration_set(path: str | Path) -> list[CalibrationCase]:
64
+ """Read a labelled JSONL calibration file.
65
+
66
+ Refuses rather than skips on a missing label. A calibration run that quietly drops
67
+ the unlabelled half of the file reports agreement over whatever happened to be
68
+ labelled, and that number will be used to justify gating a merge.
69
+ """
70
+ source = Path(path)
71
+ if not source.exists():
72
+ msg = f"calibration set not found: {source}"
73
+ raise CalibrationDataError(msg)
74
+
75
+ cases: list[CalibrationCase] = []
76
+ seen: set[str] = set()
77
+ for number, line in enumerate(source.read_text(encoding="utf-8").splitlines(), start=1):
78
+ if not line.strip() or line.lstrip().startswith("//"):
79
+ continue
80
+ try:
81
+ row = json.loads(line)
82
+ except json.JSONDecodeError as exc:
83
+ msg = f"{source}:{number}: not valid JSON — {exc}"
84
+ raise CalibrationDataError(msg) from exc
85
+ if not isinstance(row, dict):
86
+ msg = f"{source}:{number}: expected a JSON object, got {type(row).__name__}"
87
+ raise CalibrationDataError(msg)
88
+
89
+ cases.append(_to_case(row, source=str(source), number=number, seen=seen))
90
+
91
+ if not cases:
92
+ msg = f"{source} contains no examples"
93
+ raise CalibrationDataError(msg)
94
+ return cases
95
+
96
+
97
+ def _to_case(row: dict[str, Any], *, source: str, number: int, seen: set[str]) -> CalibrationCase:
98
+ example_id = str(row.get("id") or f"line-{number}")
99
+ if example_id in seen:
100
+ # Duplicate ids would double-count an example in every rate in the report.
101
+ msg = f"{source}:{number}: duplicate example id {example_id!r}"
102
+ raise CalibrationDataError(msg)
103
+ seen.add(example_id)
104
+
105
+ metadata = row.get("metadata") or {}
106
+ if not isinstance(metadata, dict):
107
+ msg = f"{source}:{number}: metadata must be an object"
108
+ raise CalibrationDataError(msg)
109
+
110
+ label = row.get(LABEL_KEY, metadata.get(LABEL_KEY))
111
+ if label is None or str(label) == "":
112
+ msg = (
113
+ f"{source}:{number}: example {example_id!r} has no {LABEL_KEY!r}. "
114
+ "Every calibration example needs a human label — that is the whole point "
115
+ "of the file. Remove the example or label it."
116
+ )
117
+ raise CalibrationDataError(msg)
118
+
119
+ output = row.get(OUTPUT_KEY, metadata.get(OUTPUT_KEY))
120
+ text = output if isinstance(output, str) else json.dumps(output, default=str)
121
+
122
+ return CalibrationCase(
123
+ labelled=LabelledExample(
124
+ example_id=example_id,
125
+ human_label=str(label),
126
+ second_human_label=_optional_str(
127
+ row.get(SECOND_LABEL_KEY, metadata.get(SECOND_LABEL_KEY))
128
+ ),
129
+ adjudicated_label=_optional_str(
130
+ row.get(ADJUDICATED_KEY, metadata.get(ADJUDICATED_KEY))
131
+ ),
132
+ output_length=len(text) if output is not None else None,
133
+ metadata={k: str(v) for k, v in metadata.items()},
134
+ ),
135
+ example=Example(
136
+ id=example_id,
137
+ input=row.get("input") or {},
138
+ # `expected` is carried so the file can double as a golden dataset, but the
139
+ # judge's `inputs` allow-list is what decides whether it is ever sent — and
140
+ # a judge that declares `expected.*` is rejected below.
141
+ expected=row.get("expected"),
142
+ metadata=metadata,
143
+ ),
144
+ output=output,
145
+ )
146
+
147
+
148
+ def _optional_str(value: Any) -> str | None:
149
+ return None if value is None or value == "" else str(value)
150
+
151
+
152
+ def assert_judge_cannot_see_labels(judge: LLMJudge) -> None:
153
+ """Refuse to calibrate a judge that reads the answer key.
154
+
155
+ A judge whose declared inputs include `expected.*` — or the label fields themselves
156
+ — will agree with the humans almost perfectly, and the resulting report is a
157
+ certificate for a leak. Better to fail loudly at the start of a paid run than to
158
+ produce a number that means the opposite of what it appears to.
159
+ """
160
+ forbidden = [
161
+ path
162
+ for path in judge.inputs
163
+ if path.startswith("expected")
164
+ or LABEL_KEY in path
165
+ or SECOND_LABEL_KEY in path
166
+ or ADJUDICATED_KEY in path
167
+ ]
168
+ if forbidden:
169
+ msg = (
170
+ f"judge {judge.name!r} declares input(s) {forbidden} which expose the human "
171
+ "label. Calibrating against labels the judge can read measures nothing. "
172
+ "Remove them from `inputs`."
173
+ )
174
+ raise CalibrationDataError(msg)
175
+
176
+
177
+ async def run_calibration(
178
+ judge: LLMJudge,
179
+ cases: Sequence[CalibrationCase],
180
+ models: ModelClient,
181
+ *,
182
+ concurrency: int = 4,
183
+ passing_labels: Sequence[str] | None = None,
184
+ ordinal_order: Sequence[str] | None = None,
185
+ probes: Sequence[PairwiseProbe] = (),
186
+ seed: int = 42,
187
+ on_progress: Any = None,
188
+ ) -> CalibrationReport:
189
+ """Score every case with the judge, then compute the report.
190
+
191
+ Concurrency is bounded and separate from any task concurrency: this contends for the
192
+ judge provider's rate limit and nothing else.
193
+ """
194
+ assert_judge_cannot_see_labels(judge)
195
+
196
+ limit = asyncio.Semaphore(max(1, concurrency))
197
+ verdicts: list[JudgeVerdict] = []
198
+
199
+ async def one(case: CalibrationCase) -> JudgeVerdict:
200
+ async with limit:
201
+ context = EvalContext(
202
+ example=case.example,
203
+ output=case.output,
204
+ # Withheld deliberately, belt as well as braces: even if a judge slipped
205
+ # past the allow-list check, there is nothing here to read.
206
+ expected=None,
207
+ metadata=dict(case.example.metadata),
208
+ models=models,
209
+ seed=seed,
210
+ )
211
+ score = await judge.evaluate(context)
212
+ return _to_verdict(case.labelled.example_id, score, ordinal_order=ordinal_order)
213
+
214
+ async with asyncio.TaskGroup() as group:
215
+ tasks = [group.create_task(one(case)) for case in cases]
216
+ for index, task in enumerate(tasks):
217
+ verdicts.append(task.result())
218
+ if on_progress is not None:
219
+ with contextlib.suppress(Exception):
220
+ # Progress reporting is a convenience; a broken callback must not lose a
221
+ # paid calibration run.
222
+ on_progress(index + 1, len(cases))
223
+
224
+ return calibrate(
225
+ [case.labelled for case in cases],
226
+ verdicts,
227
+ passing_labels=passing_labels,
228
+ ordinal_order=ordinal_order,
229
+ probes=probes,
230
+ seed=seed,
231
+ )
232
+
233
+
234
+ def _to_verdict(
235
+ example_id: str, score: Any, *, ordinal_order: Sequence[str] | None
236
+ ) -> JudgeVerdict:
237
+ """Project a `Score` onto the label space the human labels live in."""
238
+ if score.errored:
239
+ return JudgeVerdict(
240
+ example_id,
241
+ errored=True,
242
+ error=score.error,
243
+ cost=score.cost,
244
+ latency_ms=score.latency_ms,
245
+ )
246
+
247
+ if score.label is not None:
248
+ label: str | None = str(score.label)
249
+ elif score.passed is not None:
250
+ label = "pass" if score.passed else "fail"
251
+ elif score.value is not None and ordinal_order:
252
+ label = _nearest_ordinal(score, ordinal_order)
253
+ elif score.value is not None:
254
+ # A bare numeric score with no declared scale has no label to compare against.
255
+ # Treated as an error rather than rounded to something arbitrary.
256
+ return JudgeVerdict(
257
+ example_id,
258
+ errored=True,
259
+ error=(
260
+ "judge returned a numeric score but the calibration set declares no "
261
+ "ordinal scale, so there is nothing to compare it to"
262
+ ),
263
+ cost=score.cost,
264
+ latency_ms=score.latency_ms,
265
+ )
266
+ else:
267
+ label = None
268
+
269
+ return JudgeVerdict(
270
+ example_id,
271
+ label=label,
272
+ errored=label is None,
273
+ error=None if label else "judge produced no label",
274
+ cost=score.cost,
275
+ latency_ms=score.latency_ms,
276
+ )
277
+
278
+
279
+ def _nearest_ordinal(score: Any, order: Sequence[str]) -> str:
280
+ """Recover the rubric point from a normalized score.
281
+
282
+ A rubric judge normalizes 1-5 onto [0, 1], so the raw point has to be recovered to
283
+ compare against a human label of "4". The raw value is used when the judge recorded
284
+ it; otherwise the normalized value is mapped back onto the scale.
285
+ """
286
+ raw = (score.raw or {}).get("raw_score") if isinstance(score.raw, dict) else None
287
+ if raw is not None and str(raw) in order:
288
+ return str(raw)
289
+
290
+ index = round(float(score.value) * (len(order) - 1))
291
+ return order[max(0, min(len(order) - 1, index))]
292
+
293
+
294
+ def summarize_labels(cases: Iterable[CalibrationCase]) -> dict[str, int]:
295
+ """Human label distribution, for the "is this set balanced" question."""
296
+ counts: dict[str, int] = {}
297
+ for case in cases:
298
+ key = case.labelled.reference_label
299
+ counts[key] = counts.get(key, 0) + 1
300
+ return dict(sorted(counts.items()))
301
+
302
+
303
+ def report_to_dict(report: CalibrationReport) -> dict[str, Any]:
304
+ """JSON-serializable form, for storage and for the CLI's `--output`."""
305
+ return {
306
+ "n_examples": report.n_examples,
307
+ "n_errored": report.n_errored,
308
+ "error_rate": report.error_rate,
309
+ "agreement": report.agreement,
310
+ "agreement_ci": list(report.agreement_ci),
311
+ "kappa": report.kappa,
312
+ "kappa_ci": list(report.kappa_ci) if report.kappa_ci else None,
313
+ "kappa_kind": report.kappa_kind,
314
+ "kappa_undefined_reason": report.kappa_undefined_reason,
315
+ "false_pass_rate": report.false_pass_rate,
316
+ "false_fail_rate": report.false_fail_rate,
317
+ "human_kappa": report.human_kappa,
318
+ "judge_kappa_on_ceiling_subset": report.judge_kappa_on_ceiling_subset,
319
+ "n_ceiling_examples": report.n_ceiling_examples,
320
+ "at_human_ceiling": report.at_human_ceiling,
321
+ "leniency": report.leniency,
322
+ "scale_compression": report.scale_compression,
323
+ "verbosity_bias": report.verbosity_bias,
324
+ "mean_cost": str(report.mean_cost),
325
+ "total_cost": str(report.total_cost),
326
+ "p50_latency_ms": report.p50_latency_ms,
327
+ "p95_latency_ms": report.p95_latency_ms,
328
+ "confusion": {
329
+ "labels": list(report.confusion.labels),
330
+ # Tuple keys are not JSON-representable; a flat list of rows keeps the
331
+ # matrix readable in a diff.
332
+ "counts": [
333
+ {"human": human, "judge": judge, "count": count}
334
+ for (human, judge), count in sorted(report.confusion.counts.items())
335
+ ],
336
+ },
337
+ "per_class": [
338
+ {
339
+ "label": c.label,
340
+ "support": c.support,
341
+ "recall": c.recall,
342
+ "precision": c.precision,
343
+ "top_confusion": list(c.top_confusion) if c.top_confusion else None,
344
+ }
345
+ for c in report.per_class
346
+ ],
347
+ "position_bias": (
348
+ {
349
+ "n_pairs": report.position_bias.n_pairs,
350
+ "inconsistency_rate": report.position_bias.inconsistency_rate,
351
+ "first_position_rate": report.position_bias.first_position_rate,
352
+ "n_unresolved": report.position_bias.n_unresolved,
353
+ "biased": report.position_bias.biased,
354
+ }
355
+ if report.position_bias
356
+ else None
357
+ ),
358
+ "notes": list(report.notes),
359
+ }
360
+
361
+
362
+ def total_cost_estimate(cases: Sequence[CalibrationCase], judge: LLMJudge) -> int:
363
+ """Number of judge calls a calibration run will make.
364
+
365
+ Reported before spending anything, because `votes: 5` on a 200-example set is a
366
+ thousand calls and that should be a decision rather than a surprise.
367
+ """
368
+ return len(cases) * max(1, judge.votes)
369
+
370
+
371
+ __all__ = [
372
+ "CalibrationCase",
373
+ "CalibrationDataError",
374
+ "assert_judge_cannot_see_labels",
375
+ "load_calibration_set",
376
+ "report_from_dict",
377
+ "report_to_dict",
378
+ "run_calibration",
379
+ "summarize_labels",
380
+ "total_cost_estimate",
381
+ ]
382
+
383
+
384
+ def report_from_dict(payload: dict[str, Any]) -> CalibrationReport:
385
+ """Rebuild a report from its stored form.
386
+
387
+ Needed so a gate can re-apply the *current* thresholds to *stored* evidence. The
388
+ alternative — trusting the `satisfied` boolean written when the calibration ran —
389
+ means tightening `min_kappa` in a suite has no effect until somebody remembers to
390
+ re-run the calibration, which is exactly the kind of silent no-op that makes a
391
+ threshold decorative.
392
+
393
+ Tolerant of missing fields: a record written by an older version should degrade to
394
+ "some checks cannot be applied", not crash the run that reads it.
395
+ """
396
+ confusion_counts: dict[tuple[str, str], int] = {}
397
+ raw_confusion = payload.get("confusion") or {}
398
+ for row in raw_confusion.get("counts") or []:
399
+ confusion_counts[str(row.get("human")), str(row.get("judge"))] = int(row.get("count") or 0)
400
+
401
+ per_class = tuple(
402
+ ClassBreakdown(
403
+ label=str(row.get("label", "")),
404
+ support=int(row.get("support") or 0),
405
+ recall=float(row.get("recall") or 0.0),
406
+ precision=float(row.get("precision") or 0.0),
407
+ top_confusion=(
408
+ (str(row["top_confusion"][0]), int(row["top_confusion"][1]))
409
+ if row.get("top_confusion")
410
+ else None
411
+ ),
412
+ )
413
+ for row in payload.get("per_class") or []
414
+ )
415
+
416
+ bias_payload = payload.get("position_bias")
417
+ bias = (
418
+ PositionBiasReport(
419
+ n_pairs=int(bias_payload.get("n_pairs") or 0),
420
+ inconsistency_rate=float(bias_payload.get("inconsistency_rate") or 0.0),
421
+ first_position_rate=float(bias_payload.get("first_position_rate") or 0.0),
422
+ n_unresolved=int(bias_payload.get("n_unresolved") or 0),
423
+ )
424
+ if isinstance(bias_payload, dict)
425
+ else None
426
+ )
427
+
428
+ return CalibrationReport(
429
+ n_examples=int(payload.get("n_examples") or 0),
430
+ n_errored=int(payload.get("n_errored") or 0),
431
+ agreement=float(payload.get("agreement") or 0.0),
432
+ agreement_ci=_pair(payload.get("agreement_ci")) or (0.0, 1.0),
433
+ kappa=_float_or_none(payload.get("kappa")),
434
+ kappa_ci=_pair(payload.get("kappa_ci")),
435
+ kappa_kind=payload.get("kappa_kind") or "unweighted",
436
+ kappa_undefined_reason=payload.get("kappa_undefined_reason"),
437
+ false_pass_rate=_float_or_none(payload.get("false_pass_rate")),
438
+ false_fail_rate=_float_or_none(payload.get("false_fail_rate")),
439
+ confusion=ConfusionMatrix(
440
+ labels=tuple(raw_confusion.get("labels") or ()), counts=confusion_counts
441
+ ),
442
+ per_class=per_class,
443
+ human_kappa=_float_or_none(payload.get("human_kappa")),
444
+ judge_kappa_on_ceiling_subset=_float_or_none(payload.get("judge_kappa_on_ceiling_subset")),
445
+ n_ceiling_examples=int(payload.get("n_ceiling_examples") or 0),
446
+ mean_cost=Decimal(str(payload.get("mean_cost") or "0")),
447
+ total_cost=Decimal(str(payload.get("total_cost") or "0")),
448
+ p50_latency_ms=float(payload.get("p50_latency_ms") or 0.0),
449
+ p95_latency_ms=float(payload.get("p95_latency_ms") or 0.0),
450
+ leniency=_float_or_none(payload.get("leniency")),
451
+ scale_compression=_float_or_none(payload.get("scale_compression")),
452
+ verbosity_bias=_float_or_none(payload.get("verbosity_bias")),
453
+ position_bias=bias,
454
+ notes=tuple(payload.get("notes") or ()),
455
+ )
456
+
457
+
458
+ def _float_or_none(value: Any) -> float | None:
459
+ if value is None:
460
+ return None
461
+ try:
462
+ return float(value)
463
+ except (TypeError, ValueError):
464
+ return None
465
+
466
+
467
+ def _pair(value: Any) -> tuple[float, float] | None:
468
+ if not isinstance(value, list | tuple) or len(value) != 2:
469
+ return None
470
+ low, high = _float_or_none(value[0]), _float_or_none(value[1])
471
+ return None if low is None or high is None else (low, high)
@@ -0,0 +1,205 @@
1
+ """Compare a candidate run against a baseline.
2
+
3
+ Two refusals are the point of this module, and both exist because the failure they
4
+ prevent is *silent*:
5
+
6
+ - **Refuse to compare across different dataset content.** The numbers still render,
7
+ and they mean nothing.
8
+ - **Refuse to compare across different evaluator versions.** If the ruler changed,
9
+ reporting the difference as a quality delta is the most misleading thing this
10
+ system could do.
11
+
12
+ Both are overridable, and both are reported when overridden.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass, field
19
+
20
+ from proofstep_core.aggregate import scores_for
21
+ from proofstep_core.stats import delta_ci
22
+ from proofstep_types import ExampleResult, Metric, MetricDelta
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ExampleRegression:
27
+ """An example that passed in the baseline and failed in the candidate."""
28
+
29
+ example_id: str
30
+ metric: str
31
+ baseline_score: float | None
32
+ candidate_score: float | None
33
+ trace_id: str | None = None
34
+
35
+ @property
36
+ def delta(self) -> float:
37
+ return (self.candidate_score or 0.0) - (self.baseline_score or 0.0)
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class Comparison:
42
+ deltas: list[MetricDelta] = field(default_factory=list)
43
+ regressions: list[ExampleRegression] = field(default_factory=list)
44
+ improvements: list[ExampleRegression] = field(default_factory=list)
45
+ dataset_match: bool = True
46
+ warnings: list[str] = field(default_factory=list)
47
+
48
+ def delta_for(self, key: str, **slice_: str) -> MetricDelta | None:
49
+ wanted = slice_ or None
50
+ return next((d for d in self.deltas if d.key == key and d.slice == wanted), None)
51
+
52
+ @property
53
+ def regressed_metrics(self) -> list[MetricDelta]:
54
+ return sorted(
55
+ (d for d in self.deltas if (d.absolute_delta or 0) < 0),
56
+ key=lambda d: d.absolute_delta or 0,
57
+ )
58
+
59
+
60
+ def compare_metrics(
61
+ candidate: Sequence[Metric],
62
+ baseline: Sequence[Metric],
63
+ *,
64
+ candidate_results: Sequence[ExampleResult] = (),
65
+ baseline_results: Sequence[ExampleResult] = (),
66
+ candidate_hash: str = "",
67
+ baseline_hash: str = "",
68
+ significance: bool = True,
69
+ ) -> Comparison:
70
+ """Produce per-metric deltas and the per-example regression list."""
71
+ warnings: list[str] = []
72
+ dataset_match = not (candidate_hash and baseline_hash) or candidate_hash == baseline_hash
73
+ if not dataset_match:
74
+ warnings.append(
75
+ f"Dataset content differs: candidate {candidate_hash[:12]} vs baseline "
76
+ f"{baseline_hash[:12]}. Metric deltas below compare different data and "
77
+ "should not be read as quality changes."
78
+ )
79
+
80
+ base_index = {(m.key, _key(m.slice)): m for m in baseline}
81
+ cand_index = {(m.key, _key(m.slice)): m for m in candidate}
82
+
83
+ deltas: list[MetricDelta] = []
84
+ for lookup in sorted(set(base_index) | set(cand_index)):
85
+ key, slice_key = lookup
86
+ base = base_index.get(lookup)
87
+ cand = cand_index.get(lookup)
88
+ deltas.append(
89
+ _delta(
90
+ key,
91
+ dict(slice_key) or None,
92
+ base,
93
+ cand,
94
+ candidate_results,
95
+ baseline_results,
96
+ significance=significance,
97
+ )
98
+ )
99
+
100
+ if only_candidate := sorted({k for k, _ in cand_index} - {k for k, _ in base_index}):
101
+ warnings.append(
102
+ f"{len(only_candidate)} metric(s) exist only in the candidate: "
103
+ f"{', '.join(only_candidate[:5])}. New evaluators have no baseline."
104
+ )
105
+ if only_baseline := sorted({k for k, _ in base_index} - {k for k, _ in cand_index}):
106
+ warnings.append(
107
+ f"{len(only_baseline)} metric(s) exist only in the baseline: "
108
+ f"{', '.join(only_baseline[:5])}. Did an evaluator get removed or renamed?"
109
+ )
110
+
111
+ regressions, improvements = _example_changes(candidate_results, baseline_results)
112
+
113
+ return Comparison(
114
+ deltas=deltas,
115
+ regressions=regressions,
116
+ improvements=improvements,
117
+ dataset_match=dataset_match,
118
+ warnings=warnings,
119
+ )
120
+
121
+
122
+ def _delta( # noqa: PLR0917
123
+ key: str,
124
+ slice_: dict[str, str] | None,
125
+ base: Metric | None,
126
+ cand: Metric | None,
127
+ candidate_results: Sequence[ExampleResult],
128
+ baseline_results: Sequence[ExampleResult],
129
+ *,
130
+ significance: bool,
131
+ ) -> MetricDelta:
132
+ if base is None or cand is None:
133
+ return MetricDelta(
134
+ key=key,
135
+ slice=slice_,
136
+ baseline=base.value if base else None,
137
+ candidate=cand.value if cand else None,
138
+ count=cand.count if cand else 0,
139
+ )
140
+
141
+ absolute = cand.value - base.value
142
+ relative = absolute / abs(base.value) if base.value else None
143
+
144
+ ci_low = ci_high = None
145
+ significant: bool | None = None
146
+ if significance and candidate_results and baseline_results and slice_ is None:
147
+ cand_values = scores_for(candidate_results, key)
148
+ base_values = scores_for(baseline_results, key)
149
+ if len(cand_values) >= 5 and len(base_values) >= 5:
150
+ ci_low, ci_high = delta_ci(base_values, cand_values)
151
+ # Advisory only. Gates use thresholds: at n=200 most real regressions are
152
+ # not "significant", and gating on that would let them through.
153
+ significant = not (ci_low <= 0 <= ci_high)
154
+
155
+ return MetricDelta(
156
+ key=key,
157
+ slice=slice_,
158
+ baseline=base.value,
159
+ candidate=cand.value,
160
+ absolute_delta=absolute,
161
+ relative_delta=relative,
162
+ count=cand.count,
163
+ ci_low=ci_low,
164
+ ci_high=ci_high,
165
+ significant=significant,
166
+ )
167
+
168
+
169
+ def _example_changes(
170
+ candidate: Sequence[ExampleResult], baseline: Sequence[ExampleResult]
171
+ ) -> tuple[list[ExampleRegression], list[ExampleRegression]]:
172
+ """Match on example id, never on position — datasets gain and lose rows."""
173
+ base_by_id = {r.example_id: r for r in baseline}
174
+ regressions: list[ExampleRegression] = []
175
+ improvements: list[ExampleRegression] = []
176
+
177
+ for cand in candidate:
178
+ base = base_by_id.get(cand.example_id)
179
+ if base is None:
180
+ continue
181
+ for score in cand.scores:
182
+ prior = base.score_for(score.metric)
183
+ if prior is None or score.errored or prior.errored:
184
+ continue
185
+ if score.value is None or prior.value is None:
186
+ continue
187
+ change = ExampleRegression(
188
+ example_id=cand.example_id,
189
+ metric=score.metric,
190
+ baseline_score=prior.value,
191
+ candidate_score=score.value,
192
+ trace_id=cand.trace.trace_id if cand.trace else None,
193
+ )
194
+ if score.value < prior.value:
195
+ regressions.append(change)
196
+ elif score.value > prior.value:
197
+ improvements.append(change)
198
+
199
+ regressions.sort(key=lambda r: r.delta)
200
+ improvements.sort(key=lambda r: -r.delta)
201
+ return regressions, improvements
202
+
203
+
204
+ def _key(slice_: dict[str, str] | None) -> tuple[tuple[str, str], ...]:
205
+ return tuple(sorted(slice_.items())) if slice_ else ()