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
|
@@ -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 ()
|