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,849 @@
1
+ """Judge calibration — deciding whether a judge's numbers are measurements.
2
+
3
+ An LLM judge is a measuring instrument. An uncalibrated instrument produces numbers,
4
+ not measurements, and gating a merge on one means blocking engineers on a figure nobody
5
+ has checked. This module is the check.
6
+
7
+ Everything here is pure: labelled examples in, a report out. No provider calls, no
8
+ database, no clock. The judge is *run* elsewhere (`calibration_runner.py`); the maths
9
+ lives here so it can be verified against hand-computed values.
10
+
11
+ Four things this module insists on, each because the obvious alternative is wrong:
12
+
13
+ 1. **Agreement alone is not evidence.** On a task where 90 % of examples are one class,
14
+ a judge that always answers that class agrees 90 % of the time and has measured
15
+ nothing. Cohen's κ corrects for chance agreement, so it is the headline number.
16
+
17
+ 2. **The human ceiling bounds the judge.** If two humans agree at κ = 0.6, a judge at
18
+ κ = 0.6 is at the ceiling and further tuning fits noise. A judge is never held to a
19
+ standard the task itself does not support.
20
+
21
+ 3. **False passes and false fails are not interchangeable.** A judge that passes what a
22
+ human failed lets real defects merge. A judge that fails what a human passed erodes
23
+ trust until people bypass the gate. They are reported separately and gated
24
+ separately, because one is a safety problem and the other is an adoption problem.
25
+
26
+ 4. **A small calibration set cannot certify anything.** κ on 30 examples has a
27
+ confidence interval roughly ±0.25 wide. Reporting `κ = 0.81 ≥ 0.80, passed` from
28
+ such a set is false precision, so sample-size floors are part of the requirement
29
+ rather than advice.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import math
35
+ import random
36
+ from collections.abc import Mapping, Sequence
37
+ from dataclasses import dataclass, field
38
+ from decimal import Decimal
39
+ from typing import Literal
40
+
41
+ from proofstep_core.stats import mean, percentile, stddev, wilson_ci
42
+
43
+ # Defaults from docs/EVALUATION_ENGINE.md §5: ≥100 examples, ≥50 per class.
44
+ MIN_EXAMPLES = 100
45
+ MIN_PER_CLASS = 50
46
+ # Below this the doubly-labelled subset cannot estimate a human ceiling worth reporting.
47
+ MIN_CEILING_EXAMPLES = 20
48
+
49
+ KappaKind = Literal["unweighted", "linear", "quadratic"]
50
+
51
+
52
+ # --------------------------------------------------------------------------- inputs
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class LabelledExample:
57
+ """One human-labelled calibration example.
58
+
59
+ Labels are strings even for a 1-5 rubric. One representation covers binary,
60
+ classification, and ordinal judges, and `ordinal_order` is what tells the maths
61
+ that "2" is nearer "3" than to "5" — without it, a judge that answers 4 where a
62
+ human said 5 is penalised exactly as much as one that answers 1.
63
+ """
64
+
65
+ example_id: str
66
+ human_label: str
67
+ #: A second independent annotator, on the subset that has one. This is the only
68
+ #: input from which a human ceiling can be computed; without it the report says so
69
+ #: rather than inventing one.
70
+ second_human_label: str | None = None
71
+ #: Adjudicated label where two annotators disagreed and a human resolved it.
72
+ adjudicated_label: str | None = None
73
+ #: Length of the evaluated output, in characters. Enables verbosity-bias detection.
74
+ output_length: int | None = None
75
+ metadata: Mapping[str, str] = field(default_factory=dict)
76
+
77
+ @property
78
+ def reference_label(self) -> str:
79
+ """The label the judge is scored against.
80
+
81
+ Adjudication wins when present: where two annotators disagreed and a human
82
+ resolved it, the resolution is the ground truth, and scoring the judge against
83
+ the unadjudicated first pass would penalise it for the annotator's error.
84
+ """
85
+ return self.adjudicated_label or self.human_label
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class JudgeVerdict:
90
+ """What the judge returned for one calibration example."""
91
+
92
+ example_id: str
93
+ label: str | None = None
94
+ errored: bool = False
95
+ error: str | None = None
96
+ cost: Decimal = Decimal(0)
97
+ latency_ms: int = 0
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class PairwiseProbe:
102
+ """One pairwise comparison run in both orders, for position-bias detection.
103
+
104
+ `winner_ab` is the item the judge chose when A was presented first; `winner_ba` is
105
+ its choice when the same two items were presented in the opposite order. A judge
106
+ with no position bias returns the same *item* both times.
107
+ """
108
+
109
+ example_id: str
110
+ item_a: str
111
+ item_b: str
112
+ winner_ab: str | None
113
+ winner_ba: str | None
114
+
115
+ @property
116
+ def consistent(self) -> bool:
117
+ """True when swapping the order did not change the judge's choice."""
118
+ return (
119
+ self.winner_ab is not None
120
+ and self.winner_ba is not None
121
+ and self.winner_ab == self.winner_ba
122
+ )
123
+
124
+ @property
125
+ def chose_first_both_times(self) -> bool:
126
+ """True when the judge picked whichever item came first, whatever it was."""
127
+ return self.winner_ab == self.item_a and self.winner_ba == self.item_b
128
+
129
+
130
+ # --------------------------------------------------------------------------- outputs
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class ConfusionMatrix:
135
+ """Counts indexed `[human_label][judge_label]`."""
136
+
137
+ labels: tuple[str, ...]
138
+ counts: Mapping[tuple[str, str], int]
139
+
140
+ def get(self, human: str, judge: str) -> int:
141
+ return self.counts.get((human, judge), 0)
142
+
143
+ @property
144
+ def total(self) -> int:
145
+ return sum(self.counts.values())
146
+
147
+ def row_total(self, human: str) -> int:
148
+ return sum(self.get(human, judge) for judge in self.labels)
149
+
150
+ def column_total(self, judge: str) -> int:
151
+ return sum(self.get(human, judge) for human in self.labels)
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class ClassBreakdown:
156
+ """Per-class behaviour, so a rare class cannot hide inside an average."""
157
+
158
+ label: str
159
+ support: int
160
+ recall: float
161
+ precision: float
162
+ #: Which label the judge most often substituted for this one, and how often.
163
+ top_confusion: tuple[str, int] | None = None
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class CalibrationReport:
168
+ """Everything measured about one judge version against one labelled set."""
169
+
170
+ n_examples: int
171
+ n_errored: int
172
+ agreement: float
173
+ agreement_ci: tuple[float, float]
174
+ kappa: float | None
175
+ kappa_ci: tuple[float, float] | None
176
+ kappa_kind: KappaKind
177
+ #: Why κ could not be computed, when it could not. Never silently zero.
178
+ kappa_undefined_reason: str | None
179
+ false_pass_rate: float | None
180
+ false_fail_rate: float | None
181
+ confusion: ConfusionMatrix
182
+ per_class: tuple[ClassBreakdown, ...]
183
+
184
+ #: κ between two human annotators on the doubly-labelled subset.
185
+ human_kappa: float | None = None
186
+ #: The judge's κ **restricted to that same subset**, which is the only comparable
187
+ #: number — see `_ceiling`.
188
+ judge_kappa_on_ceiling_subset: float | None = None
189
+ n_ceiling_examples: int = 0
190
+
191
+ mean_cost: Decimal = Decimal(0)
192
+ total_cost: Decimal = Decimal(0)
193
+ p50_latency_ms: float = 0.0
194
+ p95_latency_ms: float = 0.0
195
+
196
+ #: Judge mean minus human mean on an ordinal scale. Positive means the judge is
197
+ #: more generous than the humans.
198
+ leniency: float | None = None
199
+ #: Judge spread divided by human spread. Well below 1 means the judge compresses
200
+ #: the scale, so real regressions fall below its resolution.
201
+ scale_compression: float | None = None
202
+ #: Correlation between output length and the judge's signed error. Positive means
203
+ #: longer outputs are scored more generously than the humans scored them.
204
+ verbosity_bias: float | None = None
205
+
206
+ position_bias: PositionBiasReport | None = None
207
+
208
+ #: Findings that do not fail the calibration but change how it should be read.
209
+ notes: tuple[str, ...] = ()
210
+
211
+ @property
212
+ def error_rate(self) -> float:
213
+ attempted = self.n_examples + self.n_errored
214
+ return self.n_errored / attempted if attempted else 0.0
215
+
216
+ @property
217
+ def at_human_ceiling(self) -> bool:
218
+ """True when the judge agrees with humans about as well as humans do.
219
+
220
+ Compared on the doubly-labelled subset only, and with a tolerance, because two
221
+ κ values from ~20 examples are not distinguishable to two decimal places.
222
+ """
223
+ if self.human_kappa is None or self.judge_kappa_on_ceiling_subset is None:
224
+ return False
225
+ return self.judge_kappa_on_ceiling_subset >= self.human_kappa - 0.05
226
+
227
+
228
+ @dataclass(frozen=True, slots=True)
229
+ class PositionBiasReport:
230
+ """Order effects in a pairwise judge."""
231
+
232
+ n_pairs: int
233
+ #: Share of pairs where swapping the presentation order changed the winner.
234
+ inconsistency_rate: float
235
+ #: Share of pairs where the judge simply picked whatever came first.
236
+ first_position_rate: float
237
+ n_unresolved: int
238
+
239
+ @property
240
+ def biased(self) -> bool:
241
+ """Whether the order effect is large enough to distrust the ranking.
242
+
243
+ 0.2 rather than 0: some inconsistency is genuine indifference between two
244
+ similar outputs, and a judge is not required to be deterministic on a tie.
245
+ """
246
+ return self.inconsistency_rate > 0.2 or self.first_position_rate > 0.2
247
+
248
+
249
+ # ------------------------------------------------------------------- requirements
250
+
251
+
252
+ @dataclass(frozen=True, slots=True)
253
+ class CalibrationRequirement:
254
+ """What a gate set demands before it will trust a judge.
255
+
256
+ The defaults are the recommended values from docs/EVALUATION_ENGINE.md §5 for a
257
+ safety-relevant metric. `max_false_pass_rate` is deliberately much tighter than
258
+ `max_false_fail_rate`: a false pass ships a defect, a false fail annoys someone.
259
+ """
260
+
261
+ min_agreement: float = 0.8
262
+ min_kappa: float | None = 0.6
263
+ max_false_pass_rate: float | None = 0.05
264
+ max_false_fail_rate: float | None = 0.20
265
+ min_examples: int = MIN_EXAMPLES
266
+ min_per_class: int = MIN_PER_CLASS
267
+ max_error_rate: float = 0.05
268
+ #: Refuse a judge whose order effects make its ranking unreliable.
269
+ allow_position_bias: bool = False
270
+
271
+
272
+ @dataclass(frozen=True, slots=True)
273
+ class RequirementCheck:
274
+ satisfied: bool
275
+ failures: tuple[str, ...]
276
+ warnings: tuple[str, ...]
277
+
278
+
279
+ def check_requirement( # noqa: PLR0912 — one branch per documented failure mode
280
+ report: CalibrationReport, requirement: CalibrationRequirement
281
+ ) -> RequirementCheck:
282
+ """Decide whether a calibration report satisfies a requirement.
283
+
284
+ Failures block. Warnings do not, but they name things that make the numbers less
285
+ trustworthy than they look — chiefly a confidence interval that straddles the
286
+ threshold, which means the honest answer is "label more examples", not "passed".
287
+ """
288
+ failures: list[str] = []
289
+ warnings: list[str] = []
290
+
291
+ if report.n_examples < requirement.min_examples:
292
+ failures.append(
293
+ f"only {report.n_examples} usable labelled examples; "
294
+ f"{requirement.min_examples} are required. A judge certified on a small "
295
+ "set is not certified — κ on 30 examples has a ±0.25 interval."
296
+ )
297
+
298
+ thin = [c.label for c in report.per_class if c.support < requirement.min_per_class]
299
+ if thin:
300
+ failures.append(
301
+ f"under {requirement.min_per_class} examples for class(es) "
302
+ f"{', '.join(sorted(thin))}. Per-class agreement cannot be measured on a "
303
+ "handful of examples, and the rare class is usually the one that matters."
304
+ )
305
+
306
+ if report.error_rate > requirement.max_error_rate:
307
+ failures.append(
308
+ f"{report.error_rate:.1%} of judge calls errored, above the "
309
+ f"{requirement.max_error_rate:.1%} limit. The calibration measured "
310
+ "whichever examples happened to succeed."
311
+ )
312
+
313
+ if report.agreement < requirement.min_agreement:
314
+ failures.append(
315
+ f"agreement {report.agreement:.3f} is below the required "
316
+ f"{requirement.min_agreement:.3f}"
317
+ )
318
+ elif report.agreement_ci[0] < requirement.min_agreement:
319
+ warnings.append(
320
+ f"agreement {report.agreement:.3f} clears {requirement.min_agreement:.3f}, "
321
+ f"but its 95% interval [{report.agreement_ci[0]:.3f}, "
322
+ f"{report.agreement_ci[1]:.3f}] straddles the threshold. The set is too "
323
+ "small to resolve the question; label more examples."
324
+ )
325
+
326
+ if requirement.min_kappa is not None:
327
+ if report.kappa is None:
328
+ failures.append(
329
+ f"κ could not be computed ({report.kappa_undefined_reason}), so the "
330
+ "agreement figure is uncorrected for chance and cannot be trusted "
331
+ "against a κ threshold."
332
+ )
333
+ elif report.kappa < requirement.min_kappa:
334
+ # The ceiling is the one thing that can excuse a low κ: a task humans
335
+ # cannot agree on is not a task a judge can be held to.
336
+ if report.at_human_ceiling:
337
+ warnings.append(
338
+ f"κ {report.kappa:.3f} is below the required "
339
+ f"{requirement.min_kappa:.3f}, but human annotators only reach "
340
+ f"{report.human_kappa:.3f} on the same examples. The judge is at "
341
+ "the ceiling of the task; the rubric is the thing to fix, not the "
342
+ "judge."
343
+ )
344
+ else:
345
+ failures.append(
346
+ f"κ {report.kappa:.3f} is below the required {requirement.min_kappa:.3f}"
347
+ )
348
+
349
+ if (
350
+ requirement.max_false_pass_rate is not None
351
+ and report.false_pass_rate is not None
352
+ and report.false_pass_rate > requirement.max_false_pass_rate
353
+ ):
354
+ failures.append(
355
+ f"false-pass rate {report.false_pass_rate:.3f} exceeds "
356
+ f"{requirement.max_false_pass_rate:.3f}. This judge passes work a "
357
+ "human rejected, which is the direction that ships defects."
358
+ )
359
+
360
+ if (
361
+ requirement.max_false_fail_rate is not None
362
+ and report.false_fail_rate is not None
363
+ and report.false_fail_rate > requirement.max_false_fail_rate
364
+ ):
365
+ failures.append(
366
+ f"false-fail rate {report.false_fail_rate:.3f} exceeds "
367
+ f"{requirement.max_false_fail_rate:.3f}. A gate that blocks acceptable "
368
+ "work gets bypassed, and then it protects nothing."
369
+ )
370
+
371
+ if (
372
+ report.position_bias is not None
373
+ and report.position_bias.biased
374
+ and not requirement.allow_position_bias
375
+ ):
376
+ failures.append(
377
+ f"order effects: {report.position_bias.inconsistency_rate:.1%} of pairs "
378
+ f"changed winner when swapped, {report.position_bias.first_position_rate:.1%} "
379
+ "simply picked whichever came first. A pairwise ranking from this judge "
380
+ "measures presentation order."
381
+ )
382
+
383
+ if report.scale_compression is not None and report.scale_compression < 0.5:
384
+ warnings.append(
385
+ f"the judge uses {report.scale_compression:.0%} of the spread the humans "
386
+ "use. Leniency clustering compresses the range, so a real regression can "
387
+ "fall below the judge's resolution."
388
+ )
389
+
390
+ if report.verbosity_bias is not None and abs(report.verbosity_bias) > 0.3:
391
+ direction = "longer" if report.verbosity_bias > 0 else "shorter"
392
+ warnings.append(
393
+ f"verbosity bias {report.verbosity_bias:+.2f}: the judge scores {direction} "
394
+ "outputs more generously than the humans did, independent of quality."
395
+ )
396
+
397
+ return RequirementCheck(
398
+ satisfied=not failures, failures=tuple(failures), warnings=tuple(warnings)
399
+ )
400
+
401
+
402
+ # ---------------------------------------------------------------------- the maths
403
+
404
+
405
+ def confusion_matrix(
406
+ pairs: Sequence[tuple[str, str]], *, labels: Sequence[str] | None = None
407
+ ) -> ConfusionMatrix:
408
+ """Build a matrix from `(human, judge)` label pairs.
409
+
410
+ The label set is the union of both raters' labels, not just the humans'. A judge
411
+ that invents a label outside the schema must appear as a column rather than
412
+ vanishing from the matrix while still counting as a disagreement.
413
+ """
414
+ observed = sorted({label for pair in pairs for label in pair})
415
+ ordered = tuple(labels) if labels is not None else tuple(observed)
416
+ if labels is not None:
417
+ ordered = (*ordered, *(label for label in observed if label not in ordered))
418
+
419
+ counts: dict[tuple[str, str], int] = {}
420
+ for human, judge in pairs:
421
+ counts[human, judge] = counts.get((human, judge), 0) + 1
422
+ return ConfusionMatrix(labels=ordered, counts=counts)
423
+
424
+
425
+ def observed_agreement(pairs: Sequence[tuple[str, str]]) -> float:
426
+ if not pairs:
427
+ return 0.0
428
+ return sum(1 for human, judge in pairs if human == judge) / len(pairs)
429
+
430
+
431
+ def cohens_kappa(
432
+ pairs: Sequence[tuple[str, str]],
433
+ *,
434
+ kind: KappaKind = "unweighted",
435
+ order: Sequence[str] | None = None,
436
+ ) -> tuple[float | None, str | None]:
437
+ """Cohen's κ, returning `(value, undefined_reason)`.
438
+
439
+ κ = (po - pe) / (1 - pe), where pe is the agreement expected from the raters'
440
+ marginal frequencies. It is `None` — never 0.0, never 1.0 — when pe = 1, which
441
+ happens whenever both raters used exactly one label. That case is a genuine
442
+ degenerate: perfect agreement carrying no information, since chance alone predicts
443
+ it perfectly. Reporting 1.0 there would certify a judge that answers the same thing
444
+ every time, and reporting 0.0 would reject a judge that was never wrong.
445
+
446
+ `kind` selects weighting for ordinal scales, which requires `order`. On a 1-5
447
+ rubric, unweighted κ treats "4 where the human said 5" exactly like "1 where the
448
+ human said 5", which is not a defensible way to grade a scale.
449
+ """
450
+ if not pairs:
451
+ return None, "no labelled examples"
452
+
453
+ matrix = confusion_matrix(pairs, labels=order)
454
+ n = matrix.total
455
+ labels = matrix.labels
456
+
457
+ if kind != "unweighted":
458
+ if order is None:
459
+ msg = f"{kind} weighting needs an explicit ordinal `order`"
460
+ raise ValueError(msg)
461
+ missing = {label for pair in pairs for label in pair} - set(order)
462
+ if missing:
463
+ # A label outside the declared scale has no position, so no distance. That
464
+ # is a broken judge or a broken dataset, not something to average over.
465
+ return None, f"labels outside the declared ordinal scale: {sorted(missing)}"
466
+
467
+ weights = _weights(labels, kind)
468
+
469
+ observed = 0.0
470
+ expected = 0.0
471
+ for human in labels:
472
+ row = matrix.row_total(human)
473
+ if row == 0:
474
+ continue
475
+ for judge in labels:
476
+ column = matrix.column_total(judge)
477
+ weight = weights[human, judge]
478
+ observed += weight * matrix.get(human, judge) / n
479
+ expected += weight * (row / n) * (column / n)
480
+
481
+ if math.isclose(expected, 1.0, abs_tol=1e-12):
482
+ used = sorted({label for pair in pairs for label in pair})
483
+ return None, (
484
+ f"chance agreement is 1.0 because both raters used only {used!r}; "
485
+ "κ is undefined and the agreement figure carries no information"
486
+ )
487
+
488
+ return (observed - expected) / (1 - expected), None
489
+
490
+
491
+ def _weights(labels: Sequence[str], kind: KappaKind) -> dict[tuple[str, str], float]:
492
+ """Agreement weights: 1 for a match, less for a near miss, 0 for the extremes."""
493
+ if kind == "unweighted":
494
+ return {(a, b): 1.0 if a == b else 0.0 for a in labels for b in labels}
495
+
496
+ positions = {label: index for index, label in enumerate(labels)}
497
+ span = max(1, len(labels) - 1)
498
+ power = 1 if kind == "linear" else 2
499
+ return {
500
+ (a, b): 1.0 - (abs(positions[a] - positions[b]) / span) ** power
501
+ for a in labels
502
+ for b in labels
503
+ }
504
+
505
+
506
+ def directional_error_rates(
507
+ pairs: Sequence[tuple[str, str]], passing: Sequence[str]
508
+ ) -> tuple[float | None, float | None]:
509
+ """False-pass and false-fail rates under a pass/fail projection.
510
+
511
+ - **false pass** = judge passed / human failed, as a share of what humans failed.
512
+ The denominator is what humans failed, not the whole set, because the question is
513
+ "of the defects a human caught, how many would this judge wave through?"
514
+ - **false fail** = judge failed / human passed, as a share of what humans passed.
515
+
516
+ Each is `None` when its denominator is empty, because a rate over zero cases is not
517
+ zero — it is unmeasured, and a gate must not read the two the same way.
518
+ """
519
+ passing_set = set(passing)
520
+ human_failed = [(h, j) for h, j in pairs if h not in passing_set]
521
+ human_passed = [(h, j) for h, j in pairs if h in passing_set]
522
+
523
+ false_pass = (
524
+ sum(1 for _, judge in human_failed if judge in passing_set) / len(human_failed)
525
+ if human_failed
526
+ else None
527
+ )
528
+ false_fail = (
529
+ sum(1 for _, judge in human_passed if judge not in passing_set) / len(human_passed)
530
+ if human_passed
531
+ else None
532
+ )
533
+ return false_pass, false_fail
534
+
535
+
536
+ def per_class_breakdown(matrix: ConfusionMatrix) -> tuple[ClassBreakdown, ...]:
537
+ """Recall and precision for each human label, plus its worst substitution."""
538
+ out: list[ClassBreakdown] = []
539
+ for label in matrix.labels:
540
+ support = matrix.row_total(label)
541
+ predicted = matrix.column_total(label)
542
+ hits = matrix.get(label, label)
543
+
544
+ confusions = [
545
+ (other, matrix.get(label, other)) for other in matrix.labels if other != label
546
+ ]
547
+ confusions.sort(key=lambda item: (-item[1], item[0]))
548
+ top = confusions[0] if confusions and confusions[0][1] > 0 else None
549
+
550
+ out.append(
551
+ ClassBreakdown(
552
+ label=label,
553
+ support=support,
554
+ # Zero support means unmeasured, and 0.0 recall would read as "the judge
555
+ # missed all of them". Kept at 0.0 only when there was something to miss.
556
+ recall=hits / support if support else 0.0,
557
+ precision=hits / predicted if predicted else 0.0,
558
+ top_confusion=top,
559
+ )
560
+ )
561
+ return tuple(out)
562
+
563
+
564
+ def position_bias(probes: Sequence[PairwiseProbe]) -> PositionBiasReport | None:
565
+ """Quantify order effects from comparisons run in both orders.
566
+
567
+ Pairwise judges without order swapping are measurably biased, which is why the
568
+ probe carries both directions. Unresolved pairs — where either direction failed to
569
+ produce a winner — are excluded from the rates and counted separately, so a judge
570
+ that mostly errors cannot look consistent.
571
+ """
572
+ if not probes:
573
+ return None
574
+
575
+ resolved = [p for p in probes if p.winner_ab is not None and p.winner_ba is not None]
576
+ unresolved = len(probes) - len(resolved)
577
+ if not resolved:
578
+ return PositionBiasReport(
579
+ n_pairs=0, inconsistency_rate=0.0, first_position_rate=0.0, n_unresolved=unresolved
580
+ )
581
+
582
+ inconsistent = sum(1 for p in resolved if not p.consistent)
583
+ first = sum(1 for p in resolved if p.chose_first_both_times)
584
+ return PositionBiasReport(
585
+ n_pairs=len(resolved),
586
+ inconsistency_rate=inconsistent / len(resolved),
587
+ first_position_rate=first / len(resolved),
588
+ n_unresolved=unresolved,
589
+ )
590
+
591
+
592
+ # ------------------------------------------------------------------ the entry point
593
+
594
+
595
+ def calibrate(
596
+ labelled: Sequence[LabelledExample],
597
+ verdicts: Sequence[JudgeVerdict],
598
+ *,
599
+ passing_labels: Sequence[str] | None = None,
600
+ ordinal_order: Sequence[str] | None = None,
601
+ kappa_kind: KappaKind | None = None,
602
+ probes: Sequence[PairwiseProbe] = (),
603
+ bootstrap_resamples: int = 2_000,
604
+ seed: int = 42,
605
+ ) -> CalibrationReport:
606
+ """Compute a full calibration report.
607
+
608
+ `ordinal_order` turns on scale-aware maths: weighted κ, leniency, compression. Pass
609
+ it for a rubric judge and leave it out for a classifier, where "nearer" is
610
+ meaningless.
611
+ """
612
+ by_id = {example.example_id: example for example in labelled}
613
+ notes: list[str] = []
614
+
615
+ usable: list[tuple[LabelledExample, JudgeVerdict]] = []
616
+ errored = 0
617
+ unknown_ids: list[str] = []
618
+ for verdict in verdicts:
619
+ example = by_id.get(verdict.example_id)
620
+ if example is None:
621
+ unknown_ids.append(verdict.example_id)
622
+ continue
623
+ if verdict.errored or verdict.label is None:
624
+ errored += 1
625
+ continue
626
+ usable.append((example, verdict))
627
+
628
+ if unknown_ids:
629
+ notes.append(
630
+ f"{len(unknown_ids)} judge verdict(s) referenced example ids absent from the "
631
+ f"labelled set (e.g. {unknown_ids[0]!r}) and were ignored."
632
+ )
633
+ judged_ids = {verdict.example_id for verdict in verdicts}
634
+ unjudged = [e.example_id for e in labelled if e.example_id not in judged_ids]
635
+ if unjudged:
636
+ # Silently reporting agreement over a subset would overstate coverage.
637
+ notes.append(f"{len(unjudged)} labelled example(s) were never judged and are excluded.")
638
+
639
+ pairs = [(example.reference_label, verdict.label or "") for example, verdict in usable]
640
+ matrix = confusion_matrix(pairs, labels=ordinal_order)
641
+
642
+ kind: KappaKind = kappa_kind or ("quadratic" if ordinal_order else "unweighted")
643
+ kappa, undefined = cohens_kappa(pairs, kind=kind, order=ordinal_order)
644
+
645
+ agreement = observed_agreement(pairs)
646
+ hits = sum(1 for human, judge in pairs if human == judge)
647
+ agreement_ci = wilson_ci(hits, len(pairs)) if pairs else (0.0, 1.0)
648
+ kappa_ci = (
649
+ _kappa_ci(pairs, kind=kind, order=ordinal_order, resamples=bootstrap_resamples, seed=seed)
650
+ if kappa is not None
651
+ else None
652
+ )
653
+
654
+ false_pass, false_fail = (
655
+ directional_error_rates(pairs, passing_labels) if passing_labels else (None, None)
656
+ )
657
+ if passing_labels and false_pass is None:
658
+ notes.append(
659
+ "no human-failed examples, so the false-pass rate is unmeasured. A "
660
+ "calibration set with no negatives cannot show whether the judge catches "
661
+ "anything."
662
+ )
663
+
664
+ human_kappa, judge_on_subset, n_ceiling, ceiling_note = _ceiling(
665
+ usable, kind=kind, order=ordinal_order
666
+ )
667
+ if ceiling_note:
668
+ notes.append(ceiling_note)
669
+
670
+ costs = [verdict.cost for _, verdict in usable]
671
+ latencies = [float(verdict.latency_ms) for _, verdict in usable]
672
+
673
+ leniency, compression = _scale_effects(pairs, ordinal_order)
674
+ verbosity = _verbosity_bias(usable, ordinal_order)
675
+
676
+ return CalibrationReport(
677
+ n_examples=len(usable),
678
+ n_errored=errored,
679
+ agreement=agreement,
680
+ agreement_ci=agreement_ci,
681
+ kappa=kappa,
682
+ kappa_ci=kappa_ci,
683
+ kappa_kind=kind,
684
+ kappa_undefined_reason=undefined,
685
+ false_pass_rate=false_pass,
686
+ false_fail_rate=false_fail,
687
+ confusion=matrix,
688
+ per_class=per_class_breakdown(matrix),
689
+ human_kappa=human_kappa,
690
+ judge_kappa_on_ceiling_subset=judge_on_subset,
691
+ n_ceiling_examples=n_ceiling,
692
+ mean_cost=(sum(costs, Decimal(0)) / len(costs)) if costs else Decimal(0),
693
+ total_cost=sum(costs, Decimal(0)),
694
+ p50_latency_ms=percentile(latencies, 50) if latencies else 0.0,
695
+ p95_latency_ms=percentile(latencies, 95) if latencies else 0.0,
696
+ leniency=leniency,
697
+ scale_compression=compression,
698
+ verbosity_bias=verbosity,
699
+ position_bias=position_bias(probes),
700
+ notes=tuple(notes),
701
+ )
702
+
703
+
704
+ def _kappa_ci(
705
+ pairs: Sequence[tuple[str, str]],
706
+ *,
707
+ kind: KappaKind,
708
+ order: Sequence[str] | None,
709
+ resamples: int,
710
+ seed: int,
711
+ confidence: float = 0.95,
712
+ ) -> tuple[float, float]:
713
+ """Percentile bootstrap interval for κ.
714
+
715
+ Bootstrapped rather than using the large-sample analytic standard error: the
716
+ analytic formula is only trustworthy well above the sample sizes calibration sets
717
+ actually reach, and resampling 100 pairs 2 000 times costs milliseconds. Seeded, so
718
+ the same set always yields the same interval.
719
+ """
720
+ if len(pairs) < 2:
721
+ return 0.0, 0.0
722
+
723
+ rng = random.Random(seed) # noqa: S311 — resampling, not security
724
+ n = len(pairs)
725
+ values: list[float] = []
726
+ for _ in range(resamples):
727
+ sample = [pairs[rng.randrange(n)] for _ in range(n)]
728
+ value, _reason = cohens_kappa(sample, kind=kind, order=order)
729
+ # A resample can be degenerate (every draw the same label). Skipping those
730
+ # rather than substituting 0 keeps the interval about κ instead of about how
731
+ # often the bootstrap collapsed.
732
+ if value is not None:
733
+ values.append(value)
734
+
735
+ if not values:
736
+ return 0.0, 0.0
737
+ values.sort()
738
+ alpha = (1 - confidence) / 2
739
+ low = values[max(0, int(alpha * len(values)) - 1)]
740
+ high = values[min(len(values) - 1, int((1 - alpha) * len(values)))]
741
+ return low, high
742
+
743
+
744
+ def _ceiling(
745
+ usable: Sequence[tuple[LabelledExample, JudgeVerdict]],
746
+ *,
747
+ kind: KappaKind,
748
+ order: Sequence[str] | None,
749
+ ) -> tuple[float | None, float | None, int, str | None]:
750
+ """Human-human κ, and the judge's κ on exactly the same examples.
751
+
752
+ The restriction matters and is easy to get wrong. Doubly-labelled examples are the
753
+ ones deliberately chosen to include boundary cases, so they are systematically
754
+ harder than the rest. Comparing the judge's κ over the whole set against the
755
+ humans' κ over that hard subset flatters the judge — it would let a judge look like
756
+ it had reached a ceiling it was never measured against.
757
+ """
758
+ subset = [
759
+ (example, verdict) for example, verdict in usable if example.second_human_label is not None
760
+ ]
761
+ if not subset:
762
+ return (
763
+ None,
764
+ None,
765
+ 0,
766
+ (
767
+ "no examples carry a second annotator's label, so there is no human "
768
+ "agreement ceiling to compare against. Double-label at least "
769
+ f"{MIN_CEILING_EXAMPLES} examples to find out whether the task is one "
770
+ "humans agree on at all."
771
+ ),
772
+ )
773
+
774
+ human_pairs = [(e.human_label, e.second_human_label or "") for e, _ in subset]
775
+ judge_pairs = [(e.reference_label, v.label or "") for e, v in subset]
776
+
777
+ human_kappa, _ = cohens_kappa(human_pairs, kind=kind, order=order)
778
+ judge_kappa, _ = cohens_kappa(judge_pairs, kind=kind, order=order)
779
+
780
+ note = None
781
+ if len(subset) < MIN_CEILING_EXAMPLES:
782
+ note = (
783
+ f"the human ceiling rests on {len(subset)} doubly-labelled example(s), "
784
+ f"below the {MIN_CEILING_EXAMPLES} needed for it to mean much."
785
+ )
786
+ return human_kappa, judge_kappa, len(subset), note
787
+
788
+
789
+ def _scale_effects(
790
+ pairs: Sequence[tuple[str, str]], order: Sequence[str] | None
791
+ ) -> tuple[float | None, float | None]:
792
+ """Leniency and scale compression, for ordinal judges only."""
793
+ if not order or not pairs:
794
+ return None, None
795
+
796
+ positions = {label: index for index, label in enumerate(order)}
797
+ human = [positions[h] for h, _ in pairs if h in positions]
798
+ judge = [positions[j] for _, j in pairs if j in positions]
799
+ if len(human) != len(pairs) or len(judge) != len(pairs):
800
+ return None, None
801
+
802
+ human_spread = stddev(human)
803
+ leniency = mean(judge) - mean(human)
804
+ # A human set with no spread cannot say whether the judge compressed anything.
805
+ compression = (stddev(judge) / human_spread) if human_spread > 0 else None
806
+ return leniency, compression
807
+
808
+
809
+ def _verbosity_bias(
810
+ usable: Sequence[tuple[LabelledExample, JudgeVerdict]], order: Sequence[str] | None
811
+ ) -> float | None:
812
+ """Correlation between output length and the judge's signed error.
813
+
814
+ Signed error, not raw score: a positive correlation between length and *score*
815
+ might just mean longer answers are genuinely better. Correlating length with
816
+ judge-minus-human isolates the part the humans did not agree was quality — which is
817
+ the definition of the bias.
818
+ """
819
+ if not order:
820
+ return None
821
+
822
+ positions = {label: index for index, label in enumerate(order)}
823
+ lengths: list[float] = []
824
+ errors: list[float] = []
825
+ for example, verdict in usable:
826
+ if example.output_length is None or verdict.label not in positions:
827
+ continue
828
+ if example.reference_label not in positions:
829
+ continue
830
+ lengths.append(float(example.output_length))
831
+ errors.append(float(positions[verdict.label] - positions[example.reference_label]))
832
+
833
+ if len(lengths) < 3:
834
+ return None
835
+ return _pearson(lengths, errors)
836
+
837
+
838
+ def _pearson(xs: Sequence[float], ys: Sequence[float]) -> float | None:
839
+ """Pearson correlation, or None when either series is constant."""
840
+ n = len(xs)
841
+ if n < 2:
842
+ return None
843
+ mx, my = mean(xs), mean(ys)
844
+ numerator = math.fsum((x - mx) * (y - my) for x, y in zip(xs, ys, strict=True))
845
+ dx = math.sqrt(math.fsum((x - mx) ** 2 for x in xs))
846
+ dy = math.sqrt(math.fsum((y - my) ** 2 for y in ys))
847
+ if dx == 0 or dy == 0:
848
+ return None
849
+ return numerator / (dx * dy)